diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 9cb8e1cd3..bdbac9886 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -67,11 +67,11 @@ jobs: jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp mv MCPForUnity/package.json.tmp MCPForUnity/package.json - echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" - sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" + echo "Updating Server/pyproject.toml to $NEW_VERSION" + sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "Server/pyproject.toml" - echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" - echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" + echo "Updating Server/server_version.txt to $NEW_VERSION" + echo "$NEW_VERSION" > "Server/server_version.txt" - name: Commit and push changes env: @@ -81,7 +81,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add MCPForUnity/package.json "MCPForUnity/UnityMcpServer~/src/pyproject.toml" "MCPForUnity/UnityMcpServer~/src/server_version.txt" + git add MCPForUnity/package.json "Server/pyproject.toml" "Server/server_version.txt" if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.gitignore b/.gitignore index d56cf6cc3..81f0d6684 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,6 @@ build/ dist/ wheels/ *.egg-info -UnityMcpServer/**/*.meta -UnityMcpServer.meta # Virtual environments .venv diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs b/MCPForUnity/Editor/Data/PythonToolsAsset.cs deleted file mode 100644 index 22719a57c..000000000 --- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; - -namespace MCPForUnity.Editor.Data -{ - /// - /// Registry of Python tool files to sync to the MCP server. - /// Add your Python files here - they can be stored anywhere in your project. - /// - [CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")] - public class PythonToolsAsset : ScriptableObject - { - [Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")] - public List pythonFiles = new List(); - - [Header("Sync Options")] - [Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")] - public bool useContentHashing = true; - - [Header("Sync State (Read-only)")] - [Tooltip("Internal tracking - do not modify")] - public List fileStates = new List(); - - /// - /// Gets all valid Python files (filters out null/missing references) - /// - public IEnumerable GetValidFiles() - { - return pythonFiles.Where(f => f != null); - } - - /// - /// Checks if a file needs syncing - /// - public bool NeedsSync(TextAsset file, string currentHash) - { - if (!useContentHashing) return true; // Always sync if hashing disabled - - var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file)); - return state == null || state.contentHash != currentHash; - } - - /// - /// Records that a file was synced - /// - public void RecordSync(TextAsset file, string hash) - { - string guid = GetAssetGuid(file); - var state = fileStates.FirstOrDefault(s => s.assetGuid == guid); - - if (state == null) - { - state = new PythonFileState { assetGuid = guid }; - fileStates.Add(state); - } - - state.contentHash = hash; - state.lastSyncTime = DateTime.UtcNow; - state.fileName = file.name; - } - - /// - /// Removes state entries for files no longer in the list - /// - public void CleanupStaleStates() - { - var validGuids = new HashSet(GetValidFiles().Select(GetAssetGuid)); - fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid)); - } - - private string GetAssetGuid(TextAsset asset) - { - return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset)); - } - - /// - /// Called when the asset is modified in the Inspector - /// Triggers sync to handle file additions/removals - /// - private void OnValidate() - { - // Cleanup stale states immediately - CleanupStaleStates(); - - // Trigger sync after a delay to handle file removals - // Delay ensures the asset is saved before sync runs - UnityEditor.EditorApplication.delayCall += () => - { - if (this != null) // Check if asset still exists - { - MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools(); - } - }; - } - } - - [Serializable] - public class PythonFileState - { - public string assetGuid; - public string fileName; - public string contentHash; - public DateTime lastSyncTime; - } -} \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs index ce6efef23..cdb0e7bbc 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -60,10 +60,6 @@ public static DependencyCheckResult CheckAllDependencies() var uvStatus = detector.DetectUV(); result.Dependencies.Add(uvStatus); - // Check MCP Server - var serverStatus = detector.DetectMCPServer(); - result.Dependencies.Add(serverStatus); - // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs index 7fba58f92..773b9db65 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -27,11 +27,6 @@ public interface IPlatformDetector /// DependencyStatus DetectUV(); - /// - /// Detect MCP server installation on this platform - /// - DependencyStatus DetectMCPServer(); - /// /// Get platform-specific installation recommendations /// diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index 98044f17e..5c5446090 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -3,6 +3,7 @@ using System.IO; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -29,15 +30,15 @@ public virtual DependencyStatus DetectUV() try { // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(verifyPath: false); + if (!string.IsNullOrEmpty(uvxPath)) { - if (TryValidateUV(uvPath, out string version)) + if (TryValidateUvx(uvxPath, out string version)) { status.IsAvailable = true; status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; + status.Path = uvxPath; + status.Details = $"Found UV {version} at {uvxPath}"; return status; } } @@ -53,55 +54,7 @@ public virtual DependencyStatus DetectUV() return status; } - public virtual DependencyStatus DetectMCPServer() - { - var status = new DependencyStatus("MCP Server", isRequired: false); - - try - { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) - { - status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) - { - status.Version = File.ReadAllText(versionFile).Trim(); - } - - status.Details = $"MCP Server found at {serverPath}"; - } - else - { - // Check for embedded server - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) - { - status.IsAvailable = true; - status.Path = embeddedPath; - status.Details = "MCP Server available (embedded in package)"; - } - else - { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; - } - } - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; - } - - return status; - } - - protected bool TryValidateUV(string uvPath, out string version) + protected bool TryValidateUvx(string uvxPath, out string version) { version = null; @@ -109,7 +62,7 @@ protected bool TryValidateUV(string uvPath, out string version) { var psi = new ProcessStartInfo { - FileName = uvPath, + FileName = uvxPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index dac1facfd..a3620c96a 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -4,6 +4,7 @@ using UnityEditor; using UnityEngine; using PackageInfo = UnityEditor.PackageManager.PackageInfo; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Helpers { @@ -136,7 +137,50 @@ public static JObject GetPackageJson() } /// - /// Gets the version string from the package.json file. + /// Gets the uvx command with the correct package version for running the MCP server + /// + /// Uvx command string, or "uvx" if version is unknown + public static string GetUvxCommand() + { + string version = GetPackageVersion(); + if (version == "unknown") + { + return "uvx"; + } + + return $"uvx --from git+https://github.com/CoplayDev/unity-mcp@v{version}#subdirectory=Server"; + } + + /// + /// Gets just the git URL part for the MCP server package + /// + /// Git URL string, or empty string if version is unknown + public static string GetMcpServerGitUrl() + { + string version = GetPackageVersion(); + if (version == "unknown") + { + return ""; + } + + return $"git+https://github.com/CoplayDev/unity-mcp@v{version}#subdirectory=Server"; + } + + /// + /// Gets structured uvx command parts for different client configurations + /// + /// Tuple containing (uvxPath, fromUrl, packageName) + public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath() ?? "uvx"; + string fromUrl = GetMcpServerGitUrl(); + string packageName = "mcp-for-unity"; + + return (uvxPath, fromUrl, packageName); + } + + /// + /// Gets the package version from package.json /// /// Version string, or "unknown" if not found public static string GetPackageVersion() diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs index a4728901c..a41eb6ee2 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -14,36 +14,37 @@ namespace MCPForUnity.Editor.Helpers /// public static class CodexConfigHelper { - public static bool IsCodexConfigured(string pythonDir) + public static string BuildCodexServerBlock(string uvPath) { - try - { - string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (string.IsNullOrEmpty(basePath)) return false; - - string configPath = Path.Combine(basePath, ".codex", "config.toml"); - if (!File.Exists(configPath)) return false; - - string toml = File.ReadAllText(configPath); - if (!TryParseCodexServer(toml, out _, out var args)) return false; - - string dir = McpConfigurationHelper.ExtractDirectoryArg(args); - if (string.IsNullOrEmpty(dir)) return false; + var table = new TomlTable(); + var mcpServers = new TomlTable(); - return McpConfigurationHelper.PathsEqual(dir, pythonDir); - } - catch + // Use structured uvx command parts for proper TOML formatting + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + var unityMCP = new TomlTable(); + unityMCP["command"] = uvxPath; + + var args = new TomlArray(); + if (!string.IsNullOrEmpty(fromUrl)) { - return false; + args.Add(new TomlString { Value = "--from" }); + args.Add(new TomlString { Value = fromUrl }); } - } + args.Add(new TomlString { Value = packageName }); + + unityMCP["args"] = args; - public static string BuildCodexServerBlock(string uvPath, string serverSrc) - { - var table = new TomlTable(); - var mcpServers = new TomlTable(); + // Add Windows-specific environment configuration, see: https://github.com/CoplayDev/unity-mcp/issues/315 + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } - mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); + mcpServers["unityMCP"] = unityMCP; table["mcp_servers"] = mcpServers; using var writer = new StringWriter(); @@ -51,7 +52,7 @@ public static string BuildCodexServerBlock(string uvPath, string serverSrc) return writer.ToString(); } - public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc) + public static string UpsertCodexServerBlock(string existingToml, string uvPath) { // Parse existing TOML or create new root table var root = TryParseToml(existingToml) ?? new TomlTable(); @@ -64,7 +65,7 @@ public static string UpsertCodexServerBlock(string existingToml, string uvPath, var mcpServers = root["mcp_servers"] as TomlTable; // Create or update unityMCP table - mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); + mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath); // Serialize back to TOML using var writer = new StringWriter(); @@ -126,18 +127,22 @@ private static TomlTable TryParseToml(string toml) /// /// Creates a TomlTable for the unityMCP server configuration /// - /// Path to uv executable - /// Path to server source directory - private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc) + /// Path to uv executable (used as fallback if uvx is not available) + private static TomlTable CreateUnityMcpTable(string uvPath) { var unityMCP = new TomlTable(); - unityMCP["command"] = new TomlString { Value = uvPath }; + + // Use structured uvx command parts for proper TOML formatting + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + unityMCP["command"] = new TomlString { Value = uvxPath }; var argsArray = new TomlArray(); - argsArray.Add(new TomlString { Value = "run" }); - argsArray.Add(new TomlString { Value = "--directory" }); - argsArray.Add(new TomlString { Value = serverSrc }); - argsArray.Add(new TomlString { Value = "server.py" }); + if (!string.IsNullOrEmpty(fromUrl)) + { + argsArray.Add(new TomlString { Value = "--from" }); + argsArray.Add(new TomlString { Value = fromUrl }); + } + argsArray.Add(new TomlString { Value = packageName }); unityMCP["args"] = argsArray; // Add Windows-specific environment configuration, see: https://github.com/CoplayDev/unity-mcp/issues/315 diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 5889e4f6b..fe311456e 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -1,12 +1,15 @@ using Newtonsoft.Json; +using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Helpers; +using UnityEditor; namespace MCPForUnity.Editor.Helpers { public static class ConfigJsonBuilder { - public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) + public static string BuildManualConfigJson(string uvPath, McpClient client) { var root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; @@ -21,20 +24,20 @@ public static string BuildManualConfigJson(string uvPath, string pythonDir, McpC } var unity = new JObject(); - PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); + PopulateUnityNode(unity, uvPath, client, isVSCode); container["unityMCP"] = unity; return root.ToString(Formatting.Indented); } - public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client) { if (root == null) root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); - PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); + PopulateUnityNode(unity, uvPath, client, isVSCode); container["unityMCP"] = unity; return root; @@ -42,66 +45,63 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa /// /// Centralized builder that applies all caveats consistently. - /// - Sets command/args with provided directory + /// - Sets command/args with uvx and package version /// - Ensures env exists - /// - Adds type:"stdio" for VSCode + /// - Adds transport configuration (HTTP or stdio) /// - Adds disabled:false for Windsurf/Kiro only when missing /// - private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) + private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { - unity["command"] = uvPath; - - // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners - string effectiveDir = directory; -#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX - bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); - if (isCursor && !string.IsNullOrEmpty(directory)) + // Get transport preference (default to HTTP) + bool useHttpTransport = EditorPrefs.GetBool("MCPForUnity.UseHttpTransport", true); + + if (useHttpTransport) { - // Replace canonical path segment with the symlink path if present - const string canonical = "/Library/Application Support/"; - const string symlinkSeg = "/Library/AppSupport/"; - try + // HTTP mode: Use URL, no command + string httpUrl = EditorPrefs.GetString("MCPForUnity.HttpUrl", "http://localhost:8080"); + unity["url"] = httpUrl; + + // Remove command/args if they exist from previous config + if (unity["command"] != null) unity.Remove("command"); + if (unity["args"] != null) unity.Remove("args"); + + if (isVSCode) { - // Normalize to full path style - if (directory.Contains(canonical)) - { - var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - else - { - // If installer returned XDG-style on macOS, map to canonical symlink - string norm = directory.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); - if (idx >= 0) - { - string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); - if (System.IO.Directory.Exists(candidate)) - { - effectiveDir = candidate; - } - } - } + unity["type"] = "http"; } - catch { /* fallback to original directory on any error */ } } -#endif - - unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); - - if (isVSCode) + else { - unity["type"] = "stdio"; + // Stdio mode: Use uvx command + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unity["command"] = uvxPath; + + var args = new List { packageName }; + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Insert(0, fromUrl); + args.Insert(0, "--from"); + } + + args.Add("--transport"); + args.Add("stdio"); + + unity["args"] = JArray.FromObject(args.ToArray()); + + // Remove url if it exists from previous config + if (unity["url"] != null) unity.Remove("url"); + + if (isVSCode) + { + unity["type"] = "stdio"; + } } - else + + // Remove type for non-VSCode clients + if (!isVSCode && unity["type"] != null) { - // Remove type if it somehow exists from previous clients - if (unity["type"] != null) unity.Remove("type"); + unity.Remove("type"); } if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) diff --git a/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs new file mode 100644 index 000000000..adced50f3 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs @@ -0,0 +1,184 @@ +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Handles automatic registration of custom tools with the MCP server + /// + public static class CustomToolRegistrationProcessor + { + private static bool _isRegistrationEnabled = true; + private static bool _hasRegisteredOnStartup = false; + + static CustomToolRegistrationProcessor() + { + // Load saved preference + _isRegistrationEnabled = EditorPrefs.GetBool("MCPForUnity.CustomToolRegistrationEnabled", true); + } + + /// + /// Enable or disable automatic tool registration + /// + public static bool IsRegistrationEnabled + { + get => _isRegistrationEnabled; + set + { + _isRegistrationEnabled = value; + EditorPrefs.SetBool("MCPForUnity.CustomToolRegistrationEnabled", value); + } + } + + /// + /// Register all discovered tools with the MCP server + /// + public static async void RegisterAllTools() + { + if (!_isRegistrationEnabled) + { + McpLog.Info("Custom tool registration is disabled"); + return; + } + + try + { + McpLog.Info("Starting custom tool registration..."); + + var registrationService = MCPServiceLocator.CustomToolRegistration; + bool success = await registrationService.RegisterAllToolsAsync(); + + if (success) + { + McpLog.Info("Custom tool registration completed successfully"); + } + else + { + McpLog.Warn("Custom tool registration failed - check server logs for details"); + } + } + catch (System.Exception ex) + { + McpLog.Error($"Error during custom tool registration: {ex.Message}"); + } + } + + /// + /// Register all tools synchronously + /// + public static void RegisterAllToolsSync() + { + if (!_isRegistrationEnabled) + { + McpLog.Info("Custom tool registration is disabled"); + return; + } + + try + { + McpLog.Info("Starting custom tool registration (sync)..."); + + var registrationService = MCPServiceLocator.CustomToolRegistration; + bool success = registrationService.RegisterAllTools(); + + if (success) + { + McpLog.Info("Custom tool registration completed successfully"); + } + else + { + McpLog.Warn("Custom tool registration failed - check server logs for details"); + } + } + catch (System.Exception ex) + { + McpLog.Error($"Error during custom tool registration: {ex.Message}"); + } + } + + /// + /// Called when Unity editor starts up + /// + [InitializeOnLoadMethod] + private static void OnEditorStartup() + { + // Delay registration to allow editor to fully initialize + EditorApplication.delayCall += () => + { + if (!_hasRegisteredOnStartup && _isRegistrationEnabled) + { + // Wait a bit more for MCP server to potentially start + EditorApplication.delayCall += () => + { + RegisterAllTools(); + _hasRegisteredOnStartup = true; + }; + } + }; + } + + /// + /// Called when scripts are reloaded + /// + [UnityEditor.Callbacks.DidReloadScripts] + private static void OnScriptsReloaded() + { + // Invalidate discovery cache to pick up new tools + var discoveryService = MCPServiceLocator.ToolDiscovery; + discoveryService.InvalidateCache(); + + // Re-register tools after a delay + if (_isRegistrationEnabled) + { + EditorApplication.delayCall += RegisterAllTools; + } + } + + /// + /// Force re-registration of all tools + /// + public static void ForceReregistration() + { + McpLog.Info("Force re-registering custom tools..."); + + // Invalidate cache + var discoveryService = MCPServiceLocator.ToolDiscovery; + discoveryService.InvalidateCache(); + + // Re-register + RegisterAllTools(); + } + + /// + /// Get information about discovered tools + /// + public static string GetToolInfo() + { + try + { + var discoveryService = MCPServiceLocator.ToolDiscovery; + var tools = discoveryService.DiscoverAllTools(); + + if (tools.Count == 0) + { + return "No custom tools discovered"; + } + + var info = $"Discovered {tools.Count} custom tools:\n"; + foreach (var tool in tools) + { + info += $" - {tool.Name}: {tool.Description}\n"; + } + + return info; + } + catch (System.Exception ex) + { + return $"Error getting tool info: {ex.Message}"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta rename to MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta index f1e14f70a..f9d9bad46 100644 --- a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta +++ b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c40bd28f2310d463c8cd00181202cbe4 +guid: 80f36b8b3f86a45299c7d816c099c98b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 20c1200b9..924408fce 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -157,12 +157,6 @@ internal static void ClearClaudeCliPath() catch { } } - // Use existing UV resolver; returns absolute path or null. - internal static string ResolveUv() - { - return ServerInstaller.FindUvPath(); - } - internal static bool TryRun( string file, string args, diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 96ad7ec29..3037f70b9 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -10,6 +10,7 @@ using MCPForUnity.Editor.Dependencies; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Helpers { @@ -25,7 +26,7 @@ public static class McpConfigurationHelper /// Writes MCP configuration to the specified path using sophisticated logic /// that preserves existing configuration and only writes when necessary /// - public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) + public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) try @@ -94,19 +95,8 @@ public static string WriteMcpConfiguration(string pythonDir, string configPath, catch { } // 1) Start from existing, only fill gaps (prefer trusted resolver) - string uvPath = ServerInstaller.FindUvPath(); - // Optionally trust existingCommand if it looks like uv/uv.exe - try - { - var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(verifyPath: true); + if (uvxPath == null) return "UV package manager not found. Please install UV first."; // Ensure containers exist and write back configuration JObject existingRoot; @@ -115,7 +105,7 @@ public static string WriteMcpConfiguration(string pythonDir, string configPath, else existingRoot = JObject.FromObject(existingConfig); - existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); @@ -124,8 +114,7 @@ public static string WriteMcpConfiguration(string pythonDir, string configPath, try { - if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + if (File.Exists(uvxPath)) EditorPrefs.SetString("MCPForUnity.UvxPath", uvxPath); } catch { } @@ -135,7 +124,7 @@ public static string WriteMcpConfiguration(string pythonDir, string configPath, /// /// Configures a Codex client with sophisticated TOML handling /// - public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) + public static string ConfigureCodexClient(string configPath, McpClient mcpClient) { try { @@ -165,66 +154,26 @@ public static string ConfigureCodexClient(string pythonDir, string configPath, M CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); } - string uvPath = ServerInstaller.FindUvPath(); - try - { - var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - - if (uvPath == null) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) { return "UV package manager not found. Please install UV first."; } - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); - - string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc); + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath); EnsureConfigDirectoryExists(configPath); WriteAtomicFile(configPath, updatedToml); try { - if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + if (File.Exists(uvxPath)) EditorPrefs.SetString("MCPForUnity.UvxPath", uvxPath); } catch { } return "Configured successfully"; } - /// - /// Validates UV binary by running --version command - /// - private static bool IsValidUvBinary(string path) - { - try - { - if (!File.Exists(path)) return false; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = path, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return false; - if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode != 0) return false; - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - catch { return false; } - } - /// /// Gets the appropriate config file path for the given MCP client based on OS /// @@ -258,12 +207,12 @@ public static void EnsureConfigDirectoryExists(string configPath) Directory.CreateDirectory(Path.GetDirectoryName(configPath)); } - public static string ExtractDirectoryArg(string[] args) + public static string ExtractUvxUrl(string[] args) { if (args == null) return null; for (int i = 0; i < args.Length - 1; i++) { - if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase)) { return args[i + 1]; } @@ -290,58 +239,6 @@ public static bool PathsEqual(string a, string b) } } - /// - /// Resolves the server directory to use for MCP tools, preferring - /// existing config values and falling back to installed/embedded copies. - /// - public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) - { - string serverSrc = ExtractDirectoryArg(existingArgs); - bool serverValid = !string.IsNullOrEmpty(serverSrc) - && File.Exists(Path.Combine(serverSrc, "server.py")); - if (!serverValid) - { - if (!string.IsNullOrEmpty(pythonDir) - && File.Exists(Path.Combine(pythonDir, "server.py"))) - { - serverSrc = pythonDir; - } - else - { - serverSrc = ResolveServerSource(); - } - } - - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) - { - string norm = serverSrc.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); - if (idx >= 0) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); - serverSrc = Path.Combine(home, "Library", "Application Support", suffix); - } - } - } - catch - { - // Ignore failures and fall back to the original path. - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - && !string.IsNullOrEmpty(serverSrc) - && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 - && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - serverSrc = ServerInstaller.GetServerPath(); - } - - return serverSrc; - } - public static void WriteAtomicFile(string path, string contents) { string tmp = path + ".tmp"; @@ -393,39 +290,5 @@ public static void WriteAtomicFile(string path, string contents) try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } } } - - public static string ResolveServerSource() - { - try - { - string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); - if (!string.IsNullOrEmpty(remembered) - && File.Exists(Path.Combine(remembered, "server.py"))) - { - return remembered; - } - - ServerInstaller.EnsureServerInstalled(); - string installed = ServerInstaller.GetServerPath(); - if (File.Exists(Path.Combine(installed, "server.py"))) - { - return installed; - } - - bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); - if (useEmbedded - && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) - && File.Exists(Path.Combine(embedded, "server.py"))) - { - return embedded; - } - - return installed; - } - catch - { - return ServerInstaller.GetServerPath(); - } - } } } diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs b/MCPForUnity/Editor/Helpers/McpPathResolver.cs deleted file mode 100644 index 04082a945..000000000 --- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.IO; -using UnityEngine; -using UnityEditor; -using MCPForUnity.Editor.Helpers; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Shared helper for resolving MCP server directory paths with support for - /// development mode, embedded servers, and installed packages - /// - public static class McpPathResolver - { - private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; - - /// - /// Resolves the MCP server directory path with comprehensive logic - /// including development mode support and fallback mechanisms - /// - public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) - { - string pythonDir = McpConfigurationHelper.ResolveServerSource(); - - try - { - // Only check dev paths if we're using a file-based package (development mode) - bool isDevelopmentMode = IsDevelopmentMode(); - if (isDevelopmentMode) - { - string currentPackagePath = Path.GetDirectoryName(Application.dataPath); - string[] devPaths = { - Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), - }; - - foreach (string devPath in devPaths) - { - if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) - { - if (debugLogsEnabled) - { - Debug.Log($"Currently in development mode. Package: {devPath}"); - } - return devPath; - } - } - } - - // Resolve via shared helper (handles local registry and older fallback) only if dev override on - if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) - { - return embedded; - } - } - - // Log only if the resolved path does not actually contain server.py - if (debugLogsEnabled) - { - bool hasServer = false; - try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } - if (!hasServer) - { - Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); - } - } - } - catch (Exception e) - { - Debug.LogError($"Error finding package path: {e.Message}"); - } - - return pythonDir; - } - - /// - /// Checks if the current Unity project is in development mode - /// (i.e., the package is referenced as a local file path in manifest.json) - /// - private static bool IsDevelopmentMode() - { - try - { - // Only treat as development if manifest explicitly references a local file path for the package - string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); - if (!File.Exists(manifestPath)) return false; - - string manifestContent = File.ReadAllText(manifestPath); - // Look specifically for our package dependency set to a file: URL - // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk - if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); - // Crude but effective: check for "file:" in the same line/value - if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 - && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - catch - { - return false; - } - } - - /// - /// Gets the appropriate PATH prepend for the current platform when running external processes - /// - public static string GetPathPrepend() - { - if (Application.platform == RuntimePlatform.OSXEditor) - return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; - else if (Application.platform == RuntimePlatform.LinuxEditor) - return "/usr/local/bin:/usr/bin:/bin"; - return null; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs b/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs deleted file mode 100644 index 02e482c29..000000000 --- a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Manages package lifecycle events including first-time installation, - /// version updates, and legacy installation detection. - /// Consolidates the functionality of PackageInstaller and PackageDetector. - /// - [InitializeOnLoad] - public static class PackageLifecycleManager - { - private const string VersionKeyPrefix = "MCPForUnity.InstalledVersion:"; - private const string LegacyInstallFlagKey = "MCPForUnity.ServerInstalled"; // For migration - private const string InstallErrorKeyPrefix = "MCPForUnity.InstallError:"; // Stores last installation error - - static PackageLifecycleManager() - { - // Schedule the check for after Unity is fully loaded - EditorApplication.delayCall += CheckAndInstallServer; - } - - private static void CheckAndInstallServer() - { - try - { - string currentVersion = GetPackageVersion(); - string versionKey = VersionKeyPrefix + currentVersion; - bool hasRunForThisVersion = EditorPrefs.GetBool(versionKey, false); - - // Check for conditions that require installation/verification - bool isFirstTimeInstall = !EditorPrefs.HasKey(LegacyInstallFlagKey) && !hasRunForThisVersion; - bool legacyPresent = LegacyRootsExist(); - bool canonicalMissing = !File.Exists( - Path.Combine(ServerInstaller.GetServerPath(), "server.py") - ); - - // Run if: first install, version update, legacy detected, or canonical missing - if (isFirstTimeInstall || !hasRunForThisVersion || legacyPresent || canonicalMissing) - { - PerformInstallation(currentVersion, versionKey, isFirstTimeInstall); - } - } - catch (System.Exception ex) - { - McpLog.Info($"Package lifecycle check failed: {ex.Message}. Open Window > MCP For Unity if needed.", always: false); - } - } - - private static void PerformInstallation(string version, string versionKey, bool isFirstTimeInstall) - { - string error = null; - - try - { - ServerInstaller.EnsureServerInstalled(); - - // Mark as installed for this version - EditorPrefs.SetBool(versionKey, true); - - // Migrate legacy flag if this is first time - if (isFirstTimeInstall) - { - EditorPrefs.SetBool(LegacyInstallFlagKey, true); - } - - // Clean up old version keys (keep only current version) - CleanupOldVersionKeys(version); - - // Clean up legacy preference keys - CleanupLegacyPrefs(); - - // Only log success if server was actually embedded and copied - if (ServerInstaller.HasEmbeddedServer() && isFirstTimeInstall) - { - McpLog.Info("MCP server installation completed successfully."); - } - } - catch (System.Exception ex) - { - error = ex.Message; - - // Store the error for display in the UI, but don't mark as handled - // This allows the user to manually rebuild via the "Rebuild Server" button - string errorKey = InstallErrorKeyPrefix + version; - EditorPrefs.SetString(errorKey, ex.Message ?? "Unknown error"); - - // Don't mark as installed - user needs to manually rebuild - } - - if (!string.IsNullOrEmpty(error)) - { - McpLog.Info($"Server installation failed: {error}. Use Window > MCP For Unity > Rebuild Server to retry.", always: false); - } - } - - private static string GetPackageVersion() - { - try - { - var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly( - typeof(PackageLifecycleManager).Assembly - ); - if (info != null && !string.IsNullOrEmpty(info.version)) - { - return info.version; - } - } - catch { } - - // Fallback to embedded server version - return GetEmbeddedServerVersion(); - } - - private static string GetEmbeddedServerVersion() - { - try - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) - { - var versionPath = Path.Combine(embeddedSrc, "server_version.txt"); - if (File.Exists(versionPath)) - { - return File.ReadAllText(versionPath)?.Trim() ?? "unknown"; - } - } - } - catch { } - return "unknown"; - } - - private static bool LegacyRootsExist() - { - try - { - string home = System.Environment.GetFolderPath( - System.Environment.SpecialFolder.UserProfile - ) ?? string.Empty; - - string[] legacyRoots = - { - Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), - Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") - }; - - foreach (var root in legacyRoots) - { - try - { - if (File.Exists(Path.Combine(root, "server.py"))) - { - return true; - } - } - catch { } - } - } - catch { } - return false; - } - - private static void CleanupOldVersionKeys(string currentVersion) - { - try - { - // Get all EditorPrefs keys that start with our version prefix - // Note: Unity doesn't provide a way to enumerate all keys, so we can only - // clean up known legacy keys. Future versions will be cleaned up when - // a newer version runs. - // This is a best-effort cleanup. - } - catch { } - } - - private static void CleanupLegacyPrefs() - { - try - { - // Clean up old preference keys that are no longer used - string[] legacyKeys = - { - "MCPForUnity.ServerSrc", - "MCPForUnity.PythonDirOverride", - "MCPForUnity.LegacyDetectLogged" // Old prefix without version - }; - - foreach (var key in legacyKeys) - { - try - { - if (EditorPrefs.HasKey(key)) - { - EditorPrefs.DeleteKey(key); - } - } - catch { } - } - } - catch { } - } - - /// - /// Gets the last installation error for the current package version, if any. - /// Returns null if there was no error or the error has been cleared. - /// - public static string GetLastInstallError() - { - try - { - string currentVersion = GetPackageVersion(); - string errorKey = InstallErrorKeyPrefix + currentVersion; - if (EditorPrefs.HasKey(errorKey)) - { - return EditorPrefs.GetString(errorKey, null); - } - } - catch { } - return null; - } - - /// - /// Clears the last installation error. Should be called after a successful manual rebuild. - /// - public static void ClearLastInstallError() - { - try - { - string currentVersion = GetPackageVersion(); - string errorKey = InstallErrorKeyPrefix + currentVersion; - if (EditorPrefs.HasKey(errorKey)) - { - EditorPrefs.DeleteKey(errorKey); - } - } - catch { } - } - } -} diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs deleted file mode 100644 index de6167a77..000000000 --- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.IO; -using System.Linq; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Services; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - /// - /// Automatically syncs Python tools to the MCP server when: - /// - PythonToolsAsset is modified - /// - Python files are imported/reimported - /// - Unity starts up - /// - [InitializeOnLoad] - public class PythonToolSyncProcessor : AssetPostprocessor - { - private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled"; - private static bool _isSyncing = false; - - static PythonToolSyncProcessor() - { - // Sync on Unity startup - EditorApplication.delayCall += () => - { - if (IsAutoSyncEnabled()) - { - SyncAllTools(); - } - }; - } - - /// - /// Called after any assets are imported, deleted, or moved - /// - private static void OnPostprocessAllAssets( - string[] importedAssets, - string[] deletedAssets, - string[] movedAssets, - string[] movedFromAssetPaths) - { - // Prevent infinite loop - don't process if we're currently syncing - if (_isSyncing || !IsAutoSyncEnabled()) - return; - - bool needsSync = false; - - // Only check for .py file changes, not PythonToolsAsset changes - // (PythonToolsAsset changes are internal state updates from syncing) - foreach (string path in importedAssets.Concat(movedAssets)) - { - // Check if any .py files were modified - if (path.EndsWith(".py")) - { - needsSync = true; - break; - } - } - - // Check if any .py files were deleted - if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py"))) - { - needsSync = true; - } - - if (needsSync) - { - SyncAllTools(); - } - } - - /// - /// Syncs all Python tools from all PythonToolsAsset instances to the MCP server - /// - public static void SyncAllTools() - { - // Prevent re-entrant calls - if (_isSyncing) - { - McpLog.Warn("Sync already in progress, skipping..."); - return; - } - - _isSyncing = true; - try - { - if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath)) - { - McpLog.Warn("Cannot sync Python tools: MCP server source not found"); - return; - } - - string toolsDir = Path.Combine(srcPath, "tools", "custom"); - - var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir); - - if (result.Success) - { - if (result.CopiedCount > 0 || result.SkippedCount > 0) - { - McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped"); - } - } - else - { - McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors"); - foreach (var msg in result.Messages) - { - McpLog.Error($" - {msg}"); - } - } - } - catch (System.Exception ex) - { - McpLog.Error($"Python tool sync exception: {ex.Message}"); - } - finally - { - _isSyncing = false; - } - } - - /// - /// Checks if auto-sync is enabled (default: true) - /// - public static bool IsAutoSyncEnabled() - { - return EditorPrefs.GetBool(SyncEnabledKey, true); - } - - /// - /// Enables or disables auto-sync - /// - public static void SetAutoSyncEnabled(bool enabled) - { - EditorPrefs.SetBool(SyncEnabledKey, enabled); - McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}"); - } - - /// - /// Reimport all Python files in the project - /// - public static void ReimportPythonFiles() - { - // Find all Python files (imported as TextAssets by PythonFileImporter) - var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" }) - .Select(AssetDatabase.GUIDToAssetPath) - .Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - foreach (string path in pythonGuids) - { - AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); - } - - int count = pythonGuids.Length; - McpLog.Info($"Reimported {count} Python files"); - AssetDatabase.Refresh(); - } - - /// - /// Manually trigger sync - /// - public static void ManualSync() - { - McpLog.Info("Manually syncing Python tools..."); - SyncAllTools(); - } - - /// - /// Toggle auto-sync - /// - public static void ToggleAutoSync() - { - SetAutoSyncEnabled(!IsAutoSyncEnabled()); - } - - /// - /// Validate menu item (shows checkmark when enabled) - /// - public static bool ToggleAutoSyncValidate() - { - Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled()); - return true; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs deleted file mode 100644 index 2e149bb44..000000000 --- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs +++ /dev/null @@ -1,1001 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net; -using System.Runtime.InteropServices; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerInstaller - { - private const string RootFolder = "UnityMCP"; - private const string ServerFolder = "UnityMcpServer"; - private const string VersionFileName = "server_version.txt"; - - /// - /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. - /// No network calls or Git operations are performed. - /// - public static void EnsureServerInstalled() - { - try - { - string saveLocation = GetSaveLocation(); - TryCreateMacSymlinkForAppSupport(); - string destRoot = Path.Combine(saveLocation, ServerFolder); - string destSrc = Path.Combine(destRoot, "src"); - - // Detect legacy installs and version state (logs) - DetectAndLogLegacyInstallStates(destRoot); - - // Resolve embedded source and versions - if (!TryGetEmbeddedServerSource(out string embeddedSrc)) - { - // Asset Store install - no embedded server - // Check if server was already downloaded - if (File.Exists(Path.Combine(destSrc, "server.py"))) - { - McpLog.Info("Using previously downloaded MCP server.", always: false); - } - else - { - McpLog.Info("MCP server not found. Download via Window > MCP For Unity > Open MCP Window.", always: false); - } - return; // Graceful exit - no exception - } - - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; - string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); - - bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); - bool needOverwrite = !destHasServer - || string.IsNullOrEmpty(installedVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); - - // Ensure destination exists - Directory.CreateDirectory(destRoot); - - if (needOverwrite) - { - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); - - // Write/refresh version file - try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } - McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); - } - - // Cleanup legacy installs that are missing version or older than embedded - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - try - { - string legacySrc = Path.Combine(legacyRoot, "src"); - if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - bool legacyOlder = string.IsNullOrEmpty(legacyVer) - || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); - if (legacyOlder) - { - TryKillUvForPath(legacySrc); - if (DeleteDirectoryWithRetry(legacyRoot)) - { - McpLog.Info($"Removed legacy server at '{legacyRoot}'."); - } - else - { - McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}' (files may be in use)"); - } - } - } - catch { } - } - - // Clear overrides that might point at legacy locations - try - { - EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); - EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); - } - catch { } - return; - } - catch (Exception ex) - { - // If a usable server is already present (installed or embedded), don't fail hard—just warn. - bool hasInstalled = false; - try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } - - if (hasInstalled || TryGetEmbeddedServerSource(out _)) - { - McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); - return; - } - - McpLog.Error($"Failed to ensure server installation: {ex.Message}"); - } - } - - public static string GetServerPath() - { - return Path.Combine(GetSaveLocation(), ServerFolder, "src"); - } - - /// - /// Gets the platform-specific save location for the server. - /// - private static string GetSaveLocation() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Use per-user LocalApplicationData for canonical install location - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdg)) - { - xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, - ".local", "share"); - } - return Path.Combine(xdg, RootFolder); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // On macOS, use LocalApplicationData (~/Library/Application Support) - var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support - bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); - if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) - { - // Fallback: construct from $HOME - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - localAppSupport = Path.Combine(home, "Library", "Application Support"); - } - TryCreateMacSymlinkForAppSupport(); - return Path.Combine(localAppSupport, RootFolder); - } - throw new Exception("Unsupported operating system"); - } - - /// - /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support - /// to mitigate arg parsing and quoting issues in some MCP clients. - /// Safe to call repeatedly. - /// - private static void TryCreateMacSymlinkForAppSupport() - { - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - if (string.IsNullOrEmpty(home)) return; - - string canonical = Path.Combine(home, "Library", "Application Support"); - string symlink = Path.Combine(home, "Library", "AppSupport"); - - // If symlink exists already, nothing to do - if (Directory.Exists(symlink) || File.Exists(symlink)) return; - - // Create symlink only if canonical exists - if (!Directory.Exists(canonical)) return; - - // Use 'ln -s' to create a directory symlink (macOS) - var psi = new ProcessStartInfo - { - FileName = "/bin/ln", - Arguments = $"-s \"{canonical}\" \"{symlink}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - p?.WaitForExit(2000); - } - catch { /* best-effort */ } - } - - private static bool IsDirectoryWritable(string path) - { - try - { - File.Create(Path.Combine(path, "test.txt")).Dispose(); - File.Delete(Path.Combine(path, "test.txt")); - return true; - } - catch - { - return false; - } - } - - /// - /// Checks if the server is installed at the specified location. - /// - private static bool IsServerInstalled(string location) - { - return Directory.Exists(location) - && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); - } - - /// - /// Detects legacy installs or older versions and logs findings (no deletion yet). - /// - private static void DetectAndLogLegacyInstallStates(string canonicalRoot) - { - try - { - string canonicalSrc = Path.Combine(canonicalRoot, "src"); - // Normalize canonical root for comparisons - string normCanonicalRoot = NormalizePathSafe(canonicalRoot); - string embeddedSrc = null; - TryGetEmbeddedServerSource(out embeddedSrc); - - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); - string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); - - // Legacy paths (macOS/Linux .config; Windows roaming as example) - foreach (var legacyRoot in GetLegacyRootsForDetection()) - { - // Skip logging for the canonical root itself - if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) - continue; - string legacySrc = Path.Combine(legacyRoot, "src"); - bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); - string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); - - if (hasServer) - { - // Case 1: No version file - if (string.IsNullOrEmpty(legacyVer)) - { - McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); - } - - // Case 2: Lives in legacy path - McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); - - // Case 3: Has version but appears older than embedded - if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) - { - McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); - } - } - } - - // Also log if canonical is missing version (treated as older) - if (Directory.Exists(canonicalRoot)) - { - if (string.IsNullOrEmpty(installedVer)) - { - McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); - } - else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) - { - McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); - } - } - } - catch (Exception ex) - { - McpLog.Warn("Detect legacy/version state failed: " + ex.Message); - } - } - - private static string NormalizePathSafe(string path) - { - try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } - catch { return path; } - } - - private static bool PathsEqualSafe(string a, string b) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - string na = NormalizePathSafe(a); - string nb = NormalizePathSafe(b); - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - } - return string.Equals(na, nb, StringComparison.Ordinal); - } - catch { return false; } - } - - private static IEnumerable GetLegacyRootsForDetection() - { - var roots = new List(); - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - // macOS/Linux legacy - roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); - roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); - // Windows roaming example - try - { - string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(roaming)) - roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); - // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer - // Detect this location so we can clean up older copies during install/update. - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - if (!string.IsNullOrEmpty(localAppData)) - roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer")); - } - catch { } - return roots; - } - - /// - /// Attempts to kill UV and Python processes associated with a specific server path. - /// This is necessary on Windows because the OS blocks file deletion when processes - /// have open file handles, unlike macOS/Linux which allow unlinking open files. - /// - private static void TryKillUvForPath(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - KillWindowsUvProcesses(serverSrcPath); - return; - } - - // Unix: use pgrep to find processes by command line - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/pgrep", - Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - if (p == null) return; - string outp = p.StandardOutput.ReadToEnd(); - p.WaitForExit(1500); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) - { - foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) - { - if (int.TryParse(line.Trim(), out int pid)) - { - try { Process.GetProcessById(pid).Kill(); } catch { } - } - } - } - } - catch { } - } - - /// - /// Kills Windows processes running from the virtual environment directory. - /// Uses WMIC (Windows Management Instrumentation) to safely query only processes - /// with executables in the .venv path, avoiding the need to iterate all system processes. - /// This prevents accidentally killing IDE processes or other critical system processes. - /// - /// Why this is needed on Windows: - /// - Windows blocks file/directory deletion when ANY process has an open file handle - /// - UV creates a virtual environment with python.exe and other executables - /// - These processes may hold locks on DLLs, .pyd files, or the executables themselves - /// - macOS/Linux allow deletion of open files (unlink), but Windows does not - /// - private static void KillWindowsUvProcesses(string serverSrcPath) - { - try - { - if (string.IsNullOrEmpty(serverSrcPath)) return; - - string venvPath = Path.Combine(serverSrcPath, ".venv"); - if (!Directory.Exists(venvPath)) return; - - string normalizedVenvPath = Path.GetFullPath(venvPath).ToLowerInvariant(); - - // Use WMIC to find processes with executables in the .venv directory - // This is much safer than iterating all processes - var psi = new ProcessStartInfo - { - FileName = "wmic", - Arguments = $"process where \"ExecutablePath like '%{normalizedVenvPath.Replace("\\", "\\\\")}%'\" get ProcessId", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi); - if (proc == null) return; - - string output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(5000); - - if (proc.ExitCode != 0) return; - - // Parse PIDs from WMIC output - var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - string trimmed = line.Trim(); - if (trimmed.Equals("ProcessId", StringComparison.OrdinalIgnoreCase)) continue; - if (string.IsNullOrWhiteSpace(trimmed)) continue; - - if (int.TryParse(trimmed, out int pid)) - { - try - { - using var p = Process.GetProcessById(pid); - // Double-check it's not a critical process - string name = p.ProcessName.ToLowerInvariant(); - if (name == "unity" || name == "code" || name == "devenv" || name == "rider64") - { - continue; // Skip IDE processes - } - p.Kill(); - p.WaitForExit(2000); - } - catch { } - } - } - - // Give processes time to fully exit - System.Threading.Thread.Sleep(500); - } - catch { } - } - - /// - /// Attempts to delete a directory with retry logic to handle Windows file locking issues. - /// - /// Why retries are necessary on Windows: - /// - Even after killing processes, Windows may take time to release file handles - /// - Antivirus, Windows Defender, or indexing services may temporarily lock files - /// - File Explorer previews can hold locks on certain file types - /// - Readonly attributes on files (common in .venv) block deletion - /// - /// This method handles these cases by: - /// - Retrying deletion after a delay to allow handle release - /// - Clearing readonly attributes that block deletion - /// - Distinguishing between temporary locks (retry) and permanent failures - /// - private static bool DeleteDirectoryWithRetry(string path, int maxRetries = 3, int delayMs = 500) - { - for (int i = 0; i < maxRetries; i++) - { - try - { - if (!Directory.Exists(path)) return true; - - Directory.Delete(path, recursive: true); - return true; - } - catch (UnauthorizedAccessException) - { - if (i < maxRetries - 1) - { - // Wait for file handles to be released - System.Threading.Thread.Sleep(delayMs); - - // Try to clear readonly attributes - try - { - foreach (var file in Directory.GetFiles(path, "*", SearchOption.AllDirectories)) - { - try - { - var attrs = File.GetAttributes(file); - if ((attrs & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) - { - File.SetAttributes(file, attrs & ~FileAttributes.ReadOnly); - } - } - catch { } - } - } - catch { } - } - } - catch (IOException) - { - if (i < maxRetries - 1) - { - // File in use, wait and retry - System.Threading.Thread.Sleep(delayMs); - } - } - catch - { - return false; - } - } - return false; - } - - private static string ReadVersionFile(string path) - { - try - { - if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; - string v = File.ReadAllText(path).Trim(); - return string.IsNullOrEmpty(v) ? null : v; - } - catch { return null; } - } - - private static int CompareSemverSafe(string a, string b) - { - try - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; - var ap = a.Split('.'); - var bp = b.Split('.'); - for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) - { - int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; - int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; - if (ai != bi) return ai.CompareTo(bi); - } - return 0; - } - catch { return 0; } - } - - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. - /// - private static bool TryGetEmbeddedServerSource(out string srcPath) - { - return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); - } - - private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; - - private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) - { - Directory.CreateDirectory(destinationDir); - - foreach (string filePath in Directory.GetFiles(sourceDir)) - { - string fileName = Path.GetFileName(filePath); - string destFile = Path.Combine(destinationDir, fileName); - File.Copy(filePath, destFile, overwrite: true); - } - - foreach (string dirPath in Directory.GetDirectories(sourceDir)) - { - string dirName = Path.GetFileName(dirPath); - foreach (var skip in _skipDirs) - { - if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) - goto NextDir; - } - try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } - string destSubDir = Path.Combine(destinationDir, dirName); - CopyDirectoryRecursive(dirPath, destSubDir); - NextDir:; - } - } - - public static bool RebuildMcpServer() - { - try - { - // Find embedded source - if (!TryGetEmbeddedServerSource(out string embeddedSrc)) - { - McpLog.Error("RebuildMcpServer: Could not find embedded server source."); - return false; - } - - string saveLocation = GetSaveLocation(); - string destRoot = Path.Combine(saveLocation, ServerFolder); - string destSrc = Path.Combine(destRoot, "src"); - - // Kill any running uv processes for this server - TryKillUvForPath(destSrc); - - // Delete the entire installed server directory - if (Directory.Exists(destRoot)) - { - if (!DeleteDirectoryWithRetry(destRoot, maxRetries: 5, delayMs: 1000)) - { - McpLog.Error($"Failed to delete existing server at {destRoot}. Please close any applications using the Python virtual environment and try again."); - return false; - } - McpLog.Info($"Deleted existing server at {destRoot}"); - } - - // Re-copy from embedded source - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; - Directory.CreateDirectory(destRoot); - CopyDirectoryRecursive(embeddedRoot, destRoot); - - // Write version file - string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; - try - { - File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to write version file: {ex.Message}"); - } - - McpLog.Info($"Server rebuilt successfully at {destRoot} (version {embeddedVer})"); - - // Clear any previous installation error - - PackageLifecycleManager.ClearLastInstallError(); - - - return true; - } - catch (Exception ex) - { - McpLog.Error($"RebuildMcpServer failed: {ex.Message}"); - return false; - } - } - - internal static string FindUvPath() - { - // Allow user override via EditorPrefs - try - { - string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - if (ValidateUvBinary(overridePath)) return overridePath; - } - } - catch { } - - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - - // Platform-specific candidate lists - string[] candidates; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; - string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - - // Fast path: resolve from PATH first - try - { - var wherePsi = new ProcessStartInfo - { - FileName = "where", - Arguments = "uv.exe", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var wp = Process.Start(wherePsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(1500); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - string path = line.Trim(); - if (File.Exists(path) && ValidateUvBinary(path)) return path; - } - } - } - catch { } - - // Windows Store (PythonSoftwareFoundation) install location probe - // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe - try - { - string pkgsRoot = Path.Combine(localAppData, "Packages"); - if (Directory.Exists(pkgsRoot)) - { - var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly) - .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase); - foreach (var pkg in pythonPkgs) - { - string localCache = Path.Combine(pkg, "LocalCache", "local-packages"); - if (!Directory.Exists(localCache)) continue; - var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly) - .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase); - foreach (var pyRoot in pyRoots) - { - string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe"); - if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe; - } - } - } - } - catch { } - - candidates = new[] - { - // Preferred: WinGet Links shims (stable entrypoints) - // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) - Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), - Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), - - // Common per-user installs - Path.Combine(localAppData, @"Programs\Python\Python314\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), - Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python314\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), - Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), - - // Program Files style installs (if a native installer was used) - Path.Combine(programFiles, @"uv\uv.exe"), - - // Try simple name resolution later via PATH - "uv.exe", - "uv" - }; - } - else - { - candidates = new[] - { - "/opt/homebrew/bin/uv", - "/usr/local/bin/uv", - "/usr/bin/uv", - "/opt/local/bin/uv", - Path.Combine(home, ".local", "bin", "uv"), - "/opt/homebrew/opt/uv/bin/uv", - // Framework Python installs - "/Library/Frameworks/Python.framework/Versions/3.14/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/uv", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/uv", - // Fallback to PATH resolution by name - "uv" - }; - } - - foreach (string c in candidates) - { - try - { - if (File.Exists(c) && ValidateUvBinary(c)) return c; - } - catch { /* ignore */ } - } - - // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) - try - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var whichPsi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "uv", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - try - { - // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string prepend = string.Join(":", new[] - { - Path.Combine(homeDir, ".local", "bin"), - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/bin" - }); - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); - } - catch { } - using var wp = Process.Start(whichPsi); - string output = wp.StandardOutput.ReadToEnd().Trim(); - wp.WaitForExit(3000); - if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - if (ValidateUvBinary(output)) return output; - } - } - } - catch { } - - // Manual PATH scan - try - { - string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - string[] parts = pathEnv.Split(Path.PathSeparator); - foreach (string part in parts) - { - try - { - // Check both uv and uv.exe - string candidateUv = Path.Combine(part, "uv"); - string candidateUvExe = Path.Combine(part, "uv.exe"); - if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; - if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; - } - catch { } - } - } - catch { } - - return null; - } - - private static bool ValidateUvBinary(string uvPath) - { - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = Process.Start(psi); - if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode == 0) - { - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - } - catch { } - return false; - } - - /// - /// Download and install server from GitHub release (Asset Store workflow) - /// - public static bool DownloadAndInstallServer() - { - string packageVersion = AssetPathUtility.GetPackageVersion(); - if (packageVersion == "unknown") - { - McpLog.Error("Cannot determine package version for download."); - return false; - } - - string downloadUrl = $"https://github.com/CoplayDev/unity-mcp/releases/download/v{packageVersion}/mcp-for-unity-server-v{packageVersion}.zip"; - string tempZip = Path.Combine(Path.GetTempPath(), $"mcp-server-v{packageVersion}.zip"); - string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); - - try - { - EditorUtility.DisplayProgressBar("MCP for Unity", "Downloading server...", 0.3f); - - // Download - using (var client = new WebClient()) - { - client.DownloadFile(downloadUrl, tempZip); - } - - EditorUtility.DisplayProgressBar("MCP for Unity", "Extracting server...", 0.7f); - - // Kill any running UV processes - string destSrc = Path.Combine(destRoot, "src"); - TryKillUvForPath(destSrc); - - // Delete old installation - if (Directory.Exists(destRoot)) - { - if (!DeleteDirectoryWithRetry(destRoot, maxRetries: 5, delayMs: 1000)) - { - McpLog.Warn($"Could not fully delete old server (files may be in use)"); - } - } - - // Extract to temp location first - string tempExtractDir = Path.Combine(Path.GetTempPath(), $"mcp-server-extract-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempExtractDir); - - try - { - ZipFile.ExtractToDirectory(tempZip, tempExtractDir); - - // The ZIP contains UnityMcpServer~ folder, find it and move its contents - string extractedServerFolder = Path.Combine(tempExtractDir, "UnityMcpServer~"); - Directory.CreateDirectory(destRoot); - CopyDirectoryRecursive(extractedServerFolder, destRoot); - } - finally - { - // Cleanup temp extraction directory - try - { - if (Directory.Exists(tempExtractDir)) - { - Directory.Delete(tempExtractDir, recursive: true); - } - } - catch (Exception ex) - { - McpLog.Warn($"Could not fully delete temp extraction directory: {ex.Message}"); - } - } - - EditorUtility.ClearProgressBar(); - McpLog.Info($"Server v{packageVersion} downloaded and installed successfully!"); - return true; - } - catch (Exception ex) - { - EditorUtility.ClearProgressBar(); - McpLog.Error($"Failed to download server: {ex.Message}"); - EditorUtility.DisplayDialog( - "Download Failed", - $"Could not download server from GitHub.\n\n{ex.Message}\n\nPlease check your internet connection or try again later.", - "OK" - ); - return false; - } - finally - { - try - { - if (File.Exists(tempZip)) File.Delete(tempZip); - } - catch (Exception ex) - { - McpLog.Warn($"Could not delete temp zip file: {ex.Message}"); - } - } - } - - /// - /// Check if the package has an embedded server (Git install vs Asset Store) - /// - public static bool HasEmbeddedServer() - { - return TryGetEmbeddedServerSource(out _); - } - - /// - /// Get the installed server version from the local installation - /// - public static string GetInstalledServerVersion() - { - try - { - string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); - string versionPath = Path.Combine(destRoot, "src", VersionFileName); - if (File.Exists(versionPath)) - { - return File.ReadAllText(versionPath)?.Trim() ?? string.Empty; - } - } - catch (Exception ex) - { - McpLog.Warn($"Could not read version file: {ex.Message}"); - } - return string.Empty; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta b/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta deleted file mode 100644 index dfd9023b7..000000000 --- a/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5862c6a6d0a914f4d83224f8d039cf7b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs deleted file mode 100644 index 0e4629458..000000000 --- a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.IO; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Helpers -{ - public static class ServerPathResolver - { - /// - /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package - /// or common development locations. Returns true if found and sets srcPath to the folder - /// containing server.py. - /// - public static bool TryFindEmbeddedServerSource(out string srcPath) - { - // 1) Repo development layouts commonly used alongside this package - try - { - string projectRoot = Path.GetDirectoryName(Application.dataPath); - string[] devCandidates = - { - Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), - }; - foreach (string candidate in devCandidates) - { - string full = Path.GetFullPath(candidate); - if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) - { - srcPath = full; - return true; - } - } - } - catch { /* ignore */ } - - // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. - try - { -#if UNITY_2021_2_OR_NEWER - // Primary: the package that owns this assembly - var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); - if (owner != null) - { - if (TryResolveWithinPackage(owner, out srcPath)) - { - return true; - } - } - - // Secondary: scan all registered packages locally - foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) - { - if (TryResolveWithinPackage(p, out srcPath)) - { - return true; - } - } -#else - // Older Unity versions: use Package Manager Client.List as a fallback - var list = UnityEditor.PackageManager.Client.List(); - while (!list.IsCompleted) { } - if (list.Status == UnityEditor.PackageManager.StatusCode.Success) - { - foreach (var pkg in list.Result) - { - if (TryResolveWithinPackage(pkg, out srcPath)) - { - return true; - } - } - } -#endif - } - catch { /* ignore */ } - - // 3) Fallback to previous common install locations - try - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), - Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), - }; - foreach (string candidate in candidates) - { - if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) - { - srcPath = candidate; - return true; - } - } - } - catch { /* ignore */ } - - srcPath = null; - return false; - } - - private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) - { - const string CurrentId = "com.coplaydev.unity-mcp"; - - srcPath = null; - if (p == null || p.name != CurrentId) - { - return false; - } - - string packagePath = p.resolvedPath; - - // Preferred tilde folder (embedded but excluded from import) - string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); - if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) - { - srcPath = embeddedTilde; - return true; - } - - // Legacy non-tilde folder - string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); - if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) - { - srcPath = embedded; - return true; - } - - // Dev-linked sibling of the package folder - string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); - if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) - { - srcPath = sibling; - return true; - } - - return false; - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta deleted file mode 100644 index d02df608b..000000000 --- a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef index 88448922b..50e0897de 100644 --- a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -11,7 +11,9 @@ "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, - "precompiledReferences": [], + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/MCPForUnityBridge.cs index 23537b817..48ded904d 100644 --- a/MCPForUnity/Editor/MCPForUnityBridge.cs +++ b/MCPForUnity/Editor/MCPForUnityBridge.cs @@ -408,7 +408,7 @@ public static void Start() isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); - string serverVer = ReadInstalledServerVersionSafe(); + string serverVer = AssetPathUtility.GetPackageVersion(); McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); // Start background listener with cooperative cancellation cts = new CancellationTokenSource(); @@ -1259,22 +1259,6 @@ private static void WriteHeartbeat(bool reloading, string reason = null) } } - private static string ReadInstalledServerVersionSafe() - { - try - { - string serverSrc = ServerInstaller.GetServerPath(); - string verFile = Path.Combine(serverSrc, "server_version.txt"); - if (File.Exists(verFile)) - { - string v = File.ReadAllText(verFile)?.Trim(); - if (!string.IsNullOrEmpty(v)) return v; - } - } - catch { } - return "unknown"; - } - private static string ComputeProjectHash(string input) { try diff --git a/MCPForUnity/Editor/MCPForUnityMenu.cs b/MCPForUnity/Editor/MCPForUnityMenu.cs index 714e48535..db82a8c98 100644 --- a/MCPForUnity/Editor/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MCPForUnityMenu.cs @@ -1,7 +1,9 @@ using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Setup; using MCPForUnity.Editor.Windows; using UnityEditor; +using UnityEngine; namespace MCPForUnity.Editor { @@ -31,45 +33,42 @@ public static void OpenMCPWindow() { MCPForUnityEditorWindow.ShowWindow(); } - + // ======================================== - // Tool Sync Menu Items + // Maintenance Menu Items // ======================================== - - /// - /// Reimport all Python files in the project - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)] - public static void ReimportPythonFiles() - { - PythonToolSyncProcessor.ReimportPythonFiles(); - } - - /// - /// Manually sync Python tools to the MCP server - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)] - public static void SyncPythonTools() - { - PythonToolSyncProcessor.ManualSync(); - } - - /// - /// Toggle auto-sync for Python tools - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)] - public static void ToggleAutoSync() - { - PythonToolSyncProcessor.ToggleAutoSync(); - } - + /// - /// Validate menu item (shows checkmark when auto-sync is enabled) + /// Clear the local uvx cache for the MCP server /// - [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)] - public static bool ToggleAutoSyncValidate() + [MenuItem("Window/MCP For Unity/Maintenance/Clear UVX Cache", priority = 200)] + public static void ClearUvxCache() { - return PythonToolSyncProcessor.ToggleAutoSyncValidate(); + if (EditorUtility.DisplayDialog( + "Clear UVX Cache", + "This will clear the local uvx cache for the MCP server package. " + + "The server will be re-downloaded on next launch.\n\n" + + "Continue?", + "Clear Cache", + "Cancel")) + { + bool success = MCPServiceLocator.Cache.ClearUvxCache(); + + if (success) + { + EditorUtility.DisplayDialog( + "Success", + "UVX cache cleared successfully. The server will be re-downloaded on next launch.", + "OK"); + } + else + { + EditorUtility.DisplayDialog( + "Error", + "Failed to clear UVX cache. Check the console for details.", + "OK"); + } + } } } } diff --git a/TestProjects/UnityMCPTests/Assets/Temp.meta b/MCPForUnity/Editor/MenuItems.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp.meta rename to MCPForUnity/Editor/MenuItems.meta index 30148f258..ad5fb5e60 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp.meta +++ b/MCPForUnity/Editor/MenuItems.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 02a6714b521ec47868512a8db433975c +guid: 9e7f37616736f4d3cbd8bdbc626f5ab9 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs new file mode 100644 index 000000000..1cebdbdda --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs @@ -0,0 +1,62 @@ +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Menu items for custom tool management + /// + public static class CustomToolsMenuItems + { + [MenuItem("Window/MCP For Unity/Custom Tools/Register All Tools")] + public static void RegisterAllTools() + { + CustomToolRegistrationProcessor.RegisterAllToolsSync(); + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Force Re-registration")] + public static void ForceReregistration() + { + CustomToolRegistrationProcessor.ForceReregistration(); + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Show Tool Info")] + public static void ShowToolInfo() + { + string info = CustomToolRegistrationProcessor.GetToolInfo(); + Debug.Log($"MCP Custom Tools Info:\n{info}"); + + // Also show in dialog + EditorUtility.DisplayDialog("MCP Custom Tools", info, "OK"); + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Enable Registration")] + public static void EnableRegistration() + { + CustomToolRegistrationProcessor.IsRegistrationEnabled = true; + EditorPrefs.SetBool("MCPForUnity.CustomToolRegistrationEnabled", true); + Debug.Log("MCP Custom Tool Registration enabled"); + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Disable Registration")] + public static void DisableRegistration() + { + CustomToolRegistrationProcessor.IsRegistrationEnabled = false; + EditorPrefs.SetBool("MCPForUnity.CustomToolRegistrationEnabled", false); + Debug.Log("MCP Custom Tool Registration disabled"); + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Enable Registration", true)] + public static bool EnableRegistrationValidate() + { + return !CustomToolRegistrationProcessor.IsRegistrationEnabled; + } + + [MenuItem("Window/MCP For Unity/Custom Tools/Disable Registration", true)] + public static bool DisableRegistrationValidate() + { + return CustomToolRegistrationProcessor.IsRegistrationEnabled; + } + } +} diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs.meta similarity index 83% rename from MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta rename to MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs.meta index bfe30d9f5..f0869e318 100644 --- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta +++ b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1ad9865b38bcc4efe85d4970c6d3a997 +guid: d14bd1fefa6944e97a66138e14887cde MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs index a462e68b3..744c72dc0 100644 --- a/MCPForUnity/Editor/Services/BridgeControlService.cs +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -3,34 +3,114 @@ using System.Net; using System.Net.Sockets; using System.Text; +using UnityEditor; +using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Services { /// /// Implementation of bridge control service + /// Supports both HTTP and TCP socket (stdio) transports /// public class BridgeControlService : IBridgeControlService { - public bool IsRunning => MCPForUnityBridge.IsRunning; + private HttpMcpClient _httpClient; + private bool _useHttpTransport; + public bool IsRunning + { + get + { + if (_useHttpTransport) + { + return _httpClient != null && _httpClient.IsConnected; + } + return MCPForUnityBridge.IsRunning; + } + } public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode(); public void Start() { - // If server is installed, use auto-connect mode - // Otherwise use standard mode - string serverPath = MCPServiceLocator.Paths.GetMcpServerPath(); - if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py"))) + // Check transport mode from EditorPrefs + _useHttpTransport = EditorPrefs.GetBool("MCPForUnity.UseHttpTransport", true); + + if (_useHttpTransport) { - MCPForUnityBridge.StartAutoConnect(); + StartHttpTransport(); } else { - MCPForUnityBridge.Start(); + StartStdioTransport(); } } + private void StartHttpTransport() + { + try + { + string httpUrl = EditorPrefs.GetString("MCPForUnity.HttpUrl", "http://localhost:8080"); + McpLog.Info($"Starting HTTP transport to {httpUrl}"); + + // Dispose existing client if any + _httpClient?.Dispose(); + + // Create new HTTP client + _httpClient = new HttpMcpClient(httpUrl); + + // Test connection asynchronously + System.Threading.Tasks.Task.Run(async () => + { + bool connected = await _httpClient.TestConnectionAsync(); + if (connected) + { + McpLog.Info("HTTP transport connected successfully"); + } + else + { + McpLog.Warn("HTTP transport connection test failed - server may not be running"); + } + }); + } + catch (Exception ex) + { + McpLog.Error($"Failed to start HTTP transport: {ex.Message}"); + } + } + + private void StartStdioTransport() + { + McpLog.Info("Starting stdio (TCP socket) transport"); + MCPForUnityBridge.StartAutoConnect(); + } + public void Stop() + { + if (_useHttpTransport) + { + StopHttpTransport(); + } + else + { + StopStdioTransport(); + } + } + + private void StopHttpTransport() + { + try + { + _httpClient?.Dispose(); + _httpClient = null; + McpLog.Info("HTTP transport stopped"); + } + catch (Exception ex) + { + McpLog.Error($"Error stopping HTTP transport: {ex.Message}"); + } + } + + private void StopStdioTransport() { MCPForUnityBridge.Stop(); } @@ -106,7 +186,7 @@ private static void WriteFrame(NetworkStream stream, byte[] payload, int timeout { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); - + byte[] header = new byte[8]; ulong len = (ulong)payload.LongLength; header[0] = (byte)(len >> 56); diff --git a/MCPForUnity/Editor/Services/CacheManagementService.cs b/MCPForUnity/Editor/Services/CacheManagementService.cs new file mode 100644 index 000000000..92d989bdf --- /dev/null +++ b/MCPForUnity/Editor/Services/CacheManagementService.cs @@ -0,0 +1,54 @@ +using System; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing cache operations + /// + public class CacheManagementService : ICacheManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + public bool ClearUvxCache() + { + try + { + var pathService = MCPServiceLocator.Paths; + string uvxPath = pathService.GetUvxPath(); + + if (string.IsNullOrEmpty(uvxPath)) + { + McpLog.Error("UVX not found. Please install UV/UVX first."); + return false; + } + + // Get the package name + string packageName = "mcp-for-unity"; + + // Run uvx cache clean command + string args = $"cache clean {packageName}"; + + if (ExecPath.TryRun(uvxPath, args, Application.dataPath, out var stdout, out var stderr, 30000)) + { + McpLog.Info($"UVX cache cleared successfully: {stdout}"); + return true; + } + else + { + McpLog.Warn($"Failed to clear uvx cache: {stderr}"); + return false; + } + } + catch (Exception ex) + { + McpLog.Error($"Error clearing uvx cache: {ex.Message}"); + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta b/MCPForUnity/Editor/Services/CacheManagementService.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta rename to MCPForUnity/Editor/Services/CacheManagementService.cs.meta index d3a3719ca..ff1d90325 100644 --- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta +++ b/MCPForUnity/Editor/Services/CacheManagementService.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4bdcf382960c842aab0a08c90411ab43 +guid: 7cee3887f299d4a9aa9f2f0ffea593f0 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 8a9c4cafd..b2c2f0e40 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -20,38 +20,24 @@ public class ClientConfigurationService : IClientConfigurationService public void ConfigureClient(McpClient client) { - try - { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); - - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - throw new InvalidOperationException("Server not found. Please use manual configuration or set server path in Advanced Settings."); - } + var pathService = MCPServiceLocator.Paths; + string uvxPath = pathService.GetUvxPath(); + + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); - string result = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + string result = client.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) + : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); - if (result == "Configured successfully") - { - client.SetStatus(McpStatus.Configured); - Debug.Log($"MCP-FOR-UNITY: {client.name} configured successfully"); - } - else - { - Debug.LogWarning($"Configuration completed with message: {result}"); - } - - CheckClientStatus(client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); } - catch (Exception ex) + else { - Debug.LogError($"Failed to configure {client.name}: {ex.Message}"); - throw; + client.SetStatus(McpStatus.NotConfigured); + throw new InvalidOperationException($"Configuration failed: {result}"); } } @@ -89,11 +75,11 @@ public ClientConfigurationSummary ConfigureAllDetectedClients() } else { - // Other clients require UV - if (!pathService.IsUvDetected()) + // Other clients require UVX + if (!pathService.IsUvxDetected()) { summary.SkippedCount++; - summary.Messages.Add($"➜ {client.name}: UV not found"); + summary.Messages.Add($"➜ {client.name}: UVX not found"); continue; } @@ -134,8 +120,6 @@ public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) } string configJson = File.ReadAllText(configPath); - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - // Check configuration based on client type string[] args = null; bool configExists = false; @@ -176,9 +160,10 @@ public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) if (configExists) { - string configuredDir = McpConfigurationHelper.ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && - McpConfigurationHelper.PathsEqual(configuredDir, pythonDir); + string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + bool matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); if (matches) { @@ -190,15 +175,15 @@ public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) try { string rewriteResult = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) + : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); if (rewriteResult == "Configured successfully") { bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); if (debugLogsEnabled) { - McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false); + McpLog.Info($"Auto-updated MCP config for '{client.name}' to new version: {expectedUvxUrl}", always: false); } client.SetStatus(McpStatus.Configured); } @@ -233,21 +218,16 @@ public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) public void RegisterClaudeCode() { var pathService = MCPServiceLocator.Paths; - string pythonDir = pathService.GetMcpServerPath(); - - if (string.IsNullOrEmpty(pythonDir)) - { - throw new InvalidOperationException("Cannot register: Python directory not found"); - } - string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } - string uvPath = pathService.GetUvPath() ?? "uv"; - string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + // Use structured uvx command parts for proper quoting + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + string args = $"mcp add UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; @@ -278,7 +258,7 @@ public void RegisterClaudeCode() string combined = ($"{stdout}\n{stderr}") ?? string.Empty; if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) { - Debug.Log("MCP-FOR-UNITY: MCP for Unity already registered with Claude Code."); + McpLog.Info("MCP for Unity already registered with Claude Code."); } else { @@ -287,7 +267,7 @@ public void RegisterClaudeCode() return; } - Debug.Log("MCP-FOR-UNITY: Successfully registered with Claude Code."); + McpLog.Info("Successfully registered with Claude Code."); // Update status var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); @@ -301,7 +281,7 @@ public void UnregisterClaudeCode() { var pathService = MCPServiceLocator.Paths; string claudePath = pathService.GetClaudeCliPath(); - + if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); @@ -323,14 +303,14 @@ public void UnregisterClaudeCode() { claudeClient.SetStatus(McpStatus.NotConfigured); } - Debug.Log("MCP-FOR-UNITY: No MCP for Unity server found - already unregistered."); + McpLog.Info("No MCP for Unity server found - already unregistered."); return; } // Remove the server if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { - Debug.Log("MCP-FOR-UNITY: MCP server successfully unregistered from Claude Code."); + McpLog.Info("MCP server successfully unregistered from Claude Code."); } else { @@ -366,19 +346,19 @@ public string GetConfigPath(McpClient client) public string GenerateConfigJson(McpClient client) { - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); - string uvPath = MCPServiceLocator.Paths.GetUvPath(); + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); // Claude Code uses CLI commands, not JSON config if (client.mcpType == McpTypes.ClaudeCode) { - if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) + if (string.IsNullOrEmpty(uvxPath)) { return "# Error: Configuration not available - check paths in Advanced Settings"; } // Show the actual command that RegisterClaudeCode() uses - string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); + string registerCommand = $"claude mcp add UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity"; return "# Register the MCP server with Claude Code:\n" + $"{registerCommand}\n\n" + @@ -388,19 +368,18 @@ public string GenerateConfigJson(McpClient client) "claude mcp list # Only works when claude is run in the project's directory"; } - if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) + if (string.IsNullOrEmpty(uvxPath)) return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }"; try { if (client.mcpType == McpTypes.Codex) { - return CodexConfigHelper.BuildCodexServerBlock(uvPath, - McpConfigurationHelper.ResolveServerDirectory(pythonDir, null)); + return CodexConfigHelper.BuildCodexServerBlock(uvxPath); } else { - return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client); + return ConfigJsonBuilder.BuildManualConfigJson(uvxPath, client); } } catch (Exception ex) diff --git a/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs new file mode 100644 index 000000000..6db12c076 --- /dev/null +++ b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + public class CustomToolRegistrationService : ICustomToolRegistrationService + { + private readonly IToolDiscoveryService _discoveryService; + + public CustomToolRegistrationService(IToolDiscoveryService discoveryService = null) + { + _discoveryService = discoveryService ?? new ToolDiscoveryService(); + } + + public async Task RegisterAllToolsAsync() + { + try + { + string projectId = GetProjectId(); + + // Discover tools via reflection + var tools = _discoveryService.DiscoverAllTools(); + + if (tools.Count == 0) + { + McpLog.Info("No tools found, skipping registration"); + return true; + } + + // Convert to registration format + var toolDefinitions = tools.Select(t => new + { + name = t.Name, + description = t.Description, + structured_output = t.StructuredOutput, + parameters = t.Parameters.Select(p => new + { + name = p.Name, + description = p.Description, + type = p.Type, + required = p.Required, + default_value = p.DefaultValue + }).ToList() + }).ToList(); + + // Call the FastMCP tool registration endpoint + var result = await CallMcpToolAsync("register_custom_tools", new + { + project_id = projectId, + tools = toolDefinitions + }); + + if (result != null && result.success == true) + { + McpLog.Info($"Successfully registered {result.registered?.Count ?? 0} tools with MCP server"); + return true; + } + else + { + McpLog.Error($"Failed to register tools: {result?.error ?? "Unknown error"}"); + return false; + } + } + catch (Exception ex) + { + McpLog.Error($"Error registering tools: {ex.Message}"); + return false; + } + } + + public bool RegisterAllTools() + { + return RegisterAllToolsAsync().GetAwaiter().GetResult(); + } + + private string GetProjectId() + { + // Use project name + path hash as unique identifier + string projectName = Application.productName; + string projectPath = Application.dataPath; + string combined = $"{projectName}:{projectPath}"; + + using (var sha256 = System.Security.Cryptography.SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined)); + return BitConverter.ToString(hash).Replace("-", "").Substring(0, 16); + } + } + + private async Task CallMcpToolAsync(string toolName, object parameters) + { + try + { + // For HTTP transport mode, we can make direct HTTP calls to FastMCP + // For stdio transport mode, we need to use the bridge + bool isHttpTransport = EditorPrefs.GetBool("MCPForUnity.UseHttpTransport", false); + + if (isHttpTransport) + { + // Make direct HTTP call to FastMCP HTTP endpoint + return await CallFastMcpHttpAsync(toolName, parameters); + } + else + { + // Use the existing MCP bridge for stdio transport + return await CallMcpBridgeAsync(toolName, parameters); + } + } + catch (Exception ex) + { + McpLog.Error($"Error calling MCP tool {toolName}: {ex.Message}"); + return new { success = false, error = ex.Message }; + } + } + + private async Task CallFastMcpHttpAsync(string toolName, object parameters) + { + try + { + string httpUrl = EditorPrefs.GetString("MCPForUnity.HttpUrl", "http://localhost:8080"); + // Ensure URL doesn't have trailing slash before adding path + httpUrl = httpUrl.TrimEnd('/'); + string url = $"{httpUrl}/tools/call"; + + using (var client = new System.Net.Http.HttpClient()) + { + client.Timeout = TimeSpan.FromSeconds(30); + + var requestBody = new + { + name = toolName, + arguments = parameters + }; + + string jsonContent = Newtonsoft.Json.JsonConvert.SerializeObject(requestBody); + var content = new System.Net.Http.StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(url, content); + string responseText = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return Newtonsoft.Json.JsonConvert.DeserializeObject(responseText); + } + else + { + McpLog.Error($"HTTP call failed: {response.StatusCode} - {responseText}"); + return new { success = false, error = $"HTTP {response.StatusCode}: {responseText}" }; + } + } + } + catch (Exception ex) + { + McpLog.Error($"HTTP call exception: {ex.Message}"); + return new { success = false, error = ex.Message }; + } + } + + private async Task CallMcpBridgeAsync(string toolName, object parameters) + { + // Use the existing MCP bridge for stdio transport + // This would send the tool call through the Unity socket bridge + try + { + // For stdio mode, we need to send through the bridge + // The bridge service would handle the MCP protocol communication + var bridgeService = MCPServiceLocator.Bridge; + + if (!bridgeService.IsRunning) + { + return new { success = false, error = "Bridge is not running" }; + } + + // Serialize the tool call + string jsonParams = Newtonsoft.Json.JsonConvert.SerializeObject(parameters); + + // Send via bridge (this is a simplified version - actual implementation may vary) + // For now, return a placeholder indicating stdio mode needs the server running + await Task.Delay(100); + return new { success = true, registered = new List(), message = "Stdio mode: Tools will be registered when server starts" }; + } + catch (Exception ex) + { + McpLog.Error($"Bridge call exception: {ex.Message}"); + return new { success = false, error = ex.Message }; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta rename to MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta index 38f19973a..eda96cfad 100644 --- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta +++ b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2c76f0c7ff138ba4a952481e04bc3974 +guid: 6bab1a9bfedfc496b873c20f450f70bc MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Services/HttpMcpClient.cs b/MCPForUnity/Editor/Services/HttpMcpClient.cs new file mode 100644 index 000000000..d05f40d37 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpMcpClient.cs @@ -0,0 +1,139 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Simple HTTP client for connecting to FastMCP server + /// Uses REST API for tool execution + /// + public class HttpMcpClient : IDisposable + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private bool _isConnected; + + public bool IsConnected => _isConnected; + public string BaseUrl => _baseUrl; + + public HttpMcpClient(string baseUrl) + { + if (string.IsNullOrEmpty(baseUrl)) + { + throw new ArgumentException("Base URL cannot be null or empty", nameof(baseUrl)); + } + + _baseUrl = baseUrl.TrimEnd('/'); + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + _isConnected = false; + } + + /// + /// Test connection to the MCP server + /// + public async Task TestConnectionAsync() + { + try + { + // Try to ping the server + var response = await _httpClient.GetAsync($"{_baseUrl}/health"); + _isConnected = response.IsSuccessStatusCode; + return _isConnected; + } + catch (Exception ex) + { + McpLog.Error($"HTTP connection test failed: {ex.Message}"); + _isConnected = false; + return false; + } + } + + /// + /// Execute a tool on the MCP server via HTTP + /// + public async Task ExecuteToolAsync(string toolName, JObject parameters) + { + try + { + var requestBody = new + { + name = toolName, + arguments = parameters + }; + + string jsonContent = JsonConvert.SerializeObject(requestBody); + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"{_baseUrl}/tools/call", content); + string responseText = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + return JObject.Parse(responseText); + } + else + { + McpLog.Error($"HTTP tool execution failed: {response.StatusCode} - {responseText}"); + return new JObject + { + ["success"] = false, + ["error"] = $"HTTP {response.StatusCode}: {responseText}" + }; + } + } + catch (Exception ex) + { + McpLog.Error($"HTTP tool execution exception: {ex.Message}"); + return new JObject + { + ["success"] = false, + ["error"] = ex.Message + }; + } + } + + /// + /// Send a command to Unity via the MCP server + /// This is the main method for Unity plugin communication + /// + public async Task SendCommandAsync(string commandJson) + { + try + { + // Parse the command to extract tool name and parameters + var command = JObject.Parse(commandJson); + string toolName = command["tool"]?.ToString(); + var parameters = command["parameters"] as JObject ?? new JObject(); + + if (string.IsNullOrEmpty(toolName)) + { + return JsonConvert.SerializeObject(new { success = false, error = "Tool name is required" }); + } + + // Execute the tool + var result = await ExecuteToolAsync(toolName, parameters); + return result.ToString(); + } + catch (Exception ex) + { + McpLog.Error($"HTTP send command exception: {ex.Message}"); + return JsonConvert.SerializeObject(new { success = false, error = ex.Message }); + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + _isConnected = false; + } + } +} diff --git a/MCPForUnity/Editor/Services/HttpMcpClient.cs.meta b/MCPForUnity/Editor/Services/HttpMcpClient.cs.meta new file mode 100644 index 000000000..49a949e85 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpMcpClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a6f33fa3b16e41c7a2535354f1b8fdc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ICacheManagementService.cs b/MCPForUnity/Editor/Services/ICacheManagementService.cs new file mode 100644 index 000000000..d8ac0fd07 --- /dev/null +++ b/MCPForUnity/Editor/Services/ICacheManagementService.cs @@ -0,0 +1,14 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Interface for cache management operations + /// + public interface ICacheManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + bool ClearUvxCache(); + } +} diff --git a/MCPForUnity/Editor/Services/ICacheManagementService.cs.meta b/MCPForUnity/Editor/Services/ICacheManagementService.cs.meta new file mode 100644 index 000000000..7f5307c90 --- /dev/null +++ b/MCPForUnity/Editor/Services/ICacheManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 056c7877da3584a1395a97fe26f13382 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs new file mode 100644 index 000000000..7265d955e --- /dev/null +++ b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for registering custom tools with MCP server via HTTP + /// + public interface ICustomToolRegistrationService + { + /// + /// Registers all discovered tools with the MCP server + /// + Task RegisterAllToolsAsync(); + + /// + /// Registers all discovered tools with the MCP server (synchronous) + /// + bool RegisterAllTools(); + } +} diff --git a/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs.meta b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs.meta new file mode 100644 index 000000000..a205a229c --- /dev/null +++ b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 85ff4cd02f04f4bd6bb4d7ccb80290be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs index 9968af656..c69e1f3f3 100644 --- a/MCPForUnity/Editor/Services/IPathResolverService.cs +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -4,18 +4,12 @@ namespace MCPForUnity.Editor.Services /// Service for resolving paths to required tools and supporting user overrides /// public interface IPathResolverService - { + { /// - /// Gets the MCP server path (respects override if set) + /// Gets the UVX package manager path (respects override if set) /// - /// Path to the MCP server directory containing server.py, or null if not found - string GetMcpServerPath(); - - /// - /// Gets the UV package manager path (respects override if set) - /// - /// Path to the uv executable, or null if not found - string GetUvPath(); + /// Path to the uvx executable, or null if not found + string GetUvxPath(bool verifyPath = true); /// /// Gets the Claude CLI path (respects override if set) @@ -30,10 +24,10 @@ public interface IPathResolverService bool IsPythonDetected(); /// - /// Checks if UV is detected on the system + /// Checks if UVX is detected on the system /// - /// True if UV is found - bool IsUvDetected(); + /// True if UVX is found + bool IsUvxDetected(); /// /// Checks if Claude CLI is detected on the system @@ -42,16 +36,10 @@ public interface IPathResolverService bool IsClaudeCliDetected(); /// - /// Sets an override for the MCP server path - /// - /// Path to override with - void SetMcpServerOverride(string path); - - /// - /// Sets an override for the UV path + /// Sets an override for the UVX path /// /// Path to override with - void SetUvPathOverride(string path); + void SetUvxPathOverride(string path); /// /// Sets an override for the Claude CLI path @@ -60,14 +48,9 @@ public interface IPathResolverService void SetClaudeCliPathOverride(string path); /// - /// Clears the MCP server path override + /// Clears the UVX path override /// - void ClearMcpServerOverride(); - - /// - /// Clears the UV path override - /// - void ClearUvPathOverride(); + void ClearUvxPathOverride(); /// /// Clears the Claude CLI path override @@ -75,18 +58,19 @@ public interface IPathResolverService void ClearClaudeCliPathOverride(); /// - /// Gets whether a MCP server path override is active + /// Gets whether a UVX path override is active /// - bool HasMcpServerOverride { get; } + bool HasUvxPathOverride { get; } /// - /// Gets whether a UV path override is active + /// Gets whether a Claude CLI path override is active /// - bool HasUvPathOverride { get; } + bool HasClaudeCliPathOverride { get; } /// - /// Gets whether a Claude CLI path override is active + /// Gets the source path of the uvx-installed unity-mcp package /// - bool HasClaudeCliPathOverride { get; } + /// The path to the source directory, or null if not found + string GetUvxPackageSourcePath(); } } diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs deleted file mode 100644 index dde40d101..000000000 --- a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using MCPForUnity.Editor.Data; - -namespace MCPForUnity.Editor.Services -{ - public interface IPythonToolRegistryService - { - IEnumerable GetAllRegistries(); - bool NeedsSync(PythonToolsAsset registry, TextAsset file); - void RecordSync(PythonToolsAsset registry, TextAsset file); - string ComputeHash(TextAsset file); - } -} diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta deleted file mode 100644 index 3f4835fce..000000000 --- a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a2487319df5cc47baa2c635b911038c5 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs new file mode 100644 index 000000000..ccd17aba1 --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered tool + /// + public class ToolMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public bool StructuredOutput { get; set; } + public List Parameters { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + } + + /// + /// Metadata for a tool parameter + /// + public class ParameterMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string Type { get; set; } // "string", "int", "bool", "float", etc. + public bool Required { get; set; } + public string DefaultValue { get; set; } + } + + /// + /// Service for discovering MCP tools via reflection + /// + public interface IToolDiscoveryService + { + /// + /// Discovers all tools marked with [McpForUnityTool] + /// + List DiscoverAllTools(); + + /// + /// Gets metadata for a specific tool + /// + ToolMetadata GetToolMetadata(string toolName); + + /// + /// Invalidates the tool discovery cache + /// + void InvalidateCache(); + } +} diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta new file mode 100644 index 000000000..a25b7492b --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 497592a93fd994b2cb9803e7c8636ff7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs b/MCPForUnity/Editor/Services/IToolSyncService.cs deleted file mode 100644 index 3a62fdfba..000000000 --- a/MCPForUnity/Editor/Services/IToolSyncService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; - -namespace MCPForUnity.Editor.Services -{ - public class ToolSyncResult - { - public int CopiedCount { get; set; } - public int SkippedCount { get; set; } - public int ErrorCount { get; set; } - public List Messages { get; set; } = new List(); - public bool Success => ErrorCount == 0; - } - - public interface IToolSyncService - { - ToolSyncResult SyncProjectTools(string destToolsDir); - } -} diff --git a/MCPForUnity/Editor/Services/IToolSyncService.cs.meta b/MCPForUnity/Editor/Services/IToolSyncService.cs.meta deleted file mode 100644 index 028282851..000000000 --- a/MCPForUnity/Editor/Services/IToolSyncService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b9627dbaa92d24783a9f20e42efcea18 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/MCPServiceLocator.cs b/MCPForUnity/Editor/Services/MCPServiceLocator.cs index a743d4ce8..de34ed1b1 100644 --- a/MCPForUnity/Editor/Services/MCPServiceLocator.cs +++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs @@ -1,4 +1,5 @@ using System; +using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Services { @@ -10,20 +11,22 @@ public static class MCPServiceLocator private static IBridgeControlService _bridgeService; private static IClientConfigurationService _clientService; private static IPathResolverService _pathService; - private static IPythonToolRegistryService _pythonToolRegistryService; private static ITestRunnerService _testRunnerService; - private static IToolSyncService _toolSyncService; private static IPackageUpdateService _packageUpdateService; private static IPlatformService _platformService; + private static IToolDiscoveryService _toolDiscoveryService; + private static ICustomToolRegistrationService _customToolRegistrationService; + private static ICacheManagementService _cacheManagementService; public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); public static IPathResolverService Paths => _pathService ??= new PathResolverService(); - public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService(); public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); - public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService(); public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); public static IPlatformService Platform => _platformService ??= new PlatformService(); + public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService(); + public static ICustomToolRegistrationService CustomToolRegistration => _customToolRegistrationService ??= new CustomToolRegistrationService(); + public static ICacheManagementService Cache => _cacheManagementService ??= new CacheManagementService(); /// /// Registers a custom implementation for a service (useful for testing) @@ -38,16 +41,18 @@ public static void Register(T implementation) where T : class _clientService = c; else if (implementation is IPathResolverService p) _pathService = p; - else if (implementation is IPythonToolRegistryService ptr) - _pythonToolRegistryService = ptr; else if (implementation is ITestRunnerService t) _testRunnerService = t; - else if (implementation is IToolSyncService ts) - _toolSyncService = ts; else if (implementation is IPackageUpdateService pu) _packageUpdateService = pu; else if (implementation is IPlatformService ps) _platformService = ps; + else if (implementation is IToolDiscoveryService td) + _toolDiscoveryService = td; + else if (implementation is ICustomToolRegistrationService ctr) + _customToolRegistrationService = ctr; + else if (implementation is ICacheManagementService cm) + _cacheManagementService = cm; } /// @@ -58,20 +63,22 @@ public static void Reset() (_bridgeService as IDisposable)?.Dispose(); (_clientService as IDisposable)?.Dispose(); (_pathService as IDisposable)?.Dispose(); - (_pythonToolRegistryService as IDisposable)?.Dispose(); (_testRunnerService as IDisposable)?.Dispose(); - (_toolSyncService as IDisposable)?.Dispose(); (_packageUpdateService as IDisposable)?.Dispose(); (_platformService as IDisposable)?.Dispose(); + (_toolDiscoveryService as IDisposable)?.Dispose(); + (_customToolRegistrationService as IDisposable)?.Dispose(); + (_cacheManagementService as IDisposable)?.Dispose(); _bridgeService = null; _clientService = null; _pathService = null; - _pythonToolRegistryService = null; _testRunnerService = null; - _toolSyncService = null; _packageUpdateService = null; _platformService = null; + _toolDiscoveryService = null; + _customToolRegistrationService = null; + _cacheManagementService = null; } } } diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 083115f76..c797c6b48 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; @@ -12,201 +14,347 @@ namespace MCPForUnity.Editor.Services /// public class PathResolverService : IPathResolverService { - private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; - private const string UvPathOverrideKey = "MCPForUnity.UvPath"; + private const string UvxPathOverrideKey = "MCPForUnity.UvxPath"; private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath"; - public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); - public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, null)); + public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvxPathOverrideKey, null)); public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(ClaudeCliPathOverrideKey, null)); - public string GetMcpServerPath() + public string GetUvxPath(bool verifyPath = true) { - // Check for override first - string overridePath = EditorPrefs.GetString(PythonDirOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(Path.Combine(overridePath, "server.py"))) - { - return overridePath; - } - - // Fall back to automatic detection - return McpPathResolver.FindPackagePythonDirectory(false); - } - - public string GetUvPath() - { - // Check for override first - string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - return overridePath; - } - - // Fall back to automatic detection + // If the user overrided the path in EditorPrefs, use it try { - return ServerInstaller.FindUvPath(); - } - catch - { - return null; - } - } - - public string GetClaudeCliPath() - { - // Check for override first - string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) - { - return overridePath; + string overridePath = EditorPrefs.GetString(UvxPathOverrideKey, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (verifyPath && VerifyUvxPath(overridePath)) return overridePath; + return overridePath; + } } + catch { /* ignore */ } - // Fall back to automatic detection - return ExecPath.ResolveClaude(); - } + // Get environment variables + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; - public bool IsPythonDetected() - { - try + // Then let's check if it's available via PATH + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Windows-specific Python detection - if (Application.platform == RuntimePlatform.WindowsEditor) + try { - // Common Windows Python installation paths - string[] windowsCandidates = - { - @"C:\Python314\python.exe", - @"C:\Python313\python.exe", - @"C:\Python312\python.exe", - @"C:\Python311\python.exe", - @"C:\Python310\python.exe", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python314\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python314\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), - }; - - foreach (string c in windowsCandidates) - { - if (File.Exists(c)) return true; - } - - // Try 'where python' command (Windows equivalent of 'which') - var psi = new ProcessStartInfo + var wherePsi = new ProcessStartInfo { FileName = "where", - Arguments = "python", + Arguments = "uvx.exe", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; - using (var p = Process.Start(psi)) + using var wp = Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(1500); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { - string[] lines = outp.Split('\n'); - foreach (string line in lines) - { - string trimmed = line.Trim(); - if (File.Exists(trimmed)) return true; - } + string path = line.Trim(); + if (File.Exists(path) && (verifyPath ? VerifyUvxPath(path) : true)) return path; } } } - else + catch { } + + // Try common installation paths + string[] candidates = new[] + { + Path.Combine(programFiles, "uvx", "bin", "uvx.exe"), + Path.Combine(localAppData, "uvx", "bin", "uvx.exe"), + Path.Combine(localAppData, "Python", "Python310", "Scripts", "uvx.exe"), + Path.Combine(localAppData, "Programs", "uvx", "uvx.exe"), + Path.Combine(localAppData, "Microsoft", "WindowsApps", "uvx.exe") + }; + + foreach (var c in candidates) { - // macOS/Linux detection - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = + if (File.Exists(c) && (verifyPath ? VerifyUvxPath(c) : true)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // Check for uvx in common macOS locations + string[] candidates = new[] + { + "/opt/homebrew/bin/uvx", + "/usr/local/bin/uvx", + Path.Combine(appData, "uvx", "bin", "uvx"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "uvx") + }; + + foreach (var c in candidates) + { + if (File.Exists(c) && (verifyPath ? VerifyUvxPath(c) : true)) return c; + } + + // Try 'which uvx' command + try + { + var whichPsi = new ProcessStartInfo { - "/opt/homebrew/bin/python3", - "/usr/local/bin/python3", - "/usr/bin/python3", - "/opt/local/bin/python3", - Path.Combine(home, ".local", "bin", "python3"), - "/Library/Frameworks/Python.framework/Versions/3.14/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", - "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3", + FileName = "/bin/bash", + Arguments = "-c \"which uvx\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true }; - foreach (string c in candidates) + using var wp = Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(1500); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output) && + (verifyPath ? VerifyUvxPath(output) : true)) { - if (File.Exists(c)) return true; + return output; } + } + catch { } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Check for uvx in common Linux locations + string[] candidates = new[] + { + "/usr/bin/uvx", + "/usr/local/bin/uvx", + Path.Combine(appData, "uvx", "bin", "uvx"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "uvx") + }; - // Try 'which python3' - var psi = new ProcessStartInfo + foreach (var c in candidates) + { + if (File.Exists(c) && (verifyPath ? VerifyUvxPath(c) : true)) return c; + } + + // Try 'which uvx' command + try + { + var whichPsi = new ProcessStartInfo { - FileName = "/usr/bin/which", - Arguments = "python3", + FileName = "/bin/bash", + Arguments = "-c \"which uvx\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; - using (var p = Process.Start(psi)) + using var wp = Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(1500); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output) && + (verifyPath ? VerifyUvxPath(output) : true)) { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + return output; } } + catch { } + } + + // Fallback: try just "uvx" (may work if in PATH) + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows, try uvx.exe directly + if (verifyPath ? VerifyUvxPath("uvx.exe") : true) return "uvx.exe"; + } + else + { + // On Unix-like systems, try uvx + if (verifyPath ? VerifyUvxPath("uvx") : true) return "uvx"; + } } catch { } - return false; + + return null; + } + + public string GetClaudeCliPath() + { + try + { + string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } + } + catch { /* ignore */ } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string[] candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), + "claude.exe" + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string[] candidates = new[] + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string[] candidates = new[] + { + "/usr/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + + return null; } - public bool IsUvDetected() + public bool IsPythonDetected() { - return !string.IsNullOrEmpty(GetUvPath()); + try + { + var psi = new ProcessStartInfo + { + FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3", + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p.WaitForExit(2000); + return p.ExitCode == 0; + } + catch + { + return false; + } } - public bool IsClaudeCliDetected() + public bool IsUvxDetected() { - return !string.IsNullOrEmpty(GetClaudeCliPath()); + return !string.IsNullOrEmpty(GetUvxPath()); } - public void SetMcpServerOverride(string path) + public string GetUvxPackageSourcePath() { - if (string.IsNullOrEmpty(path)) + try { - ClearMcpServerOverride(); - return; - } + // Get the uv cache directory + var cacheDirProcess = new ProcessStartInfo + { + FileName = "uv", + Arguments = "cache dir", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(cacheDirProcess); + if (process == null) return null; + + string cacheDir = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode != 0 || string.IsNullOrEmpty(cacheDir)) + { + McpLog.Error("Failed to get uv cache directory"); + return null; + } + + // Normalize path for cross-platform compatibility + cacheDir = Path.GetFullPath(cacheDir); + + // Look for git checkouts directory + string gitCheckoutsDir = Path.Combine(cacheDir, "git-v0", "checkouts"); + if (!Directory.Exists(gitCheckoutsDir)) + { + McpLog.Error($"Git checkouts directory not found: {gitCheckoutsDir}"); + return null; + } + + // Find the unity-mcp checkout directory + // The pattern is: git-v0/checkouts/{hash}/{commit-hash}/ + foreach (var hashDir in Directory.GetDirectories(gitCheckoutsDir)) + { + foreach (var commitDir in Directory.GetDirectories(hashDir)) + { + // Check for the new Server structure + string serverPath = Path.Combine(commitDir, "Server"); + if (Directory.Exists(serverPath)) + { + // Verify it has the expected pyproject.toml + string pyprojectPath = Path.Combine(serverPath, "pyproject.toml"); + if (File.Exists(pyprojectPath)) + { + McpLog.Info($"Found uvx package source at: {serverPath}"); + return serverPath; + } + } + } + } - if (!File.Exists(Path.Combine(path, "server.py"))) + var packageVersion = AssetPathUtility.GetPackageVersion(); + McpLog.Error($"Server package source not found in uv cache. Make sure to run: uvx --from git+https://github.com/CoplayDev/unity-mcp@{packageVersion}#subdirectory=Server mcp-for-unity"); + return null; + } + catch (System.Exception ex) { - throw new ArgumentException("The selected folder does not contain server.py"); + McpLog.Error($"Failed to find uvx package source: {ex.Message}"); + return null; } + } - EditorPrefs.SetString(PythonDirOverrideKey, path); + public bool IsClaudeCliDetected() + { + return !string.IsNullOrEmpty(GetClaudeCliPath()); } - public void SetUvPathOverride(string path) + public void SetUvxPathOverride(string path) { if (string.IsNullOrEmpty(path)) { - ClearUvPathOverride(); + ClearUvxPathOverride(); return; } if (!File.Exists(path)) { - throw new ArgumentException("The selected UV executable does not exist"); + throw new ArgumentException("The selected UVX executable does not exist"); } - EditorPrefs.SetString(UvPathOverrideKey, path); + EditorPrefs.SetString(UvxPathOverrideKey, path); } public void SetClaudeCliPathOverride(string path) @@ -223,23 +371,40 @@ public void SetClaudeCliPathOverride(string path) } EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); - // Also update the ExecPath helper for backwards compatibility - ExecPath.SetClaudeCliPath(path); } - public void ClearMcpServerOverride() + public void ClearUvxPathOverride() { - EditorPrefs.DeleteKey(PythonDirOverrideKey); + EditorPrefs.DeleteKey(UvxPathOverrideKey); } - public void ClearUvPathOverride() + public void ClearClaudeCliPathOverride() { - EditorPrefs.DeleteKey(UvPathOverrideKey); + EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); } - public void ClearClaudeCliPathOverride() + private static bool VerifyUvxPath(string uvxPath) { - EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); + try + { + var psi = new ProcessStartInfo + { + FileName = uvxPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); + p.WaitForExit(2000); + return p.ExitCode == 0 && output.Contains("uvx"); + } + catch + { + return false; + } } } } diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs deleted file mode 100644 index 1fab20c8d..000000000 --- a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Data; - -namespace MCPForUnity.Editor.Services -{ - public class PythonToolRegistryService : IPythonToolRegistryService - { - public IEnumerable GetAllRegistries() - { - // Find all PythonToolsAsset instances in the project - string[] guids = AssetDatabase.FindAssets("t:PythonToolsAsset"); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - var asset = AssetDatabase.LoadAssetAtPath(path); - if (asset != null) - yield return asset; - } - } - - public bool NeedsSync(PythonToolsAsset registry, TextAsset file) - { - if (!registry.useContentHashing) return true; - - string currentHash = ComputeHash(file); - return registry.NeedsSync(file, currentHash); - } - - public void RecordSync(PythonToolsAsset registry, TextAsset file) - { - string hash = ComputeHash(file); - registry.RecordSync(file, hash); - EditorUtility.SetDirty(registry); - } - - public string ComputeHash(TextAsset file) - { - if (file == null || string.IsNullOrEmpty(file.text)) - return string.Empty; - - using (var sha256 = SHA256.Create()) - { - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(file.text); - byte[] hash = sha256.ComputeHash(bytes); - return BitConverter.ToString(hash).Replace("-", "").ToLower(); - } - } - } -} diff --git a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta b/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta deleted file mode 100644 index 9fba1e9f7..000000000 --- a/MCPForUnity/Editor/Services/PythonToolRegistryService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2da2869749c764f16a45e010eefbd679 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs new file mode 100644 index 000000000..edb4c387e --- /dev/null +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + public class ToolDiscoveryService : IToolDiscoveryService + { + private Dictionary _cachedTools; + + public List DiscoverAllTools() + { + if (_cachedTools != null) + { + return _cachedTools.Values.ToList(); + } + + _cachedTools = new Dictionary(); + + // Scan all assemblies for [McpForUnityTool] attributes + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes(); + + foreach (var type in types) + { + var toolAttr = type.GetCustomAttribute(); + if (toolAttr == null) + continue; + + var metadata = ExtractToolMetadata(type, toolAttr); + if (metadata != null) + { + _cachedTools[metadata.Name] = metadata; + } + } + } + catch (Exception ex) + { + // Skip assemblies that can't be reflected + McpLog.Info($"Skipping assembly {assembly.FullName}: {ex.Message}"); + } + } + + McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection"); + return _cachedTools.Values.ToList(); + } + + public ToolMetadata GetToolMetadata(string toolName) + { + if (_cachedTools == null) + { + DiscoverAllTools(); + } + + return _cachedTools.TryGetValue(toolName, out var metadata) ? metadata : null; + } + + private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute toolAttr) + { + try + { + // Get tool name + string toolName = toolAttr.Name; + if (string.IsNullOrEmpty(toolName)) + { + // Derive from class name: CaptureScreenshotTool -> capture_screenshot + toolName = ConvertToSnakeCase(type.Name.Replace("Tool", "")); + } + + // Get description + string description = toolAttr.Description ?? $"Tool: {toolName}"; + + // Extract parameters + var parameters = ExtractParameters(type); + + return new ToolMetadata + { + Name = toolName, + Description = description, + StructuredOutput = toolAttr.StructuredOutput, + Parameters = parameters, + ClassName = type.Name, + Namespace = type.Namespace ?? "" + }; + } + catch (Exception ex) + { + McpLog.Error($"Failed to extract metadata for {type.Name}: {ex.Message}"); + return null; + } + } + + private List ExtractParameters(Type type) + { + var parameters = new List(); + + // Look for nested Parameters class + var parametersType = type.GetNestedType("Parameters"); + if (parametersType == null) + { + return parameters; + } + + // Get all properties with [ToolParameter] + var properties = parametersType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var paramAttr = prop.GetCustomAttribute(); + if (paramAttr == null) + continue; + + string paramName = prop.Name; + string paramType = GetParameterType(prop.PropertyType); + + parameters.Add(new ParameterMetadata + { + Name = paramName, + Description = paramAttr.Description, + Type = paramType, + Required = paramAttr.Required, + DefaultValue = paramAttr.DefaultValue + }); + } + + return parameters; + } + + private string GetParameterType(Type type) + { + // Handle nullable types + if (Nullable.GetUnderlyingType(type) != null) + { + type = Nullable.GetUnderlyingType(type); + } + + // Map C# types to JSON schema types + if (type == typeof(string)) + return "string"; + if (type == typeof(int) || type == typeof(long)) + return "integer"; + if (type == typeof(float) || type == typeof(double)) + return "number"; + if (type == typeof(bool)) + return "boolean"; + if (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) + return "array"; + + return "object"; + } + + private string ConvertToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Convert PascalCase to snake_case + var result = System.Text.RegularExpressions.Regex.Replace( + input, + "([a-z0-9])([A-Z])", + "$1_$2" + ).ToLower(); + + return result; + } + + public void InvalidateCache() + { + _cachedTools = null; + } + } +} diff --git a/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta new file mode 100644 index 000000000..46b740329 --- /dev/null +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec81a561be4c14c9cb243855d3273a94 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs b/MCPForUnity/Editor/Services/ToolSyncService.cs deleted file mode 100644 index bd17f9966..000000000 --- a/MCPForUnity/Editor/Services/ToolSyncService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using MCPForUnity.Editor.Helpers; -using UnityEditor; - -namespace MCPForUnity.Editor.Services -{ - public class ToolSyncService : IToolSyncService - { - private readonly IPythonToolRegistryService _registryService; - - public ToolSyncService(IPythonToolRegistryService registryService = null) - { - _registryService = registryService ?? MCPServiceLocator.PythonToolRegistry; - } - - public ToolSyncResult SyncProjectTools(string destToolsDir) - { - var result = new ToolSyncResult(); - - try - { - Directory.CreateDirectory(destToolsDir); - - // Get all PythonToolsAsset instances in the project - var registries = _registryService.GetAllRegistries().ToList(); - - if (!registries.Any()) - { - McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools"); - return result; - } - - var syncedFiles = new HashSet(); - - // Batch all asset modifications together to minimize reimports - AssetDatabase.StartAssetEditing(); - try - { - foreach (var registry in registries) - { - foreach (var file in registry.GetValidFiles()) - { - try - { - // Check if needs syncing (hash-based or always) - if (_registryService.NeedsSync(registry, file)) - { - string destPath = Path.Combine(destToolsDir, file.name + ".py"); - - // Write the Python file content - File.WriteAllText(destPath, file.text); - - // Record sync - _registryService.RecordSync(registry, file); - - result.CopiedCount++; - syncedFiles.Add(destPath); - McpLog.Info($"Synced Python tool: {file.name}.py"); - } - else - { - string destPath = Path.Combine(destToolsDir, file.name + ".py"); - syncedFiles.Add(destPath); - result.SkippedCount++; - } - } - catch (Exception ex) - { - result.ErrorCount++; - result.Messages.Add($"Failed to sync {file.name}: {ex.Message}"); - } - } - - // Cleanup stale states in registry - registry.CleanupStaleStates(); - EditorUtility.SetDirty(registry); - } - - // Cleanup stale Python files in destination - CleanupStaleFiles(destToolsDir, syncedFiles); - } - finally - { - // End batch editing - this triggers a single asset refresh - AssetDatabase.StopAssetEditing(); - } - - // Save all modified registries - AssetDatabase.SaveAssets(); - } - catch (Exception ex) - { - result.ErrorCount++; - result.Messages.Add($"Sync failed: {ex.Message}"); - } - - return result; - } - - private void CleanupStaleFiles(string destToolsDir, HashSet currentFiles) - { - try - { - if (!Directory.Exists(destToolsDir)) return; - - // Find all .py files in destination that aren't in our current set - var existingFiles = Directory.GetFiles(destToolsDir, "*.py"); - - foreach (var file in existingFiles) - { - if (!currentFiles.Contains(file)) - { - try - { - File.Delete(file); - McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}"); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to cleanup {file}: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - McpLog.Warn($"Failed to cleanup stale files: {ex.Message}"); - } - } - } -} diff --git a/MCPForUnity/Editor/Services/ToolSyncService.cs.meta b/MCPForUnity/Editor/Services/ToolSyncService.cs.meta deleted file mode 100644 index 31db4399b..000000000 --- a/MCPForUnity/Editor/Services/ToolSyncService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9ad084cf3b6c04174b9202bf63137bae -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index bb4e0431a..81e964d4d 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -3,17 +3,36 @@ namespace MCPForUnity.Editor.Tools { /// - /// Marks a class as an MCP tool handler for auto-discovery. - /// The class must have a public static HandleCommand(JObject) method. + /// Marks a class as an MCP tool handler /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class McpForUnityToolAttribute : Attribute { + /// + /// Tool name (if null, derived from class name) + /// + public string Name { get; set; } + + /// + /// Tool description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this tool returns structured output + /// + public bool StructuredOutput { get; set; } = true; + /// /// The command name used to route requests to this tool. /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// Kept for backward compatibility. /// - public string CommandName { get; } + public string CommandName + { + get => Name; + set => Name = value; + } /// /// Create an MCP tool attribute with auto-generated command name. @@ -22,16 +41,48 @@ public class McpForUnityToolAttribute : Attribute /// public McpForUnityToolAttribute() { - CommandName = null; // Will be auto-generated + Name = null; // Will be auto-generated } /// /// Create an MCP tool attribute with explicit command name. /// - /// The command name (e.g., "manage_asset") - public McpForUnityToolAttribute(string commandName) + /// The command name (e.g., "manage_asset") + public McpForUnityToolAttribute(string name = null) + { + Name = name; + } + } + + /// + /// Describes a tool parameter + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] + public class ToolParameterAttribute : Attribute + { + /// + /// Parameter name (if null, derived from property/field name) + /// + public string Name { get; } + + /// + /// Parameter description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this parameter is required + /// + public bool Required { get; set; } = true; + + /// + /// Default value (as string) + /// + public string DefaultValue { get; set; } + + public ToolParameterAttribute(string description) { - CommandName = commandName; + Description = description; } } } diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index b8fb3c3a5..5c20e7381 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -16,11 +16,11 @@ namespace MCPForUnity.Editor.Windows { public class MCPForUnityEditorWindow : EditorWindow { - // Protocol enum for future HTTP support - private enum ConnectionProtocol + // Transport protocol enum + private enum TransportProtocol { - Stdio, - // HTTPStreaming // Future + HTTP, + Stdio } // Settings UI Elements @@ -29,29 +29,23 @@ private enum ConnectionProtocol private EnumField validationLevelField; private Label validationDescription; private Foldout advancedSettingsFoldout; - private TextField mcpServerPathOverride; - private TextField uvPathOverride; - private Button browsePythonButton; - private Button clearPythonButton; - private Button browseUvButton; - private Button clearUvButton; - private VisualElement mcpServerPathStatus; - private VisualElement uvPathStatus; + private TextField uvxPathOverride; + private Button browseUvxButton; + private Button clearUvxButton; + private VisualElement uvxPathStatus; // Connection UI Elements - private EnumField protocolDropdown; + private EnumField transportDropdown; + private VisualElement httpUrlRow; + private TextField httpUrlField; + private VisualElement unitySocketPortRow; private TextField unityPortField; - private TextField serverPortField; private VisualElement statusIndicator; private Label connectionStatusLabel; private Button connectionToggleButton; private VisualElement healthIndicator; private Label healthStatusLabel; private Button testConnectionButton; - private VisualElement serverStatusBanner; - private Label serverStatusMessage; - private Button downloadServerButton; - private Button rebuildServerButton; // Client UI Elements private DropdownField clientDropdown; @@ -128,7 +122,6 @@ public void CreateGUI() // Initial update UpdateConnectionStatus(); - UpdateServerStatusBanner(); UpdateClientStatus(); UpdatePathOverrides(); // Technically not required to connect, but if we don't do this, the UI will be blank @@ -197,29 +190,23 @@ private void CacheUIElements() validationLevelField = rootVisualElement.Q("validation-level"); validationDescription = rootVisualElement.Q public class MCPToolParameterTests { - private static void AssertShaderIsSupported(Shader s) - { - Assert.IsNotNull(s, "Shader should not be null"); - // Accept common defaults across pipelines - var name = s.name; - bool ok = name == "Universal Render Pipeline/Lit" - || name == "HDRP/Lit" - || name == "Standard" - || name == "Unlit/Color"; - Assert.IsTrue(ok, $"Unexpected shader: {name}"); - } + private const string TempDir = "Assets/Temp/MCPToolParameterTests"; + private const string TempLiveDir = "Assets/Temp/LiveTests"; + + private static void AssertColorsEqual(Color expected, Color actual, string message) + { + const float tolerance = 0.001f; + Assert.AreEqual(expected.r, actual.r, tolerance, $"{message} - Red component mismatch"); + Assert.AreEqual(expected.g, actual.g, tolerance, $"{message} - Green component mismatch"); + Assert.AreEqual(expected.b, actual.b, tolerance, $"{message} - Blue component mismatch"); + Assert.AreEqual(expected.a, actual.a, tolerance, $"{message} - Alpha component mismatch"); + } + + private static void AssertShaderIsSupported(Shader s) + { + Assert.IsNotNull(s, "Shader should not be null"); + // Accept common defaults across pipelines + var name = s.name; + bool ok = name == "Universal Render Pipeline/Lit" + || name == "HDRP/Lit" + || name == "Standard" + || name == "Unlit/Color"; + Assert.IsTrue(ok, $"Unexpected shader: {name}"); + } + + [TearDown] + public void TearDown() + { + // Clean up temp directories after each test + if (AssetDatabase.IsValidFolder(TempDir)) + { + AssetDatabase.DeleteAsset(TempDir); + } + + if (AssetDatabase.IsValidFolder(TempLiveDir)) + { + AssetDatabase.DeleteAsset(TempLiveDir); + } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + var remainingDirs = Directory.GetDirectories("Assets/Temp"); + var remainingFiles = Directory.GetFiles("Assets/Temp"); + if (remainingDirs.Length == 0 && remainingFiles.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } + } [Test] public void Test_ManageAsset_ShouldAcceptJSONProperties() { // Arrange: create temp folder - const string tempDir = "Assets/Temp/MCPToolParameterTests"; if (!AssetDatabase.IsValidFolder("Assets/Temp")) { AssetDatabase.CreateFolder("Assets", "Temp"); } - if (!AssetDatabase.IsValidFolder(tempDir)) + if (!AssetDatabase.IsValidFolder(TempDir)) { AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); } - var matPath = $"{tempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat"; + var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat"; // Build params with properties as a JSON string (to be coerced) var p = new JObject @@ -61,9 +99,9 @@ public void Test_ManageAsset_ShouldAcceptJSONProperties() Assert.IsNotNull(result, "Handler should return a JObject result"); Assert.IsTrue(result!.Value("success"), result.ToString()); - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should be created at path"); - AssertShaderIsSupported(mat.shader); + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created at path"); + AssertShaderIsSupported(mat.shader); if (mat.HasProperty("_Color")) { Assert.AreEqual(Color.blue, mat.GetColor("_Color")); @@ -93,7 +131,7 @@ public void Test_ManageGameObject_ShouldAcceptJSONComponentProperties() ["action"] = "create", ["path"] = matPath, ["assetType"] = "Material", - ["properties"] = new JObject { ["shader"] = "Universal Render Pipeline/Lit", ["color"] = new JArray(0,0,1,1) } + ["properties"] = new JObject { ["shader"] = "Universal Render Pipeline/Lit", ["color"] = new JArray(0, 0, 1, 1) } }; var createMatRes = ManageAsset.HandleCommand(createMat); var createMatObj = createMatRes as JObject ?? JObject.FromObject(createMatRes); @@ -128,7 +166,7 @@ public void Test_ManageGameObject_ShouldAcceptJSONComponentProperties() Assert.IsNotNull(renderer, "MeshRenderer should exist"); var assignedMat = renderer.sharedMaterial; Assert.IsNotNull(assignedMat, "sharedMaterial should be assigned"); - AssertShaderIsSupported(assignedMat.shader); + AssertShaderIsSupported(assignedMat.shader); var createdMat = AssetDatabase.LoadAssetAtPath(matPath); Assert.AreEqual(createdMat, assignedMat, "Assigned material should match created material"); } @@ -185,7 +223,7 @@ public void Test_JSONParsing_ShouldWorkInMCPTools() // Verify shader on created material var createdMat = AssetDatabase.LoadAssetAtPath(matPath); Assert.IsNotNull(createdMat); - AssertShaderIsSupported(createdMat.shader); + AssertShaderIsSupported(createdMat.shader); } finally { @@ -218,9 +256,9 @@ public void Test_ManageAsset_JSONStringParsing_CreateMaterial() Assert.IsNotNull(result, "Handler should return a JObject result"); Assert.IsTrue(result!.Value("success"), $"Create failed: {result}"); - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should be created at path"); - AssertShaderIsSupported(mat.shader); + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created at path"); + AssertShaderIsSupported(mat.shader); if (mat.HasProperty("_Color")) { Assert.AreEqual(Color.red, mat.GetColor("_Color"), "Material should have red color"); @@ -271,9 +309,9 @@ public void Test_ManageAsset_JSONStringParsing_ModifyMaterial() Assert.IsNotNull(modifyResult, "Modify should return a result"); Assert.IsTrue(modifyResult!.Value("success"), $"Modify failed: {modifyResult}"); - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should exist"); - AssertShaderIsSupported(mat.shader); + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should exist"); + AssertShaderIsSupported(mat.shader); if (mat.HasProperty("_Color")) { Assert.AreEqual(Color.blue, mat.GetColor("_Color"), "Material should have blue color after modify"); @@ -304,412 +342,414 @@ public void Test_ManageAsset_InvalidJSONString_HandledGracefully() ["properties"] = "{\"invalid\": json, \"missing\": quotes}" // Invalid JSON }; - try - { - LogAssert.Expect(LogType.Warning, new Regex("(failed to parse)|(Could not parse 'properties' JSON string)", RegexOptions.IgnoreCase)); - var raw = ManageAsset.HandleCommand(p); - var result = raw as JObject ?? JObject.FromObject(raw); - // Should either succeed with defaults or fail gracefully - Assert.IsNotNull(result, "Handler should return a result"); - // The result might be success (with defaults) or failure, both are acceptable - } - finally - { - if (AssetDatabase.LoadAssetAtPath(matPath) != null) - { - AssetDatabase.DeleteAsset(matPath); - } - AssetDatabase.Refresh(); - } - } - - [Test] - public void Test_ManageAsset_JSONStringParsing_FloatProperty_Metallic_CreateAndModify() - { - // Validate float property handling via JSON string for create and modify - const string tempDir = "Assets/Temp/MCPToolParameterTests"; - if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); - if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); - var matPath = $"{tempDir}/JsonFloatTest_{Guid.NewGuid().ToString("N")}.mat"; - - var createParams = new JObject - { - ["action"] = "create", - ["path"] = matPath, - ["assetType"] = "Material", - ["properties"] = "{\"shader\": \"Universal Render Pipeline/Lit\", \"metallic\": 0.75}" - }; - - try - { - var createRaw = ManageAsset.HandleCommand(createParams); - var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); - Assert.IsTrue(createResult!.Value("success"), createResult.ToString()); - - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should be created at path"); - AssertShaderIsSupported(mat.shader); - if (mat.HasProperty("_Metallic")) - { - Assert.AreEqual(0.75f, mat.GetFloat("_Metallic"), 1e-3f, "Metallic should be ~0.75 after create"); - } - - var modifyParams = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = "{\"metallic\": 0.1}" - }; - - var modifyRaw = ManageAsset.HandleCommand(modifyParams); - var modifyResult = modifyRaw as JObject ?? JObject.FromObject(modifyRaw); - Assert.IsTrue(modifyResult!.Value("success"), modifyResult.ToString()); - - var mat2 = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat2, "Material should still exist"); - if (mat2.HasProperty("_Metallic")) - { - Assert.AreEqual(0.1f, mat2.GetFloat("_Metallic"), 1e-3f, "Metallic should be ~0.1 after modify"); - } - } - finally - { - if (AssetDatabase.LoadAssetAtPath(matPath) != null) - { - AssetDatabase.DeleteAsset(matPath); - } - AssetDatabase.Refresh(); - } - } - - [Test] - public void Test_ManageAsset_JSONStringParsing_TextureAssignment_CreateAndModify() - { - // Uses flexible direct property assignment to set _BaseMap/_MainTex by path - const string tempDir = "Assets/Temp/MCPToolParameterTests"; - if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); - if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); - var matPath = $"{tempDir}/JsonTexTest_{Guid.NewGuid().ToString("N")}.mat"; - var texPath = "Assets/Temp/LiveTests/TempBaseTex.asset"; // created by GenTempTex - - // Ensure the texture exists BEFORE creating the material so assignment succeeds during create - var preTex = AssetDatabase.LoadAssetAtPath(texPath); - if (preTex == null) - { - if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); - if (!AssetDatabase.IsValidFolder("Assets/Temp/LiveTests")) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); - var tex2D = new Texture2D(4, 4, TextureFormat.RGBA32, false); - var pixels = new Color[16]; - for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.white; - tex2D.SetPixels(pixels); - tex2D.Apply(); - AssetDatabase.CreateAsset(tex2D, texPath); - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - } - - var createParams = new JObject - { - ["action"] = "create", - ["path"] = matPath, - ["assetType"] = "Material", - ["properties"] = new JObject - { - ["shader"] = "Universal Render Pipeline/Lit", - ["_BaseMap"] = texPath // resolves to _BaseMap or _MainTex internally - } - }; - - try - { - var createRaw = ManageAsset.HandleCommand(createParams); - var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); - Assert.IsTrue(createResult!.Value("success"), createResult.ToString()); - - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should be created at path"); - AssertShaderIsSupported(mat.shader); - var tex = AssetDatabase.LoadAssetAtPath(texPath); - if (tex == null) - { - // Create a tiny white texture if missing to make the test self-sufficient - if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); - if (!AssetDatabase.IsValidFolder("Assets/Temp/LiveTests")) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); - var tex2D = new Texture2D(4, 4, TextureFormat.RGBA32, false); - var pixels = new Color[16]; - for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.white; - tex2D.SetPixels(pixels); - tex2D.Apply(); - AssetDatabase.CreateAsset(tex2D, texPath); - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - tex = AssetDatabase.LoadAssetAtPath(texPath); - } - Assert.IsNotNull(tex, "Test texture should exist"); - // Verify either _BaseMap or _MainTex holds the texture - bool hasTexture = (mat.HasProperty("_BaseMap") && mat.GetTexture("_BaseMap") == tex) - || (mat.HasProperty("_MainTex") && mat.GetTexture("_MainTex") == tex); - Assert.IsTrue(hasTexture, "Material should reference the assigned texture"); - - // Modify by changing to same texture via alternate alias key - var modifyParams = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = new JObject { ["_MainTex"] = texPath } - }; - var modifyRaw = ManageAsset.HandleCommand(modifyParams); - var modifyResult = modifyRaw as JObject ?? JObject.FromObject(modifyRaw); - Assert.IsTrue(modifyResult!.Value("success"), modifyResult.ToString()); - - var mat2 = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat2); - bool hasTexture2 = (mat2.HasProperty("_BaseMap") && mat2.GetTexture("_BaseMap") == tex) - || (mat2.HasProperty("_MainTex") && mat2.GetTexture("_MainTex") == tex); - Assert.IsTrue(hasTexture2, "Material should keep the assigned texture after modify"); - } - finally - { - if (AssetDatabase.LoadAssetAtPath(matPath) != null) - { - AssetDatabase.DeleteAsset(matPath); - } - AssetDatabase.Refresh(); - } - } - - [Test] - public void Test_EndToEnd_PropertyHandling_AllScenarios() - { - // Comprehensive end-to-end test of all 10 property handling scenarios - const string tempDir = "Assets/Temp/LiveTests"; - if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); - if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); - - string guidSuffix = Guid.NewGuid().ToString("N").Substring(0, 8); - string matPath = $"{tempDir}/Mat_{guidSuffix}.mat"; - string texPath = $"{tempDir}/TempBaseTex.asset"; - string sphereName = $"LiveSphere_{guidSuffix}"; - string badJsonPath = $"{tempDir}/BadJson_{guidSuffix}.mat"; - - // Ensure clean state from previous runs - var preSphere = GameObject.Find(sphereName); - if (preSphere != null) UnityEngine.Object.DestroyImmediate(preSphere); - if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); - if (AssetDatabase.LoadAssetAtPath(badJsonPath) != null) AssetDatabase.DeleteAsset(badJsonPath); - AssetDatabase.Refresh(); - - try - { - // 1. Create material via JSON string - var createParams = new JObject - { - ["action"] = "create", - ["path"] = matPath, - ["assetType"] = "Material", - ["properties"] = "{\"shader\":\"Universal Render Pipeline/Lit\",\"color\":[1,0,0,1]}" - }; - var createRaw = ManageAsset.HandleCommand(createParams); - var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); - Assert.IsTrue(createResult.Value("success"), $"Test 1 failed: {createResult}"); - var mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.IsNotNull(mat, "Material should be created"); - var expectedRed = Color.red; - if (mat.HasProperty("_BaseColor")) - Assert.AreEqual(expectedRed, mat.GetColor("_BaseColor"), "Test 1: _BaseColor should be red"); - else if (mat.HasProperty("_Color")) - Assert.AreEqual(expectedRed, mat.GetColor("_Color"), "Test 1: _Color should be red"); - else - Assert.Inconclusive("Material has neither _BaseColor nor _Color"); - - // 2. Modify color and metallic (friendly names) - var modify1 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = "{\"color\":[0,0.5,1,1],\"metallic\":0.6}" - }; - var modifyRaw1 = ManageAsset.HandleCommand(modify1); - var modifyResult1 = modifyRaw1 as JObject ?? JObject.FromObject(modifyRaw1); - Assert.IsTrue(modifyResult1.Value("success"), $"Test 2 failed: {modifyResult1}"); - mat = AssetDatabase.LoadAssetAtPath(matPath); - var expectedCyan = new Color(0, 0.5f, 1, 1); - if (mat.HasProperty("_BaseColor")) - Assert.AreEqual(expectedCyan, mat.GetColor("_BaseColor"), "Test 2: _BaseColor should be cyan"); - else if (mat.HasProperty("_Color")) - Assert.AreEqual(expectedCyan, mat.GetColor("_Color"), "Test 2: _Color should be cyan"); - else - Assert.Inconclusive("Material has neither _BaseColor nor _Color"); - Assert.AreEqual(0.6f, mat.GetFloat("_Metallic"), 0.001f, "Test 2: Metallic should be 0.6"); - - // 3. Modify using structured float block - var modify2 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = new JObject - { - ["float"] = new JObject { ["name"] = "_Metallic", ["value"] = 0.1 } - } - }; - var modifyRaw2 = ManageAsset.HandleCommand(modify2); - var modifyResult2 = modifyRaw2 as JObject ?? JObject.FromObject(modifyRaw2); - Assert.IsTrue(modifyResult2.Value("success"), $"Test 3 failed: {modifyResult2}"); - mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.AreEqual(0.1f, mat.GetFloat("_Metallic"), 0.001f, "Test 3: Metallic should be 0.1"); - - // 4. Assign texture via direct prop alias (skip if texture doesn't exist) - if (AssetDatabase.LoadAssetAtPath(texPath) != null) - { - var modify3 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = "{\"_BaseMap\":\"" + texPath + "\"}" - }; - var modifyRaw3 = ManageAsset.HandleCommand(modify3); - var modifyResult3 = modifyRaw3 as JObject ?? JObject.FromObject(modifyRaw3); - Assert.IsTrue(modifyResult3.Value("success"), $"Test 4 failed: {modifyResult3}"); - Debug.Log("Test 4: Texture assignment successful"); - } - else - { - Debug.LogWarning("Test 4: Skipped - texture not found at " + texPath); - } - - // 5. Assign texture via structured block (skip if texture doesn't exist) - if (AssetDatabase.LoadAssetAtPath(texPath) != null) - { - var modify4 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = new JObject - { - ["texture"] = new JObject { ["name"] = "_MainTex", ["path"] = texPath } - } - }; - var modifyRaw4 = ManageAsset.HandleCommand(modify4); - var modifyResult4 = modifyRaw4 as JObject ?? JObject.FromObject(modifyRaw4); - Assert.IsTrue(modifyResult4.Value("success"), $"Test 5 failed: {modifyResult4}"); - Debug.Log("Test 5: Structured texture assignment successful"); - } - else - { - Debug.LogWarning("Test 5: Skipped - texture not found at " + texPath); - } - - // 6. Create sphere and assign material via componentProperties JSON string - var createSphere = new JObject - { - ["action"] = "create", - ["name"] = sphereName, - ["primitiveType"] = "Sphere" - }; - var sphereRaw = ManageGameObject.HandleCommand(createSphere); - var sphereResult = sphereRaw as JObject ?? JObject.FromObject(sphereRaw); - Assert.IsTrue(sphereResult.Value("success"), $"Test 6 - Create sphere failed: {sphereResult}"); - - var modifySphere = new JObject - { - ["action"] = "modify", - ["target"] = sphereName, - ["searchMethod"] = "by_name", - ["componentProperties"] = "{\"MeshRenderer\":{\"sharedMaterial\":\"" + matPath + "\"}}" - }; - var sphereModifyRaw = ManageGameObject.HandleCommand(modifySphere); - var sphereModifyResult = sphereModifyRaw as JObject ?? JObject.FromObject(sphereModifyRaw); - Assert.IsTrue(sphereModifyResult.Value("success"), $"Test 6 - Assign material failed: {sphereModifyResult}"); - var sphere = GameObject.Find(sphereName); - Assert.IsNotNull(sphere, "Test 6: Sphere should exist"); - var renderer = sphere.GetComponent(); - Assert.IsNotNull(renderer.sharedMaterial, "Test 6: Material should be assigned"); - - // 7. Use URP color alias key - var modify5 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = new JObject - { - ["_BaseColor"] = new JArray(0.2, 0.8, 0.3, 1) - } - }; - var modifyRaw5 = ManageAsset.HandleCommand(modify5); - var modifyResult5 = modifyRaw5 as JObject ?? JObject.FromObject(modifyRaw5); - Assert.IsTrue(modifyResult5.Value("success"), $"Test 7 failed: {modifyResult5}"); - mat = AssetDatabase.LoadAssetAtPath(matPath); - Color expectedColor = new Color(0.2f, 0.8f, 0.3f, 1f); - if (mat.HasProperty("_BaseColor")) - { - Assert.AreEqual(expectedColor, mat.GetColor("_BaseColor"), "Test 7: _BaseColor should be set"); - } - else if (mat.HasProperty("_Color")) - { - Assert.AreEqual(expectedColor, mat.GetColor("_Color"), "Test 7: Fallback _Color should be set"); - } - - // 8. Invalid JSON should warn (don't fail) - var invalidJson = new JObject - { - ["action"] = "create", - ["path"] = badJsonPath, - ["assetType"] = "Material", - ["properties"] = "{\"invalid\": json, \"missing\": quotes}" - }; - LogAssert.Expect(LogType.Warning, new Regex("(failed to parse)|(Could not parse 'properties' JSON string)", RegexOptions.IgnoreCase)); - var invalidRaw = ManageAsset.HandleCommand(invalidJson); - var invalidResult = invalidRaw as JObject ?? JObject.FromObject(invalidRaw); - // Should either succeed with defaults or fail gracefully - Assert.IsNotNull(invalidResult, "Test 8: Should return a result"); - Debug.Log($"Test 8: Invalid JSON handled - {invalidResult!["success"]}"); - - // 9. Switch shader pipeline dynamically - var modify6 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = "{\"shader\":\"Standard\",\"color\":[1,1,0,1]}" - }; - var modifyRaw6 = ManageAsset.HandleCommand(modify6); - var modifyResult6 = modifyRaw6 as JObject ?? JObject.FromObject(modifyRaw6); - Assert.IsTrue(modifyResult6.Value("success"), $"Test 9 failed: {modifyResult6}"); - mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.AreEqual("Standard", mat.shader.name, "Test 9: Shader should be Standard"); - var c9 = mat.GetColor("_Color"); - Assert.IsTrue(Mathf.Abs(c9.r - 1f) < 0.02f && Mathf.Abs(c9.g - 1f) < 0.02f && Mathf.Abs(c9.b - 0f) < 0.02f, "Test 9: Color should be near yellow"); - - // 10. Mixed friendly and alias keys in one go - var modify7 = new JObject - { - ["action"] = "modify", - ["path"] = matPath, - ["properties"] = new JObject - { - ["metallic"] = 0.8, - ["smoothness"] = 0.3, - ["albedo"] = texPath // Texture path if exists - } - }; - var modifyRaw7 = ManageAsset.HandleCommand(modify7); - var modifyResult7 = modifyRaw7 as JObject ?? JObject.FromObject(modifyRaw7); - Assert.IsTrue(modifyResult7.Value("success"), $"Test 10 failed: {modifyResult7}"); - mat = AssetDatabase.LoadAssetAtPath(matPath); - Assert.AreEqual(0.8f, mat.GetFloat("_Metallic"), 0.001f, "Test 10: Metallic should be 0.8"); - Assert.AreEqual(0.3f, mat.GetFloat("_Glossiness"), 0.001f, "Test 10: Smoothness should be 0.3"); - - Debug.Log("All 10 end-to-end property handling tests completed successfully!"); - } - finally - { - // Cleanup - var sphere = GameObject.Find(sphereName); - if (sphere != null) UnityEngine.Object.DestroyImmediate(sphere); - if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); - if (AssetDatabase.LoadAssetAtPath(badJsonPath) != null) AssetDatabase.DeleteAsset(badJsonPath); - AssetDatabase.Refresh(); - } - } + try + { + LogAssert.Expect(LogType.Warning, new Regex("(failed to parse)|(Could not parse 'properties' JSON string)", RegexOptions.IgnoreCase)); + var raw = ManageAsset.HandleCommand(p); + var result = raw as JObject ?? JObject.FromObject(raw); + // Should either succeed with defaults or fail gracefully + Assert.IsNotNull(result, "Handler should return a result"); + // The result might be success (with defaults) or failure, both are acceptable + } + finally + { + if (AssetDatabase.LoadAssetAtPath(matPath) != null) + { + AssetDatabase.DeleteAsset(matPath); + } + AssetDatabase.Refresh(); + } + } + + [Test] + public void Test_ManageAsset_JSONStringParsing_FloatProperty_Metallic_CreateAndModify() + { + // Validate float property handling via JSON string for create and modify + const string tempDir = "Assets/Temp/MCPToolParameterTests"; + if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); + if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); + var matPath = $"{tempDir}/JsonFloatTest_{Guid.NewGuid().ToString("N")}.mat"; + + var createParams = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = "{\"shader\": \"Universal Render Pipeline/Lit\", \"metallic\": 0.75}" + }; + + try + { + var createRaw = ManageAsset.HandleCommand(createParams); + var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); + Assert.IsTrue(createResult!.Value("success"), createResult.ToString()); + + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created at path"); + AssertShaderIsSupported(mat.shader); + if (mat.HasProperty("_Metallic")) + { + Assert.AreEqual(0.75f, mat.GetFloat("_Metallic"), 1e-3f, "Metallic should be ~0.75 after create"); + } + + var modifyParams = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = "{\"metallic\": 0.1}" + }; + + var modifyRaw = ManageAsset.HandleCommand(modifyParams); + var modifyResult = modifyRaw as JObject ?? JObject.FromObject(modifyRaw); + Assert.IsTrue(modifyResult!.Value("success"), modifyResult.ToString()); + + var mat2 = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat2, "Material should still exist"); + if (mat2.HasProperty("_Metallic")) + { + Assert.AreEqual(0.1f, mat2.GetFloat("_Metallic"), 1e-3f, "Metallic should be ~0.1 after modify"); + } + } + finally + { + if (AssetDatabase.LoadAssetAtPath(matPath) != null) + { + AssetDatabase.DeleteAsset(matPath); + } + AssetDatabase.Refresh(); + } + } + + [Test] + public void Test_ManageAsset_JSONStringParsing_TextureAssignment_CreateAndModify() + { + // Uses flexible direct property assignment to set _BaseMap/_MainTex by path + const string tempDir = "Assets/Temp/MCPToolParameterTests"; + if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); + if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); + var matPath = $"{tempDir}/JsonTexTest_{Guid.NewGuid().ToString("N")}.mat"; + var texPath = "Assets/Temp/LiveTests/TempBaseTex.asset"; // created by GenTempTex + + // Ensure the texture exists BEFORE creating the material so assignment succeeds during create + var preTex = AssetDatabase.LoadAssetAtPath(texPath); + if (preTex == null) + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); + if (!AssetDatabase.IsValidFolder("Assets/Temp/LiveTests")) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); + var tex2D = new Texture2D(4, 4, TextureFormat.RGBA32, false); + var pixels = new Color[16]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.white; + tex2D.SetPixels(pixels); + tex2D.Apply(); + AssetDatabase.CreateAsset(tex2D, texPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + var createParams = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = new JObject + { + ["shader"] = "Universal Render Pipeline/Lit", + ["_BaseMap"] = texPath // resolves to _BaseMap or _MainTex internally + } + }; + + try + { + var createRaw = ManageAsset.HandleCommand(createParams); + var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); + Assert.IsTrue(createResult!.Value("success"), createResult.ToString()); + + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created at path"); + AssertShaderIsSupported(mat.shader); + var tex = AssetDatabase.LoadAssetAtPath(texPath); + if (tex == null) + { + // Create a tiny white texture if missing to make the test self-sufficient + if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); + if (!AssetDatabase.IsValidFolder("Assets/Temp/LiveTests")) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); + var tex2D = new Texture2D(4, 4, TextureFormat.RGBA32, false); + var pixels = new Color[16]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.white; + tex2D.SetPixels(pixels); + tex2D.Apply(); + AssetDatabase.CreateAsset(tex2D, texPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + tex = AssetDatabase.LoadAssetAtPath(texPath); + } + Assert.IsNotNull(tex, "Test texture should exist"); + // Verify either _BaseMap or _MainTex holds the texture + bool hasTexture = (mat.HasProperty("_BaseMap") && mat.GetTexture("_BaseMap") == tex) + || (mat.HasProperty("_MainTex") && mat.GetTexture("_MainTex") == tex); + Assert.IsTrue(hasTexture, "Material should reference the assigned texture"); + + // Modify by changing to same texture via alternate alias key + var modifyParams = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = new JObject { ["_MainTex"] = texPath } + }; + var modifyRaw = ManageAsset.HandleCommand(modifyParams); + var modifyResult = modifyRaw as JObject ?? JObject.FromObject(modifyRaw); + Assert.IsTrue(modifyResult!.Value("success"), modifyResult.ToString()); + + var mat2 = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat2); + bool hasTexture2 = (mat2.HasProperty("_BaseMap") && mat2.GetTexture("_BaseMap") == tex) + || (mat2.HasProperty("_MainTex") && mat2.GetTexture("_MainTex") == tex); + Assert.IsTrue(hasTexture2, "Material should keep the assigned texture after modify"); + } + finally + { + if (AssetDatabase.LoadAssetAtPath(matPath) != null) + { + AssetDatabase.DeleteAsset(matPath); + } + AssetDatabase.Refresh(); + } + } + + [Test] + public void Test_EndToEnd_PropertyHandling_AllScenarios() + { + // Comprehensive end-to-end test of all 10 property handling scenarios + const string tempDir = "Assets/Temp/LiveTests"; + if (!AssetDatabase.IsValidFolder("Assets/Temp")) AssetDatabase.CreateFolder("Assets", "Temp"); + if (!AssetDatabase.IsValidFolder(tempDir)) AssetDatabase.CreateFolder("Assets/Temp", "LiveTests"); + + string guidSuffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string matPath = $"{tempDir}/Mat_{guidSuffix}.mat"; + string texPath = $"{tempDir}/TempBaseTex.asset"; + string sphereName = $"LiveSphere_{guidSuffix}"; + string badJsonPath = $"{tempDir}/BadJson_{guidSuffix}.mat"; + + // Ensure clean state from previous runs + var preSphere = GameObject.Find(sphereName); + if (preSphere != null) UnityEngine.Object.DestroyImmediate(preSphere); + if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); + if (AssetDatabase.LoadAssetAtPath(badJsonPath) != null) AssetDatabase.DeleteAsset(badJsonPath); + AssetDatabase.Refresh(); + + try + { + // 1. Create material via JSON string + var createParams = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = "{\"shader\":\"Universal Render Pipeline/Lit\",\"color\":[1,0,0,1]}" + }; + var createRaw = ManageAsset.HandleCommand(createParams); + var createResult = createRaw as JObject ?? JObject.FromObject(createRaw); + Assert.IsTrue(createResult.Value("success"), $"Test 1 failed: {createResult}"); + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created"); + var expectedRed = Color.red; + if (mat.HasProperty("_BaseColor")) + Assert.AreEqual(expectedRed, mat.GetColor("_BaseColor"), "Test 1: _BaseColor should be red"); + else if (mat.HasProperty("_Color")) + Assert.AreEqual(expectedRed, mat.GetColor("_Color"), "Test 1: _Color should be red"); + else + Assert.Inconclusive("Material has neither _BaseColor nor _Color"); + + // 2. Modify color and metallic (friendly names) + var modify1 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = "{\"color\":[0,0.5,1,1],\"metallic\":0.6}" + }; + var modifyRaw1 = ManageAsset.HandleCommand(modify1); + var modifyResult1 = modifyRaw1 as JObject ?? JObject.FromObject(modifyRaw1); + Assert.IsTrue(modifyResult1.Value("success"), $"Test 2 failed: {modifyResult1}"); + mat = AssetDatabase.LoadAssetAtPath(matPath); + var expectedCyan = new Color(0, 0.5f, 1, 1); + if (mat.HasProperty("_BaseColor")) + Assert.AreEqual(expectedCyan, mat.GetColor("_BaseColor"), "Test 2: _BaseColor should be cyan"); + else if (mat.HasProperty("_Color")) + Assert.AreEqual(expectedCyan, mat.GetColor("_Color"), "Test 2: _Color should be cyan"); + else + Assert.Inconclusive("Material has neither _BaseColor nor _Color"); + Assert.AreEqual(0.6f, mat.GetFloat("_Metallic"), 0.001f, "Test 2: Metallic should be 0.6"); + + // 3. Modify using structured float block + var modify2 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = new JObject + { + ["float"] = new JObject { ["name"] = "_Metallic", ["value"] = 0.1 } + } + }; + var modifyRaw2 = ManageAsset.HandleCommand(modify2); + var modifyResult2 = modifyRaw2 as JObject ?? JObject.FromObject(modifyRaw2); + Assert.IsTrue(modifyResult2.Value("success"), $"Test 3 failed: {modifyResult2}"); + mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.AreEqual(0.1f, mat.GetFloat("_Metallic"), 0.001f, "Test 3: Metallic should be 0.1"); + + // 4. Assign texture via direct prop alias (skip if texture doesn't exist) + if (AssetDatabase.LoadAssetAtPath(texPath) != null) + { + var modify3 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = "{\"_BaseMap\":\"" + texPath + "\"}" + }; + var modifyRaw3 = ManageAsset.HandleCommand(modify3); + var modifyResult3 = modifyRaw3 as JObject ?? JObject.FromObject(modifyRaw3); + Assert.IsTrue(modifyResult3.Value("success"), $"Test 4 failed: {modifyResult3}"); + Debug.Log("Test 4: Texture assignment successful"); + } + else + { + Debug.LogWarning("Test 4: Skipped - texture not found at " + texPath); + } + + // 5. Assign texture via structured block (skip if texture doesn't exist) + if (AssetDatabase.LoadAssetAtPath(texPath) != null) + { + var modify4 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = new JObject + { + ["texture"] = new JObject { ["name"] = "_MainTex", ["path"] = texPath } + } + }; + var modifyRaw4 = ManageAsset.HandleCommand(modify4); + var modifyResult4 = modifyRaw4 as JObject ?? JObject.FromObject(modifyRaw4); + Assert.IsTrue(modifyResult4.Value("success"), $"Test 5 failed: {modifyResult4}"); + Debug.Log("Test 5: Structured texture assignment successful"); + } + else + { + Debug.LogWarning("Test 5: Skipped - texture not found at " + texPath); + } + + // 6. Create sphere and assign material via componentProperties JSON string + var createSphere = new JObject + { + ["action"] = "create", + ["name"] = sphereName, + ["primitiveType"] = "Sphere" + }; + var sphereRaw = ManageGameObject.HandleCommand(createSphere); + var sphereResult = sphereRaw as JObject ?? JObject.FromObject(sphereRaw); + Assert.IsTrue(sphereResult.Value("success"), $"Test 6 - Create sphere failed: {sphereResult}"); + + var modifySphere = new JObject + { + ["action"] = "modify", + ["target"] = sphereName, + ["searchMethod"] = "by_name", + ["componentProperties"] = "{\"MeshRenderer\":{\"sharedMaterial\":\"" + matPath + "\"}}" + }; + var sphereModifyRaw = ManageGameObject.HandleCommand(modifySphere); + var sphereModifyResult = sphereModifyRaw as JObject ?? JObject.FromObject(sphereModifyRaw); + Assert.IsTrue(sphereModifyResult.Value("success"), $"Test 6 - Assign material failed: {sphereModifyResult}"); + var sphere = GameObject.Find(sphereName); + Assert.IsNotNull(sphere, "Test 6: Sphere should exist"); + var renderer = sphere.GetComponent(); + Assert.IsNotNull(renderer.sharedMaterial, "Test 6: Material should be assigned"); + + // 7. Use URP color alias key + var modify5 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = new JObject + { + ["_BaseColor"] = new JArray(0.2, 0.8, 0.3, 1) + } + }; + var modifyRaw5 = ManageAsset.HandleCommand(modify5); + var modifyResult5 = modifyRaw5 as JObject ?? JObject.FromObject(modifyRaw5); + Assert.IsTrue(modifyResult5.Value("success"), $"Test 7 failed: {modifyResult5}"); + mat = AssetDatabase.LoadAssetAtPath(matPath); + Color expectedColor = new Color(0.2f, 0.8f, 0.3f, 1f); + if (mat.HasProperty("_BaseColor")) + { + Color actualColor = mat.GetColor("_BaseColor"); + AssertColorsEqual(expectedColor, actualColor, "Test 7: _BaseColor should be set"); + } + else if (mat.HasProperty("_Color")) + { + Color actualColor = mat.GetColor("_Color"); + AssertColorsEqual(expectedColor, actualColor, "Test 7: Fallback _Color should be set"); + } + + // 8. Invalid JSON should warn (don't fail) + var invalidJson = new JObject + { + ["action"] = "create", + ["path"] = badJsonPath, + ["assetType"] = "Material", + ["properties"] = "{\"invalid\": json, \"missing\": quotes}" + }; + LogAssert.Expect(LogType.Warning, new Regex("(failed to parse)|(Could not parse 'properties' JSON string)", RegexOptions.IgnoreCase)); + var invalidRaw = ManageAsset.HandleCommand(invalidJson); + var invalidResult = invalidRaw as JObject ?? JObject.FromObject(invalidRaw); + // Should either succeed with defaults or fail gracefully + Assert.IsNotNull(invalidResult, "Test 8: Should return a result"); + Debug.Log($"Test 8: Invalid JSON handled - {invalidResult!["success"]}"); + + // 9. Switch shader pipeline dynamically + var modify6 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = "{\"shader\":\"Standard\",\"color\":[1,1,0,1]}" + }; + var modifyRaw6 = ManageAsset.HandleCommand(modify6); + var modifyResult6 = modifyRaw6 as JObject ?? JObject.FromObject(modifyRaw6); + Assert.IsTrue(modifyResult6.Value("success"), $"Test 9 failed: {modifyResult6}"); + mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.AreEqual("Standard", mat.shader.name, "Test 9: Shader should be Standard"); + var c9 = mat.GetColor("_Color"); + Assert.IsTrue(Mathf.Abs(c9.r - 1f) < 0.02f && Mathf.Abs(c9.g - 1f) < 0.02f && Mathf.Abs(c9.b - 0f) < 0.02f, "Test 9: Color should be near yellow"); + + // 10. Mixed friendly and alias keys in one go + var modify7 = new JObject + { + ["action"] = "modify", + ["path"] = matPath, + ["properties"] = new JObject + { + ["metallic"] = 0.8, + ["smoothness"] = 0.3, + ["albedo"] = texPath // Texture path if exists + } + }; + var modifyRaw7 = ManageAsset.HandleCommand(modify7); + var modifyResult7 = modifyRaw7 as JObject ?? JObject.FromObject(modifyRaw7); + Assert.IsTrue(modifyResult7.Value("success"), $"Test 10 failed: {modifyResult7}"); + mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.AreEqual(0.8f, mat.GetFloat("_Metallic"), 0.001f, "Test 10: Metallic should be 0.8"); + Assert.AreEqual(0.3f, mat.GetFloat("_Glossiness"), 0.001f, "Test 10: Smoothness should be 0.3"); + + Debug.Log("All 10 end-to-end property handling tests completed successfully!"); + } + finally + { + // Cleanup + var sphere = GameObject.Find(sphereName); + if (sphere != null) UnityEngine.Object.DestroyImmediate(sphere); + if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); + if (AssetDatabase.LoadAssetAtPath(badJsonPath) != null) AssetDatabase.DeleteAsset(badJsonPath); + AssetDatabase.Refresh(); + } + } } } \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs deleted file mode 100644 index 1c9f71e15..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using UnityEngine; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Services; - -namespace MCPForUnityTests.Editor.Services -{ - public class PythonToolRegistryServiceTests - { - private PythonToolRegistryService _service; - - [SetUp] - public void SetUp() - { - _service = new PythonToolRegistryService(); - } - - [Test] - public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist() - { - var registries = _service.GetAllRegistries().ToList(); - - // Note: This might find assets in the test project, so we just verify it doesn't throw - Assert.IsNotNull(registries, "Should return a non-null list"); - } - - [Test] - public void NeedsSync_ReturnsTrue_WhenHashingDisabled() - { - var asset = ScriptableObject.CreateInstance(); - asset.useContentHashing = false; - - var textAsset = new TextAsset("print('test')"); - - bool needsSync = _service.NeedsSync(asset, textAsset); - - Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled"); - - Object.DestroyImmediate(asset); - } - - [Test] - public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced() - { - var asset = ScriptableObject.CreateInstance(); - asset.useContentHashing = true; - - var textAsset = new TextAsset("print('test')"); - - bool needsSync = _service.NeedsSync(asset, textAsset); - - Assert.IsTrue(needsSync, "Should need sync for new file"); - - Object.DestroyImmediate(asset); - } - - [Test] - public void NeedsSync_ReturnsFalse_WhenHashMatches() - { - var asset = ScriptableObject.CreateInstance(); - asset.useContentHashing = true; - - var textAsset = new TextAsset("print('test')"); - - // First sync - _service.RecordSync(asset, textAsset); - - // Check if needs sync again - bool needsSync = _service.NeedsSync(asset, textAsset); - - Assert.IsFalse(needsSync, "Should not need sync when hash matches"); - - Object.DestroyImmediate(asset); - } - - [Test] - public void RecordSync_StoresFileState() - { - var asset = ScriptableObject.CreateInstance(); - var textAsset = new TextAsset("print('test')"); - - _service.RecordSync(asset, textAsset); - - Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded"); - Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored"); - Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored"); - - Object.DestroyImmediate(asset); - } - - [Test] - public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded() - { - var asset = ScriptableObject.CreateInstance(); - var textAsset = new TextAsset("print('test')"); - - // Record twice - _service.RecordSync(asset, textAsset); - var firstHash = asset.fileStates[0].contentHash; - - _service.RecordSync(asset, textAsset); - - Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state"); - Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same"); - - Object.DestroyImmediate(asset); - } - - [Test] - public void ComputeHash_ReturnsSameHash_ForSameContent() - { - var textAsset1 = new TextAsset("print('hello')"); - var textAsset2 = new TextAsset("print('hello')"); - - string hash1 = _service.ComputeHash(textAsset1); - string hash2 = _service.ComputeHash(textAsset2); - - Assert.AreEqual(hash1, hash2, "Same content should produce same hash"); - } - - [Test] - public void ComputeHash_ReturnsDifferentHash_ForDifferentContent() - { - var textAsset1 = new TextAsset("print('hello')"); - var textAsset2 = new TextAsset("print('world')"); - - string hash1 = _service.ComputeHash(textAsset1); - string hash2 = _service.ComputeHash(textAsset2); - - Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash"); - } - } -} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta deleted file mode 100644 index b694a93a7..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: fb9be9b99beba4112a7e3182df1d1d10 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs deleted file mode 100644 index a00c88daf..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.IO; -using NUnit.Framework; -using UnityEngine; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Services; - -namespace MCPForUnityTests.Editor.Services -{ - public class ToolSyncServiceTests - { - private ToolSyncService _service; - private string _testToolsDir; - - [SetUp] - public void SetUp() - { - _service = new ToolSyncService(); - _testToolsDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", "tools"); - - // Clean up any existing test directory - if (Directory.Exists(_testToolsDir)) - { - Directory.Delete(_testToolsDir, true); - } - } - - [TearDown] - public void TearDown() - { - // Clean up test directory - if (Directory.Exists(_testToolsDir)) - { - try - { - Directory.Delete(_testToolsDir, true); - } - catch - { - // Ignore cleanup errors - } - } - } - - [Test] - public void SyncProjectTools_CreatesDestinationDirectory() - { - _service.SyncProjectTools(_testToolsDir); - - Assert.IsTrue(Directory.Exists(_testToolsDir), "Should create destination directory"); - } - - [Test] - public void SyncProjectTools_ReturnsSuccess_WhenNoPythonToolsAssets() - { - var result = _service.SyncProjectTools(_testToolsDir); - - Assert.IsNotNull(result, "Should return a result"); - Assert.AreEqual(0, result.CopiedCount, "Should not copy any files"); - Assert.AreEqual(0, result.ErrorCount, "Should not have errors"); - } - - [Test] - public void SyncProjectTools_ReportsCorrectCounts() - { - var result = _service.SyncProjectTools(_testToolsDir); - - Assert.IsTrue(result.CopiedCount >= 0, "Copied count should be non-negative"); - Assert.IsTrue(result.SkippedCount >= 0, "Skipped count should be non-negative"); - Assert.IsTrue(result.ErrorCount >= 0, "Error count should be non-negative"); - } - } -} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta deleted file mode 100644 index a91f013a4..000000000 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolSyncServiceTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b2c3d4e5f67890123456789012345abc -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs index a562f6677..95c9adaee 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -23,16 +23,23 @@ public void SetUp() public void TearDown() { StageUtility.GoToMainStage(); - } - - [OneTimeTearDown] - public void CleanupAll() - { - StageUtility.GoToMainStage(); + + // Clean up temp directory after each test if (AssetDatabase.IsValidFolder(TempDirectory)) { AssetDatabase.DeleteAsset(TempDirectory); } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + var remainingDirs = Directory.GetDirectories("Assets/Temp"); + var remainingFiles = Directory.GetFiles("Assets/Temp"); + if (remainingDirs.Length == 0 && remainingFiles.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } } [Test] diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs index d92f0f8e0..befc9986d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs @@ -50,6 +50,24 @@ public void TearDown() TryDeleteAsset(_baseMapPath); TryDeleteAsset(_normalMapPath); TryDeleteAsset(_occlusionMapPath); + + // Clean up temp directory after each test + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + var remainingDirs = Directory.GetDirectories("Assets/Temp"); + var remainingFiles = Directory.GetFiles("Assets/Temp"); + if (remainingDirs.Length == 0 && remainingFiles.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } + AssetDatabase.Refresh(); } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs index 1ae73e0bb..92fc5e672 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs @@ -55,6 +55,24 @@ public void TearDown() { AssetDatabase.DeleteAsset(_matPath); } + + // Clean up temp directory after each test + if (AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.DeleteAsset(TempRoot); + } + + // Clean up parent Temp folder if it's empty + if (AssetDatabase.IsValidFolder("Assets/Temp")) + { + var remainingDirs = Directory.GetDirectories("Assets/Temp"); + var remainingFiles = Directory.GetFiles("Assets/Temp"); + if (remainingDirs.Length == 0 && remainingFiles.Length == 0) + { + AssetDatabase.DeleteAsset("Assets/Temp"); + } + } + AssetDatabase.Refresh(); }