diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5bc1e9073 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Server/build +.git +.venv +__pycache__ +*.pyc +.DS_Store diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 9cb8e1cd3..e99be1730 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -67,11 +67,19 @@ 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 README.md version references to v$NEW_VERSION" + sed -i 's|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v[0-9]\+\.[0-9]\+\.[0-9]\+|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v'"$NEW_VERSION"'|g' README.md + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' README.md + + echo "Updating README-zh.md version references to v$NEW_VERSION" + sed -i 's|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v[0-9]\+\.[0-9]\+\.[0-9]\+|https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v'"$NEW_VERSION"'|g' README-zh.md + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' README-zh.md + + echo "Updating Server/README.md version references to v$NEW_VERSION" + sed -i 's|git+https://github.com/CoplayDev/unity-mcp@v[0-9]\+\.[0-9]\+\.[0-9]\+#subdirectory=Server|git+https://github.com/CoplayDev/unity-mcp@v'"$NEW_VERSION"'#subdirectory=Server|g' Server/README.md - name: Commit and push changes env: @@ -81,7 +89,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" README.md README-zh.md Server/README.md if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 513dfb762..0b7315b9a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -55,14 +55,14 @@ jobs: uv venv echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - if [ -f MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then - uv pip install -e MCPForUnity/UnityMcpServer~/src - elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then - uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt - elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then - uv pip install -e MCPForUnity/UnityMcpServer~/ - elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then - uv pip install -r MCPForUnity/UnityMcpServer~/requirements.txt + if [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt + elif [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt else echo "No MCP Python deps found (skipping)" fi @@ -217,7 +217,7 @@ jobs: -stackTraceLogType Full \ -projectPath /workspace/TestProjects/UnityMCPTests \ "${manual_args[@]}" \ - -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + -executeMethod MCPForUnity.Editor.Services.Transport.Transports.StdioBridgeHost.StartAutoConnect # ---------- Wait for Unity bridge ---------- - name: Wait for Unity bridge (robust) @@ -285,7 +285,7 @@ jobs: "mcpServers": { "unity": { "command": "uv", - "args": ["run","--active","--directory","MCPForUnity/UnityMcpServer~/src","python","server.py"], + "args": ["run","--active","--directory","Server","python","server.py"], "transport": { "type": "stdio" }, "env": { "PYTHONUNBUFFERED": "1", diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8364d1ba1..2269758cd 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -4,7 +4,7 @@ on: push: branches: ["**"] paths: - - MCPForUnity/UnityMcpServer~/src/** + - Server/** - .github/workflows/python-tests.yml workflow_dispatch: {} @@ -26,13 +26,13 @@ jobs: - name: Install dependencies run: | - cd MCPForUnity/UnityMcpServer~/src + cd Server uv sync uv pip install -e ".[dev]" - name: Run tests run: | - cd MCPForUnity/UnityMcpServer~/src + cd Server uv run pytest tests/ -v --tb=short - name: Upload test results @@ -41,5 +41,5 @@ jobs: with: name: pytest-results path: | - MCPForUnity/UnityMcpServer~/src/.pytest_cache/ - MCPForUnity/UnityMcpServer~/src/tests/ + Server/.pytest_cache/ + Server/tests/ 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/Importers.meta b/MCPForUnity/Editor/Constants.meta similarity index 77% rename from MCPForUnity/Editor/Importers.meta rename to MCPForUnity/Editor/Constants.meta index 3d2420861..7c23235a7 100644 --- a/MCPForUnity/Editor/Importers.meta +++ b/MCPForUnity/Editor/Constants.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b104663d2f6c648e1b99633082385db2 +guid: f7e009cbf3e74f6c987331c2b438ec59 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs new file mode 100644 index 000000000..917185f06 --- /dev/null +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -0,0 +1,40 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Centralized list of EditorPrefs keys used by the MCP for Unity package. + /// Keeping them in one place avoids typos and simplifies migrations. + /// + internal static class EditorPrefKeys + { + internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport"; + internal const string DebugLogs = "MCPForUnity.DebugLogs"; + internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; + internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; + internal const string ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload"; + + internal const string UvxPathOverride = "MCPForUnity.UvxPath"; + internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath"; + + internal const string HttpBaseUrl = "MCPForUnity.HttpUrl"; + internal const string WebSocketSessionId = "MCPForUnity.WebSocketSessionId"; + internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; + internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + + internal const string ServerSrc = "MCPForUnity.ServerSrc"; + internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; + internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; + internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled"; + + internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; + internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; + + internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled"; + + internal const string LastUpdateCheck = "MCPForUnity.LastUpdateCheck"; + internal const string LatestKnownVersion = "MCPForUnity.LatestKnownVersion"; + internal const string LastStdIoUpgradeVersion = "MCPForUnity.LastStdIoUpgradeVersion"; + + internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; + internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + } +} diff --git a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta rename to MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta index f1e14f70a..9c923daae 100644 --- a/MCPForUnity/Editor/Helpers/PackageLifecycleManager.cs.meta +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c40bd28f2310d463c8cd00181202cbe4 +guid: 7317786cfb9304b0db20ca73a774b9fa MonoImporter: externalObjects: {} serializedVersion: 2 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..f88b13bcb 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); @@ -140,7 +136,7 @@ private static void GenerateRecommendations(DependencyCheckResult result, IPlatf if (result.GetMissingRequired().Count > 0) { - result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); + result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Setup Window) for guided installation."); } } } 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/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs index f654612c5..70c02c892 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -25,45 +25,33 @@ public override DependencyStatus DetectPython() try { - // Check common Python installation paths on Linux - var candidates = new[] + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/python/bin/python3", - "/snap/bin/python3" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; } - // Try PATH resolution using 'which' command + // Fallback: try 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including system, snap, and user-local locations."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -102,6 +90,51 @@ public override string GetInstallationRecommendations() Note: Make sure ~/.local/bin is in your PATH for user-local installations."; } + public override DependencyStatus DetectUV() + { + var status = new DependencyStatus("UV Package Manager", isRequired: true) + { + InstallationHint = GetUVInstallUrl() + }; + + try + { + // Try running uv/uvx directly with augmented PATH + if (TryValidateUv("uv", out string version, out string fullPath) || + TryValidateUv("uvx", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found UV {version} in PATH"; + return status; + } + + // Fallback: use which with augmented PATH + if (TryFindInPath("uv", out string pathResult) || + TryFindInPath("uvx", out pathResult)) + { + if (TryValidateUv(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found UV {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "UV not found in PATH"; + status.Details = "Install UV package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting UV: {ex.Message}"; + } + + return status; + } + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; @@ -159,6 +192,65 @@ private bool TryValidatePython(string pythonPath, out string version, out string return false; } + private bool TryValidateUv(string uvPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + psi.EnvironmentVariables["PATH"] = BuildAugmentedPath(); + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private string BuildAugmentedPath() + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + return string.Join(":", GetPathAdditions()) + ":" + currentPath; + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs index 7d54d2331..67dceef48 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -25,49 +25,33 @@ public override DependencyStatus DetectPython() try { - // Check common Python installation paths on macOS - var candidates = new[] + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) { - "python3", - "python", - "/usr/bin/python3", - "/usr/local/bin/python3", - "/opt/homebrew/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" - }; - - foreach (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; } - // Try PATH resolution using 'which' command + // Fallback: try 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { @@ -104,6 +88,51 @@ public override string GetInstallationRecommendations() Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; } + public override DependencyStatus DetectUV() + { + var status = new DependencyStatus("UV Package Manager", isRequired: true) + { + InstallationHint = GetUVInstallUrl() + }; + + try + { + // Try running uv/uvx directly with augmented PATH + if (TryValidateUv("uv", out string version, out string fullPath) || + TryValidateUv("uvx", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found UV {version} in PATH"; + return status; + } + + // Fallback: use which with augmented PATH + if (TryFindInPath("uv", out string pathResult) || + TryFindInPath("uvx", out pathResult)) + { + if (TryValidateUv(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found UV {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "UV not found in PATH"; + status.Details = "Install UV package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting UV: {ex.Message}"; + } + + return status; + } + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; @@ -160,6 +189,67 @@ private bool TryValidatePython(string pythonPath, out string version, out string return false; } + private bool TryValidateUv(string uvPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + var augmentedPath = BuildAugmentedPath(); + psi.EnvironmentVariables["PATH"] = augmentedPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3).Trim(); + fullPath = uvPath; + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private string BuildAugmentedPath() + { + var pathAdditions = GetPathAdditions(); + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + return string.Join(":", pathAdditions) + ":" + currentPath; + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs index 98044f17e..b3dfbc439 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -1,8 +1,6 @@ using System; using System.Diagnostics; -using System.IO; using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { @@ -28,22 +26,18 @@ public virtual DependencyStatus DetectUV() try { - // Use existing UV detection from ServerInstaller - string uvPath = ServerInstaller.FindUvPath(); - if (!string.IsNullOrEmpty(uvPath)) + // Try to find uv/uvx in PATH + if (TryFindUvInPath(out string uvPath, out string version)) { - if (TryValidateUV(uvPath, out string version)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = uvPath; - status.Details = $"Found UV {version} at {uvPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = uvPath; + status.Details = $"Found UV {version} in PATH"; + return status; } - status.ErrorMessage = "UV package manager not found. Please install UV."; - status.Details = "UV is required for managing Python dependencies."; + status.ErrorMessage = "UV not found in PATH"; + status.Details = "Install UV package manager and ensure it's added to PATH."; } catch (Exception ex) { @@ -53,86 +47,46 @@ public virtual DependencyStatus DetectUV() return status; } - public virtual DependencyStatus DetectMCPServer() + protected bool TryFindUvInPath(out string uvPath, out string version) { - var status = new DependencyStatus("MCP Server", isRequired: false); + uvPath = null; + version = null; - try + // Try common UV command names + var commands = new[] { "uvx", "uv" }; + + foreach (var cmd in commands) { - // Check if server is installed - string serverPath = ServerInstaller.GetServerPath(); - string serverPy = Path.Combine(serverPath, "server.py"); - - if (File.Exists(serverPy)) + try { - status.IsAvailable = true; - status.Path = serverPath; - - // Try to get version - string versionFile = Path.Combine(serverPath, "server_version.txt"); - if (File.Exists(versionFile)) + var psi = new ProcessStartInfo { - status.Version = File.ReadAllText(versionFile).Trim(); - } + FileName = cmd, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; - 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 + using var process = Process.Start(psi); + if (process == null) continue; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) { - status.ErrorMessage = "MCP Server not found"; - status.Details = "Server will be installed automatically when needed"; + version = output.Substring(3).Trim(); + uvPath = cmd; + return true; } } - } - catch (Exception ex) - { - status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; - } - - return status; - } - - protected bool TryValidateUV(string uvPath, out string version) - { - version = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = uvPath, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - if (process == null) return false; - - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(5000); - - if (process.ExitCode == 0 && output.StartsWith("uv ")) + catch { - version = output.Substring(3); // Remove "uv " prefix - return true; + // Try next command } } - catch - { - // Ignore validation errors - } return false; } diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs index 6a534ebc4..68dea9612 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -25,61 +25,33 @@ public override DependencyStatus DetectPython() try { - // Check common Python installation paths - var candidates = new[] + // Try running python directly first (works with Windows App Execution Aliases) + if (TryValidatePython("python3.exe", out string version, out string fullPath) || + TryValidatePython("python.exe", out version, out fullPath)) { - "python.exe", - "python3.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 (var candidate in candidates) - { - if (TryValidatePython(candidate, out string version, out string fullPath)) - { - status.IsAvailable = true; - status.Version = version; - status.Path = fullPath; - status.Details = $"Found Python {version} at {fullPath}"; - return status; - } + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; } - // Try PATH resolution using 'where' command - if (TryFindInPath("python.exe", out string pathResult) || - TryFindInPath("python3.exe", out pathResult)) + // Fallback: try 'where' command + if (TryFindInPath("python3.exe", out string pathResult) || + TryFindInPath("python.exe", out pathResult)) { - if (TryValidatePython(pathResult, out string version, out string fullPath)) + if (TryValidatePython(pathResult, out version, out fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; - status.Details = $"Found Python {version} in PATH at {fullPath}"; + status.Details = $"Found Python {version} in PATH"; return status; } } - status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; - status.Details = "Checked common installation paths and PATH environment variable."; + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; } catch (Exception ex) { diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index dac1facfd..3c912e2e5 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -3,7 +3,9 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; using PackageInfo = UnityEditor.PackageManager.PackageInfo; +using MCPForUnity.Editor.Services; namespace MCPForUnity.Editor.Helpers { @@ -49,7 +51,7 @@ public static string GetMcpPackageRootPath() // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); - + if (guids.Length == 0) { McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); @@ -57,11 +59,11 @@ public static string GetMcpPackageRootPath() } string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); - + // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs // Extract {packageRoot} int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal); - + if (editorIndex >= 0) { return scriptPath.Substring(0, editorIndex); @@ -136,7 +138,60 @@ 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 + /// Checks for EditorPrefs override first, then falls back to package version + /// + /// Git URL string, or empty string if version is unknown and no override + public static string GetMcpServerGitUrl() + { + // Check for Git URL override first + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(gitUrlOverride)) + { + return gitUrlOverride; + } + + // Fall back to default package version + string version = GetPackageVersion(); + if (version == "unknown") + { + // Fall back to main repo without pinned version so configs remain valid in test scenarios + return "git+https://github.com/CoplayDev/unity-mcp#subdirectory=Server"; + } + + 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..b4f786ee1 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -4,6 +4,7 @@ using System.Linq; using MCPForUnity.External.Tommy; using MCPForUnity.Editor.Services; +using UnityEditor; namespace MCPForUnity.Editor.Helpers { @@ -14,36 +15,50 @@ 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; + var table = new TomlTable(); + var mcpServers = new TomlTable(); + var unityMCP = new TomlTable(); - string dir = McpConfigurationHelper.ExtractDirectoryArg(args); - if (string.IsNullOrEmpty(dir)) return false; + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); - return McpConfigurationHelper.PathsEqual(dir, pythonDir); - } - catch + if (useHttpTransport) { - return false; + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; } - } + else + { + // Stdio mode: Use command and args + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + unityMCP["command"] = uvxPath; - public static string BuildCodexServerBlock(string uvPath, string serverSrc) - { - var table = new TomlTable(); - var mcpServers = new TomlTable(); + var args = new TomlArray(); + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Add(new TomlString { Value = "--from" }); + args.Add(new TomlString { Value = fromUrl }); + } + args.Add(new TomlString { Value = packageName }); + args.Add(new TomlString { Value = "--transport" }); + args.Add(new TomlString { Value = "stdio" }); + + unityMCP["args"] = args; - mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); + // Add Windows-specific environment configuration for stdio mode + 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"] = unityMCP; table["mcp_servers"] = mcpServers; using var writer = new StringWriter(); @@ -51,7 +66,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 +79,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(); @@ -73,9 +88,15 @@ public static string UpsertCodexServerBlock(string existingToml, string uvPath, } public static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + return TryParseCodexServer(toml, out command, out args, out _); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url) { command = null; args = null; + url = null; var root = TryParseToml(toml); if (root == null) return false; @@ -91,6 +112,15 @@ public static bool TryParseCodexServer(string toml, out string command, out stri return false; } + // Check for HTTP mode (url field) + url = GetTomlString(unity, "url"); + if (!string.IsNullOrEmpty(url)) + { + // HTTP mode detected - return true with url + return true; + } + + // Check for stdio mode (command + args) command = GetTomlString(unity, "command"); args = GetTomlStringArray(unity, "args"); @@ -126,27 +156,45 @@ 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 }; - - 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" }); - unityMCP["args"] = argsArray; - - // 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; + + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + } + else + { + // Stdio mode: Use command and args + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + unityMCP["command"] = new TomlString { Value = uvxPath }; + + var argsArray = new TomlArray(); + if (!string.IsNullOrEmpty(fromUrl)) + { + argsArray.Add(new TomlString { Value = "--from" }); + argsArray.Add(new TomlString { Value = fromUrl }); + } + argsArray.Add(new TomlString { Value = packageName }); + argsArray.Add(new TomlString { Value = "--transport" }); + argsArray.Add(new TomlString { Value = "stdio" }); + unityMCP["args"] = argsArray; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } } return unityMCP; diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 5889e4f6b..084e2a7ea 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -1,12 +1,16 @@ using Newtonsoft.Json; +using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; +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 +25,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,79 +46,93 @@ 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; + // Get transport preference (default to HTTP) + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool isWindsurf = client?.mcpType == McpTypes.Windsurf; - // 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)) + 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 = HttpEndpointUtility.GetMcpRpcUrl(); + string httpProperty = isWindsurf ? "serverUrl" : "url"; + unity[httpProperty] = httpUrl; + + // Remove legacy property for Windsurf (or vice versa) + string staleProperty = isWindsurf ? "url" : "serverUrl"; + if (unity[staleProperty] != null) { - // 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.Remove(staleProperty); } - catch { /* fallback to original directory on any error */ } - } -#endif - unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); + // 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) - { - unity["type"] = "stdio"; + if (isVSCode) + { + unity["type"] = "http"; + } } else { - // Remove type if it somehow exists from previous clients - if (unity["type"] != null) unity.Remove("type"); + // 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/serverUrl if they exist from previous config + if (unity["url"] != null) unity.Remove("url"); + if (unity["serverUrl"] != null) unity.Remove("serverUrl"); + + if (isVSCode) + { + unity["type"] = "stdio"; + } + } + + // Remove type for non-VSCode clients + if (!isVSCode && unity["type"] != null) + { + unity.Remove("type"); } - if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + bool requiresEnv = client?.mcpType == McpTypes.Kiro; + bool requiresDisabled = client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro); + + if (requiresEnv) { if (unity["env"] == null) { unity["env"] = new JObject(); } + } + else if (isWindsurf && unity["env"] != null) + { + unity.Remove("env"); + } - if (unity["disabled"] == null) - { - unity["disabled"] = false; - } + if (requiresDisabled && unity["disabled"] == null) + { + unity["disabled"] = false; } } diff --git a/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs new file mode 100644 index 000000000..6f0ca788e --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs @@ -0,0 +1,162 @@ +using UnityEditor; +using UnityEditor.Callbacks; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Constants; + +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 _autoRegistrationPending = false; + + static CustomToolRegistrationProcessor() + { + // Load saved preference + _isRegistrationEnabled = EditorPrefs.GetBool(EditorPrefKeys.CustomToolRegistrationEnabled, true); + } + + /// + /// Enable or disable automatic tool registration + /// + public static bool IsRegistrationEnabled + { + get => _isRegistrationEnabled; + set + { + _isRegistrationEnabled = value; + EditorPrefs.SetBool(EditorPrefKeys.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; + } + + var transportManager = MCPServiceLocator.TransportManager; + var activeMode = transportManager.ActiveMode; + bool isHttpMode = activeMode == TransportMode.Http; + + if (!isHttpMode) + { + McpLog.Info("Skipping custom tool registration: HTTP transport is not active"); + return; + } + + var transportState = transportManager.GetState(); + if (!transportState.IsConnected) + { + McpLog.Info("Skipping custom tool registration: MCP transport not connected"); + 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}"); + } + } + + /// + /// Called when scripts are reloaded + /// + [DidReloadScripts] + private static void OnScriptsReloaded() + { + // Invalidate discovery cache to pick up new tools + var discoveryService = MCPServiceLocator.ToolDiscovery; + discoveryService.InvalidateCache(); + + _autoRegistrationPending = true; + } + + internal static void NotifyHttpConnectionHealthy() + { + if (!_isRegistrationEnabled) + { + return; + } + + if (!_autoRegistrationPending) + { + return; + } + + _autoRegistrationPending = false; + 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) + { + string status = tool.AutoRegister ? "enabled" : "disabled"; + info += $" - {tool.Name} ({status}): {tool.Description}\n"; + } + + return info; + } + catch (System.Exception ex) + { + return $"Error getting tool info: {ex.Message}"; + } + } + } +} diff --git a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta similarity index 83% rename from MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta rename to MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta index bfe30d9f5..f9d9bad46 100644 --- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta +++ b/MCPForUnity/Editor/Helpers/CustomToolRegistrationProcessor.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1ad9865b38bcc4efe85d4970c6d3a997 +guid: 80f36b8b3f86a45299c7d816c099c98b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 20c1200b9..9190ec388 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -5,12 +5,13 @@ using System.Text; using System.Runtime.InteropServices; using UnityEditor; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { internal static class ExecPath { - private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; + private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride; // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. internal static string ResolveClaude() @@ -157,12 +158,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/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs index f9abf1f1c..05d1b8b31 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -255,25 +255,25 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ var declaredFields = currentType.GetFields(fieldFlags); // Process the declared Fields for caching - foreach (var fieldInfo in declaredFields) + foreach (var fieldInfo in declaredFields) { if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields // Add if not already added (handles hiding - keep the most derived version) if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; - bool shouldInclude = false; - if (includeNonPublicSerializedFields) - { - // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) - var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); - shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); - } - else // includeNonPublicSerializedFields is FALSE - { - // If FALSE, include ONLY if it is explicitly Public. - shouldInclude = fieldInfo.IsPublic; - } + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) + var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); + shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } if (shouldInclude) { @@ -358,7 +358,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // --- Add detailed logging --- // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); // --- End detailed logging --- - + // --- Special handling for material/mesh properties in edit mode --- object value; if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh")) @@ -386,7 +386,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ value = propInfo.GetValue(c); } // --- End special handling --- - + Type propType = propInfo.PropertyType; AddSerializableValue(serializablePropertiesOutput, propName, propType, value); } diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs new file mode 100644 index 000000000..bda33cbb4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -0,0 +1,86 @@ +using System; +using UnityEditor; +using MCPForUnity.Editor.Constants; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper methods for managing HTTP endpoint URLs used by the MCP bridge. + /// Ensures the stored value is always the base URL (without trailing path), + /// and provides convenience accessors for specific endpoints. + /// + public static class HttpEndpointUtility + { + private const string PrefKey = EditorPrefKeys.HttpBaseUrl; + private const string DefaultBaseUrl = "http://localhost:8080"; + + /// + /// Returns the normalized base URL currently stored in EditorPrefs. + /// + public static string GetBaseUrl() + { + string stored = EditorPrefs.GetString(PrefKey, DefaultBaseUrl); + return NormalizeBaseUrl(stored); + } + + /// + /// Saves a user-provided URL after normalizing it to a base form. + /// + public static void SaveBaseUrl(string userValue) + { + string normalized = NormalizeBaseUrl(userValue); + EditorPrefs.SetString(PrefKey, normalized); + } + + /// + /// Builds the JSON-RPC endpoint used by FastMCP clients (base + /mcp). + /// + public static string GetMcpRpcUrl() + { + return AppendPathSegment(GetBaseUrl(), "mcp"); + } + + /// + /// Builds the endpoint used when POSTing custom-tool registration payloads. + /// + public static string GetRegisterToolsUrl() + { + return AppendPathSegment(GetBaseUrl(), "register-tools"); + } + + /// + /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). + /// + private static string NormalizeBaseUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return DefaultBaseUrl; + } + + string trimmed = value.Trim(); + + // Ensure scheme exists; default to http:// if user omitted it. + if (!trimmed.Contains("://")) + { + trimmed = $"http://{trimmed}"; + } + + // Remove trailing slash segments. + trimmed = trimmed.TrimEnd('/'); + + // Strip trailing "/mcp" (case-insensitive) if provided. + if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^4]; + } + + return trimmed; + } + + private static string AppendPathSegment(string baseUrl, string segment) + { + return $"{baseUrl.TrimEnd('/')}/{segment}"; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta rename to MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta index d3a3719ca..55d67cbf6 100644 --- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 4bdcf382960c842aab0a08c90411ab43 +guid: 2051d90316ea345c09240c80c7138e3b MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 96ad7ec29..cca4469ca 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -10,6 +10,8 @@ using MCPForUnity.Editor.Dependencies; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { @@ -19,13 +21,13 @@ namespace MCPForUnity.Editor.Helpers /// public static class McpConfigurationHelper { - private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; + private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig; /// /// 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 +96,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,27 +106,20 @@ 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); EnsureConfigDirectoryExists(configPath); WriteAtomicFile(configPath, mergedJson); - try - { - if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - return "Configured successfully"; } /// /// 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 +149,20 @@ 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); - } - 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 +196,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 +228,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 +279,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/McpJobStateStore.cs b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs new file mode 100644 index 000000000..5db093b35 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility for persisting tool state across domain reloads. State is stored in + /// Library so it stays local to the project and is cleared by Unity as needed. + /// + public static class McpJobStateStore + { + private static string GetStatePath(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + { + throw new ArgumentException("toolName cannot be null or empty", nameof(toolName)); + } + + var libraryPath = Path.Combine(Application.dataPath, "..", "Library"); + var fileName = $"McpState_{toolName}.json"; + return Path.GetFullPath(Path.Combine(libraryPath, fileName)); + } + + public static void SaveState(string toolName, T state) + { + var path = GetStatePath(toolName); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance()); + File.WriteAllText(path, json); + } + + public static T LoadState(string toolName) + { + var path = GetStatePath(toolName); + if (!File.Exists(path)) + { + return default; + } + + try + { + var json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); + } + catch (Exception) + { + return default; + } + } + + public static void ClearState(string toolName) + { + var path = GetStatePath(toolName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta similarity index 83% rename from MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta rename to MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta index 38f19973a..df45ef510 100644 --- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2c76f0c7ff138ba4a952481e04bc3974 +guid: 28912085dd68342f8a9fda8a43c83a59 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs index 8d31c5562..2b0a31483 100644 --- a/MCPForUnity/Editor/Helpers/McpLog.cs +++ b/MCPForUnity/Editor/Helpers/McpLog.cs @@ -1,33 +1,53 @@ using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { internal static class McpLog { - private const string LogPrefix = "MCP-FOR-UNITY:"; + private const string InfoPrefix = "MCP-FOR-UNITY:"; + private const string DebugPrefix = "MCP-FOR-UNITY:"; private const string WarnPrefix = "MCP-FOR-UNITY:"; private const string ErrorPrefix = "MCP-FOR-UNITY:"; - private static bool IsDebugEnabled() + private static volatile bool _debugEnabled = ReadDebugPreference(); + + private static bool IsDebugEnabled() => _debugEnabled; + + private static bool ReadDebugPreference() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + public static void SetDebugLoggingEnabled(bool enabled) + { + _debugEnabled = enabled; + try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); } + catch { } + } + + public static void Debug(string message) { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + if (!IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{DebugPrefix} {message}"); } public static void Info(string message, bool always = true) { if (!always && !IsDebugEnabled()) return; - Debug.Log($"{LogPrefix} {message}"); + UnityEngine.Debug.Log($"{InfoPrefix} {message}"); } public static void Warn(string message) { - Debug.LogWarning($"{WarnPrefix} {message}"); + UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}"); } public static void Error(string message) { - Debug.LogError($"{ErrorPrefix} {message}"); + UnityEngine.Debug.LogError($"{ErrorPrefix} {message}"); } } } 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/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs index e7c489198..1de6f02de 100644 --- a/MCPForUnity/Editor/Helpers/PortManager.cs +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -8,6 +8,7 @@ using System.Threading; using Newtonsoft.Json; using UnityEngine; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Helpers { @@ -18,7 +19,7 @@ public static class PortManager { private static bool IsDebugEnabled() { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; } } @@ -35,42 +36,20 @@ public class PortConfig } /// - /// Get the port to use - either from storage or discover a new one - /// Will try stored port first, then fallback to discovering new port + /// Get the port to use from storage, or return the default if none has been saved yet. /// /// Port number to use public static int GetPortWithFallback() { - // Try to load stored port first, but only if it's from the current project var storedConfig = GetStoredPortConfig(); if (storedConfig != null && storedConfig.unity_port > 0 && - string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - IsPortAvailable(storedConfig.unity_port)) + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using stored port {storedConfig.unity_port} for current project"); return storedConfig.unity_port; } - // If stored port exists but is currently busy, wait briefly for release - if (storedConfig != null && storedConfig.unity_port > 0) - { - if (WaitForPortRelease(storedConfig.unity_port, 1500)) - { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} became available after short wait"); - return storedConfig.unity_port; - } - // Port is still busy after waiting - find a new available port instead - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative..."); - int newPort = FindAvailablePort(); - SavePort(newPort); - return newPort; - } - - // If no valid stored port, find a new one and save it - int foundPort = FindAvailablePort(); - SavePort(foundPort); - return foundPort; + return DefaultPort; } /// @@ -81,10 +60,30 @@ public static int DiscoverNewPort() { int newPort = FindAvailablePort(); SavePort(newPort); - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Discovered and saved new port: {newPort}"); + if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}"); return newPort; } + /// + /// Persist a user-selected port and return the value actually stored. + /// If is unavailable, the next available port is chosen instead. + /// + public static int SetPreferredPort(int port) + { + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "Port must be positive."); + } + + if (!IsPortAvailable(port)) + { + throw new InvalidOperationException($"Port {port} is already in use."); + } + + SavePort(port); + return port; + } + /// /// Find an available port starting from the default port /// @@ -94,18 +93,18 @@ private static int FindAvailablePort() // Always try default port first if (IsPortAvailable(DefaultPort)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using default port {DefaultPort}"); + if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}"); return DefaultPort; } - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Default port {DefaultPort} is in use, searching for alternative..."); + if (IsDebugEnabled()) McpLog.Info($"Default port {DefaultPort} is in use, searching for alternative..."); // Search for alternatives for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) { if (IsPortAvailable(port)) { - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Found available port {port}"); + if (IsDebugEnabled()) McpLog.Info($"Found available port {port}"); return port; } } @@ -214,11 +213,11 @@ private static void SavePort(int port) string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); - if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Saved port {port} to storage"); + if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); } catch (Exception ex) { - Debug.LogWarning($"Could not save port to storage: {ex.Message}"); + McpLog.Warn($"Could not save port to storage: {ex.Message}"); } } @@ -250,7 +249,7 @@ private static int LoadStoredPort() } catch (Exception ex) { - Debug.LogWarning($"Could not load port from storage: {ex.Message}"); + McpLog.Warn($"Could not load port from storage: {ex.Message}"); return 0; } } @@ -281,7 +280,7 @@ public static PortConfig GetStoredPortConfig() } catch (Exception ex) { - Debug.LogWarning($"Could not load port config: {ex.Message}"); + McpLog.Warn($"Could not load port config: {ex.Message}"); return null; } } diff --git a/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs new file mode 100644 index 000000000..f752ff400 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs @@ -0,0 +1,250 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides shared utilities for deriving deterministic project identity information + /// used by transport clients (hash, name, persistent session id). + /// + [InitializeOnLoad] + internal static class ProjectIdentityUtility + { + private const string SessionPrefKey = EditorPrefKeys.WebSocketSessionId; + private static bool _legacyKeyCleared; + private static string _cachedProjectName = "Unknown"; + private static string _cachedProjectHash = "default"; + private static bool _cacheScheduled; + + static ProjectIdentityUtility() + { + ScheduleCacheRefresh(); + EditorApplication.projectChanged += ScheduleCacheRefresh; + } + + private static void ScheduleCacheRefresh() + { + if (_cacheScheduled) + { + return; + } + + _cacheScheduled = true; + EditorApplication.delayCall += CacheIdentityOnMainThread; + } + + private static void CacheIdentityOnMainThread() + { + EditorApplication.delayCall -= CacheIdentityOnMainThread; + _cacheScheduled = false; + UpdateIdentityCache(); + } + + private static void UpdateIdentityCache() + { + try + { + string dataPath = Application.dataPath; + if (string.IsNullOrEmpty(dataPath)) + { + return; + } + + _cachedProjectHash = ComputeProjectHash(dataPath); + _cachedProjectName = ComputeProjectName(dataPath); + } + catch + { + // Ignore and keep defaults + } + } + + /// + /// Returns the SHA1 hash of the current project path (truncated to 8 characters). + /// Matches the legacy hash used by the stdio bridge and server registry. + /// + public static string GetProjectHash() + { + EnsureIdentityCache(); + return _cachedProjectHash; + } + + /// + /// Returns a human friendly project name derived from the Assets directory path. + /// + public static string GetProjectName() + { + return _cachedProjectName; + } + + private static string ComputeProjectHash(string dataPath) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(dataPath); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(0, Math.Min(16, sb.Length)); + } + catch + { + return "default"; + } + } + + private static string ComputeProjectName(string dataPath) + { + try + { + string projectPath = dataPath; + projectPath = projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + projectPath = projectPath[..^6].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + string name = Path.GetFileName(projectPath); + return string.IsNullOrEmpty(name) ? "Unknown" : name; + } + catch + { + return "Unknown"; + } + } + + /// + /// Persists a server-assigned session id. + /// Safe to call from background threads. + /// + public static void SetSessionId(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + EditorApplication.delayCall += () => + { + try + { + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to persist session ID: {ex.Message}"); + } + }; + } + + /// + /// Retrieves a persistent session id for the plugin, creating one if absent. + /// The session id is unique per project (scoped by project hash). + /// + public static string GetOrCreateSessionId() + { + try + { + // Make the session ID project-specific by including the project hash in the key + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + return sessionId; + } + catch + { + // If prefs are unavailable (e.g. during batch tests) fall back to runtime guid. + return Guid.NewGuid().ToString(); + } + } + + /// + /// Clears the persisted session id (mainly for tests). + /// + public static void ResetSessionId() + { + try + { + // Clear the project-specific session ID + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + if (EditorPrefs.HasKey(projectSpecificKey)) + { + EditorPrefs.DeleteKey(projectSpecificKey); + } + + if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey)) + { + EditorPrefs.DeleteKey(SessionPrefKey); + _legacyKeyCleared = true; + } + } + catch + { + // Ignore + } + } + + private static void EnsureIdentityCache() + { + // When Application.dataPath is unavailable (e.g., batch mode) we fall back to + // hashing the current working directory/Assets path so each project still + // derives a deterministic, per-project session id rather than sharing "default". + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + UpdateIdentityCache(); + + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + string fallback = TryComputeFallbackProjectHash(); + if (!string.IsNullOrEmpty(fallback)) + { + _cachedProjectHash = fallback; + } + } + + private static string TryComputeFallbackProjectHash() + { + try + { + string workingDirectory = Directory.GetCurrentDirectory(); + if (string.IsNullOrEmpty(workingDirectory)) + { + return "default"; + } + + // Normalise trailing separators so hashes remain stable + workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return ComputeProjectHash(Path.Combine(workingDirectory, "Assets")); + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta new file mode 100644 index 000000000..b7879e1f8 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 936e878ce1275453bae5e0cf03bd9d30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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/Response.cs b/MCPForUnity/Editor/Helpers/Response.cs index cfcd2efb2..39d7f6c0b 100644 --- a/MCPForUnity/Editor/Helpers/Response.cs +++ b/MCPForUnity/Editor/Helpers/Response.cs @@ -1,62 +1,108 @@ -using System; -using System.Collections.Generic; +using Newtonsoft.Json; namespace MCPForUnity.Editor.Helpers { - /// - /// Provides static methods for creating standardized success and error response objects. - /// Ensures consistent JSON structure for communication back to the Python server. - /// - public static class Response +public interface IMcpResponse +{ + [JsonProperty("success")] + bool Success { get; } + } + + public sealed class SuccessResponse : IMcpResponse { - /// - /// Creates a standardized success response object. - /// - /// A message describing the successful operation. - /// Optional additional data to include in the response. - /// An object representing the success response. - public static object Success(string message, object data = null) + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("message")] + public string Message { get; } + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public SuccessResponse(string message, object data = null) { - if (data != null) - { - return new - { - success = true, - message = message, - data = data, - }; - } - else - { - return new { success = true, message = message }; - } + Message = message; + Data = data; } + } + + public sealed class ErrorResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => false; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] + public string Code { get; } + + [JsonIgnore] + public string code => Code; + + [JsonProperty("error")] + public string Error { get; } + + [JsonIgnore] + public string error => Error; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public ErrorResponse(string messageOrCode, object data = null) + { + Code = messageOrCode; + Error = messageOrCode; + Data = data; + } + } + + public sealed class PendingResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("_mcp_status")] + public string Status => "pending"; + + [JsonIgnore] + public string _mcp_status => Status; + + [JsonProperty("_mcp_poll_interval")] + public double PollIntervalSeconds { get; } + + [JsonIgnore] + public double _mcp_poll_interval => PollIntervalSeconds; + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; } + + [JsonIgnore] + public string message => Message; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; - /// - /// Creates a standardized error response object. - /// - /// A message describing the error. - /// Optional additional data (e.g., error details) to include. - /// An object representing the error response. - public static object Error(string errorCodeOrMessage, object data = null) + public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null) { - if (data != null) - { - // Note: The key is "error" for error messages, not "message" - return new - { - success = false, - // Preserve original behavior while adding a machine-parsable code field. - // If callers pass a code string, it will be echoed in both code and error. - code = errorCodeOrMessage, - error = errorCodeOrMessage, - data = data, - }; - } - else - { - return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; - } + Message = string.IsNullOrEmpty(message) ? null : message; + PollIntervalSeconds = pollIntervalSeconds; + Data = data; } } } 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/Helpers/TelemetryHelper.cs b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs index 0f4362318..953fa7950 100644 --- a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Threading; using UnityEngine; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Helpers { @@ -11,8 +13,8 @@ namespace MCPForUnity.Editor.Helpers /// public static class TelemetryHelper { - private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; - private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; + private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled; + private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid; private static Action> s_sender; /// @@ -140,8 +142,8 @@ public static void RecordBridgeStartup() { RecordEvent("bridge_startup", new Dictionary { - ["bridge_version"] = "3.0.2", - ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() + ["bridge_version"] = AssetPathUtility.GetPackageVersion(), + ["auto_connect"] = StdioBridgeHost.IsAutoConnectMode() }); } @@ -213,7 +215,7 @@ private static bool IsDebugEnabled() { try { - return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs b/MCPForUnity/Editor/Importers/PythonFileImporter.cs deleted file mode 100644 index 8c60a1c20..000000000 --- a/MCPForUnity/Editor/Importers/PythonFileImporter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using UnityEngine; -using UnityEditor.AssetImporters; -using System.IO; - -namespace MCPForUnity.Editor.Importers -{ - /// - /// Custom importer that allows Unity to recognize .py files as TextAssets. - /// This enables Python files to be selected in the Inspector and used like any other text asset. - /// - [ScriptedImporter(1, "py")] - public class PythonFileImporter : ScriptedImporter - { - public override void OnImportAsset(AssetImportContext ctx) - { - var textAsset = new TextAsset(File.ReadAllText(ctx.assetPath)); - ctx.AddObjectToAsset("main obj", textAsset); - ctx.SetMainObject(textAsset); - } - } -} diff --git a/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta b/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta deleted file mode 100644 index 7e2edb2ea..000000000 --- a/MCPForUnity/Editor/Importers/PythonFileImporter.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: d68ef794590944f1ea7ee102c91887c7 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef index 88448922b..47621bdf6 100644 --- a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -3,17 +3,18 @@ "rootNamespace": "MCPForUnity.Editor", "references": [ "MCPForUnity.Runtime", - "GUID:560b04d1a97f54a46a2660c3cc343a6f" + "GUID:560b04d1a97f54a46a2660c3cc343a6f" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], - "allowUnsafeCode": false, "overrideReferences": false, - "precompiledReferences": [], + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false -} \ No newline at end of file +} \ No newline at end of file diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs.meta b/MCPForUnity/Editor/MCPForUnityBridge.cs.meta deleted file mode 100644 index f8d1f46e9..000000000 --- a/MCPForUnity/Editor/MCPForUnityBridge.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 96dc847eb7f7a45e0b91241db934a4be -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnityMenu.cs b/MCPForUnity/Editor/MCPForUnityMenu.cs deleted file mode 100644 index 714e48535..000000000 --- a/MCPForUnity/Editor/MCPForUnityMenu.cs +++ /dev/null @@ -1,75 +0,0 @@ -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Setup; -using MCPForUnity.Editor.Windows; -using UnityEditor; - -namespace MCPForUnity.Editor -{ - /// - /// Centralized menu items for MCP For Unity - /// - public static class MCPForUnityMenu - { - // ======================================== - // Main Menu Items - // ======================================== - - /// - /// Show the setup wizard - /// - [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] - public static void ShowSetupWizard() - { - SetupWizard.ShowSetupWizard(); - } - - /// - /// Open the main MCP For Unity window - /// - [MenuItem("Window/MCP For Unity/Open MCP Window %#m", priority = 2)] - public static void OpenMCPWindow() - { - MCPForUnityEditorWindow.ShowWindow(); - } - - // ======================================== - // Tool Sync 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) - /// - [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)] - public static bool ToggleAutoSyncValidate() - { - return PythonToolSyncProcessor.ToggleAutoSyncValidate(); - } - } -} 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..dd0b3b82d --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs @@ -0,0 +1,63 @@ +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; + +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.RegisterAllTools(); + } + + [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(EditorPrefKeys.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(EditorPrefKeys.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/MenuItems/CustomToolsMenuItems.cs.meta b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs.meta new file mode 100644 index 000000000..f0869e318 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/CustomToolsMenuItems.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d14bd1fefa6944e97a66138e14887cde +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs new file mode 100644 index 000000000..32fde7203 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -0,0 +1,46 @@ +using MCPForUnity.Editor.Setup; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Centralized menu items for MCP For Unity + /// + public static class MCPForUnityMenu + { + // ======================================== + // Main Menu Items + // ======================================== + + /// + /// Show the Setup Window + /// + [MenuItem("Window/MCP For Unity/Setup Window", priority = 1)] + public static void ShowSetupWindow() + { + SetupWindowService.ShowSetupWindow(); + } + + /// + /// Toggle the main MCP For Unity window + /// + [MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 2)] + public static void ToggleMCPWindow() + { + if (EditorWindow.HasOpenInstances()) + { + foreach (var window in UnityEngine.Resources.FindObjectsOfTypeAll()) + { + window.Close(); + } + } + else + { + MCPForUnityEditorWindow.ShowWindow(); + } + } + + } +} diff --git a/MCPForUnity/Editor/MCPForUnityMenu.cs.meta b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta similarity index 100% rename from MCPForUnity/Editor/MCPForUnityMenu.cs.meta rename to MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta b/MCPForUnity/Editor/Migrations.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta rename to MCPForUnity/Editor/Migrations.meta index 16c8bb616..62d67f08f 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp/LiveTests.meta +++ b/MCPForUnity/Editor/Migrations.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 0c392d9059b864f608a4d32e4347c3d6 +guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs new file mode 100644 index 000000000..186f62f9b --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs @@ -0,0 +1,71 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Detects legacy embedded-server preferences and migrates configs to the new uvx/stdio path once. + /// + [InitializeOnLoad] + internal static class LegacyServerSrcMigration + { + private const string ServerSrcKey = EditorPrefKeys.ServerSrc; + private const string UseEmbeddedKey = EditorPrefKeys.UseEmbeddedServer; + + static LegacyServerSrcMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + bool hasServerSrc = EditorPrefs.HasKey(ServerSrcKey); + bool hasUseEmbedded = EditorPrefs.HasKey(UseEmbeddedKey); + + if (!hasServerSrc && !hasUseEmbedded) + { + return; + } + + try + { + McpLog.Info("Detected legacy embedded MCP server configuration. Updating all client configs..."); + + var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); + + if (summary.FailureCount > 0 || summary.SuccessCount == 0) + { + McpLog.Warn($"Legacy configuration migration incomplete ({summary.GetSummaryMessage()}). Will retry next session."); + return; + } + + if (hasServerSrc) + { + EditorPrefs.DeleteKey(ServerSrcKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.ServerSrc"); + } + + if (hasUseEmbedded) + { + EditorPrefs.DeleteKey(UseEmbeddedKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.UseEmbeddedServer"); + } + + McpLog.Info($"Legacy configuration migration complete ({summary.GetSummaryMessage()})"); + } + catch (Exception ex) + { + McpLog.Error($"Legacy MCP server migration failed: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta new file mode 100644 index 000000000..ddc85c039 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4436b2149abf4b0d8014f81cd29a2bd0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs new file mode 100644 index 000000000..9f43734ff --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Constants; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Keeps stdio MCP clients in sync with the current package version by rewriting their configs when the package updates. + /// + [InitializeOnLoad] + internal static class StdIoVersionMigration + { + private const string LastUpgradeKey = EditorPrefKeys.LastStdIoUpgradeVersion; + + static StdIoVersionMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + string currentVersion = AssetPathUtility.GetPackageVersion(); + if (string.IsNullOrEmpty(currentVersion) || string.Equals(currentVersion, "unknown", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string lastUpgradeVersion = string.Empty; + try { lastUpgradeVersion = EditorPrefs.GetString(LastUpgradeKey, string.Empty); } catch { } + + if (string.Equals(lastUpgradeVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return; // Already refreshed for this package version + } + + bool hadFailures = false; + bool touchedAny = false; + + var clients = new McpClients().clients; + foreach (var client in clients) + { + try + { + if (!ConfigUsesStdIo(client)) + continue; + + MCPServiceLocator.Client.ConfigureClient(client); + touchedAny = true; + } + catch (Exception ex) + { + hadFailures = true; + McpLog.Warn($"Failed to refresh stdio config for {client.name}: {ex.Message}"); + } + } + + if (!touchedAny) + { + // Nothing needed refreshing; still record version so we don't rerun every launch + try { EditorPrefs.SetString(LastUpgradeKey, currentVersion); } catch { } + return; + } + + if (hadFailures) + { + McpLog.Warn("Stdio MCP upgrade encountered errors; will retry next session."); + return; + } + + try + { + EditorPrefs.SetString(LastUpgradeKey, currentVersion); + } + catch { } + + McpLog.Info($"Updated stdio MCP configs to package version {currentVersion}."); + } + + private static bool ConfigUsesStdIo(McpClient client) + { + switch (client.mcpType) + { + case McpTypes.Codex: + return CodexConfigUsesStdIo(client); + default: + return JsonConfigUsesStdIo(client); + } + } + + private static bool JsonConfigUsesStdIo(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + try + { + var root = JObject.Parse(File.ReadAllText(configPath)); + + JToken unityNode = null; + if (client.mcpType == McpTypes.VSCode) + { + unityNode = root.SelectToken("servers.unityMCP") + ?? root.SelectToken("mcp.servers.unityMCP"); + } + else + { + unityNode = root.SelectToken("mcpServers.unityMCP"); + } + + if (unityNode == null) return false; + + return unityNode["command"] != null; + } + catch + { + return false; + } + } + + private static bool CodexConfigUsesStdIo(McpClient client) + { + try + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + string toml = File.ReadAllText(configPath); + return CodexConfigHelper.TryParseCodexServer(toml, out var command, out _) + && !string.IsNullOrEmpty(command); + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta new file mode 100644 index 000000000..872a35714 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1d589c8c8684e6f919ffb393c4b4db5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs index 0a3fa8608..13a5564ab 100644 --- a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs @@ -40,11 +40,11 @@ public static object HandleCommand(JObject @params) } }; - return Response.Success("Retrieved active tool information.", toolInfo); + return new SuccessResponse("Retrieved active tool information.", toolInfo); } catch (Exception e) { - return Response.Error($"Error getting active tool: {e.Message}"); + return new ErrorResponse($"Error getting active tool: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/MCPForUnity/Editor/Resources/Editor/EditorState.cs index fdcff7e66..57f70f745 100644 --- a/MCPForUnity/Editor/Resources/Editor/EditorState.cs +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -28,12 +28,12 @@ public static object HandleCommand(JObject @params) selectionCount = UnityEditor.Selection.count, activeObjectName = UnityEditor.Selection.activeObject?.name }; - - return Response.Success("Retrieved editor state.", state); + + return new SuccessResponse("Retrieved editor state.", state); } catch (Exception e) { - return Response.Error($"Error getting editor state: {e.Message}"); + return new ErrorResponse($"Error getting editor state: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs index 2f66a01f4..ee47d6f59 100644 --- a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs +++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs @@ -16,10 +16,10 @@ public static object HandleCommand(JObject @params) try { var stage = PrefabStageUtility.GetCurrentPrefabStage(); - + if (stage == null) { - return Response.Success("No prefab stage is currently open.", new { isOpen = false }); + return new SuccessResponse("No prefab stage is currently open.", new { isOpen = false }); } var stageInfo = new @@ -31,11 +31,11 @@ public static object HandleCommand(JObject @params) isDirty = stage.scene.isDirty }; - return Response.Success("Prefab stage info retrieved.", stageInfo); + return new SuccessResponse("Prefab stage info retrieved.", stageInfo); } catch (Exception e) { - return Response.Error($"Error getting prefab stage info: {e.Message}"); + return new ErrorResponse($"Error getting prefab stage info: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs b/MCPForUnity/Editor/Resources/Editor/Selection.cs index 07bb34d84..022d9c488 100644 --- a/MCPForUnity/Editor/Resources/Editor/Selection.cs +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -41,11 +41,11 @@ public static object HandleCommand(JObject @params) assetGUIDs = UnityEditor.Selection.assetGUIDs }; - return Response.Success("Retrieved current selection details.", selectionInfo); + return new SuccessResponse("Retrieved current selection details.", selectionInfo); } catch (Exception e) { - return Response.Error($"Error getting selection: {e.Message}"); + return new ErrorResponse($"Error getting selection: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs b/MCPForUnity/Editor/Resources/Editor/Windows.cs index a637c1e2b..571908868 100644 --- a/MCPForUnity/Editor/Resources/Editor/Windows.cs +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs @@ -48,11 +48,11 @@ public static object HandleCommand(JObject @params) } } - return Response.Success("Retrieved list of open editor windows.", openWindows); + return new SuccessResponse("Retrieved list of open editor windows.", openWindows); } catch (Exception e) { - return Response.Error($"Error getting editor windows: {e.Message}"); + return new ErrorResponse($"Error getting editor windows: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs index c554be2d7..f6a84282f 100644 --- a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs +++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs @@ -33,7 +33,7 @@ public static object HandleCommand(JObject @params) } string message = $"Retrieved {items.Count} menu items"; - return Response.Success(message, items); + return new SuccessResponse(message, items); } internal static List GetMenuItemsInternal(bool forceRefresh) diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs b/MCPForUnity/Editor/Resources/Project/Layers.cs index eb7f1a30a..9e9ef7d54 100644 --- a/MCPForUnity/Editor/Resources/Project/Layers.cs +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs @@ -27,12 +27,12 @@ public static object HandleCommand(JObject @params) layers.Add(i, layerName); } } - - return Response.Success("Retrieved current named layers.", layers); + + return new SuccessResponse("Retrieved current named layers.", layers); } catch (Exception e) { - return Response.Error($"Failed to retrieve layers: {e.Message}"); + return new ErrorResponse($"Failed to retrieve layers: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs index 33069831e..6e6d12f93 100644 --- a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs @@ -20,7 +20,7 @@ public static object HandleCommand(JObject @params) string assetsPath = Application.dataPath.Replace('\\', '/'); string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); string projectName = Path.GetFileName(projectRoot); - + var info = new { projectRoot = projectRoot ?? "", @@ -29,12 +29,12 @@ public static object HandleCommand(JObject @params) platform = EditorUserBuildSettings.activeBuildTarget.ToString(), assetsPath = assetsPath }; - - return Response.Success("Retrieved project info.", info); + + return new SuccessResponse("Retrieved project info.", info); } catch (Exception e) { - return Response.Error($"Error getting project info: {e.Message}"); + return new ErrorResponse($"Error getting project info: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs b/MCPForUnity/Editor/Resources/Project/Tags.cs index 665e8d777..756f00d8d 100644 --- a/MCPForUnity/Editor/Resources/Project/Tags.cs +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs @@ -16,11 +16,11 @@ public static object HandleCommand(JObject @params) try { string[] tags = InternalEditorUtility.tags; - return Response.Success("Retrieved current tags.", tags); + return new SuccessResponse("Retrieved current tags.", tags); } catch (Exception e) { - return Response.Error($"Failed to retrieve tags: {e.Message}"); + return new ErrorResponse($"Failed to retrieve tags: {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/MCPForUnity/Editor/Resources/Tests/GetTests.cs index 3efb1c6be..f7eeda92f 100644 --- a/MCPForUnity/Editor/Resources/Tests/GetTests.cs +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -27,12 +27,12 @@ public static async Task HandleCommand(JObject @params) catch (Exception ex) { McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); - return Response.Error("Failed to retrieve tests"); + return new ErrorResponse("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} tests"; - return Response.Success(message, result); + return new SuccessResponse(message, result); } } @@ -49,12 +49,12 @@ public static async Task HandleCommand(JObject @params) string modeStr = @params["mode"]?.ToString(); if (string.IsNullOrEmpty(modeStr)) { - return Response.Error("'mode' parameter is required"); + return new ErrorResponse("'mode' parameter is required"); } if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { - return Response.Error(parseError); + return new ErrorResponse(parseError); } McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}"); @@ -66,11 +66,11 @@ public static async Task HandleCommand(JObject @params) catch (Exception ex) { McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); - return Response.Error("Failed to retrieve tests"); + return new ErrorResponse("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} {parsedMode.Value} tests"; - return Response.Success(message, result); + return new SuccessResponse(message, result); } } diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs index a462e68b3..c67efd1b6 100644 --- a/MCPForUnity/Editor/Services/BridgeControlService.cs +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -1,174 +1,130 @@ + using System; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; +using System.Threading.Tasks; +using UnityEditor; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Services { /// - /// Implementation of bridge control service + /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio). /// public class BridgeControlService : IBridgeControlService { - public bool IsRunning => MCPForUnityBridge.IsRunning; - public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); - public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode(); + private readonly TransportManager _transportManager; + private TransportMode _preferredMode = TransportMode.Http; - public void Start() + public BridgeControlService() { - // 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"))) - { - MCPForUnityBridge.StartAutoConnect(); - } - else - { - MCPForUnityBridge.Start(); - } + _transportManager = MCPServiceLocator.TransportManager; } - public void Stop() + private TransportMode ResolvePreferredMode() { - MCPForUnityBridge.Stop(); + bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio; + return _preferredMode; } - public BridgeVerificationResult Verify(int port) + private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null) { - var result = new BridgeVerificationResult + bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true); + string transportLabel = string.IsNullOrWhiteSpace(state.TransportName) + ? mode.ToString().ToLowerInvariant() + : state.TransportName; + string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]"; + string message = messageOverride + ?? state.Error + ?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}"); + + return new BridgeVerificationResult { - Success = false, - HandshakeValid = false, - PingSucceeded = false, - Message = "Verification not started" + Success = pingSucceeded && handshakeValid, + HandshakeValid = handshakeValid, + PingSucceeded = pingSucceeded, + Message = message }; + } - const int ConnectTimeoutMs = 1000; - const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout + public bool IsRunning => _transportManager.GetState().IsConnected; + public int CurrentPort + { + get + { + var state = _transportManager.GetState(); + if (state.Port.HasValue) + { + return state.Port.Value; + } + + // Legacy fallback while the stdio bridge is still in play + return StdioBridgeHost.GetCurrentPort(); + } + } + + public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode(); + public TransportMode? ActiveMode => _transportManager.ActiveMode; + + public async Task StartAsync() + { + var mode = ResolvePreferredMode(); try { - using (var client = new TcpClient()) + bool started = await _transportManager.StartAsync(mode); + if (!started) { - // Attempt connection - var connectTask = client.ConnectAsync(IPAddress.Loopback, port); - if (!connectTask.Wait(ConnectTimeoutMs)) - { - result.Message = "Connection timeout"; - return result; - } - - using (var stream = client.GetStream()) - { - try { client.NoDelay = true; } catch { } - - // 1) Read handshake line (ASCII, newline-terminated) - string handshake = ReadLineAscii(stream, 2000); - if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) - { - result.Message = "Bridge handshake missing FRAMING=1"; - return result; - } - - result.HandshakeValid = true; - - // 2) Send framed "ping" - byte[] payload = Encoding.UTF8.GetBytes("ping"); - WriteFrame(stream, payload, FrameTimeoutMs); - - // 3) Read framed response and check for pong - string response = ReadFrameUtf8(stream, FrameTimeoutMs); - if (!string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0) - { - result.PingSucceeded = true; - result.Success = true; - result.Message = "Bridge verified successfully"; - } - else - { - result.Message = $"Ping failed; response='{response}'"; - } - } + McpLog.Warn($"Failed to start MCP transport: {mode}"); } + return started; } catch (Exception ex) { - result.Message = $"Verification error: {ex.Message}"; + McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}"); + return false; } - - return result; } - // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts - private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) + public async Task StopAsync() { - 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); - header[1] = (byte)(len >> 48); - header[2] = (byte)(len >> 40); - header[3] = (byte)(len >> 32); - header[4] = (byte)(len >> 24); - header[5] = (byte)(len >> 16); - header[6] = (byte)(len >> 8); - header[7] = (byte)(len); - - stream.WriteTimeout = timeoutMs; - stream.Write(header, 0, header.Length); - stream.Write(payload, 0, payload.Length); + try + { + await _transportManager.StopAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping MCP transport: {ex.Message}"); + } } - private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) + public async Task VerifyAsync() { - byte[] header = ReadExact(stream, 8, timeoutMs); - ulong len = ((ulong)header[0] << 56) - | ((ulong)header[1] << 48) - | ((ulong)header[2] << 40) - | ((ulong)header[3] << 32) - | ((ulong)header[4] << 24) - | ((ulong)header[5] << 16) - | ((ulong)header[6] << 8) - | header[7]; - if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); - if (len > int.MaxValue) throw new IOException("Frame too large"); - byte[] payload = ReadExact(stream, (int)len, timeoutMs); - return Encoding.UTF8.GetString(payload); + var mode = _transportManager.ActiveMode ?? ResolvePreferredMode(); + bool pingSucceeded = await _transportManager.VerifyAsync(); + var state = _transportManager.GetState(); + return BuildVerificationResult(state, mode, pingSucceeded); } - private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) + public BridgeVerificationResult Verify(int port) { - byte[] buffer = new byte[count]; - int offset = 0; - stream.ReadTimeout = timeoutMs; - while (offset < count) - { - int read = stream.Read(buffer, offset, count - offset); - if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); - offset += read; - } - return buffer; - } + var mode = _transportManager.ActiveMode ?? ResolvePreferredMode(); + bool pingSucceeded = _transportManager.VerifyAsync().GetAwaiter().GetResult(); + var state = _transportManager.GetState(); - private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) - { - stream.ReadTimeout = timeoutMs; - using (var ms = new MemoryStream()) + if (mode == TransportMode.Stdio) { - byte[] one = new byte[1]; - while (ms.Length < maxLen) - { - int n = stream.Read(one, 0, 1); - if (n <= 0) break; - if (one[0] == (byte)'\n') break; - ms.WriteByte(one[0]); - } - return Encoding.ASCII.GetString(ms.ToArray()); + bool handshakeValid = state.IsConnected && port == CurrentPort; + string message = handshakeValid + ? $"STDIO transport listening on port {CurrentPort}" + : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})"; + return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid); } + + return BuildVerificationResult(state, mode, pingSucceeded); } + } } diff --git a/MCPForUnity/Editor/Services/CacheManagementService.cs b/MCPForUnity/Editor/Services/CacheManagementService.cs new file mode 100644 index 000000000..bb73ce8d3 --- /dev/null +++ b/MCPForUnity/Editor/Services/CacheManagementService.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Linq; +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; + bool hasOverride = pathService.HasUvxPathOverride; + string uvCommand = "uv"; + + if (hasOverride) + { + string overridePath = pathService.GetUvxPath(); + + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + string overrideDirectory = Path.GetDirectoryName(overridePath); + string overrideExtension = Path.GetExtension(overridePath); + string overrideName = Path.GetFileNameWithoutExtension(overridePath); + + if (!string.IsNullOrEmpty(overrideDirectory) && overrideName.Equals("uvx", StringComparison.OrdinalIgnoreCase)) + { + string uvSibling = Path.Combine(overrideDirectory, string.IsNullOrEmpty(overrideExtension) ? "uv" : $"uv{overrideExtension}"); + if (File.Exists(uvSibling)) + { + uvCommand = uvSibling; + McpLog.Debug($"Using UV executable inferred from override: {uvSibling}"); + } + else + { + uvCommand = overridePath; + McpLog.Debug($"Using override executable: {overridePath}"); + } + } + else + { + uvCommand = overridePath; + McpLog.Debug($"Using override executable: {overridePath}"); + } + } + else + { + McpLog.Debug("UV override was not found at specified location, falling back to system PATH."); + } + } + else if (string.Equals(uvCommand, "uv", StringComparison.OrdinalIgnoreCase)) + { + McpLog.Debug("No UV override configured; using 'uv' from system PATH."); + } + + // Get the package name + string packageName = "mcp-for-unity"; + + // Run uvx cache clean command + string args = $"cache clean {packageName}"; + + bool success; + string stdout; + string stderr; + + if (!string.Equals(uvCommand, "uv", StringComparison.OrdinalIgnoreCase)) + { + success = ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000); + } + else + { + string command = $"uv {args}"; + string extraPathPrepend = null; + + if (Application.platform == RuntimePlatform.OSXEditor) + { + extraPathPrepend = string.Join(Path.PathSeparator.ToString(), new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + extraPathPrepend = string.Join(Path.PathSeparator.ToString(), new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + else if (Application.platform == RuntimePlatform.WindowsEditor) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + extraPathPrepend = string.Join(Path.PathSeparator.ToString(), new[] + { + !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null, + !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null + }.Where(p => !string.IsNullOrEmpty(p)).ToArray()); + } + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + success = ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + else + { + string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; + + if (!string.IsNullOrEmpty(shell) && File.Exists(shell)) + { + string escaped = command.Replace("\"", "\\\""); + success = ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + else + { + success = ExecPath.TryRun("uv", args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + } + } + + if (success) + { + McpLog.Debug($"uv cache cleared successfully: {stdout}"); + return true; + } + else + { + string errorMessage = string.IsNullOrEmpty(stderr) + ? "Unknown error" + : stderr; + + McpLog.Error($"Failed to clear uv cache using '{uvCommand} {args}': {errorMessage}. Ensure UV/UVX is installed, available on PATH, or set an override in Advanced Settings."); + return false; + } + } + catch (Exception ex) + { + McpLog.Error($"Error clearing uv cache: {ex.Message}"); + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Services/CacheManagementService.cs.meta b/MCPForUnity/Editor/Services/CacheManagementService.cs.meta new file mode 100644 index 000000000..ff1d90325 --- /dev/null +++ b/MCPForUnity/Editor/Services/CacheManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cee3887f299d4a9aa9f2f0ffea593f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 8a9c4cafd..07f1f910f 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -4,8 +4,10 @@ using System.Runtime.InteropServices; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; @@ -20,38 +22,24 @@ public class ClientConfigurationService : IClientConfigurationService public void ConfigureClient(McpClient client) { - try - { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + var pathService = MCPServiceLocator.Paths; + string uvxPath = pathService.GetUvxPath(); - string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); - 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."); - } + string result = client.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) + : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); - string result = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, 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}"); } } @@ -64,14 +52,8 @@ public ClientConfigurationSummary ConfigureAllDetectedClients() { try { - // Skip if already configured + // Always re-run configuration so core fields stay current CheckClientStatus(client, attemptAutoRewrite: false); - if (client.status == McpStatus.Configured) - { - summary.SkippedCount++; - summary.Messages.Add($"✓ {client.name}: Already configured"); - continue; - } // Check if required tools are available if (client.mcpType == McpTypes.ClaudeCode) @@ -83,17 +65,19 @@ public ClientConfigurationSummary ConfigureAllDetectedClients() continue; } + // Force a fresh registration so transport settings stay current + UnregisterClaudeCode(); RegisterClaudeCode(); summary.SuccessCount++; - summary.Messages.Add($"✓ {client.name}: Registered successfully"); + summary.Messages.Add($"✓ {client.name}: Re-registered successfully"); } 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,32 +118,45 @@ 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; + string configuredUrl = null; bool configExists = false; switch (client.mcpType) { case McpTypes.VSCode: - dynamic vsConfig = JsonConvert.DeserializeObject(configJson); - if (vsConfig?.servers?.unityMCP != null) + var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; + if (vsConfig != null) { - args = vsConfig.servers.unityMCP.args.ToObject(); - configExists = true; - } - else if (vsConfig?.mcp?.servers?.unityMCP != null) - { - args = vsConfig.mcp.servers.unityMCP.args.ToObject(); - configExists = true; + var unityToken = + vsConfig["servers"]?["unityMCP"] + ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; + + if (unityToken is JObject unityObj) + { + configExists = true; + + var argsToken = unityObj["args"]; + if (argsToken is JArray) + { + args = argsToken.ToObject(); + } + + var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } + } } break; case McpTypes.Codex: - if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) + if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs, out var codexUrl)) { args = codexArgs; + configuredUrl = codexUrl; configExists = true; } break; @@ -176,9 +173,20 @@ public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) if (configExists) { - string configuredDir = McpConfigurationHelper.ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && - McpConfigurationHelper.PathsEqual(configuredDir, pythonDir); + bool matches = false; + + if (args != null && args.Length > 0) + { + string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } if (matches) { @@ -190,15 +198,18 @@ 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); + bool debugLogsEnabled = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); if (debugLogsEnabled) { - McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false); + string targetDescriptor = args != null && args.Length > 0 + ? AssetPathUtility.GetMcpServerGitUrl() + : HttpEndpointUtility.GetMcpRpcUrl(); + McpLog.Info($"Auto-updated MCP config for '{client.name}' to new version: {targetDescriptor}", always: false); } client.SetStatus(McpStatus.Configured); } @@ -233,21 +244,29 @@ 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"; + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + string args; + if (useHttpTransport) + { + // HTTP mode: Use --transport http with URL + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + else + { + // Stdio mode: Use command with uvx + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; + } + string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; @@ -278,7 +297,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 +306,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 +320,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 +342,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 +385,32 @@ 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)) + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + string registerCommand; + if (useHttpTransport) { - return "# Error: Configuration not available - check paths in Advanced Settings"; + // HTTP mode + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + registerCommand = $"claude mcp add --transport http UnityMCP {httpUrl}"; } + else + { + // Stdio mode + 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(); + registerCommand = $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity"; + } return "# Register the MCP server with Claude Code:\n" + $"{registerCommand}\n\n" + @@ -388,19 +420,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) @@ -479,22 +510,46 @@ private void CheckClaudeCodeConfiguration(McpClient client) { try { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - - if (!File.Exists(configPath)) + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) { - client.SetStatus(McpStatus.NotConfigured); + client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); return; } - string configJson = File.ReadAllText(configPath); - dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); + // Use 'claude mcp list' to check if UnityMCP is registered + string args = "mcp list"; + string projectDir = Path.GetDirectoryName(Application.dataPath); + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + // Add the directory containing Claude CLI to PATH + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } - if (claudeConfig?.mcpServers != null) + if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { - var servers = claudeConfig.mcpServers; - // Only check for UnityMCP (fixed - removed candidate hacks) - if (servers.UnityMCP != null) + // Check if UnityMCP is in the output + if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) { client.SetStatus(McpStatus.Configured); return; @@ -508,5 +563,28 @@ private void CheckClaudeCodeConfiguration(McpClient client) client.SetStatus(McpStatus.Error, ex.Message); } } + + private static bool UrlsEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return false; + } + + if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && + Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) + { + return Uri.Compare( + uriA, + uriB, + UriComponents.HttpRequestUrl, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + string Normalize(string value) => value.Trim().TrimEnd('/'); + + return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); + } } } diff --git a/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs new file mode 100644 index 000000000..5d6e2028f --- /dev/null +++ b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + public class CustomToolRegistrationService : ICustomToolRegistrationService + { + private static readonly HttpClient HttpClient = new HttpClient(); + private readonly IToolDiscoveryService _discoveryService; + + public CustomToolRegistrationService(IToolDiscoveryService discoveryService = null) + { + _discoveryService = discoveryService ?? new ToolDiscoveryService(); + } + + public async Task RegisterAllToolsAsync(string projectId = null) + { + try + { + projectId ??= GetProjectId(); + + var tools = _discoveryService.DiscoverAllTools(); + if (tools.Count == 0) + { + McpLog.Info("No tools found, skipping registration"); + return true; + } + + var candidates = tools.Where(t => t.AutoRegister).ToList(); + if (candidates.Count == 0) + { + McpLog.Info("No tools marked for auto-registration, skipping"); + return true; + } + + var request = BuildRegisterRequest(projectId, candidates); + string endpoint = HttpEndpointUtility.GetRegisterToolsUrl(); + var response = await SendRegistrationAsync(endpoint, request); + + if (response.success) + { + McpLog.Info($"Successfully registered {response.registered?.Count ?? 0} tools with MCP server"); + return true; + } + + McpLog.Error($"Failed to register tools: {response.error ?? "Unknown error"}"); + return false; + } + catch (Exception ex) + { + McpLog.Error($"Error registering tools: {ex.Message}"); + return false; + } + } + + private RegisterToolsRequest BuildRegisterRequest(string projectId, List tools) + { + return new RegisterToolsRequest + { + project_id = projectId, + tools = tools.Select(t => new ToolDefinition + { + name = t.Name, + description = t.Description, + structured_output = t.StructuredOutput, + requires_polling = t.RequiresPolling, + poll_action = t.PollAction, + parameters = (t.Parameters ?? new List()).Select(p => new ParameterDefinition + { + name = p.Name, + description = p.Description, + type = p.Type, + required = p.Required, + default_value = p.DefaultValue + }).ToList() + }).ToList() + }; + } + + private async Task SendRegistrationAsync(string endpoint, RegisterToolsRequest request) + { + try + { + string payload = JsonConvert.SerializeObject(request); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await HttpClient.PostAsync(endpoint, content); + string responseText = await response.Content.ReadAsStringAsync(); + + RegisterToolsResponse parsedResponse = null; + try + { + parsedResponse = JsonConvert.DeserializeObject(responseText); + } + catch (Exception ex) + { + McpLog.Error($"Failed to parse tool registration response: {ex.Message}"); + } + + if (response.IsSuccessStatusCode) + { + return parsedResponse ?? new RegisterToolsResponse { success = false, error = "Empty response from server" }; + } + + if (response.StatusCode == HttpStatusCode.Conflict) + { + var duplicates = parsedResponse?.duplicates ?? new List(); + string duplicateList = duplicates.Count > 0 ? string.Join(", ", duplicates) : "existing tools"; + McpLog.Info($"Tool registration skipped - already registered ({duplicateList})"); + return new RegisterToolsResponse + { + success = true, + registered = duplicates + }; + } + + string errorText = parsedResponse?.error ?? responseText; + McpLog.Error($"Tool registration failed: HTTP {(int)response.StatusCode} {response.ReasonPhrase} - {responseText}"); + return new RegisterToolsResponse + { + success = false, + error = errorText + }; + } + catch (HttpRequestException ex) + { + McpLog.Error($"Tool registration HTTP request failed: {ex.Message}"); + return new RegisterToolsResponse { success = false, error = ex.Message }; + } + } + + private string GetProjectId() + { + 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); + } + } + + [Serializable] + private class RegisterToolsRequest + { + public string project_id; + public List tools; + } + + [Serializable] + private class ToolDefinition + { + public string name; + public string description; + public bool structured_output; + public bool requires_polling; + public string poll_action; + public List parameters; + } + + [Serializable] + private class ParameterDefinition + { + public string name; + public string description; + public string type; + public bool required; + public string default_value; + } + + private class RegisterToolsResponse + { + public bool success; + public string error; + public List registered; + public List duplicates; + } + } +} diff --git a/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta new file mode 100644 index 000000000..eda96cfad --- /dev/null +++ b/MCPForUnity/Editor/Services/CustomToolRegistrationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6bab1a9bfedfc496b873c20f450f70bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs new file mode 100644 index 000000000..16b8bd87e --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs @@ -0,0 +1,143 @@ +using System; +using System.Threading.Tasks; +using UnityEditor; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Windows; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge. + /// + [InitializeOnLoad] + internal static class HttpBridgeReloadHandler + { + static HttpBridgeReloadHandler() + { + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + private static void OnBeforeAssemblyReload() + { + try + { + var bridge = MCPServiceLocator.Bridge; + bool shouldResume = bridge.IsRunning && bridge.ActiveMode == TransportMode.Http; + + if (shouldResume) + { + EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + + if (bridge.IsRunning) + { + var stopTask = bridge.StopAsync(); + stopTask.ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + { + McpLog.Warn($"Error stopping MCP bridge before reload: {t.Exception.GetBaseException().Message}"); + } + }, TaskScheduler.Default); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}"); + } + } + + private static void OnAfterAssemblyReload() + { + bool resume = false; + try + { + resume = EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false); + if (resume) + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}"); + resume = false; + } + + if (!resume) + { + return; + } + + // If the editor is not compiling, attempt an immediate restart without relying on editor focus. + bool isCompiling = EditorApplication.isCompiling; + try + { + var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) isCompiling |= (bool)prop.GetValue(null); + } + catch { } + + if (!isCompiling) + { + try + { + var startTask = MCPServiceLocator.Bridge.StartAsync(); + startTask.ContinueWith(t => + { + if (t.IsFaulted) + { + var baseEx = t.Exception?.GetBaseException(); + McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {baseEx?.Message}"); + return; + } + bool started = t.Result; + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + }, TaskScheduler.Default); + return; + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + return; + } + } + + // Fallback when compiling: schedule on the editor loop + EditorApplication.delayCall += async () => + { + try + { + bool started = await MCPServiceLocator.Bridge.StartAsync(); + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta new file mode 100644 index 000000000..ae5e9edd0 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c0cf970a7b494a659be151dc0124296 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IBridgeControlService.cs b/MCPForUnity/Editor/Services/IBridgeControlService.cs index 6233ed773..7cc593e08 100644 --- a/MCPForUnity/Editor/Services/IBridgeControlService.cs +++ b/MCPForUnity/Editor/Services/IBridgeControlService.cs @@ -1,3 +1,6 @@ +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.Transport; + namespace MCPForUnity.Editor.Services { /// @@ -9,35 +12,48 @@ public interface IBridgeControlService /// Gets whether the bridge is currently running /// bool IsRunning { get; } - + /// /// Gets the current port the bridge is listening on /// int CurrentPort { get; } - + /// /// Gets whether the bridge is in auto-connect mode /// bool IsAutoConnectMode { get; } - + /// - /// Starts the MCP for Unity Bridge + /// Gets the currently active transport mode, if any /// - void Start(); - + TransportMode? ActiveMode { get; } + /// - /// Stops the MCP for Unity Bridge + /// Starts the MCP for Unity Bridge asynchronously /// - void Stop(); - + /// True if the bridge started successfully + Task StartAsync(); + + /// + /// Stops the MCP for Unity Bridge asynchronously + /// + Task StopAsync(); + /// /// Verifies the bridge connection by sending a ping and waiting for a pong response /// /// The port to verify /// Verification result with detailed status BridgeVerificationResult Verify(int port); + + /// + /// Verifies the connection asynchronously (works for both HTTP and stdio transports) + /// + /// Verification result with detailed status + Task VerifyAsync(); + } - + /// /// Result of a bridge verification attempt /// @@ -47,17 +63,17 @@ public class BridgeVerificationResult /// Whether the verification was successful /// public bool Success { get; set; } - + /// /// Human-readable message about the verification result /// public string Message { get; set; } - + /// /// Whether the handshake was valid (FRAMING=1 protocol) /// public bool HandshakeValid { get; set; } - + /// /// Whether the ping/pong exchange succeeded /// 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/IClientConfigurationService.cs b/MCPForUnity/Editor/Services/IClientConfigurationService.cs index e64742499..24b01fad7 100644 --- a/MCPForUnity/Editor/Services/IClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs @@ -12,13 +12,13 @@ public interface IClientConfigurationService /// /// The client to configure void ConfigureClient(McpClient client); - + /// /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) /// /// Summary of configuration results ClientConfigurationSummary ConfigureAllDetectedClients(); - + /// /// Checks the configuration status of a client /// @@ -26,31 +26,31 @@ public interface IClientConfigurationService /// If true, attempts to auto-fix mismatched paths /// True if status changed, false otherwise bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true); - + /// /// Registers MCP for Unity with Claude Code CLI /// void RegisterClaudeCode(); - + /// /// Unregisters MCP for Unity from Claude Code CLI /// void UnregisterClaudeCode(); - + /// /// Gets the configuration file path for a client /// /// The client /// Platform-specific config path string GetConfigPath(McpClient client); - + /// /// Generates the configuration JSON for a client /// /// The client /// JSON configuration string string GenerateConfigJson(McpClient client); - + /// /// Gets human-readable installation steps for a client /// @@ -58,7 +58,7 @@ public interface IClientConfigurationService /// Installation instructions string GetInstallationSteps(McpClient client); } - + /// /// Summary of configuration results for multiple clients /// @@ -68,22 +68,22 @@ public class ClientConfigurationSummary /// Number of clients successfully configured /// public int SuccessCount { get; set; } - + /// /// Number of clients that failed to configure /// public int FailureCount { get; set; } - + /// /// Number of clients skipped (already configured or tool not found) /// public int SkippedCount { get; set; } - + /// /// Detailed messages for each client /// public System.Collections.Generic.List Messages { get; set; } = new(); - + /// /// Gets a human-readable summary message /// diff --git a/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs new file mode 100644 index 000000000..0ea4c41d5 --- /dev/null +++ b/MCPForUnity/Editor/Services/ICustomToolRegistrationService.cs @@ -0,0 +1,17 @@ +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 + /// + /// Optional pre-captured project ID (for thread safety) + Task RegisterAllToolsAsync(string projectId = null); + + } +} 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/IPackageUpdateService.cs b/MCPForUnity/Editor/Services/IPackageUpdateService.cs index a9a149137..9d4d2e487 100644 --- a/MCPForUnity/Editor/Services/IPackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs @@ -11,7 +11,7 @@ public interface IPackageUpdateService /// The current package version /// Update check result containing availability and latest version info UpdateCheckResult CheckForUpdate(string currentVersion); - + /// /// Compares two version strings to determine if the first is newer than the second /// @@ -19,19 +19,19 @@ public interface IPackageUpdateService /// Second version string /// True if version1 is newer than version2 bool IsNewerVersion(string version1, string version2); - + /// /// Determines if the package was installed via Git or Asset Store /// /// True if installed via Git, false if Asset Store or unknown bool IsGitInstallation(); - + /// /// Clears the cached update check data, forcing a fresh check on next request /// void ClearCache(); } - + /// /// Result of an update check operation /// @@ -41,17 +41,17 @@ public class UpdateCheckResult /// Whether an update is available /// public bool UpdateAvailable { get; set; } - + /// /// The latest version available (null if check failed or no update) /// public string LatestVersion { get; set; } - + /// /// Whether the check was successful (false if network error, etc.) /// public bool CheckSucceeded { get; set; } - + /// /// Optional message about the check result /// diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs index 9968af656..225750723 100644 --- a/MCPForUnity/Editor/Services/IPathResolverService.cs +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -6,87 +6,71 @@ namespace MCPForUnity.Editor.Services 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) /// /// Path to the claude executable, or null if not found string GetClaudeCliPath(); - + /// /// Checks if Python is detected on the system /// /// True if Python is found 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 /// /// True if Claude CLI is found 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 /// /// Path to override with 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 /// void ClearClaudeCliPathOverride(); - - /// - /// Gets whether a MCP server path override is active - /// - bool HasMcpServerOverride { get; } - + /// - /// Gets whether a UV path override is active + /// Gets whether a UVX path override is active /// - bool HasUvPathOverride { get; } - + bool HasUvxPathOverride { get; } + /// /// Gets whether a Claude CLI path override is active /// bool HasClaudeCliPathOverride { get; } + + /// + /// Gets the source path of the uvx-installed unity-mcp package + /// + /// 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/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs new file mode 100644 index 000000000..f6f653abc --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -0,0 +1,34 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Interface for server management operations + /// + public interface IServerManagementService + { + /// + /// Start the local HTTP server in a new terminal window + /// + /// True if server was started successfully, false otherwise + bool StartLocalHttpServer(); + + /// + /// Attempts to get the command that will be executed when starting the local HTTP server + /// + /// The command that will be executed when available + /// Reason why a command could not be produced + /// True if a command is available, false otherwise + bool TryGetLocalHttpServerCommand(out string command, out string error); + + /// + /// Check if the configured HTTP URL is a local address + /// + /// True if URL is local (localhost, 127.0.0.1, etc.) + bool IsLocalUrl(); + + /// + /// Check if the local HTTP server can be started + /// + /// True if HTTP transport is enabled and URL is local + bool CanStartLocalServer(); + } +} diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs.meta b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta new file mode 100644 index 000000000..9f12dc3c1 --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d41bfc9780b774affa6afbffd081eb79 +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..01ceb2b63 --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs @@ -0,0 +1,53 @@ +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; } + public bool AutoRegister { get; set; } = true; + public bool RequiresPolling { get; set; } = false; + public string PollAction { get; set; } = "status"; + } + + /// + /// 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..4d3feb108 100644 --- a/MCPForUnity/Editor/Services/MCPServiceLocator.cs +++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs @@ -1,4 +1,7 @@ using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnity.Editor.Services { @@ -10,20 +13,26 @@ 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; + private static IServerManagementService _serverManagementService; + private static TransportManager _transportManager; 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(ToolDiscovery); + public static ICacheManagementService Cache => _cacheManagementService ??= new CacheManagementService(); + public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService(); + public static TransportManager TransportManager => _transportManager ??= new TransportManager(); /// /// Registers a custom implementation for a service (useful for testing) @@ -38,16 +47,22 @@ 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; + else if (implementation is IServerManagementService sm) + _serverManagementService = sm; + else if (implementation is TransportManager tm) + _transportManager = tm; } /// @@ -58,20 +73,26 @@ 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(); + (_serverManagementService as IDisposable)?.Dispose(); + (_transportManager 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; + _serverManagementService = null; + _transportManager = null; } } } diff --git a/MCPForUnity/Editor/Services/PackageUpdateService.cs b/MCPForUnity/Editor/Services/PackageUpdateService.cs index 7a5bc9f1a..b4384d9a9 100644 --- a/MCPForUnity/Editor/Services/PackageUpdateService.cs +++ b/MCPForUnity/Editor/Services/PackageUpdateService.cs @@ -3,6 +3,7 @@ using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; +using MCPForUnity.Editor.Constants; namespace MCPForUnity.Editor.Services { @@ -11,8 +12,8 @@ namespace MCPForUnity.Editor.Services /// public class PackageUpdateService : IPackageUpdateService { - private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck"; - private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion"; + private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck; + private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion; private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; /// diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 083115f76..8316ad7c1 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -1,7 +1,10 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Runtime.InteropServices; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; using UnityEditor; using UnityEngine; @@ -12,164 +15,181 @@ namespace MCPForUnity.Editor.Services /// public class PathResolverService : IPathResolverService { - private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; - private const string UvPathOverrideKey = "MCPForUnity.UvPath"; - private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath"; + public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null)); + public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null)); - public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); - public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, 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"))) + try { - return overridePath; + string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath)) + { + return overridePath; + } + } + catch + { + // ignore EditorPrefs read errors and fall back to default command } - // Fall back to automatic detection - return McpPathResolver.FindPackagePythonDirectory(false); + return "uvx"; } - public string GetUvPath() + public string GetClaudeCliPath() { - // Check for override first - string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + try { - return overridePath; + string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + return overridePath; + } } + catch { /* ignore */ } - // Fall back to automatic detection - try + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return ServerInstaller.FindUvPath(); + 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; + } } - catch + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - return null; + 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 string GetClaudeCliPath() + public bool IsPythonDetected() { - // Check for override first - string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); - if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + 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 overridePath; + return false; } + } - // Fall back to automatic detection - return ExecPath.ResolveClaude(); + public bool IsUvxDetected() + { + return !string.IsNullOrEmpty(GetUvxPath()); } - public bool IsPythonDetected() + public string GetUvxPackageSourcePath() { try { - // Windows-specific Python detection - if (Application.platform == RuntimePlatform.WindowsEditor) + // Get the uv cache directory + var cacheDirProcess = new ProcessStartInfo { - // 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; - } + FileName = "uv", + Arguments = "cache dir", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; - // Try 'where python' command (Windows equivalent of 'which') - var psi = new ProcessStartInfo - { - FileName = "where", - Arguments = "python", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using (var p = Process.Start(psi)) + 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)) { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + // Check for the new Server structure + string serverPath = Path.Combine(commitDir, "Server"); + if (Directory.Exists(serverPath)) { - string[] lines = outp.Split('\n'); - foreach (string line in lines) + // Verify it has the expected pyproject.toml + string pyprojectPath = Path.Combine(serverPath, "pyproject.toml"); + if (File.Exists(pyprojectPath)) { - string trimmed = line.Trim(); - if (File.Exists(trimmed)) return true; + McpLog.Info($"Found uvx package source at: {serverPath}"); + return serverPath; } } } } - else - { - // macOS/Linux detection - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; - string[] candidates = - { - "/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", - }; - foreach (string c in candidates) - { - if (File.Exists(c)) return true; - } - // Try 'which python3' - var psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = "python3", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using (var p = Process.Start(psi)) - { - string outp = p.StandardOutput.ReadToEnd().Trim(); - p.WaitForExit(2000); - if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; - } - } + 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) + { + McpLog.Error($"Failed to find uvx package source: {ex.Message}"); + return null; } - catch { } - return false; - } - - public bool IsUvDetected() - { - return !string.IsNullOrEmpty(GetUvPath()); } public bool IsClaudeCliDetected() @@ -177,36 +197,20 @@ public bool IsClaudeCliDetected() return !string.IsNullOrEmpty(GetClaudeCliPath()); } - public void SetMcpServerOverride(string path) - { - if (string.IsNullOrEmpty(path)) - { - ClearMcpServerOverride(); - return; - } - - if (!File.Exists(Path.Combine(path, "server.py"))) - { - throw new ArgumentException("The selected folder does not contain server.py"); - } - - EditorPrefs.SetString(PythonDirOverrideKey, path); - } - - 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(EditorPrefKeys.UvxPathOverride, path); } public void SetClaudeCliPathOverride(string path) @@ -222,24 +226,17 @@ public void SetClaudeCliPathOverride(string path) throw new ArgumentException("The selected Claude CLI executable does not exist"); } - EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); - // Also update the ExecPath helper for backwards compatibility - ExecPath.SetClaudeCliPath(path); - } - - public void ClearMcpServerOverride() - { - EditorPrefs.DeleteKey(PythonDirOverrideKey); + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path); } - public void ClearUvPathOverride() + public void ClearUvxPathOverride() { - EditorPrefs.DeleteKey(UvPathOverrideKey); + EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride); } public void ClearClaudeCliPathOverride() { - EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); + EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); } } } 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/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs new file mode 100644 index 000000000..6da243b86 --- /dev/null +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -0,0 +1,222 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing MCP server lifecycle + /// + public class ServerManagementService : IServerManagementService + { + /// + /// Start the local HTTP server in a new terminal window + /// + public bool StartLocalHttpServer() + { + if (!TryGetLocalHttpServerCommand(out var command, out var error)) + { + EditorUtility.DisplayDialog( + "Cannot Start HTTP Server", + error ?? "The server command could not be constructed with the current settings.", + "OK"); + return false; + } + + if (EditorUtility.DisplayDialog( + "Start Local HTTP Server", + $"This will start the MCP server in HTTP mode:\n\n{command}\n\n" + + "The server will run in a separate terminal window. " + + "Close the terminal to stop the server.\n\n" + + "Continue?", + "Start Server", + "Cancel")) + { + try + { + // Start the server in a new terminal window (cross-platform) + var startInfo = CreateTerminalProcessStartInfo(command); + + System.Diagnostics.Process.Start(startInfo); + + McpLog.Info($"Started local HTTP server: {command}"); + return true; + } + catch (Exception ex) + { + McpLog.Error($"Failed to start server: {ex.Message}"); + EditorUtility.DisplayDialog( + "Error", + $"Failed to start server: {ex.Message}", + "OK"); + return false; + } + } + + return false; + } + + /// + /// Attempts to build the command used for starting the local HTTP server + /// + public bool TryGetLocalHttpServerCommand(out string command, out string error) + { + command = null; + error = null; + + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + if (!useHttpTransport) + { + error = "HTTP transport is disabled. Enable it in the MCP For Unity window first."; + return false; + } + + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl()) + { + error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; + return false; + } + + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrEmpty(uvxPath)) + { + error = "UV/UVX is not installed or found in PATH. Install it or set an override in Advanced Settings."; + return false; + } + + string args = string.IsNullOrEmpty(fromUrl) + ? $"{packageName} --transport http --http-url {httpUrl}" + : $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; + + command = $"{uvxPath} {args}"; + return true; + } + + /// + /// Check if the configured HTTP URL is a local address + /// + public bool IsLocalUrl() + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + return IsLocalUrl(httpUrl); + } + + /// + /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0) + /// + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) return false; + + try + { + var uri = new Uri(url); + string host = uri.Host.ToLower(); + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + } + catch + { + return false; + } + } + + /// + /// Check if the local HTTP server can be started + /// + public bool CanStartLocalServer() + { + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + return useHttpTransport && IsLocalUrl(); + } + + /// + /// Creates a ProcessStartInfo for opening a terminal window with the given command + /// Works cross-platform: macOS, Windows, and Linux + /// + private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) + { +#if UNITY_EDITOR_OSX + // macOS: Use osascript to open Terminal.app + return new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"-c \"osascript -e 'tell app \\\"Terminal\\\" to do script \\\"{command}\\\"'\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#elif UNITY_EDITOR_WIN + // Windows: Use cmd.exe with start command to open new window + return new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start cmd.exe /k \"{command}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#else + // Linux: Try common terminal emulators + // Priority: gnome-terminal, xterm, konsole, xfce4-terminal + string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" }; + string terminalCmd = null; + + foreach (var term in terminals) + { + try + { + var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "which", + Arguments = term, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + which.WaitForExit(); + if (which.ExitCode == 0) + { + terminalCmd = term; + break; + } + } + catch { } + } + + if (terminalCmd == null) + { + terminalCmd = "xterm"; // Fallback + } + + // Different terminals have different argument formats + string args; + if (terminalCmd == "gnome-terminal") + { + args = $"-- bash -c \"{command}; exec bash\""; + } + else if (terminalCmd == "konsole") + { + args = $"-e bash -c \"{command}; exec bash\""; + } + else if (terminalCmd == "xfce4-terminal") + { + args = $"--hold -e \"bash -c '{command}'\""; + } + else // xterm and others + { + args = $"-hold -e bash -c \"{command}\""; + } + + return new System.Diagnostics.ProcessStartInfo + { + FileName = terminalCmd, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true + }; +#endif + } + } +} diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs.meta b/MCPForUnity/Editor/Services/ServerManagementService.cs.meta new file mode 100644 index 000000000..8b0fea069 --- /dev/null +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e60df35c5a76462d8aaa8078da86d75 +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..0f3406a1e --- /dev/null +++ b/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -0,0 +1,184 @@ +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 ?? "", + AutoRegister = toolAttr.AutoRegister, + RequiresPolling = toolAttr.RequiresPolling, + PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction + }; + } + 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/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta b/MCPForUnity/Editor/Services/Transport.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta rename to MCPForUnity/Editor/Services/Transport.meta index fd2be787a..58fe0d710 100644 --- a/TestProjects/UnityMCPTests/Assets/Temp/MCPToolParameterTests.meta +++ b/MCPForUnity/Editor/Services/Transport.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d5876265244e44b0dbea3a1351bf24be +guid: 8d189635a5d364f55a810203798c09ba folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs new file mode 100644 index 000000000..3d8584fd9 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Abstraction for MCP transport implementations (e.g. WebSocket push, stdio). + /// + public interface IMcpTransportClient + { + bool IsConnected { get; } + string TransportName { get; } + TransportState State { get; } + + Task StartAsync(); + Task StopAsync(); + Task VerifyAsync(); + } +} diff --git a/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta new file mode 100644 index 000000000..2bdf09517 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 042446a50a4744170bb294acf827376f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs new file mode 100644 index 000000000..5490508b8 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Centralised command execution pipeline shared by all transport implementations. + /// Guarantees that MCP commands are executed on the Unity main thread while preserving + /// the legacy response format expected by the server. + /// + internal static class TransportCommandDispatcher + { + private sealed class PendingCommand + { + public PendingCommand( + string commandJson, + TaskCompletionSource completionSource, + CancellationToken cancellationToken, + CancellationTokenRegistration registration) + { + CommandJson = commandJson; + CompletionSource = completionSource; + CancellationToken = cancellationToken; + CancellationRegistration = registration; + } + + public string CommandJson { get; } + public TaskCompletionSource CompletionSource { get; } + public CancellationToken CancellationToken { get; } + public CancellationTokenRegistration CancellationRegistration { get; } + public bool IsExecuting { get; set; } + + public void Dispose() + { + CancellationRegistration.Dispose(); + } + + public void TrySetResult(string payload) + { + CompletionSource.TrySetResult(payload); + } + + public void TrySetCanceled() + { + CompletionSource.TrySetCanceled(CancellationToken); + } + } + + private static readonly Dictionary Pending = new(); + private static readonly object PendingLock = new(); + private static bool updateHooked; + private static bool initialised; + + /// + /// Schedule a command for execution on the Unity main thread and await its JSON response. + /// + public static Task ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken) + { + if (commandJson is null) + { + throw new ArgumentNullException(nameof(commandJson)); + } + + EnsureInitialised(); + + var id = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var registration = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => CancelPending(id, cancellationToken)) + : default; + + var pending = new PendingCommand(commandJson, tcs, cancellationToken, registration); + + lock (PendingLock) + { + Pending[id] = pending; + HookUpdate(); + } + + return tcs.Task; + } + + private static void EnsureInitialised() + { + if (initialised) + { + return; + } + + CommandRegistry.Initialize(); + initialised = true; + } + + private static void HookUpdate() + { + if (updateHooked) + { + return; + } + + updateHooked = true; + EditorApplication.update += ProcessQueue; + } + + private static void UnhookUpdateIfIdle() + { + if (Pending.Count > 0 || !updateHooked) + { + return; + } + + updateHooked = false; + EditorApplication.update -= ProcessQueue; + } + + private static void ProcessQueue() + { + List<(string id, PendingCommand pending)> ready; + + lock (PendingLock) + { + ready = new List<(string, PendingCommand)>(Pending.Count); + foreach (var kvp in Pending) + { + if (kvp.Value.IsExecuting) + { + continue; + } + + kvp.Value.IsExecuting = true; + ready.Add((kvp.Key, kvp.Value)); + } + + if (ready.Count == 0) + { + UnhookUpdateIfIdle(); + return; + } + } + + foreach (var (id, pending) in ready) + { + ProcessCommand(id, pending); + } + } + + private static void ProcessCommand(string id, PendingCommand pending) + { + if (pending.CancellationToken.IsCancellationRequested) + { + RemovePending(id, pending); + pending.TrySetCanceled(); + return; + } + + string commandText = pending.CommandJson?.Trim(); + if (string.IsNullOrEmpty(commandText)) + { + pending.TrySetResult(SerializeError("Empty command received")); + RemovePending(id, pending); + return; + } + + if (string.Equals(commandText, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText + }; + pending.TrySetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + RemovePending(id, pending); + return; + } + + try + { + var command = JsonConvert.DeserializeObject(commandText); + if (command == null) + { + pending.TrySetResult(SerializeError("Command deserialized to null", "Unknown", commandText)); + RemovePending(id, pending); + return; + } + + if (string.IsNullOrWhiteSpace(command.type)) + { + pending.TrySetResult(SerializeError("Command type cannot be empty")); + RemovePending(id, pending); + return; + } + + if (string.Equals(command.type, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + var parameters = command.@params ?? new JObject(); + var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource); + + if (result == null) + { + // Async command – cleanup after completion on next editor frame to preserve order. + pending.CompletionSource.Task.ContinueWith(_ => + { + EditorApplication.delayCall += () => RemovePending(id, pending); + }, TaskScheduler.Default); + return; + } + + var response = new { status = "success", result }; + pending.TrySetResult(JsonConvert.SerializeObject(response)); + RemovePending(id, pending); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + pending.TrySetResult(SerializeError(ex.Message, "Unknown (error during processing)", ex.StackTrace)); + RemovePending(id, pending); + } + } + + private static void CancelPending(string id, CancellationToken token) + { + PendingCommand pending = null; + lock (PendingLock) + { + if (Pending.Remove(id, out pending)) + { + UnhookUpdateIfIdle(); + } + } + + pending?.TrySetCanceled(); + pending?.Dispose(); + } + + private static void RemovePending(string id, PendingCommand pending) + { + lock (PendingLock) + { + Pending.Remove(id); + UnhookUpdateIfIdle(); + } + + pending.Dispose(); + } + + private static string SerializeError(string message, string commandType = null, string stackTrace = null) + { + var errorResponse = new + { + status = "error", + error = message, + command = commandType ?? "Unknown", + stackTrace + }; + return JsonConvert.SerializeObject(errorResponse); + } + + private static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ((text.StartsWith("{") && text.EndsWith("}")) || (text.StartsWith("[") && text.EndsWith("]"))) + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta new file mode 100644 index 000000000..494c010cd --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 27407cc9c1ea0412d80b9f8964a5a29d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs b/MCPForUnity/Editor/Services/Transport/TransportManager.cs new file mode 100644 index 000000000..dfc03a540 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Coordinates the active transport client and exposes lifecycle helpers. + /// + public class TransportManager + { + private IMcpTransportClient _active; + private TransportMode? _activeMode; + private Func _webSocketFactory; + private Func _stdioFactory; + + public TransportManager() + { + Configure( + () => new WebSocketTransportClient(), + () => new StdioTransportClient()); + } + + public IMcpTransportClient ActiveTransport => _active; + public TransportMode? ActiveMode => _activeMode; + + public void Configure( + Func webSocketFactory, + Func stdioFactory) + { + _webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory)); + _stdioFactory = stdioFactory ?? throw new ArgumentNullException(nameof(stdioFactory)); + } + + public async Task StartAsync(TransportMode mode) + { + await StopAsync(); + + IMcpTransportClient next = mode switch + { + TransportMode.Stdio => _stdioFactory(), + TransportMode.Http => _webSocketFactory(), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode") + } ?? throw new InvalidOperationException($"Factory returned null for transport mode {mode}"); + + bool started = await next.StartAsync(); + if (!started) + { + await next.StopAsync(); + _active = null; + _activeMode = null; + return false; + } + + _active = next; + _activeMode = mode; + return true; + } + + public async Task StopAsync() + { + if (_active != null) + { + try + { + await _active.StopAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"Error while stopping transport {_active.TransportName}: {ex.Message}"); + } + finally + { + _active = null; + _activeMode = null; + } + } + } + + public async Task VerifyAsync() + { + if (_active == null) + { + return false; + } + return await _active.VerifyAsync(); + } + + public TransportState GetState() + { + if (_active == null) + { + return TransportState.Disconnected(_activeMode?.ToString()?.ToLowerInvariant() ?? "unknown", "Transport not started"); + } + + return _active.State ?? TransportState.Disconnected(_active.TransportName, "No state reported"); + } + } + + public enum TransportMode + { + Http, + Stdio + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta new file mode 100644 index 000000000..7adde46ca --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/TransportState.cs b/MCPForUnity/Editor/Services/Transport/TransportState.cs new file mode 100644 index 000000000..7fb6f20c6 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportState.cs @@ -0,0 +1,52 @@ +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Lightweight snapshot of a transport's runtime status for editor UI and diagnostics. + /// + public sealed class TransportState + { + public bool IsConnected { get; } + public string TransportName { get; } + public int? Port { get; } + public string SessionId { get; } + public string Details { get; } + public string Error { get; } + + private TransportState( + bool isConnected, + string transportName, + int? port, + string sessionId, + string details, + string error) + { + IsConnected = isConnected; + TransportName = transportName; + Port = port; + SessionId = sessionId; + Details = details; + Error = error; + } + + public static TransportState Connected( + string transportName, + int? port = null, + string sessionId = null, + string details = null) + => new TransportState(true, transportName, port, sessionId, details, null); + + public static TransportState Disconnected( + string transportName, + string error = null, + int? port = null) + => new TransportState(false, transportName, port, null, null, error); + + public TransportState WithError(string error) => new TransportState( + IsConnected, + TransportName, + Port, + SessionId, + Details, + error); + } +} diff --git a/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta b/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta new file mode 100644 index 000000000..5c592ce60 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67ab8e43f6a804698bb5b216cdef0645 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports.meta b/MCPForUnity/Editor/Services/Transport/Transports.meta new file mode 100644 index 000000000..878b705ac --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d467a63b6fad42fa975c731af4b83b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs similarity index 64% rename from MCPForUnity/Editor/MCPForUnityBridge.cs rename to MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 23537b817..5cc1585a6 100644 --- a/MCPForUnity/Editor/MCPForUnityBridge.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -11,17 +11,15 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.Prefabs; +using MCPForUnity.Editor.Services.Transport; -namespace MCPForUnity.Editor +namespace MCPForUnity.Editor.Services.Transport.Transports { - - /// - /// Outbound message structure for the writer thread - /// class Outbound { public byte[] Payload; @@ -29,24 +27,22 @@ class Outbound public int? ReqId; } - /// - /// Queued command structure for main thread processing - /// class QueuedCommand { public string CommandJson; public TaskCompletionSource Tcs; public bool IsExecuting; } + [InitializeOnLoad] - public static partial class MCPForUnityBridge + public static class StdioBridgeHost { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); - private static readonly System.Collections.Generic.HashSet activeClients = new(); + private static readonly HashSet activeClients = new(); private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); private static CancellationTokenSource cts; private static Task listenerTask; @@ -59,19 +55,18 @@ public static partial class MCPForUnityBridge private static int heartbeatSeq = 0; private static Dictionary commandQueue = new(); private static int mainThreadId; - private static int currentUnityPort = 6400; // Dynamic port, starts with default + private static int currentUnityPort = 6400; private static bool isAutoConnectMode = false; - private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads - private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients + private static bool shouldRestartAfterReload = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; + private const int FrameIOTimeoutMs = 30000; - // IO diagnostics private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } - // Debug helpers private static bool IsDebugEnabled() { - try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; } } private static void LogBreadcrumb(string stage) @@ -86,28 +81,21 @@ private static void LogBreadcrumb(string stage) public static int GetCurrentPort() => currentUnityPort; public static bool IsAutoConnectMode() => isAutoConnectMode; - /// - /// Start with Auto-Connect mode - discovers new port and saves it - /// public static void StartAutoConnect() { - Stop(); // Stop current connection + Stop(); try { - // Prefer stored project port and start using the robust Start() path (with retries/options) currentUnityPort = PortManager.GetPortWithFallback(); Start(); isAutoConnectMode = true; - // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { McpLog.Error($"Auto-connect failed: {ex.Message}"); - - // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; } @@ -132,11 +120,9 @@ public static bool FolderExists(string path) return Directory.Exists(fullPath); } - static MCPForUnityBridge() + static StdioBridgeHost() { - // Record the main thread ID for safe thread checks try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } - // Start single writer thread for framed responses try { var writerThread = new Thread(() => @@ -148,10 +134,6 @@ static MCPForUnityBridge() long seq = Interlocked.Increment(ref _ioSeq); IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); var sw = System.Diagnostics.Stopwatch.StartNew(); - // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, - // writes are performed inline there. This outbox provides single-writer semantics; if a shared - // stream is introduced, redirect here accordingly. - // No-op: actual write happens in client loop using WriteFrameAsync sw.Stop(); IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); } @@ -166,36 +148,35 @@ static MCPForUnityBridge() } catch { } - // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env - // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } - // Defer start until the editor is idle and not compiling - ScheduleInitRetry(); - // Add a safety net update hook in case delayCall is missed during reload churn - if (!ensureUpdateHooked) + if (ShouldAutoStartBridge()) { - ensureUpdateHooked = true; - EditorApplication.update += EnsureStartedOnEditorIdle; + ScheduleInitRetry(); + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; - // Also coalesce play mode transitions into a deferred init - EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); + EditorApplication.playModeStateChanged += _ => + { + if (ShouldAutoStartBridge()) + { + ScheduleInitRetry(); + } + }; } - /// - /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. - /// This prevents repeated restarts during script compilation that cause port hopping. - /// private static void InitializeAfterCompilation() { initScheduled = false; - // Play-mode friendly: allow starting in play mode; only defer while compiling if (IsCompiling()) { ScheduleInitRetry(); @@ -207,7 +188,6 @@ private static void InitializeAfterCompilation() Start(); if (!isRunning) { - // If a race prevented start, retry later ScheduleInitRetry(); } } @@ -220,28 +200,35 @@ private static void ScheduleInitRetry() return; } initScheduled = true; - // Debounce: start ~200ms after the last trigger nextStartAt = EditorApplication.timeSinceStartup + 0.20f; - // Ensure the update pump is active if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } - // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } - // Safety net: ensure the bridge starts shortly after domain reload when editor is idle + private static bool ShouldAutoStartBridge() + { + try + { + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + return !useHttpTransport; + } + catch + { + return true; + } + } + private static void EnsureStartedOnEditorIdle() { - // Do nothing while compiling if (IsCompiling()) { return; } - // If already running, remove the hook if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; @@ -249,7 +236,6 @@ private static void EnsureStartedOnEditorIdle() return; } - // Debounced start: wait until the scheduled time if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) { return; @@ -263,7 +249,6 @@ private static void EnsureStartedOnEditorIdle() isStarting = true; try { - // Attempt start; if it succeeds, remove the hook to avoid overhead Start(); } finally @@ -277,7 +262,6 @@ private static void EnsureStartedOnEditorIdle() } } - // Helper to check compilation status across Unity versions private static bool IsCompiling() { if (EditorApplication.isCompiling) @@ -286,7 +270,7 @@ private static bool IsCompiling() } try { - System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (prop != null) { @@ -301,25 +285,21 @@ public static void Start() { lock (startStopLock) { - // Don't restart if already running on a working port if (isRunning && listener != null) { if (IsDebugEnabled()) { - McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}"); + McpLog.Info($"StdioBridgeHost already running on port {currentUnityPort}"); } return; } Stop(); - // Attempt fast bind with stored-port preference (sticky per-project) try { - // Always consult PortManager first so we prefer the persisted project port currentUnityPort = PortManager.GetPortWithFallback(); - // Breadcrumb: Start LogBreadcrumb("Start"); const int maxImmediateRetries = 3; @@ -342,14 +322,12 @@ public static void Start() } catch { } #endif - // Minimize TIME_WAIT by sending RST on close try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { - // Ignore if not supported on platform } listener.Start(); break; @@ -362,12 +340,26 @@ public static void Start() } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) { - // Port is occupied by another instance, get a new available port int oldPort = currentUnityPort; + + // Before switching ports, give the old one a brief chance to release if it looks like ours + try + { + if (PortManager.IsPortUsedByMCPForUnity(oldPort)) + { + const int waitStepMs = 100; + int waited = 0; + while (waited < 300 && !PortManager.IsPortAvailable(oldPort)) + { + Thread.Sleep(waitStepMs); + waited += waitStepMs; + } + } + } + catch { } + currentUnityPort = PortManager.GetPortWithFallback(); - // GetPortWithFallback() may return the same port if it became available during wait - // or a different port if switching to an alternative if (IsDebugEnabled()) { if (currentUnityPort == oldPort) @@ -408,21 +400,18 @@ public static void Start() isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); - string serverVer = ReadInstalledServerVersionSafe(); - McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); - // Start background listener with cooperative cancellation + string serverVer = AssetPathUtility.GetPackageVersion(); + McpLog.Info($"StdioBridgeHost started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); CommandRegistry.Initialize(); EditorApplication.update += ProcessCommands; - // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { } - // Write initial heartbeat immediately heartbeatSeq++; WriteHeartbeat(false, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; @@ -446,10 +435,8 @@ public static void Stop() try { - // Mark as stopping early to avoid accept logging during disposal isRunning = false; - // Quiesce background listener quickly var cancel = cts; cts = null; try { cancel?.Cancel(); } catch { } @@ -457,17 +444,15 @@ public static void Stop() try { listener?.Stop(); } catch { } listener = null; - // Capture background task to wait briefly outside the lock toWait = listenerTask; listenerTask = null; } catch (Exception ex) { - McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}"); + McpLog.Error($"Error stopping StdioBridgeHost: {ex.Message}"); } } - // Proactively close all active client sockets to unblock any pending reads TcpClient[] toClose; lock (clientsLock) { @@ -479,19 +464,16 @@ public static void Stop() try { c.Close(); } catch { } } - // Give the background loop a short window to exit without blocking the editor if (toWait != null) { try { toWait.Wait(100); } catch { } } - // Now unhook editor events safely try { EditorApplication.update -= ProcessCommands; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } - // Clean up status file when Unity stops try { string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); @@ -507,7 +489,7 @@ public static void Stop() if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); } - if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped."); + if (IsDebugEnabled()) McpLog.Info("StdioBridgeHost stopped."); } private static async Task ListenerLoopAsync(CancellationToken token) @@ -517,22 +499,18 @@ private static async Task ListenerLoopAsync(CancellationToken token) try { TcpClient client = await listener.AcceptTcpClientAsync(); - // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true ); - // Set longer receive timeout to prevent quick disconnections - client.ReceiveTimeout = 60000; // 60 seconds + client.ReceiveTimeout = 60000; - // Fire and forget each client connection _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (ObjectDisposedException) { - // Listener was disposed during stop/reload; exit quietly if (!isRunning || token.IsCancellationRequested) { break; @@ -560,7 +538,6 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken lock (clientsLock) { activeClients.Add(client); } try { - // Framed I/O only; legacy mode removed try { if (IsDebugEnabled()) @@ -570,7 +547,6 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken } } catch { } - // Strict framing: always require FRAMING=1 and frame all I/O try { client.NoDelay = true; @@ -584,21 +560,20 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else - await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}"); - return; // abort this client + return; } while (isRunning && !token.IsCancellationRequested) { try { - // Strict framed mode only: enforced framed I/O for this connection string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); try @@ -613,12 +588,9 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { - // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( - /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); await WriteFrameAsync(stream, pingResponseBytes); @@ -635,7 +607,6 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken }; } - // Wait for the handler to produce a response, but do not block indefinitely string response; try { @@ -643,13 +614,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); if (completed == tcs.Task) { - // Got a result from the handler respCts.Cancel(); response = tcs.Task.Result; } else { - // Timeout: return a structured error so the client can recover var timeoutResponse = new { status = "error", @@ -672,8 +641,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken { try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } } - // Crash-proof and self-reporting writer logs (direct write to this client's stream) - long seq = System.Threading.Interlocked.Increment(ref _ioSeq); + long seq = Interlocked.Increment(ref _ioSeq); byte[] responseBytes; try { @@ -701,12 +669,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken } catch (Exception ex) { - // Treat common disconnects/timeouts as benign; only surface hard errors string msg = ex.Message ?? string.Empty; bool isBenign = msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 - || ex is System.IO.IOException; + || ex is IOException; if (isBenign) { if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false); @@ -726,8 +693,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken } } - // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks - private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + private static async Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) { byte[] buffer = new byte[count]; int offset = 0; @@ -740,10 +706,9 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS ? Timeout.Infinite : timeoutMs - (int)stopwatch.ElapsedMilliseconds; - // If a finite timeout is configured and already elapsed, fail immediately if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) { - throw new System.IO.IOException("Read timed out"); + throw new IOException("Read timed out"); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); @@ -761,34 +726,34 @@ private static async System.Threading.Tasks.Task ReadExactAsync(NetworkS #endif if (read == 0) { - throw new System.IO.IOException("Connection closed before reading expected bytes"); + throw new IOException("Connection closed before reading expected bytes"); } offset += read; } catch (OperationCanceledException) when (!cancel.IsCancellationRequested) { - throw new System.IO.IOException("Read timed out"); + throw new IOException("Read timed out"); } } return buffer; } - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) + private static Task WriteFrameAsync(NetworkStream stream, byte[] payload) { using var cts = new CancellationTokenSource(FrameIOTimeoutMs); - await WriteFrameAsync(stream, payload, cts.Token); + return WriteFrameAsync(stream, payload, cts.Token); } - private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + private static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) { if (payload == null) { - throw new System.ArgumentNullException(nameof(payload)); + throw new ArgumentNullException(nameof(payload)); } if ((ulong)payload.LongLength > MaxFrameBytes) { - throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + throw new IOException($"Frame too large: {payload.LongLength}"); } byte[] header = new byte[8]; WriteUInt64BigEndian(header, (ulong)payload.LongLength); @@ -801,19 +766,19 @@ private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream s #endif } - private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) + private static async Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) { byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); ulong payloadLen = ReadUInt64BigEndian(header); if (payloadLen > MaxFrameBytes) { - throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + throw new IOException($"Invalid framed length: {payloadLen}"); } if (payloadLen == 0UL) - throw new System.IO.IOException("Zero-length frames are not allowed"); + throw new IOException("Zero-length frames are not allowed"); if (payloadLen > int.MaxValue) { - throw new System.IO.IOException("Frame too large for buffer"); + throw new IOException("Frame too large for buffer"); } int count = (int)payloadLen; byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); @@ -837,7 +802,7 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value) { if (dest == null || dest.Length < 8) { - throw new System.ArgumentException("Destination buffer too small for UInt64"); + throw new ArgumentException("Destination buffer too small for UInt64"); } dest[0] = (byte)(value >> 56); dest[1] = (byte)(value >> 48); @@ -852,10 +817,9 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value) private static void ProcessCommands() { if (!isRunning) return; - if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard + if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; try { - // Heartbeat without holding the queue lock double now = EditorApplication.timeSinceStartup; if (now >= nextHeartbeatAt) { @@ -863,7 +827,6 @@ private static void ProcessCommands() nextHeartbeatAt = now + 0.5f; } - // Snapshot under lock, then process outside to reduce contention List<(string id, QueuedCommand command)> work; lock (lockObj) { @@ -884,120 +847,47 @@ private static void ProcessCommands() string commandText = queuedCommand.CommandJson; TaskCompletionSource tcs = queuedCommand.Tcs; - try + if (string.IsNullOrWhiteSpace(commandText)) { - // Special case handling - if (string.IsNullOrEmpty(commandText)) - { - var emptyResponse = new - { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Trim the command text to remove any whitespace - commandText = commandText.Trim(); - - // Non-JSON direct commands handling (like ping) - if (commandText == "ping") - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Check if the command is valid JSON before attempting to deserialize - if (!IsValidJson(commandText)) + var emptyResponse = new { - var invalidJsonResponse = new - { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } - - // Normal JSON command processing - Command command = JsonConvert.DeserializeObject(commandText); + status = "error", + error = "Empty command received", + }; + tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } - if (command == null) - { - var nullCommandResponse = new - { - status = "error", - error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object", - }; - tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); - } - else + commandText = commandText.Trim(); + if (commandText == "ping") + { + var pingResponse = new { - // Use JObject for parameters as handlers expect this - JObject paramsObject = command.@params ?? new JObject(); - - // Execute command (may be sync or async) - object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs); - - // If result is null, it means async execution - TCS will be completed by the awaited task - // In this case, DON'T remove from queue yet, DON'T complete TCS - if (result == null) - { - // Async command - the task continuation will complete the TCS - // Setup cleanup when TCS completes - schedule on next frame to avoid race conditions - string asyncCommandId = id; - _ = tcs.Task.ContinueWith(_ => - { - // Use EditorApplication.delayCall to schedule cleanup on main thread, next frame - EditorApplication.delayCall += () => - { - lock (lockObj) - { - commandQueue.Remove(asyncCommandId); - } - }; - }); - continue; // Skip the queue removal below - } - - // Synchronous result - complete TCS now - var response = new { status = "success", result }; - tcs.SetResult(JsonConvert.SerializeObject(response)); - } + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; } - catch (Exception ex) - { - McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - var response = new + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new { status = "error", - error = ex.Message, - commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText, }; - string responseJson = JsonConvert.SerializeObject(response); - tcs.SetResult(responseJson); + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; } - // Remove from queue (only for sync commands - async ones skip with 'continue' above) - lock (lockObj) { commandQueue.Remove(id); } + ExecuteQueuedCommand(id, commandText, tcs); } } finally @@ -1006,20 +896,60 @@ private static void ProcessCommands() } } - // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. - // Returns null on timeout or error; caller should provide a fallback error response. + private static void ExecuteQueuedCommand(string commandId, string payload, TaskCompletionSource completionSource) + { + async void Runner() + { + try + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + string response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(payload, cts.Token).ConfigureAwait(true); + completionSource.TrySetResult(response); + } + catch (OperationCanceledException) + { + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(timeoutResponse)); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + var response = new + { + status = "error", + error = ex.Message, + receivedText = payload?.Length > 50 + ? payload[..50] + "..." + : payload, + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(response)); + } + finally + { + lock (lockObj) + { + commandQueue.Remove(commandId); + } + } + } + + Runner(); + } + private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) { if (func == null) return null; try { - // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. if (mainThreadId == 0) { try { return func(); } catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } } - // If we are already on the main thread, execute directly to avoid deadlocks try { if (Thread.CurrentThread.ManagedThreadId == mainThreadId) @@ -1048,11 +978,10 @@ private static object InvokeOnMainThreadWithTimeout(Func func, int timeo } }; - // Wait for completion with timeout (Editor thread will pump delayCall) bool completed = tcs.Task.Wait(timeoutMs); if (!completed) { - return null; // timeout + return null; } if (captured != null) { @@ -1066,7 +995,6 @@ private static object InvokeOnMainThreadWithTimeout(Func func, int timeo } } - // Helper method to check if a string is valid JSON private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) @@ -1077,9 +1005,9 @@ private static bool IsValidJson(string text) text = text.Trim(); if ( (text.StartsWith("{") && text.EndsWith("}")) - || // Object + || (text.StartsWith("[") && text.EndsWith("]")) - ) // Array + ) { try { @@ -1095,113 +1023,45 @@ private static bool IsValidJson(string text) return false; } - private static string ExecuteCommand(Command command) + private static void OnBeforeAssemblyReload() { - try - { - if (string.IsNullOrEmpty(command.type)) - { - var errorResponse = new - { - status = "error", - error = "Command type cannot be empty", - details = "A valid command type is required for processing", - }; - return JsonConvert.SerializeObject(errorResponse); - } - - // Handle ping command for connection verification - if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) - { - var pingResponse = new - { - status = "success", - result = new { message = "pong" }, - }; - return JsonConvert.SerializeObject(pingResponse); - } - - // Use JObject for parameters as the new handlers likely expect this - JObject paramsObject = command.@params ?? new JObject(); - object result = CommandRegistry.GetHandler(command.type)(paramsObject); - - // Standard success response format - var response = new { status = "success", result }; - return JsonConvert.SerializeObject(response); - } - catch (Exception ex) + if (isRunning) { - // Log the detailed error in Unity for debugging - McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"); - - // Standard error response format - var response = new - { - status = "error", - error = ex.Message, // Provide the specific error message - command = command?.type ?? "Unknown", // Include the command type if available - stackTrace = ex.StackTrace, // Include stack trace for detailed debugging - paramsSummary = command?.@params != null - ? GetParamsSummary(command.@params) - : "No parameters", // Summarize parameters for context - }; - return JsonConvert.SerializeObject(response); + shouldRestartAfterReload = true; } + try { Stop(); } catch { } } - private static object HandleManageScene(JObject paramsObject) + private static void OnAfterAssemblyReload() { - try + WriteHeartbeat(false, "idle"); + LogBreadcrumb("Idle"); + bool shouldResume = ShouldAutoStartBridge() || shouldRestartAfterReload; + if (shouldRestartAfterReload) { - if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread"); - var sw = System.Diagnostics.Stopwatch.StartNew(); - var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); - sw.Stop(); - if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); - return r ?? Response.Error("manage_scene returned null (timeout or error)"); + shouldRestartAfterReload = false; } - catch (Exception ex) + if (!shouldResume) { - return Response.Error($"manage_scene dispatch error: {ex.Message}"); + return; } - } - // Helper method to get a summary of parameters for error reporting - private static string GetParamsSummary(JObject @params) - { - try + // If we're not compiling, try to bring the bridge up immediately to avoid depending on editor focus. + if (!IsCompiling()) { - return @params == null || !@params.HasValues - ? "No parameters" - : string.Join( - ", ", - @params - .Properties() - .Select(static p => - $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" - ) - ); - } - catch - { - return "Could not summarize parameters"; + try + { + Start(); + return; // Successful immediate start; no need to schedule a delayed retry + } + catch (Exception ex) + { + // Fall through to delayed retry if immediate start fails + McpLog.Warn($"Immediate STDIO bridge restart after reload failed: {ex.Message}"); + } } - } - - // Heartbeat/status helpers - private static void OnBeforeAssemblyReload() - { - // Stop cleanly before reload so sockets close and clients see 'reloading' - try { Stop(); } catch { } - // Avoid file I/O or heavy work here - } - private static void OnAfterAssemblyReload() - { - // Will be overwritten by Start(), but mark as alive quickly - WriteHeartbeat(false, "idle"); - LogBreadcrumb("Idle"); - // Schedule a safe restart after reload to avoid races during compilation + // Fallback path when compiling or if immediate start failed ScheduleInitRetry(); } @@ -1209,7 +1069,6 @@ private static void WriteHeartbeat(bool reloading, string reason = null) { try { - // Allow override of status directory (useful in CI/containers) string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); if (string.IsNullOrWhiteSpace(dir)) { @@ -1218,14 +1077,12 @@ private static void WriteHeartbeat(bool reloading, string reason = null) Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - // Extract project name from path string projectName = "Unknown"; try { string projectPath = Application.dataPath; if (!string.IsNullOrEmpty(projectPath)) { - // Remove trailing /Assets or \Assets projectPath = projectPath.TrimEnd('/', '\\'); if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) { @@ -1255,26 +1112,9 @@ private static void WriteHeartbeat(bool reloading, string reason = null) } catch (Exception) { - // Best-effort only } } - 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/Services/Transport/Transports/StdioBridgeHost.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta new file mode 100644 index 000000000..b8368564f --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fd295cefe518e438693c12e9c7f37488 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs new file mode 100644 index 000000000..ea3ed1a22 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Adapts the existing TCP bridge into the transport abstraction. + /// + public class StdioTransportClient : IMcpTransportClient + { + private TransportState _state = TransportState.Disconnected("stdio"); + + public bool IsConnected => StdioBridgeHost.IsRunning; + public string TransportName => "stdio"; + public TransportState State => _state; + + public Task StartAsync() + { + try + { + StdioBridgeHost.StartAutoConnect(); + _state = TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()); + return Task.FromResult(true); + } + catch (Exception ex) + { + _state = TransportState.Disconnected("stdio", ex.Message); + return Task.FromResult(false); + } + } + + public Task StopAsync() + { + StdioBridgeHost.Stop(); + _state = TransportState.Disconnected("stdio"); + return Task.CompletedTask; + } + + public Task VerifyAsync() + { + bool running = StdioBridgeHost.IsRunning; + _state = running + ? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()) + : TransportState.Disconnected("stdio", "Bridge not running"); + return Task.FromResult(running); + } + + } +} diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta new file mode 100644 index 000000000..f4ac1ab0c --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2743f3468d5f433dbf2220f0838d8d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs new file mode 100644 index 000000000..3a7b822a1 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -0,0 +1,561 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Maintains a persistent WebSocket connection to the MCP server plugin hub. + /// Handles registration, keep-alives, and command dispatch back into Unity via + /// . + /// + public class WebSocketTransportClient : IMcpTransportClient, IDisposable + { + private const string TransportDisplayName = "websocket"; + private static readonly TimeSpan[] ReconnectSchedule = + { + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }; + + private static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30); + + private ClientWebSocket _socket; + private CancellationTokenSource _lifecycleCts; + private Task _receiveTask; + private Task _keepAliveTask; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private Uri _endpointUri; + private string _sessionId; + private string _projectHash; + private string _projectName; + private string _unityVersion; + private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval; + private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval; + private volatile bool _isConnected; + private volatile bool _isReconnecting; + private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started"); + + public bool IsConnected => _isConnected; + public string TransportName => TransportDisplayName; + public TransportState State => _state; + + public async Task StartAsync() + { + // Capture identity values on the main thread before any async context switching + _projectName = ProjectIdentityUtility.GetProjectName(); + _projectHash = ProjectIdentityUtility.GetProjectHash(); + _unityVersion = Application.unityVersion; + + await StopAsync(); + + _lifecycleCts = new CancellationTokenSource(); + _endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl()); + _sessionId = null; + + if (!await EstablishConnectionAsync(_lifecycleCts.Token)) + { + await StopAsync(); + return false; + } + + // State is connected but session ID might be pending until 'registered' message + _state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString()); + _isConnected = true; + return true; + } + + public async Task StopAsync() + { + if (_lifecycleCts == null) + { + return; + } + + try + { + _lifecycleCts.Cancel(); + } + catch { } + + if (_keepAliveTask != null) + { + try { await _keepAliveTask.ConfigureAwait(false); } catch { } + _keepAliveTask = null; + } + + if (_receiveTask != null) + { + try { await _receiveTask.ConfigureAwait(false); } catch { } + _receiveTask = null; + } + + if (_socket != null) + { + try + { + if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived) + { + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutdown", CancellationToken.None).ConfigureAwait(false); + } + } + catch { } + finally + { + _socket.Dispose(); + _socket = null; + } + } + + _isConnected = false; + _state = TransportState.Disconnected(TransportDisplayName); + + _lifecycleCts.Dispose(); + _lifecycleCts = null; + } + + public async Task VerifyAsync() + { + if (_socket == null || _socket.State != WebSocketState.Open) + { + return false; + } + + if (_lifecycleCts == null) + { + return false; + } + + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_lifecycleCts.Token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + await SendPongAsync(timeoutCts.Token).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Verify ping failed: {ex.Message}"); + return false; + } + } + + public void Dispose() + { + _sendLock?.Dispose(); + _socket?.Dispose(); + _lifecycleCts?.Dispose(); + } + + private async Task EstablishConnectionAsync(CancellationToken token) + { + _socket?.Dispose(); + _socket = new ClientWebSocket(); + _socket.Options.KeepAliveInterval = _socketKeepAliveInterval; + + try + { + await _socket.ConnectAsync(_endpointUri, token).ConfigureAwait(false); + } + catch (Exception ex) + { + McpLog.Error($"[WebSocket] Connection failed: {ex.Message}"); + return false; + } + + StartBackgroundLoops(token); + + try + { + await SendRegisterAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + McpLog.Error($"[WebSocket] Registration failed: {ex.Message}"); + return false; + } + + return true; + } + + private void StartBackgroundLoops(CancellationToken token) + { + _receiveTask = Task.Run(() => ReceiveLoopAsync(token), CancellationToken.None); + _keepAliveTask = Task.Run(() => KeepAliveLoopAsync(token), CancellationToken.None); + } + + private async Task ReceiveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + string message = await ReceiveMessageAsync(token).ConfigureAwait(false); + if (message == null) + { + continue; + } + await HandleMessageAsync(message, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (WebSocketException wse) + { + McpLog.Warn($"[WebSocket] Receive loop error: {wse.Message}"); + await HandleSocketClosureAsync(wse.Message).ConfigureAwait(false); + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Unexpected receive error: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task ReceiveMessageAsync(CancellationToken token) + { + if (_socket == null) + { + return null; + } + + var buffer = new ArraySegment(new byte[8192]); + using var ms = new MemoryStream(); + + while (!token.IsCancellationRequested) + { + WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, token).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await HandleSocketClosureAsync(result.CloseStatusDescription ?? "Server closed connection").ConfigureAwait(false); + return null; + } + + if (result.Count > 0) + { + ms.Write(buffer.Array!, buffer.Offset, result.Count); + } + + if (result.EndOfMessage) + { + break; + } + } + + if (ms.Length == 0) + { + return null; + } + + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private async Task HandleMessageAsync(string message, CancellationToken token) + { + JObject payload; + try + { + payload = JObject.Parse(message); + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Invalid JSON payload: {ex.Message}"); + return; + } + + string messageType = payload.Value("type") ?? string.Empty; + + switch (messageType) + { + case "welcome": + ApplyWelcome(payload); + break; + case "registered": + HandleRegistered(payload); + break; + case "execute": + await HandleExecuteAsync(payload, token).ConfigureAwait(false); + break; + case "ping": + await SendPongAsync(token).ConfigureAwait(false); + break; + default: + // No-op for unrecognised types (keep-alives, telemetry, etc.) + break; + } + } + + private void ApplyWelcome(JObject payload) + { + int? keepAliveSeconds = payload.Value("keepAliveInterval"); + if (keepAliveSeconds.HasValue && keepAliveSeconds.Value > 0) + { + _keepAliveInterval = TimeSpan.FromSeconds(keepAliveSeconds.Value); + _socketKeepAliveInterval = _keepAliveInterval; + } + + int? serverTimeoutSeconds = payload.Value("serverTimeout"); + if (serverTimeoutSeconds.HasValue) + { + int sourceSeconds = keepAliveSeconds ?? serverTimeoutSeconds.Value; + int safeSeconds = Math.Max(5, Math.Min(serverTimeoutSeconds.Value, sourceSeconds)); + _socketKeepAliveInterval = TimeSpan.FromSeconds(safeSeconds); + } + } + + private void HandleRegistered(JObject payload) + { + string newSessionId = payload.Value("session_id"); + if (!string.IsNullOrEmpty(newSessionId)) + { + _sessionId = newSessionId; + ProjectIdentityUtility.SetSessionId(_sessionId); + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}"); + } + } + + private async Task HandleExecuteAsync(JObject payload, CancellationToken token) + { + string commandId = payload.Value("id"); + string commandName = payload.Value("name"); + JObject parameters = payload.Value("params") ?? new JObject(); + int timeoutSeconds = payload.Value("timeout") ?? (int)DefaultCommandTimeout.TotalSeconds; + + if (string.IsNullOrEmpty(commandId) || string.IsNullOrEmpty(commandName)) + { + McpLog.Warn("[WebSocket] Invalid execute payload (missing id or name)"); + return; + } + + var commandEnvelope = new JObject + { + ["type"] = commandName, + ["params"] = parameters + }; + + string responseJson; + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))); + responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = $"Command '{commandName}' timed out after {timeoutSeconds} seconds" + }); + } + catch (Exception ex) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = ex.Message + }); + } + + JToken resultToken; + try + { + resultToken = JToken.Parse(responseJson); + } + catch + { + resultToken = new JObject + { + ["status"] = "error", + ["error"] = "Invalid response payload" + }; + } + + var responsePayload = new JObject + { + ["type"] = "command_result", + ["id"] = commandId, + ["result"] = resultToken + }; + + await SendJsonAsync(responsePayload, token).ConfigureAwait(false); + } + + private async Task KeepAliveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_keepAliveInterval, token).ConfigureAwait(false); + if (_socket == null || _socket.State != WebSocketState.Open) + { + break; + } + await SendPongAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Keep-alive failed: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task SendRegisterAsync(CancellationToken token) + { + var registerPayload = new JObject + { + ["type"] = "register", + // session_id is now server-authoritative; omitted here or sent as null + ["project_name"] = _projectName, + ["project_hash"] = _projectHash, + ["unity_version"] = _unityVersion + }; + + await SendJsonAsync(registerPayload, token).ConfigureAwait(false); + } + + private Task SendPongAsync(CancellationToken token) + { + var payload = new JObject + { + ["type"] = "pong", + ["session_id"] = _sessionId + }; + return SendJsonAsync(payload, token); + } + + private async Task SendJsonAsync(JObject payload, CancellationToken token) + { + if (_socket == null) + { + throw new InvalidOperationException("WebSocket is not initialised"); + } + + string json = payload.ToString(Formatting.None); + byte[] bytes = Encoding.UTF8.GetBytes(json); + var buffer = new ArraySegment(bytes); + + await _sendLock.WaitAsync(token).ConfigureAwait(false); + try + { + if (_socket.State != WebSocketState.Open) + { + throw new InvalidOperationException("WebSocket is not open"); + } + + await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private Task HandleSocketClosureAsync(string reason) + { + if (_lifecycleCts == null || _lifecycleCts.IsCancellationRequested) + { + return Task.CompletedTask; + } + + if (_isReconnecting) + { + return Task.CompletedTask; + } + + _isConnected = false; + _state = _state.WithError(reason ?? "Connection closed"); + McpLog.Warn($"[WebSocket] Connection closed: {reason}"); + + _isReconnecting = true; + _ = Task.Run(() => AttemptReconnectAsync(_lifecycleCts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + private async Task AttemptReconnectAsync(CancellationToken token) + { + try + { + foreach (TimeSpan delay in ReconnectSchedule) + { + if (token.IsCancellationRequested) + { + return; + } + + if (delay > TimeSpan.Zero) + { + try { await Task.Delay(delay, token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + + if (await EstablishConnectionAsync(token).ConfigureAwait(false)) + { + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + _isConnected = true; + McpLog.Info("[WebSocket] Reconnected to MCP server"); + return; + } + } + } + finally + { + _isReconnecting = false; + } + + _state = TransportState.Disconnected(TransportDisplayName, "Failed to reconnect"); + } + + private static Uri BuildWebSocketUri(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + baseUrl = "http://localhost:8080"; + } + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var httpUri)) + { + throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}"); + } + + string scheme = httpUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws"; + string builder = $"{scheme}://{httpUri.Authority}"; + if (!string.IsNullOrEmpty(httpUri.AbsolutePath) && httpUri.AbsolutePath != "/") + { + builder += httpUri.AbsolutePath.TrimEnd('/'); + } + + builder += "/hub/plugin"; + + return new Uri(builder); + } + } +} diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta new file mode 100644 index 000000000..91b98e004 --- /dev/null +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 044c8f7beb4af4a77a14d677190c21dc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs b/MCPForUnity/Editor/Setup/SetupWindowService.cs similarity index 71% rename from MCPForUnity/Editor/Setup/SetupWizard.cs rename to MCPForUnity/Editor/Setup/SetupWindowService.cs index 7bb77dede..b1c5ed6d0 100644 --- a/MCPForUnity/Editor/Setup/SetupWizard.cs +++ b/MCPForUnity/Editor/Setup/SetupWindowService.cs @@ -3,33 +3,34 @@ using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Windows; +using MCPForUnity.Editor.Constants; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Setup { /// - /// Handles automatic triggering of the setup wizard + /// Handles automatic triggering of the MCP setup window and exposes menu entry points /// [InitializeOnLoad] - public static class SetupWizard + public static class SetupWindowService { - private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; - private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; + private const string SETUP_COMPLETED_KEY = EditorPrefKeys.SetupCompleted; + private const string SETUP_DISMISSED_KEY = EditorPrefKeys.SetupDismissed; private static bool _hasCheckedThisSession = false; - static SetupWizard() + static SetupWindowService() { // Skip in batch mode if (Application.isBatchMode) return; - // Show setup wizard on package import + // Show Setup Window on package import EditorApplication.delayCall += CheckSetupNeeded; } /// - /// Check if setup wizard should be shown + /// Check if Setup Window should be shown /// private static void CheckSetupNeeded() { @@ -44,17 +45,17 @@ private static void CheckSetupNeeded() bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); - // Only show setup wizard if it hasn't been completed or dismissed before + // Only show Setup Window if it hasn't been completed or dismissed before if (!(setupCompleted || setupDismissed)) { - McpLog.Info("Package imported - showing setup wizard", always: false); + McpLog.Info("Package imported - showing Setup Window", always: false); var dependencyResult = DependencyManager.CheckAllDependencies(); - EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); + EditorApplication.delayCall += () => ShowSetupWindow(dependencyResult); } else { - McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); + McpLog.Info("Setup Window skipped - previously completed or dismissed", always: false); } } catch (Exception ex) @@ -64,18 +65,18 @@ private static void CheckSetupNeeded() } /// - /// Show the setup wizard window + /// Show the setup window /// - public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) + public static void ShowSetupWindow(DependencyCheckResult dependencyResult = null) { try { dependencyResult ??= DependencyManager.CheckAllDependencies(); - SetupWizardWindow.ShowWindow(dependencyResult); + MCPSetupWindow.ShowWindow(dependencyResult); } catch (Exception ex) { - McpLog.Error($"Error showing setup wizard: {ex.Message}"); + McpLog.Error($"Error showing setup window: {ex.Message}"); } } diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs.meta b/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta similarity index 100% rename from MCPForUnity/Editor/Setup/SetupWizard.cs.meta rename to MCPForUnity/Editor/Setup/SetupWindowService.cs.meta diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs deleted file mode 100644 index 61c0ef9e6..000000000 --- a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs +++ /dev/null @@ -1,344 +0,0 @@ -using System; -using System.Linq; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Dependencies; -using MCPForUnity.Editor.Dependencies.Models; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Models; -using UnityEditor; -using UnityEngine; - -namespace MCPForUnity.Editor.Setup -{ - /// - /// Setup wizard window for guiding users through dependency installation - /// - public class SetupWizardWindow : EditorWindow - { - private DependencyCheckResult _dependencyResult; - private Vector2 _scrollPosition; - private int _currentStep = 0; - - private readonly string[] _stepTitles = { - "Setup", - "Complete" - }; - - public static void ShowWindow(DependencyCheckResult dependencyResult = null) - { - var window = GetWindow("MCP for Unity Setup"); - window.minSize = new Vector2(500, 400); - window.maxSize = new Vector2(800, 600); - window._dependencyResult = dependencyResult ?? DependencyManager.CheckAllDependencies(); - window.Show(); - } - - private void OnEnable() - { - if (_dependencyResult == null) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - } - - private void OnGUI() - { - DrawHeader(); - DrawProgressBar(); - - _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); - - switch (_currentStep) - { - case 0: DrawSetupStep(); break; - case 1: DrawCompleteStep(); break; - } - - EditorGUILayout.EndScrollView(); - - DrawFooter(); - } - - private void DrawHeader() - { - EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - GUILayout.Label("MCP for Unity Setup Wizard", EditorStyles.boldLabel); - GUILayout.FlexibleSpace(); - GUILayout.Label($"Step {_currentStep + 1} of {_stepTitles.Length}"); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Step title - var titleStyle = new GUIStyle(EditorStyles.largeLabel) - { - fontSize = 16, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(_stepTitles[_currentStep], titleStyle); - EditorGUILayout.Space(); - } - - private void DrawProgressBar() - { - var rect = EditorGUILayout.GetControlRect(false, 4); - var progress = (_currentStep + 1) / (float)_stepTitles.Length; - EditorGUI.ProgressBar(rect, progress, ""); - EditorGUILayout.Space(); - } - - private void DrawSetupStep() - { - // Welcome section - DrawSectionTitle("MCP for Unity Setup"); - - EditorGUILayout.LabelField( - "This wizard will help you set up MCP for Unity to connect AI assistants with your Unity Editor.", - EditorStyles.wordWrappedLabel - ); - EditorGUILayout.Space(); - - // Dependency check section - EditorGUILayout.BeginHorizontal(); - DrawSectionTitle("System Check", 14); - GUILayout.FlexibleSpace(); - if (GUILayout.Button("Refresh", GUILayout.Width(60), GUILayout.Height(20))) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - EditorGUILayout.EndHorizontal(); - - // Show simplified dependency status - foreach (var dep in _dependencyResult.Dependencies) - { - DrawSimpleDependencyStatus(dep); - } - - // Overall status and installation guidance - EditorGUILayout.Space(); - if (!_dependencyResult.IsSystemReady) - { - // Only show critical warnings when dependencies are actually missing - EditorGUILayout.HelpBox( - "\u26A0 Missing Dependencies: MCP for Unity requires Python 3.10+ and UV package manager to function properly.", - MessageType.Warning - ); - - EditorGUILayout.Space(); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - DrawErrorStatus("Installation Required"); - - var recommendations = DependencyManager.GetInstallationRecommendations(); - EditorGUILayout.LabelField(recommendations, EditorStyles.wordWrappedLabel); - - EditorGUILayout.Space(); - if (GUILayout.Button("Open Installation Links", GUILayout.Height(25))) - { - OpenInstallationUrls(); - } - EditorGUILayout.EndVertical(); - } - else - { - DrawSuccessStatus("System Ready"); - EditorGUILayout.LabelField("All requirements are met. You can proceed to configure your AI clients.", EditorStyles.wordWrappedLabel); - } - } - - private void DrawCompleteStep() - { - DrawSectionTitle("Setup Complete"); - - // Refresh dependency check with caching to avoid heavy operations on every repaint - if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) - { - _dependencyResult = DependencyManager.CheckAllDependencies(); - } - - if (_dependencyResult.IsSystemReady) - { - DrawSuccessStatus("MCP for Unity Ready!"); - - EditorGUILayout.HelpBox( - "🎉 MCP for Unity is now set up and ready to use!\n\n" + - "• Dependencies verified\n" + - "• MCP server ready\n" + - "• Client configuration accessible", - MessageType.Info - ); - - EditorGUILayout.Space(); - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Documentation", GUILayout.Height(30))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp"); - } - if (GUILayout.Button("Client Settings", GUILayout.Height(30))) - { - Windows.MCPForUnityEditorWindow.ShowWindow(); - } - EditorGUILayout.EndHorizontal(); - } - else - { - DrawErrorStatus("Setup Incomplete - Package Non-Functional"); - - EditorGUILayout.HelpBox( - "🚨 MCP for Unity CANNOT work - dependencies still missing!\n\n" + - "Install ALL required dependencies before the package will function.", - MessageType.Error - ); - - var missingDeps = _dependencyResult.GetMissingRequired(); - if (missingDeps.Count > 0) - { - EditorGUILayout.Space(); - EditorGUILayout.LabelField("Still Missing:", EditorStyles.boldLabel); - foreach (var dep in missingDeps) - { - EditorGUILayout.LabelField($"✗ {dep.Name}", EditorStyles.label); - } - } - - EditorGUILayout.Space(); - if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) - { - _currentStep = 0; - } - } - } - - // Helper methods for consistent UI components - private void DrawSectionTitle(string title, int fontSize = 16) - { - var titleStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = fontSize, - fontStyle = FontStyle.Bold - }; - EditorGUILayout.LabelField(title, titleStyle); - EditorGUILayout.Space(); - } - - private void DrawSuccessStatus(string message) - { - var originalColor = GUI.color; - GUI.color = Color.green; - EditorGUILayout.LabelField($"✓ {message}", EditorStyles.boldLabel); - GUI.color = originalColor; - EditorGUILayout.Space(); - } - - private void DrawErrorStatus(string message) - { - var originalColor = GUI.color; - GUI.color = Color.red; - EditorGUILayout.LabelField($"✗ {message}", EditorStyles.boldLabel); - GUI.color = originalColor; - EditorGUILayout.Space(); - } - - private void DrawSimpleDependencyStatus(DependencyStatus dep) - { - EditorGUILayout.BeginHorizontal(); - - var statusIcon = dep.IsAvailable ? "✓" : "✗"; - var statusColor = dep.IsAvailable ? Color.green : Color.red; - - var originalColor = GUI.color; - GUI.color = statusColor; - GUILayout.Label(statusIcon, GUILayout.Width(20)); - EditorGUILayout.LabelField(dep.Name, EditorStyles.boldLabel); - GUI.color = originalColor; - - if (!dep.IsAvailable && !string.IsNullOrEmpty(dep.ErrorMessage)) - { - EditorGUILayout.LabelField($"({dep.ErrorMessage})", EditorStyles.miniLabel); - } - - EditorGUILayout.EndHorizontal(); - } - - private void DrawFooter() - { - EditorGUILayout.Space(); - EditorGUILayout.BeginHorizontal(); - - // Back button - GUI.enabled = _currentStep > 0; - if (GUILayout.Button("Back", GUILayout.Width(60))) - { - _currentStep--; - } - - GUILayout.FlexibleSpace(); - - // Skip button - if (GUILayout.Button("Skip", GUILayout.Width(60))) - { - bool dismiss = EditorUtility.DisplayDialog( - "Skip Setup", - "\u26A0 Skipping setup will leave MCP for Unity non-functional!\n\n" + - "You can restart setup from: Window > MCP for Unity > Setup Wizard (Required)", - "Skip Anyway", - "Cancel" - ); - - if (dismiss) - { - SetupWizard.MarkSetupDismissed(); - Close(); - } - } - - // Next/Done button - GUI.enabled = true; - string buttonText = _currentStep == _stepTitles.Length - 1 ? "Done" : "Next"; - - if (GUILayout.Button(buttonText, GUILayout.Width(80))) - { - if (_currentStep == _stepTitles.Length - 1) - { - SetupWizard.MarkSetupCompleted(); - Close(); - } - else - { - _currentStep++; - } - } - - GUI.enabled = true; - EditorGUILayout.EndHorizontal(); - } - - private void OpenInstallationUrls() - { - var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); - - bool openPython = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open Python installation page?", - "Yes", - "No" - ); - - if (openPython) - { - Application.OpenURL(pythonUrl); - } - - bool openUV = EditorUtility.DisplayDialog( - "Open Installation URLs", - "Open UV installation page?", - "Yes", - "No" - ); - - if (openUV) - { - Application.OpenURL(uvUrl); - } - } - } -} diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta deleted file mode 100644 index 5361de3d3..000000000 --- a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 45678901234abcdef0123456789abcde -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs b/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs index 503295bca..2ce1c8442 100644 --- a/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs +++ b/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs @@ -6,7 +6,7 @@ namespace MCPForUnity.Editor.Tools { - [McpForUnityTool("execute_menu_item")] + [McpForUnityTool("execute_menu_item", AutoRegister = false)] public static class ExecuteMenuItem { // Basic blacklist to prevent execution of disruptive menu items. @@ -22,12 +22,12 @@ public static object HandleCommand(JObject @params) string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); if (string.IsNullOrWhiteSpace(menuPath)) { - return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + return new ErrorResponse("Required parameter 'menu_path' or 'menuPath' is missing or empty."); } if (_menuPathBlacklist.Contains(menuPath)) { - return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + return new ErrorResponse($"Execution of menu item '{menuPath}' is blocked for safety reasons."); } try @@ -36,14 +36,14 @@ public static object HandleCommand(JObject @params) if (!executed) { McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); - return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + return new ErrorResponse($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); } - return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + return new SuccessResponse($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); } catch (Exception e) { McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); - return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + return new ErrorResponse($"Error setting up execution for menu item '{menuPath}': {e.Message}"); } } } diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 46be0ef7b..36480a242 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -22,7 +22,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles asset management operations within the Unity project. /// - [McpForUnityTool("manage_asset")] + [McpForUnityTool("manage_asset", AutoRegister = false)] public static class ManageAsset { // --- Main Handler --- @@ -48,14 +48,14 @@ public static object HandleCommand(JObject @params) string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Check if the action is valid before switching if (!ValidActions.Contains(action)) { string validActionsList = string.Join(", ", ValidActions); - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: {validActionsList}" ); } @@ -112,7 +112,7 @@ public static object HandleCommand(JObject @params) default: // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. string validActionsListDefault = string.Join(", ", ValidActions); - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" ); } @@ -120,7 +120,7 @@ public static object HandleCommand(JObject @params) catch (Exception e) { Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); - return Response.Error( + return new ErrorResponse( $"Internal error processing action '{action}' on '{path}': {e.Message}" ); } @@ -131,10 +131,10 @@ public static object HandleCommand(JObject @params) private static object ReimportAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for reimport."); + return new ErrorResponse("'path' is required for reimport."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -152,11 +152,11 @@ private static object ReimportAsset(string path, JObject properties) AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh - return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + return new SuccessResponse($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); } catch (Exception e) { - return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to reimport asset '{fullPath}': {e.Message}"); } } @@ -167,9 +167,9 @@ private static object CreateAsset(JObject @params) JObject properties = @params["properties"] as JObject; if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create."); + return new ErrorResponse("'path' is required for create."); if (string.IsNullOrEmpty(assetType)) - return Response.Error("'assetType' is required for create."); + return new ErrorResponse("'assetType' is required for create."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); @@ -182,7 +182,7 @@ private static object CreateAsset(JObject @params) } if (AssetExists(fullPath)) - return Response.Error($"Asset already exists at path: {fullPath}"); + return new ErrorResponse($"Asset already exists at path: {fullPath}"); try { @@ -205,7 +205,7 @@ private static object CreateAsset(JObject @params) ?? Shader.Find("Standard") ?? Shader.Find("Unlit/Color"); if (shader == null) - return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); + return new ErrorResponse($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); var mat = new Material(shader); if (properties != null) @@ -225,7 +225,7 @@ private static object CreateAsset(JObject @params) { string scriptClassName = properties?["scriptClass"]?.ToString(); if (string.IsNullOrEmpty(scriptClassName)) - return Response.Error( + return new ErrorResponse( "'scriptClass' property required when creating ScriptableObject asset." ); @@ -238,7 +238,7 @@ private static object CreateAsset(JObject @params) var reason = scriptType == null ? (string.IsNullOrEmpty(error) ? "Type not found." : error) : "Type found but does not inherit from ScriptableObject."; - return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); + return new ErrorResponse($"Script class '{scriptClassName}' invalid: {reason}"); } ScriptableObject so = ScriptableObject.CreateInstance(scriptType); @@ -250,7 +250,7 @@ private static object CreateAsset(JObject @params) { // Creating prefabs usually involves saving an existing GameObject hierarchy. // A common pattern is to create an empty GameObject, configure it, and then save it. - return Response.Error( + return new ErrorResponse( "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." ); // Example (conceptual): @@ -265,7 +265,7 @@ private static object CreateAsset(JObject @params) // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); - return Response.Error( + return new ErrorResponse( $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." ); } @@ -275,28 +275,28 @@ private static object CreateAsset(JObject @params) && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) ) // Check if it wasn't a folder and asset wasn't created { - return Response.Error( + return new ErrorResponse( $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." ); } AssetDatabase.SaveAssets(); // AssetDatabase.Refresh(); // CreateAsset often handles refresh - return Response.Success( + return new SuccessResponse( $"Asset '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { - return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to create asset at '{fullPath}': {e.Message}"); } } private static object CreateFolder(string path) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for create_folder."); + return new ErrorResponse("'path' is required for create_folder."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); @@ -306,14 +306,14 @@ private static object CreateFolder(string path) // Check if it's actually a folder already if (AssetDatabase.IsValidFolder(fullPath)) { - return Response.Success( + return new SuccessResponse( $"Folder already exists at path: {fullPath}", GetAssetData(fullPath) ); } else { - return Response.Error( + return new ErrorResponse( $"An asset (not a folder) already exists at path: {fullPath}" ); } @@ -331,33 +331,33 @@ private static object CreateFolder(string path) string guid = AssetDatabase.CreateFolder(parentDir, folderName); if (string.IsNullOrEmpty(guid)) { - return Response.Error( + return new ErrorResponse( $"Failed to create folder '{fullPath}'. Check logs and permissions." ); } // AssetDatabase.Refresh(); // CreateFolder usually handles refresh - return Response.Success( + return new SuccessResponse( $"Folder '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { - return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to create folder '{fullPath}': {e.Message}"); } } private static object ModifyAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for modify."); + return new ErrorResponse("'path' is required for modify."); if (properties == null || !properties.HasValues) - return Response.Error("'properties' are required for modify."); + return new ErrorResponse("'properties' are required for modify."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -365,7 +365,7 @@ private static object ModifyAsset(string path, JObject properties) fullPath ); if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); bool modified = false; // Flag to track if any changes were made @@ -484,7 +484,7 @@ prop.Value is JObject componentProperties AssetDatabase.SaveAssets(); // Refresh might be needed in some edge cases, but SaveAssets usually covers it. // AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath) ); @@ -492,11 +492,11 @@ prop.Value is JObject componentProperties else { // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. - return Response.Success( + return new SuccessResponse( $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath) ); - // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + // Previous message: return new SuccessResponse($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); } } catch (Exception e) @@ -504,17 +504,17 @@ prop.Value is JObject componentProperties // Log the detailed error internally Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); // Return a user-friendly error message - return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Failed to modify asset '{fullPath}': {e.Message}"); } } private static object DeleteAsset(string path) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for delete."); + return new ErrorResponse("'path' is required for delete."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -522,30 +522,30 @@ private static object DeleteAsset(string path) if (success) { // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh - return Response.Success($"Asset '{fullPath}' deleted successfully."); + return new SuccessResponse($"Asset '{fullPath}' deleted successfully."); } else { // This might happen if the file couldn't be deleted (e.g., locked) - return Response.Error( + return new ErrorResponse( $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." ); } } catch (Exception e) { - return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Error deleting asset '{fullPath}': {e.Message}"); } } private static object DuplicateAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for duplicate."); + return new ErrorResponse("'path' is required for duplicate."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); string destPath; if (string.IsNullOrEmpty(destinationPath)) @@ -557,7 +557,7 @@ private static object DuplicateAsset(string path, string destinationPath) { destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) - return Response.Error($"Asset already exists at destination path: {destPath}"); + return new ErrorResponse($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); } @@ -568,38 +568,38 @@ private static object DuplicateAsset(string path, string destinationPath) if (success) { // AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath) ); } else { - return Response.Error( + return new ErrorResponse( $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { - return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); + return new ErrorResponse($"Error duplicating asset '{sourcePath}': {e.Message}"); } } private static object MoveOrRenameAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for move/rename."); + return new ErrorResponse("'path' is required for move/rename."); if (string.IsNullOrEmpty(destinationPath)) - return Response.Error("'destination' path is required for move/rename."); + return new ErrorResponse("'destination' path is required for move/rename."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) - return Response.Error($"Source asset not found at path: {sourcePath}"); + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); if (AssetExists(destPath)) - return Response.Error( + return new ErrorResponse( $"An asset already exists at the destination path: {destPath}" ); @@ -612,7 +612,7 @@ private static object MoveOrRenameAsset(string path, string destinationPath) string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(error)) { - return Response.Error( + return new ErrorResponse( $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" ); } @@ -621,7 +621,7 @@ private static object MoveOrRenameAsset(string path, string destinationPath) if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success { // AssetDatabase.Refresh(); // MoveAsset usually handles refresh - return Response.Success( + return new SuccessResponse( $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath) ); @@ -629,14 +629,14 @@ private static object MoveOrRenameAsset(string path, string destinationPath) else { // This case might not be reachable if ValidateMoveAsset passes, but good to have - return Response.Error( + return new ErrorResponse( $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { - return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + return new ErrorResponse($"Error moving/renaming asset '{sourcePath}': {e.Message}"); } } @@ -728,7 +728,7 @@ out DateTime parsedDate int startIndex = (pageNumber - 1) * pageSize; var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); - return Response.Success( + return new SuccessResponse( $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new { @@ -741,28 +741,28 @@ out DateTime parsedDate } catch (Exception e) { - return Response.Error($"Error searching assets: {e.Message}"); + return new ErrorResponse($"Error searching assets: {e.Message}"); } } private static object GetAssetInfo(string path, bool generatePreview) { if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_info."); + return new ErrorResponse("'path' is required for get_info."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { - return Response.Success( + return new SuccessResponse( "Asset info retrieved.", GetAssetData(fullPath, generatePreview) ); } catch (Exception e) { - return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); + return new ErrorResponse($"Error getting info for asset '{fullPath}': {e.Message}"); } } @@ -775,12 +775,12 @@ private static object GetComponentsFromAsset(string path) { // 1. Validate input path if (string.IsNullOrEmpty(path)) - return Response.Error("'path' is required for get_components."); + return new ErrorResponse("'path' is required for get_components."); // 2. Sanitize and check existence string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) - return Response.Error($"Asset not found at path: {fullPath}"); + return new ErrorResponse($"Asset not found at path: {fullPath}"); try { @@ -789,7 +789,7 @@ private static object GetComponentsFromAsset(string path) fullPath ); if (asset == null) - return Response.Error($"Failed to load asset at path: {fullPath}"); + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); // 4. Check if it's a GameObject (Prefabs load as GameObjects) GameObject gameObject = asset as GameObject; @@ -801,11 +801,11 @@ private static object GetComponentsFromAsset(string path) { // If the asset itself *is* a component, maybe return just its info? // This is an edge case. Let's stick to GameObjects for now. - return Response.Error( + return new ErrorResponse( $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." ); } - return Response.Error( + return new ErrorResponse( $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." ); } @@ -825,7 +825,7 @@ private static object GetComponentsFromAsset(string path) .ToList(); // Explicit cast for clarity if needed // 7. Return success response - return Response.Success( + return new SuccessResponse( $"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList ); @@ -835,7 +835,7 @@ private static object GetComponentsFromAsset(string path) Debug.LogError( $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" ); - return Response.Error( + return new ErrorResponse( $"Error getting components for asset '{fullPath}': {e.Message}" ); } @@ -1051,113 +1051,113 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) } } - // --- Flexible direct property assignment --- - // Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." } - // while retaining backward compatibility with the structured keys above. - // This iterates all top-level keys except the reserved structured ones and applies them - // if they match known shader properties. - var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; - - // Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness) - string ResolvePropertyName(string name) - { - if (string.IsNullOrEmpty(name)) return name; - string[] candidates; - var lower = name.ToLowerInvariant(); - switch (lower) - { - case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; - case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; - case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; - case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; - case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; - case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - // Friendly names → shader property names - case "metallic": candidates = new[] { "_Metallic" }; break; - case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; - case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; - default: candidates = new[] { name }; break; // keep original as-is - } - foreach (var candidate in candidates) - { - if (mat.HasProperty(candidate)) return candidate; - } - return name; // fall back to original - } - - foreach (var prop in properties.Properties()) - { - if (reservedKeys.Contains(prop.Name)) continue; - string shaderProp = ResolvePropertyName(prop.Name); - JToken v = prop.Value; - - // Color: numeric array [r,g,b,(a)] - if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer)) - { - if (mat.HasProperty(shaderProp)) - { - try - { - var c = new Color( - arr[0].ToObject(), - arr[1].ToObject(), - arr[2].ToObject(), - arr.Count > 3 ? arr[3].ToObject() : 1f - ); - if (mat.GetColor(shaderProp) != c) - { - mat.SetColor(shaderProp, c); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}"); - } - } - continue; - } - - // Float: single number - if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer) - { - if (mat.HasProperty(shaderProp)) - { - try - { - float f = v.ToObject(); - if (!Mathf.Approximately(mat.GetFloat(shaderProp), f)) - { - mat.SetFloat(shaderProp, f); - modified = true; - } - } - catch (Exception ex) - { - Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}"); - } - } - continue; - } - - // Texture: string path - if (v.Type == JTokenType.String) - { - string texPath = v.ToString(); - if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp)) - { - var tex = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(texPath)); - if (tex != null && mat.GetTexture(shaderProp) != tex) - { - mat.SetTexture(shaderProp, tex); - modified = true; - } - } - continue; - } - } - - // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + // --- Flexible direct property assignment --- + // Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." } + // while retaining backward compatibility with the structured keys above. + // This iterates all top-level keys except the reserved structured ones and applies them + // if they match known shader properties. + var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; + + // Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness) + string ResolvePropertyName(string name) + { + if (string.IsNullOrEmpty(name)) return name; + string[] candidates; + var lower = name.ToLowerInvariant(); + switch (lower) + { + case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; + case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; + case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; + case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; + case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; + case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + // Friendly names → shader property names + case "metallic": candidates = new[] { "_Metallic" }; break; + case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; + default: candidates = new[] { name }; break; // keep original as-is + } + foreach (var candidate in candidates) + { + if (mat.HasProperty(candidate)) return candidate; + } + return name; // fall back to original + } + + foreach (var prop in properties.Properties()) + { + if (reservedKeys.Contains(prop.Name)) continue; + string shaderProp = ResolvePropertyName(prop.Name); + JToken v = prop.Value; + + // Color: numeric array [r,g,b,(a)] + if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer)) + { + if (mat.HasProperty(shaderProp)) + { + try + { + var c = new Color( + arr[0].ToObject(), + arr[1].ToObject(), + arr[2].ToObject(), + arr.Count > 3 ? arr[3].ToObject() : 1f + ); + if (mat.GetColor(shaderProp) != c) + { + mat.SetColor(shaderProp, c); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}"); + } + } + continue; + } + + // Float: single number + if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer) + { + if (mat.HasProperty(shaderProp)) + { + try + { + float f = v.ToObject(); + if (!Mathf.Approximately(mat.GetFloat(shaderProp), f)) + { + mat.SetFloat(shaderProp, f); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}"); + } + } + continue; + } + + // Texture: string path + if (v.Type == JTokenType.String) + { + string texPath = v.ToString(); + if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp)) + { + var tex = AssetDatabase.LoadAssetAtPath(AssetPathUtility.SanitizeAssetPath(texPath)); + if (tex != null && mat.GetTexture(shaderProp) != tex) + { + mat.SetTexture(shaderProp, tex); + modified = true; + } + } + continue; + } + } + + // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) return modified; } diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 87e4186f5..1cdcb8bd3 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Tools /// Handles editor control actions including play mode control, tool selection, /// and tag/layer management. For reading editor state, use MCP resources instead. /// - [McpForUnityTool("manage_editor")] + [McpForUnityTool("manage_editor", AutoRegister = false)] public static class ManageEditor { // Constant for starting user layer index @@ -32,7 +32,7 @@ public static object HandleCommand(JObject @params) if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Route action @@ -45,13 +45,13 @@ public static object HandleCommand(JObject @params) if (!EditorApplication.isPlaying) { EditorApplication.isPlaying = true; - return Response.Success("Entered play mode."); + return new SuccessResponse("Entered play mode."); } - return Response.Success("Already in play mode."); + return new SuccessResponse("Already in play mode."); } catch (Exception e) { - return Response.Error($"Error entering play mode: {e.Message}"); + return new ErrorResponse($"Error entering play mode: {e.Message}"); } case "pause": try @@ -59,15 +59,15 @@ public static object HandleCommand(JObject @params) if (EditorApplication.isPlaying) { EditorApplication.isPaused = !EditorApplication.isPaused; - return Response.Success( + return new SuccessResponse( EditorApplication.isPaused ? "Game paused." : "Game resumed." ); } - return Response.Error("Cannot pause/resume: Not in play mode."); + return new ErrorResponse("Cannot pause/resume: Not in play mode."); } catch (Exception e) { - return Response.Error($"Error pausing/resuming game: {e.Message}"); + return new ErrorResponse($"Error pausing/resuming game: {e.Message}"); } case "stop": try @@ -75,52 +75,52 @@ public static object HandleCommand(JObject @params) if (EditorApplication.isPlaying) { EditorApplication.isPlaying = false; - return Response.Success("Exited play mode."); + return new SuccessResponse("Exited play mode."); } - return Response.Success("Already stopped (not in play mode)."); + return new SuccessResponse("Already stopped (not in play mode)."); } catch (Exception e) { - return Response.Error($"Error stopping play mode: {e.Message}"); + return new ErrorResponse($"Error stopping play mode: {e.Message}"); } // Tool Control case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) - return Response.Error("'toolName' parameter required for set_active_tool."); + return new ErrorResponse("'toolName' parameter required for set_active_tool."); return SetActiveTool(toolName); // Tag Management case "add_tag": if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for add_tag."); + return new ErrorResponse("'tagName' parameter required for add_tag."); return AddTag(tagName); case "remove_tag": if (string.IsNullOrEmpty(tagName)) - return Response.Error("'tagName' parameter required for remove_tag."); + return new ErrorResponse("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); // Layer Management case "add_layer": if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for add_layer."); + return new ErrorResponse("'layerName' parameter required for add_layer."); return AddLayer(layerName); case "remove_layer": if (string.IsNullOrEmpty(layerName)) - return Response.Error("'layerName' parameter required for remove_layer."); + return new ErrorResponse("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject(); // int? height = @params["height"]?.ToObject(); - // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); + // if (!width.HasValue || !height.HasValue) return new ErrorResponse("'width' and 'height' parameters required."); // return SetGameViewResolution(width.Value, height.Value); // case "set_quality": // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } @@ -139,11 +139,11 @@ private static object SetActiveTool(string toolName) if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool { UnityEditor.Tools.current = targetTool; - return Response.Success($"Set active tool to '{targetTool}'."); + return new SuccessResponse($"Set active tool to '{targetTool}'."); } else { - return Response.Error( + return new ErrorResponse( $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." ); } @@ -152,14 +152,14 @@ private static object SetActiveTool(string toolName) { // Potentially try activating a custom tool by name here if needed // This often requires specific editor scripting knowledge for that tool. - return Response.Error( + return new ErrorResponse( $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." ); } } catch (Exception e) { - return Response.Error($"Error setting active tool: {e.Message}"); + return new ErrorResponse($"Error setting active tool: {e.Message}"); } } @@ -168,12 +168,12 @@ private static object SetActiveTool(string toolName) private static object AddTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); + return new ErrorResponse("Tag name cannot be empty or whitespace."); // Check if tag already exists if (System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { - return Response.Error($"Tag '{tagName}' already exists."); + return new ErrorResponse($"Tag '{tagName}' already exists."); } try @@ -182,25 +182,25 @@ private static object AddTag(string tagName) InternalEditorUtility.AddTag(tagName); // Force save assets to ensure the change persists in the TagManager asset AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' added successfully."); + return new SuccessResponse($"Tag '{tagName}' added successfully."); } catch (Exception e) { - return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); + return new ErrorResponse($"Failed to add tag '{tagName}': {e.Message}"); } } private static object RemoveTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return Response.Error("Tag name cannot be empty or whitespace."); + return new ErrorResponse("Tag name cannot be empty or whitespace."); if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) - return Response.Error("Cannot remove the built-in 'Untagged' tag."); + return new ErrorResponse("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal if (!System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { - return Response.Error($"Tag '{tagName}' does not exist."); + return new ErrorResponse($"Tag '{tagName}' does not exist."); } try @@ -209,12 +209,12 @@ private static object RemoveTag(string tagName) InternalEditorUtility.RemoveTag(tagName); // Force save assets AssetDatabase.SaveAssets(); - return Response.Success($"Tag '{tagName}' removed successfully."); + return new SuccessResponse($"Tag '{tagName}' removed successfully."); } catch (Exception e) { // Catch potential issues if the tag is somehow in use or removal fails - return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); + return new ErrorResponse($"Failed to remove tag '{tagName}': {e.Message}"); } } @@ -223,16 +223,16 @@ private static object RemoveTag(string tagName) private static object AddLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); + return new ErrorResponse("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) - return Response.Error("Could not access TagManager asset."); + return new ErrorResponse("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + return new ErrorResponse("Could not find 'layers' property in TagManager."); // Check if layer name already exists (case-insensitive check recommended) for (int i = 0; i < TotalLayerCount; i++) @@ -243,7 +243,7 @@ private static object AddLayer(string layerName) && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { - return Response.Error($"Layer '{layerName}' already exists at index {i}."); + return new ErrorResponse($"Layer '{layerName}' already exists at index {i}."); } } @@ -261,7 +261,7 @@ private static object AddLayer(string layerName) if (firstEmptyUserLayer == -1) { - return Response.Error("No empty User Layer slots available (8-31 are full)."); + return new ErrorResponse("No empty User Layer slots available (8-31 are full)."); } // Assign the name to the found slot @@ -275,29 +275,29 @@ private static object AddLayer(string layerName) tagManager.ApplyModifiedProperties(); // Save assets to make sure it's written to disk AssetDatabase.SaveAssets(); - return Response.Success( + return new SuccessResponse( $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." ); } catch (Exception e) { - return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); + return new ErrorResponse($"Failed to add layer '{layerName}': {e.Message}"); } } private static object RemoveLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) - return Response.Error("Layer name cannot be empty or whitespace."); + return new ErrorResponse("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) - return Response.Error("Could not access TagManager asset."); + return new ErrorResponse("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) - return Response.Error("Could not find 'layers' property in TagManager."); + return new ErrorResponse("Could not find 'layers' property in TagManager."); // Find the layer by name (must be user layer) int layerIndexToRemove = -1; @@ -317,7 +317,7 @@ private static object RemoveLayer(string layerName) if (layerIndexToRemove == -1) { - return Response.Error($"User layer '{layerName}' not found."); + return new ErrorResponse($"User layer '{layerName}' not found."); } // Clear the name for that index @@ -331,13 +331,13 @@ private static object RemoveLayer(string layerName) tagManager.ApplyModifiedProperties(); // Save assets AssetDatabase.SaveAssets(); - return Response.Success( + return new SuccessResponse( $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." ); } catch (Exception e) { - return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); + return new ErrorResponse($"Failed to remove layer '{layerName}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 1ad4107e0..ec6b0ef14 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -19,7 +19,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// - [McpForUnityTool("manage_gameobject")] + [McpForUnityTool("manage_gameobject", AutoRegister = false)] public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead @@ -43,13 +43,13 @@ public static object HandleCommand(JObject @params) { if (@params == null) { - return Response.Error("Parameters cannot be null."); + return new ErrorResponse("Parameters cannot be null."); } string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } // Parameters used by various actions @@ -109,11 +109,11 @@ public static object HandleCommand(JObject @params) string compName = @params["componentName"]?.ToString(); JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting if (string.IsNullOrEmpty(compName)) - return Response.Error( + return new ErrorResponse( "Missing 'componentName' for 'set_component_property' on prefab." ); if (compProps == null) - return Response.Error( + return new ErrorResponse( $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." ); @@ -124,7 +124,7 @@ public static object HandleCommand(JObject @params) { properties = @params["componentProperties"] as JObject; if (properties == null) - return Response.Error( + return new ErrorResponse( "Missing 'componentProperties' for 'modify' action on prefab." ); } @@ -142,7 +142,7 @@ public static object HandleCommand(JObject @params) ) // Added get_components here too { // Explicitly block other modifications on the prefab asset itself via manage_gameobject - return Response.Error( + return new ErrorResponse( $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." ); } @@ -166,7 +166,7 @@ public static object HandleCommand(JObject @params) case "get_components": string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string if (getCompTarget == null) - return Response.Error( + return new ErrorResponse( "'target' parameter required for get_components." ); // Pass the includeNonPublicSerialized flag here @@ -174,12 +174,12 @@ public static object HandleCommand(JObject @params) case "get_component": string getSingleCompTarget = targetToken?.ToString(); if (getSingleCompTarget == null) - return Response.Error( + return new ErrorResponse( "'target' parameter required for get_component." ); string componentName = @params["componentName"]?.ToString(); if (string.IsNullOrEmpty(componentName)) - return Response.Error( + return new ErrorResponse( "'componentName' parameter required for get_component." ); return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized); @@ -191,13 +191,13 @@ public static object HandleCommand(JObject @params) return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); default: - return Response.Error($"Unknown action: '{action}'."); + return new ErrorResponse($"Unknown action: '{action}'."); } } catch (Exception e) { Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); } } @@ -208,7 +208,7 @@ private static object CreateGameObject(JObject @params) string name = @params["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { - return Response.Error("'name' parameter is required for 'create' action."); + return new ErrorResponse("'name' parameter is required for 'create' action."); } // Get prefab creation parameters @@ -235,7 +235,7 @@ private static object CreateGameObject(JObject @params) string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); if (guids.Length == 0) { - return Response.Error( + return new ErrorResponse( $"Prefab named '{prefabNameOnly}' not found anywhere in the project." ); } @@ -245,7 +245,7 @@ private static object CreateGameObject(JObject @params) ", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) ); - return Response.Error( + return new ErrorResponse( $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." ); } @@ -283,7 +283,7 @@ private static object CreateGameObject(JObject @params) Debug.LogError( $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." ); - return Response.Error( + return new ErrorResponse( $"Failed to instantiate prefab at '{prefabPath}'." ); } @@ -303,7 +303,7 @@ private static object CreateGameObject(JObject @params) } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error instantiating prefab '{prefabPath}': {e.Message}" ); } @@ -338,7 +338,7 @@ private static object CreateGameObject(JObject @params) else { UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak - return Response.Error( + return new ErrorResponse( "'name' parameter is required when creating a primitive." ); } @@ -346,13 +346,13 @@ private static object CreateGameObject(JObject @params) } catch (ArgumentException) { - return Response.Error( + return new ErrorResponse( $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Failed to create primitive '{primitiveType}': {e.Message}" ); } @@ -361,7 +361,7 @@ private static object CreateGameObject(JObject @params) { if (string.IsNullOrEmpty(name)) { - return Response.Error( + return new ErrorResponse( "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." ); } @@ -378,7 +378,7 @@ private static object CreateGameObject(JObject @params) if (newGo == null) { // Should theoretically not happen if logic above is correct, but safety check. - return Response.Error("Failed to create or instantiate the GameObject."); + return new ErrorResponse("Failed to create or instantiate the GameObject."); } // Record potential changes to the existing prefab instance or the new GO @@ -394,7 +394,7 @@ private static object CreateGameObject(JObject @params) if (parentGo == null) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object - return Response.Error($"Parent specified ('{parentToken}') but not found."); + return new ErrorResponse($"Parent specified ('{parentToken}') but not found."); } newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true } @@ -438,7 +438,7 @@ private static object CreateGameObject(JObject @params) catch (Exception innerEx) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( + return new ErrorResponse( $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." ); } @@ -446,7 +446,7 @@ private static object CreateGameObject(JObject @params) else { UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return Response.Error( + return new ErrorResponse( $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." ); } @@ -516,7 +516,7 @@ private static object CreateGameObject(JObject @params) { // Clean up the created object before returning error UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( + return new ErrorResponse( "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." ); } @@ -555,7 +555,7 @@ private static object CreateGameObject(JObject @params) { // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) UnityEngine.Object.DestroyImmediate(newGo); - return Response.Error( + return new ErrorResponse( $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." ); } @@ -569,7 +569,7 @@ private static object CreateGameObject(JObject @params) { // Clean up the instance if prefab saving fails UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt - return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } @@ -602,8 +602,8 @@ private static object CreateGameObject(JObject @params) } // Use the new serializer helper - //return Response.Success(successMessage, GetGameObjectData(finalInstance)); - return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + //return new SuccessResponse(successMessage, GetGameObjectData(finalInstance)); + return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } private static object ModifyGameObject( @@ -615,7 +615,7 @@ string searchMethod GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -651,11 +651,11 @@ string searchMethod ) ) { - return Response.Error($"New parent ('{parentToken}') not found."); + return new ErrorResponse($"New parent ('{parentToken}') not found."); } if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) { - return Response.Error( + return new ErrorResponse( $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." ); } @@ -715,7 +715,7 @@ string searchMethod Debug.LogError( $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" ); - return Response.Error( + return new ErrorResponse( $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." ); } @@ -723,7 +723,7 @@ string searchMethod else { // If the exception was for a different reason, return the original error - return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); + return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } } @@ -735,7 +735,7 @@ string searchMethod int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1 && layerName != "Default") { - return Response.Error( + return new ErrorResponse( $"Invalid layer specified: '{layerName}'. Use a valid layer name." ); } @@ -867,7 +867,7 @@ string searchMethod catch { } } - return Response.Error( + return new ErrorResponse( $"One or more component property operations failed on '{targetGo.name}'.", new { componentErrors = componentErrors, errors = aggregatedErrors } ); @@ -876,11 +876,11 @@ string searchMethod if (!modified) { // Use the new serializer helper - // return Response.Success( + // return new SuccessResponse( // $"No modifications applied to GameObject '{targetGo.name}'.", // GetGameObjectData(targetGo)); - return Response.Success( + return new SuccessResponse( $"No modifications applied to GameObject '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -888,11 +888,11 @@ string searchMethod EditorUtility.SetDirty(targetGo); // Mark scene as dirty // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); - // return Response.Success( + // return new SuccessResponse( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); @@ -905,7 +905,7 @@ private static object DeleteGameObject(JToken targetToken, string searchMethod) if (targets.Count == 0) { - return Response.Error( + return new ErrorResponse( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -929,12 +929,12 @@ private static object DeleteGameObject(JToken targetToken, string searchMethod) targets.Count == 1 ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; - return Response.Success(message, deletedObjects); + return new SuccessResponse(message, deletedObjects); } else { // Should not happen if targets.Count > 0 initially, but defensive check - return Response.Error("Failed to delete target GameObject(s)."); + return new ErrorResponse("Failed to delete target GameObject(s)."); } } @@ -954,13 +954,13 @@ string searchMethod if (foundObjects.Count == 0) { - return Response.Success("No matching GameObjects found.", new List()); + return new SuccessResponse("No matching GameObjects found.", new List()); } // Use the new serializer helper //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); - return Response.Success($"Found {results.Count} GameObject(s).", results); + return new SuccessResponse($"Found {results.Count} GameObject(s).", results); } private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) @@ -968,7 +968,7 @@ private static object GetComponentsFromTarget(string target, string searchMethod GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1023,14 +1023,14 @@ private static object GetComponentsFromTarget(string target, string searchMethod componentsToIterate.Clear(); componentsToIterate = null; - return Response.Success( + return new SuccessResponse( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData // List was built in original order ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error getting components from '{targetGo.name}': {e.Message}" ); } @@ -1041,7 +1041,7 @@ private static object GetSingleComponentFromTarget(string target, string searchM GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1073,7 +1073,7 @@ private static object GetSingleComponentFromTarget(string target, string searchM if (targetComponent == null) { - return Response.Error( + return new ErrorResponse( $"Component '{componentName}' not found on GameObject '{targetGo.name}'." ); } @@ -1082,19 +1082,19 @@ private static object GetSingleComponentFromTarget(string target, string searchM if (componentData == null) { - return Response.Error( + return new ErrorResponse( $"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'." ); } - return Response.Success( + return new SuccessResponse( $"Retrieved component '{componentName}' from '{targetGo.name}'.", componentData ); } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}" ); } @@ -1109,7 +1109,7 @@ string searchMethod GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1140,7 +1140,7 @@ string searchMethod if (string.IsNullOrEmpty(typeName)) { - return Response.Error( + return new ErrorResponse( "Component type name ('componentName' or first element in 'componentsToAdd') is required." ); } @@ -1151,7 +1151,7 @@ string searchMethod EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Component '{typeName}' added to '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // Return updated GO data @@ -1166,7 +1166,7 @@ string searchMethod GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1187,7 +1187,7 @@ string searchMethod if (string.IsNullOrEmpty(typeName)) { - return Response.Error( + return new ErrorResponse( "Component type name ('componentName' or first element in 'componentsToRemove') is required." ); } @@ -1198,7 +1198,7 @@ string searchMethod EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -1213,7 +1213,7 @@ string searchMethod GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { - return Response.Error( + return new ErrorResponse( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } @@ -1231,12 +1231,12 @@ string searchMethod } else { - return Response.Error("'componentName' parameter is required."); + return new ErrorResponse("'componentName' parameter is required."); } if (propertiesToSet == null || !propertiesToSet.HasValues) { - return Response.Error( + return new ErrorResponse( "'componentProperties' dictionary for the specified component is required and cannot be empty." ); } @@ -1247,7 +1247,7 @@ string searchMethod EditorUtility.SetDirty(targetGo); // Use the new serializer helper - return Response.Success( + return new SuccessResponse( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); @@ -1499,19 +1499,19 @@ JObject properties Type componentType = FindType(typeName); if (componentType == null) { - return Response.Error( + return new ErrorResponse( $"Component type '{typeName}' not found or is not a valid Component." ); } if (!typeof(Component).IsAssignableFrom(componentType)) { - return Response.Error($"Type '{typeName}' is not a Component."); + return new ErrorResponse($"Type '{typeName}' is not a Component."); } // Prevent adding Transform again if (componentType == typeof(Transform)) { - return Response.Error("Cannot add another Transform component."); + return new ErrorResponse("Cannot add another Transform component."); } // Check for 2D/3D physics component conflicts @@ -1530,7 +1530,7 @@ JObject properties || targetGo.GetComponent() != null ) { - return Response.Error( + return new ErrorResponse( $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." ); } @@ -1543,7 +1543,7 @@ JObject properties || targetGo.GetComponent() != null ) { - return Response.Error( + return new ErrorResponse( $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." ); } @@ -1555,7 +1555,7 @@ JObject properties Component newComponent = Undo.AddComponent(targetGo, componentType); if (newComponent == null) { - return Response.Error( + return new ErrorResponse( $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." ); } @@ -1588,7 +1588,7 @@ JObject properties } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" ); } @@ -1603,19 +1603,19 @@ private static object RemoveComponentInternal(GameObject targetGo, string typeNa Type componentType = FindType(typeName); if (componentType == null) { - return Response.Error($"Component type '{typeName}' not found for removal."); + return new ErrorResponse($"Component type '{typeName}' not found for removal."); } // Prevent removing essential components if (componentType == typeof(Transform)) { - return Response.Error("Cannot remove the Transform component."); + return new ErrorResponse("Cannot remove the Transform component."); } Component componentToRemove = targetGo.GetComponent(componentType); if (componentToRemove == null) { - return Response.Error( + return new ErrorResponse( $"Component '{typeName}' not found on '{targetGo.name}' to remove." ); } @@ -1628,7 +1628,7 @@ private static object RemoveComponentInternal(GameObject targetGo, string typeNa } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" ); } @@ -1659,7 +1659,7 @@ private static object SetComponentPropertiesInternal( } if (targetComponent == null) { - return Response.Error( + return new ErrorResponse( $"Component '{compName}' not found on '{targetGo.name}' to set properties." ); } @@ -1697,7 +1697,7 @@ private static object SetComponentPropertiesInternal( EditorUtility.SetDirty(targetComponent); return failures.Count == 0 ? null - : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); + : new ErrorResponse($"One or more properties failed on '{compName}'.", new { errors = failures }); } /// diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 6a310d020..d8ff578d1 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles scene management operations like loading, saving, creating, and querying hierarchy. /// - [McpForUnityTool("manage_scene")] + [McpForUnityTool("manage_scene", AutoRegister = false)] public static class ManageScene { private sealed class SceneCommand @@ -78,7 +78,7 @@ public static object HandleCommand(JObject @params) if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; @@ -101,7 +101,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -113,7 +113,7 @@ public static object HandleCommand(JObject @params) { case "create": if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) - return Response.Error( + return new ErrorResponse( "'name' and 'path' parameters are required for 'create' action." ); return CreateScene(fullPath, relativePath); @@ -124,7 +124,7 @@ public static object HandleCommand(JObject @params) else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else - return Response.Error( + return new ErrorResponse( "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." ); case "save": @@ -144,7 +144,7 @@ public static object HandleCommand(JObject @params) return GetBuildSettingsScenes(); // Add cases for modifying build settings, additive loading, unloading etc. default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." ); } @@ -154,7 +154,7 @@ private static object CreateScene(string fullPath, string relativePath) { if (File.Exists(fullPath)) { - return Response.Error($"Scene already exists at '{relativePath}'."); + return new ErrorResponse($"Scene already exists at '{relativePath}'."); } try @@ -170,7 +170,7 @@ private static object CreateScene(string fullPath, string relativePath) if (saved) { AssetDatabase.Refresh(); // Ensure Unity sees the new scene file - return Response.Success( + return new SuccessResponse( $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath } ); @@ -179,12 +179,12 @@ private static object CreateScene(string fullPath, string relativePath) { // If SaveScene fails, it might leave an untitled scene open. // Optionally try to close it, but be cautious. - return Response.Error($"Failed to save new scene to '{relativePath}'."); + return new ErrorResponse($"Failed to save new scene to '{relativePath}'."); } } catch (Exception e) { - return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error creating scene '{relativePath}': {e.Message}"); } } @@ -202,24 +202,24 @@ private static object LoadScene(string relativePath) ) ) { - return Response.Error($"Scene file not found at '{relativePath}'."); + return new ErrorResponse($"Scene file not found at '{relativePath}'."); } // Check for unsaved changes in the current scene if (EditorSceneManager.GetActiveScene().isDirty) { // Optionally prompt the user or save automatically before loading - return Response.Error( + return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); - // if (!saveOK) return Response.Error("Load cancelled by user."); + // if (!saveOK) return new ErrorResponse("Load cancelled by user."); } try { EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); - return Response.Success( + return new SuccessResponse( $"Scene '{relativePath}' loaded successfully.", new { @@ -230,7 +230,7 @@ private static object LoadScene(string relativePath) } catch (Exception e) { - return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error loading scene '{relativePath}': {e.Message}"); } } @@ -238,7 +238,7 @@ private static object LoadScene(int buildIndex) { if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) { - return Response.Error( + return new ErrorResponse( $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." ); } @@ -246,7 +246,7 @@ private static object LoadScene(int buildIndex) // Check for unsaved changes if (EditorSceneManager.GetActiveScene().isDirty) { - return Response.Error( + return new ErrorResponse( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); } @@ -255,7 +255,7 @@ private static object LoadScene(int buildIndex) { string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); - return Response.Success( + return new SuccessResponse( $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { @@ -267,7 +267,7 @@ private static object LoadScene(int buildIndex) } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Error loading scene with build index {buildIndex}: {e.Message}" ); } @@ -280,7 +280,7 @@ private static object SaveScene(string fullPath, string relativePath) Scene currentScene = EditorSceneManager.GetActiveScene(); if (!currentScene.IsValid()) { - return Response.Error("No valid scene is currently active to save."); + return new ErrorResponse("No valid scene is currently active to save."); } bool saved; @@ -303,7 +303,7 @@ private static object SaveScene(string fullPath, string relativePath) if (string.IsNullOrEmpty(currentScene.path)) { // Scene is untitled, needs a path - return Response.Error( + return new ErrorResponse( "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." ); } @@ -313,19 +313,19 @@ private static object SaveScene(string fullPath, string relativePath) if (saved) { AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name } ); } else { - return Response.Error($"Failed to save scene '{currentScene.name}'."); + return new ErrorResponse($"Failed to save scene '{currentScene.name}'."); } } catch (Exception e) { - return Response.Error($"Error saving scene: {e.Message}"); + return new ErrorResponse($"Error saving scene: {e.Message}"); } } @@ -338,7 +338,7 @@ private static object GetActiveSceneInfo() try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid()) { - return Response.Error("No active scene found."); + return new ErrorResponse("No active scene found."); } var sceneInfo = new @@ -351,12 +351,12 @@ private static object GetActiveSceneInfo() rootCount = activeScene.rootCount, }; - return Response.Success("Retrieved active scene information.", sceneInfo); + return new SuccessResponse("Retrieved active scene information.", sceneInfo); } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } - return Response.Error($"Error getting active scene info: {e.Message}"); + return new ErrorResponse($"Error getting active scene info: {e.Message}"); } } @@ -378,11 +378,11 @@ private static object GetBuildSettingsScenes() } ); } - return Response.Success("Retrieved scenes from Build Settings.", scenes); + return new SuccessResponse("Retrieved scenes from Build Settings.", scenes); } catch (Exception e) { - return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); + return new ErrorResponse($"Error getting scenes from Build Settings: {e.Message}"); } } @@ -395,7 +395,7 @@ private static object GetSceneHierarchy() try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid() || !activeScene.isLoaded) { - return Response.Error( + return new ErrorResponse( "No valid and loaded scene is active to get hierarchy from." ); } @@ -405,7 +405,7 @@ private static object GetSceneHierarchy() try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); - var resp = Response.Success( + var resp = new SuccessResponse( $"Retrieved hierarchy for scene '{activeScene.name}'.", hierarchy ); @@ -415,7 +415,7 @@ private static object GetSceneHierarchy() catch (Exception e) { try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } - return Response.Error($"Error getting scene hierarchy: {e.Message}"); + return new ErrorResponse($"Error getting scene hierarchy: {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index b5cbbb1d3..5e268ca45 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using System.Threading; using System.Security.Cryptography; @@ -49,7 +50,7 @@ namespace MCPForUnity.Editor.Tools /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// - [McpForUnityTool("manage_script")] + [McpForUnityTool("manage_script", AutoRegister = false)] public static class ManageScript { /// @@ -114,7 +115,7 @@ public static object HandleCommand(JObject @params) // Handle null parameters if (@params == null) { - return Response.Error("invalid_params", "Parameters cannot be null."); + return new ErrorResponse("invalid_params", "Parameters cannot be null."); } // Extract parameters @@ -133,7 +134,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error($"Failed to decode script contents: {e.Message}"); + return new ErrorResponse($"Failed to decode script contents: {e.Message}"); } } else @@ -147,16 +148,16 @@ public static object HandleCommand(JObject @params) // Validate required parameters if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } if (string.IsNullOrEmpty(name)) { - return Response.Error("Name parameter is required."); + return new ErrorResponse("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) { - return Response.Error( + return new ErrorResponse( $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." ); } @@ -164,7 +165,7 @@ public static object HandleCommand(JObject @params) // Resolve and harden target directory under Assets/ if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { - return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + return new ErrorResponse($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } // Construct file paths @@ -181,7 +182,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -229,7 +230,7 @@ public static object HandleCommand(JObject @params) }; string fileText; try { fileText = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); var diags = (diagsRaw ?? Array.Empty()).Select(s => @@ -247,8 +248,8 @@ public static object HandleCommand(JObject @params) }).ToArray(); var result = new { diagnostics = diags }; - return ok ? Response.Success("Validation completed.", result) - : Response.Error("Validation failed.", result); + return ok ? new SuccessResponse("Validation completed.", result) + : new ErrorResponse("Validation failed.", result); } case "edit": Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); @@ -260,7 +261,7 @@ public static object HandleCommand(JObject @params) try { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); string text = File.ReadAllText(fullPath); string sha = ComputeSha256(text); @@ -276,15 +277,15 @@ public static object HandleCommand(JObject @params) lengthBytes, lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty }; - return Response.Success($"SHA computed for '{relativePath}'.", data); + return new SuccessResponse($"SHA computed for '{relativePath}'.", data); } catch (Exception ex) { - return Response.Error($"Failed to compute SHA: {ex.Message}"); + return new ErrorResponse($"Failed to compute SHA: {ex.Message}"); } } default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } @@ -320,7 +321,7 @@ string namespaceName // Check if script already exists if (File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Script already exists at '{relativePath}'. Use 'update' action to modify." ); } @@ -336,7 +337,7 @@ string namespaceName bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -361,7 +362,7 @@ string namespaceName } var uri = $"unity://path/{relativePath}"; - var ok = Response.Success( + var ok = new SuccessResponse( $"Script '{name}.cs' created successfully at '{relativePath}'.", new { uri, scheduledRefresh = false } ); @@ -372,7 +373,7 @@ string namespaceName } catch (Exception e) { - return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to create script '{relativePath}': {e.Message}"); } } @@ -380,7 +381,7 @@ private static object ReadScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); } try @@ -400,14 +401,14 @@ private static object ReadScript(string fullPath, string relativePath) contentsEncoded = isLarge, }; - return Response.Success( + return new SuccessResponse( $"Script '{Path.GetFileName(relativePath)}' read successfully.", responseData ); } catch (Exception e) { - return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to read script '{relativePath}': {e.Message}"); } } @@ -420,13 +421,13 @@ string contents { if (!File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Script not found at '{relativePath}'. Use 'create' action to add a new script." ); } if (string.IsNullOrEmpty(contents)) { - return Response.Error("Content is required for the 'update' action."); + return new ErrorResponse("Content is required for the 'update' action."); } // Validate syntax with detailed error reporting using GUI setting @@ -434,7 +435,7 @@ string contents bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); if (!isValid) { - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); } else if (validationErrors != null && validationErrors.Length > 0) { @@ -470,7 +471,7 @@ string contents // Prepare success response BEFORE any operation that can trigger a domain reload var uri = $"unity://path/{relativePath}"; - var ok = Response.Success( + var ok = new SuccessResponse( $"Script '{name}.cs' updated successfully at '{relativePath}'.", new { uri, path = relativePath, scheduledRefresh = true } ); @@ -482,7 +483,7 @@ string contents } catch (Exception e) { - return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to update script '{relativePath}': {e.Message}"); } } @@ -501,7 +502,7 @@ private static object ApplyTextEdits( string validateMode = null) { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); // Refuse edits if the target or any ancestor is a symlink try { @@ -509,7 +510,7 @@ private static object ApplyTextEdits( while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) { if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) - return Response.Error("Refusing to edit a symlinked script path."); + return new ErrorResponse("Refusing to edit a symlinked script path."); di = di.Parent; } } @@ -518,18 +519,18 @@ private static object ApplyTextEdits( // If checking attributes fails, proceed without the symlink guard } if (edits == null || edits.Count == 0) - return Response.Error("No edits provided."); + return new ErrorResponse("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } // Require precondition to avoid drift on large files string currentSha = ComputeSha256(original); if (string.IsNullOrEmpty(preconditionSha256)) - return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + return new ErrorResponse("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) - return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + return new ErrorResponse("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); // Convert edits to absolute index ranges var spans = new List<(int start, int end, string text)>(); @@ -545,9 +546,9 @@ private static object ApplyTextEdits( string newText = e.Value("newText") ?? string.Empty; if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) - return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + return new ErrorResponse($"apply_text_edits: start out of range (line {sl}, col {sc})"); if (!TryIndexFromLineCol(original, el, ec, out int eidx)) - return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + return new ErrorResponse($"apply_text_edits: end out of range (line {el}, col {ec})"); if (eidx < sidx) (sidx, eidx) = (eidx, sidx); spans.Add((sidx, eidx, newText)); @@ -558,7 +559,7 @@ private static object ApplyTextEdits( } catch (Exception ex) { - return Response.Error($"Invalid edit payload: {ex.Message}"); + return new ErrorResponse($"Invalid edit payload: {ex.Message}"); } } @@ -579,7 +580,7 @@ private static object ApplyTextEdits( { if (sp.start < headerBoundary) { - return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + return new ErrorResponse("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); } } @@ -650,7 +651,7 @@ private static object ApplyTextEdits( if (totalBytes > MaxEditPayloadBytes) { - return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + return new ErrorResponse("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); } // Ensure non-overlap and apply from back to front @@ -660,7 +661,7 @@ private static object ApplyTextEdits( if (spans[i].end > spans[i - 1].start) { var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; - return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } @@ -678,7 +679,7 @@ private static object ApplyTextEdits( int endPos = sp.start + newLength; if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) { - return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); } } working = next; @@ -688,7 +689,7 @@ private static object ApplyTextEdits( if (string.Equals(working, original, StringComparison.Ordinal)) { string noChangeSha = ComputeSha256(original); - return Response.Success( + return new SuccessResponse( $"No-op: contents unchanged for '{relativePath}'.", new { @@ -708,7 +709,7 @@ private static object ApplyTextEdits( int startLine = Math.Max(1, line - 5); int endLine = line + 5; string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; - return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); + return new ErrorResponse(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); } #if USE_ROSLYN @@ -727,7 +728,7 @@ private static object ApplyTextEdits( int firstLine = diagnostics[0].line; int startLineRos = Math.Max(1, firstLine - 5); int endLineRos = firstLine + 5; - return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); + return new ErrorResponse("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); } // Optional formatting @@ -789,7 +790,7 @@ private static object ApplyTextEdits( ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); } - return Response.Success( + return new SuccessResponse( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { @@ -803,7 +804,7 @@ private static object ApplyTextEdits( } catch (Exception ex) { - return Response.Error($"Failed to write edits: {ex.Message}"); + return new ErrorResponse($"Failed to write edits: {ex.Message}"); } } @@ -957,7 +958,7 @@ private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { - return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + return new ErrorResponse($"Script not found at '{relativePath}'. Cannot delete."); } try @@ -967,7 +968,7 @@ private static object DeleteScript(string fullPath, string relativePath) if (deleted) { AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", new { deleted = true } ); @@ -975,14 +976,14 @@ private static object DeleteScript(string fullPath, string relativePath) else { // Fallback or error if MoveAssetToTrash fails - return Response.Error( + return new ErrorResponse( $"Failed to move script '{relativePath}' to trash. It might be locked or in use." ); } } catch (Exception e) { - return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + return new ErrorResponse($"Error deleting script '{relativePath}': {e.Message}"); } } @@ -999,24 +1000,24 @@ private static object EditScript( JObject options) { if (!File.Exists(fullPath)) - return Response.Error($"Script not found at '{relativePath}'."); + return new ErrorResponse($"Script not found at '{relativePath}'."); // Refuse edits if the target is a symlink try { var attrs = File.GetAttributes(fullPath); if ((attrs & FileAttributes.ReparsePoint) != 0) - return Response.Error("Refusing to edit a symlinked script path."); + return new ErrorResponse("Refusing to edit a symlinked script path."); } catch { // ignore failures checking attributes and proceed } if (edits == null || edits.Count == 0) - return Response.Error("No edits provided."); + return new ErrorResponse("No edits provided."); string original; try { original = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } string working = original; @@ -1044,15 +1045,15 @@ private static object EditScript( string replacement = ExtractReplacement(op); if (string.IsNullOrWhiteSpace(className)) - return Response.Error("replace_class requires 'className'."); + return new ErrorResponse("replace_class requires 'className'."); if (replacement == null) - return Response.Error("replace_class requires 'replacement' (inline or base64)."); + return new ErrorResponse("replace_class requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) - return Response.Error($"replace_class failed: {why}"); + return new ErrorResponse($"replace_class failed: {why}"); if (!ValidateClassSnippet(replacement, className, out var vErr)) - return Response.Error($"Replacement snippet invalid: {vErr}"); + return new ErrorResponse($"Replacement snippet invalid: {vErr}"); if (applySequentially) { @@ -1071,10 +1072,10 @@ private static object EditScript( string className = op.Value("className"); string ns = op.Value("namespace"); if (string.IsNullOrWhiteSpace(className)) - return Response.Error("delete_class requires 'className'."); + return new ErrorResponse("delete_class requires 'className'."); if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) - return Response.Error($"delete_class failed: {why}"); + return new ErrorResponse($"delete_class failed: {why}"); if (applySequentially) { @@ -1098,12 +1099,12 @@ private static object EditScript( string parametersSignature = op.Value("parametersSignature"); string attributesContains = op.Value("attributesContains"); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); - if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); - if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("replace_method requires 'methodName'."); + if (replacement == null) return new ErrorResponse("replace_method requires 'replacement' (inline or base64)."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"replace_method failed to locate class: {whyClass}"); + return new ErrorResponse($"replace_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { @@ -1112,7 +1113,7 @@ private static object EditScript( string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; - return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + return new ErrorResponse($"replace_method failed: {whyMethod}.{hint}"); } if (applySequentially) @@ -1136,11 +1137,11 @@ private static object EditScript( string parametersSignature = op.Value("parametersSignature"); string attributesContains = op.Value("attributesContains"); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); - if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("delete_method requires 'methodName'."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"delete_method failed to locate class: {whyClass}"); + return new ErrorResponse($"delete_method failed to locate class: {whyClass}"); if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) { @@ -1149,7 +1150,7 @@ private static object EditScript( string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; - return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + return new ErrorResponse($"delete_method failed: {whyMethod}.{hint}"); } if (applySequentially) @@ -1176,19 +1177,19 @@ private static object EditScript( string snippet = ExtractReplacement(op); // Harden: refuse empty replacement for inserts if (snippet == null || snippet.Trim().Length == 0) - return Response.Error("insert_method requires a non-empty 'replacement' text."); + return new ErrorResponse("insert_method requires a non-empty 'replacement' text."); - if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); - if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("insert_method requires 'className'."); + if (snippet == null) return new ErrorResponse("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) - return Response.Error($"insert_method failed to locate class: {whyClass}"); + return new ErrorResponse($"insert_method failed to locate class: {whyClass}"); if (position == "after") { - if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (string.IsNullOrEmpty(afterMethodName)) return new ErrorResponse("insert_method with position='after' requires 'afterMethodName'."); if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) - return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + return new ErrorResponse($"insert_method(after) failed to locate anchor method: {whyAfter}"); int insAt = aStart + aLen; string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); if (applySequentially) @@ -1202,7 +1203,7 @@ private static object EditScript( } } else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) - return Response.Error($"insert_method failed: {whyIns}"); + return new ErrorResponse($"insert_method failed: {whyIns}"); else { string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); @@ -1224,14 +1225,14 @@ private static object EditScript( string anchor = op.Value("anchor"); string position = (op.Value("position") ?? "before").ToLowerInvariant(); string text = op.Value("text") ?? ExtractReplacement(op); - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); - if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return new ErrorResponse("anchor_insert requires non-empty 'text'."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_insert: anchor not found: {anchor}"); int insAt = position == "after" ? m.Index + m.Length : m.Index; string norm = NormalizeNewlines(text); if (!norm.EndsWith("\n")) @@ -1261,7 +1262,7 @@ private static object EditScript( } catch (Exception ex) { - return Response.Error($"anchor_insert failed: {ex.Message}"); + return new ErrorResponse($"anchor_insert failed: {ex.Message}"); } break; } @@ -1269,12 +1270,12 @@ private static object EditScript( case "anchor_delete": { string anchor = op.Value("anchor"); - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_delete requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}"); int delAt = m.Index; int delLen = m.Length; if (applySequentially) @@ -1289,7 +1290,7 @@ private static object EditScript( } catch (Exception ex) { - return Response.Error($"anchor_delete failed: {ex.Message}"); + return new ErrorResponse($"anchor_delete failed: {ex.Message}"); } break; } @@ -1298,12 +1299,12 @@ private static object EditScript( { string anchor = op.Value("anchor"); string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_replace requires 'anchor' (regex)."); try { var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); var m = rx.Match(working); - if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + if (!m.Success) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}"); int at = m.Index; int len = m.Length; string norm = NormalizeNewlines(replacement); @@ -1319,13 +1320,13 @@ private static object EditScript( } catch (Exception ex) { - return Response.Error($"anchor_replace failed: {ex.Message}"); + return new ErrorResponse($"anchor_replace failed: {ex.Message}"); } break; } default: - return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); + return new ErrorResponse($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); } } @@ -1339,10 +1340,10 @@ private static object EditScript( if (ordered[i].start + ordered[i].length > ordered[i - 1].start) { var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; - return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); } } - return Response.Error("overlap", new { status = "overlap" }); + return new ErrorResponse("overlap", new { status = "overlap" }); } foreach (var r in replacements.OrderByDescending(r => r.start)) @@ -1352,13 +1353,13 @@ private static object EditScript( // Guard against structural imbalance before validation if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) - return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); // No-op guard for structured edits: if text unchanged, return explicit no-op if (string.Equals(working, original, StringComparison.Ordinal)) { var sameSha = ComputeSha256(original); - return Response.Success( + return new SuccessResponse( $"No-op: contents unchanged for '{relativePath}'.", new { @@ -1391,7 +1392,7 @@ private static object EditScript( } catch { /* ignore option parsing issues */ } if (!ValidateScriptSyntax(working, level, out var errors)) - return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); else if (errors != null && errors.Length > 0) Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); @@ -1424,7 +1425,7 @@ private static object EditScript( } var newSha = ComputeSha256(working); - var ok = Response.Success( + var ok = new SuccessResponse( $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", new { @@ -1449,7 +1450,7 @@ private static object EditScript( } catch (Exception ex) { - return Response.Error($"Edit failed: {ex.Message}"); + return new ErrorResponse($"Edit failed: {ex.Message}"); } } @@ -1933,7 +1934,7 @@ string namespaceName /// private static ValidationLevel GetValidationLevelFromGUI() { - int savedLevel = EditorPrefs.GetInt("MCPForUnity.ValidationLevel", (int)ValidationLevel.Standard); + int savedLevel = EditorPrefs.GetInt(EditorPrefKeys.ValidationLevel, (int)ValidationLevel.Standard); return (ValidationLevel)Mathf.Clamp(savedLevel, 0, 3); } @@ -2500,7 +2501,7 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge // if (string.IsNullOrEmpty(contents)) // { - // return Response.Error("Contents parameter is required for validation."); + // return new ErrorResponse("Contents parameter is required for validation."); // } // // Parse validation level @@ -2512,7 +2513,7 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge // case "comprehensive": level = ValidationLevel.Comprehensive; break; // case "strict": level = ValidationLevel.Strict; break; // default: - // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // return new ErrorResponse($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); // } // // Perform validation @@ -2536,11 +2537,11 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge // if (isValid) // { - // return Response.Success("Script validation completed successfully.", result); + // return new SuccessResponse("Script validation completed successfully.", result); // } // else // { - // return Response.Error("Script validation failed.", result); + // return new ErrorResponse("Script validation failed.", result); // } // } } diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 2d7f4d0a3..8b59fb214 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles CRUD operations for shader files within the Unity project. /// - [McpForUnityTool("manage_shader")] + [McpForUnityTool("manage_shader", AutoRegister = false)] public static class ManageShader { /// @@ -36,7 +36,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error($"Failed to decode shader contents: {e.Message}"); + return new ErrorResponse($"Failed to decode shader contents: {e.Message}"); } } else @@ -47,16 +47,16 @@ public static object HandleCommand(JObject @params) // Validate required parameters if (string.IsNullOrEmpty(action)) { - return Response.Error("Action parameter is required."); + return new ErrorResponse("Action parameter is required."); } if (string.IsNullOrEmpty(name)) { - return Response.Error("Name parameter is required."); + return new ErrorResponse("Name parameter is required."); } // Basic name validation (alphanumeric, underscores, cannot start with number) if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) { - return Response.Error( + return new ErrorResponse( $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." ); } @@ -99,7 +99,7 @@ public static object HandleCommand(JObject @params) } catch (Exception e) { - return Response.Error( + return new ErrorResponse( $"Could not create directory '{fullPathDir}': {e.Message}" ); } @@ -117,7 +117,7 @@ public static object HandleCommand(JObject @params) case "delete": return DeleteShader(fullPath, relativePath); default: - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." ); } @@ -151,7 +151,7 @@ string contents // Check if shader already exists if (File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Shader already exists at '{relativePath}'. Use 'update' action to modify." ); } @@ -159,7 +159,7 @@ string contents // Add validation for shader name conflicts in Unity if (Shader.Find(name) != null) { - return Response.Error( + return new ErrorResponse( $"A shader with name '{name}' already exists in the project. Choose a different name." ); } @@ -175,14 +175,14 @@ string contents File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader - return Response.Success( + return new SuccessResponse( $"Shader '{name}.shader' created successfully at '{relativePath}'.", new { path = relativePath } ); } catch (Exception e) { - return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to create shader '{relativePath}': {e.Message}"); } } @@ -190,7 +190,7 @@ private static object ReadShader(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { - return Response.Error($"Shader not found at '{relativePath}'."); + return new ErrorResponse($"Shader not found at '{relativePath}'."); } try @@ -209,14 +209,14 @@ private static object ReadShader(string fullPath, string relativePath) contentsEncoded = isLarge, }; - return Response.Success( + return new SuccessResponse( $"Shader '{Path.GetFileName(relativePath)}' read successfully.", responseData ); } catch (Exception e) { - return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to read shader '{relativePath}': {e.Message}"); } } @@ -229,13 +229,13 @@ string contents { if (!File.Exists(fullPath)) { - return Response.Error( + return new ErrorResponse( $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." ); } if (string.IsNullOrEmpty(contents)) { - return Response.Error("Content is required for the 'update' action."); + return new ErrorResponse("Content is required for the 'update' action."); } try @@ -243,14 +243,14 @@ string contents File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); AssetDatabase.ImportAsset(relativePath); AssetDatabase.Refresh(); - return Response.Success( + return new SuccessResponse( $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", new { path = relativePath } ); } catch (Exception e) { - return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to update shader '{relativePath}': {e.Message}"); } } @@ -258,7 +258,7 @@ private static object DeleteShader(string fullPath, string relativePath) { if (!File.Exists(fullPath)) { - return Response.Error($"Shader not found at '{relativePath}'."); + return new ErrorResponse($"Shader not found at '{relativePath}'."); } try @@ -267,7 +267,7 @@ private static object DeleteShader(string fullPath, string relativePath) bool success = AssetDatabase.DeleteAsset(relativePath); if (!success) { - return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); + return new ErrorResponse($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); } // If the file still exists (rare case), try direct deletion @@ -276,11 +276,11 @@ private static object DeleteShader(string fullPath, string relativePath) File.Delete(fullPath); } - return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); + return new SuccessResponse($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); } catch (Exception e) { - return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); + return new ErrorResponse($"Failed to delete shader '{relativePath}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index bb4e0431a..e4db3a4c6 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -3,17 +3,55 @@ 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; + + /// + /// Controls whether this tool is automatically registered with FastMCP. + /// Defaults to true so most tools opt-in automatically. Set to false + /// for legacy/built-in tools that already exist server-side. + /// + public bool AutoRegister { get; set; } = true; + + /// + /// Enables the polling middleware for long-running tools. When true, Unity + /// should return a PendingResponse and the Python side will poll using + /// until completion. + /// + public bool RequiresPolling { get; set; } = false; + + /// + /// The action name to use when polling for status. Defaults to "status". + /// + public string PollAction { get; set; } = "status"; + /// /// 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 +60,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/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 9e68d20eb..d30053edd 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -9,7 +9,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs { - [McpForUnityTool("manage_prefabs")] + [McpForUnityTool("manage_prefabs", AutoRegister = false)] public static class ManagePrefabs { private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; @@ -18,13 +18,13 @@ public static object HandleCommand(JObject @params) { if (@params == null) { - return Response.Error("Parameters cannot be null."); + return new ErrorResponse("Parameters cannot be null."); } string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { - return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); + return new ErrorResponse($"Action parameter is required. Valid actions are: {SupportedActions}."); } try @@ -40,13 +40,13 @@ public static object HandleCommand(JObject @params) case "create_from_gameobject": return CreatePrefabFromGameObject(@params); default: - return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); + return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); } } catch (Exception e) { McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); - return Response.Error($"Internal error: {e.Message}"); + return new ErrorResponse($"Internal error: {e.Message}"); } } @@ -55,29 +55,29 @@ private static object OpenStage(JObject @params) string prefabPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) { - return Response.Error("'prefabPath' parameter is required for open_stage."); + return new ErrorResponse("'prefabPath' parameter is required for open_stage."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); if (prefabAsset == null) { - return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); + return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); } string modeValue = @params["mode"]?.ToString(); if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) { - return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); + return new ErrorResponse("Only PrefabStage mode 'InIsolation' is supported at this time."); } PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); if (stage == null) { - return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); + return new ErrorResponse($"Failed to open prefab stage for '{sanitizedPath}'."); } - return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); + return new SuccessResponse($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); } private static object CloseStage(JObject @params) @@ -85,7 +85,7 @@ private static object CloseStage(JObject @params) PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { - return Response.Success("No prefab stage was open."); + return new SuccessResponse("No prefab stage was open."); } bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; @@ -96,7 +96,7 @@ private static object CloseStage(JObject @params) } StageUtility.GoToMainStage(); - return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); + return new SuccessResponse($"Closed prefab stage for '{stage.assetPath}'."); } private static object SaveOpenStage() @@ -104,12 +104,12 @@ private static object SaveOpenStage() PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { - return Response.Error("No prefab stage is currently open."); + return new ErrorResponse("No prefab stage is currently open."); } SaveStagePrefab(stage); AssetDatabase.SaveAssets(); - return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + return new SuccessResponse($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); } private static void SaveStagePrefab(PrefabStage stage) @@ -131,19 +131,19 @@ private static object CreatePrefabFromGameObject(JObject @params) string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); if (string.IsNullOrEmpty(targetName)) { - return Response.Error("'target' parameter is required for create_from_gameobject."); + return new ErrorResponse("'target' parameter is required for create_from_gameobject."); } bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); if (sourceObject == null) { - return Response.Error($"GameObject '{targetName}' not found in the active scene."); + return new ErrorResponse($"GameObject '{targetName}' not found in the active scene."); } if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) { - return Response.Error( + return new ErrorResponse( $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." ); } @@ -151,7 +151,7 @@ private static object CreatePrefabFromGameObject(JObject @params) PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); if (status != PrefabInstanceStatus.NotAPrefab) { - return Response.Error( + return new ErrorResponse( $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." ); } @@ -159,7 +159,7 @@ private static object CreatePrefabFromGameObject(JObject @params) string requestedPath = @params["prefabPath"]?.ToString(); if (string.IsNullOrWhiteSpace(requestedPath)) { - return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); + return new ErrorResponse("'prefabPath' parameter is required for create_from_gameobject."); } string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); @@ -188,12 +188,12 @@ private static object CreatePrefabFromGameObject(JObject @params) if (connectedInstance == null) { - return Response.Error($"Failed to save prefab asset at '{finalPath}'."); + return new ErrorResponse($"Failed to save prefab asset at '{finalPath}'."); } Selection.activeGameObject = connectedInstance; - return Response.Success( + return new SuccessResponse( $"Prefab created at '{finalPath}' and instance linked.", new { @@ -204,7 +204,7 @@ private static object CreatePrefabFromGameObject(JObject @params) } catch (Exception e) { - return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); + return new ErrorResponse($"Error saving prefab asset at '{finalPath}': {e.Message}"); } } diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index e94e5d512..7a5046afa 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// - [McpForUnityTool("read_console")] + [McpForUnityTool("read_console", AutoRegister = false)] public static class ReadConsole { // (Calibration removed) @@ -147,7 +147,7 @@ public static object HandleCommand(JObject @params) Debug.LogError( "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." ); - return Response.Error( + return new ErrorResponse( "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." ); } @@ -190,7 +190,7 @@ public static object HandleCommand(JObject @params) } else { - return Response.Error( + return new ErrorResponse( $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." ); } @@ -198,7 +198,7 @@ public static object HandleCommand(JObject @params) catch (Exception e) { Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); - return Response.Error($"Internal error processing action '{action}': {e.Message}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); } } @@ -209,12 +209,12 @@ private static object ClearConsole() try { _clearMethod.Invoke(null, null); // Static method, no instance, no parameters - return Response.Success("Console cleared successfully."); + return new SuccessResponse("Console cleared successfully."); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); - return Response.Error($"Failed to clear console: {e.Message}"); + return new ErrorResponse($"Failed to clear console: {e.Message}"); } } @@ -359,7 +359,7 @@ bool includeStacktrace catch { /* Ignore nested exception */ } - return Response.Error($"Error retrieving log entries: {e.Message}"); + return new ErrorResponse($"Error retrieving log entries: {e.Message}"); } finally { @@ -376,7 +376,7 @@ bool includeStacktrace } // Return the filtered and formatted list (might be empty) - return Response.Success( + return new SuccessResponse( $"Retrieved {formattedEntries.Count} log entries.", formattedEntries ); diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 74dac6a44..f49e57f5f 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Tools /// /// Executes Unity tests for a specified mode and returns detailed results. /// - [McpForUnityTool("run_tests")] + [McpForUnityTool("run_tests", AutoRegister = false)] public static class RunTests { private const int DefaultTimeoutSeconds = 600; // 10 minutes @@ -25,7 +25,7 @@ public static async Task HandleCommand(JObject @params) if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { - return Response.Error(parseError); + return new ErrorResponse(parseError); } int timeoutSeconds = DefaultTimeoutSeconds; @@ -50,7 +50,7 @@ public static async Task HandleCommand(JObject @params) } catch (Exception ex) { - return Response.Error($"Failed to start test run: {ex.Message}"); + return new ErrorResponse($"Failed to start test run: {ex.Message}"); } var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds)); @@ -58,7 +58,7 @@ public static async Task HandleCommand(JObject @params) if (completed != runTask) { - return Response.Error($"Test run timed out after {timeoutSeconds} seconds"); + return new ErrorResponse($"Test run timed out after {timeoutSeconds} seconds"); } var result = await runTask.ConfigureAwait(true); @@ -67,7 +67,7 @@ public static async Task HandleCommand(JObject @params) $"{parsedMode.Value} tests completed: {result.Passed}/{result.Total} passed, {result.Failed} failed, {result.Skipped} skipped"; var data = result.ToSerializable(parsedMode.Value.ToString()); - return Response.Success(message, data); + return new SuccessResponse(message, data); } } } diff --git a/MCPForUnity/Editor/Windows/Components.meta b/MCPForUnity/Editor/Windows/Components.meta new file mode 100644 index 000000000..716e0fab9 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82074be914aefa84cb557c599d2319b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig.meta b/MCPForUnity/Editor/Windows/Components/ClientConfig.meta new file mode 100644 index 000000000..58abdee18 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4d9f5ceeb24166f47804e094440b7846 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs new file mode 100644 index 000000000..462ed4a2d --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -0,0 +1,321 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Windows.Components.ClientConfig +{ + /// + /// Controller for the Client Configuration section of the MCP For Unity editor window. + /// Handles client selection, configuration, status display, and manual configuration details. + /// + public class McpClientConfigSection + { + // UI Elements + private DropdownField clientDropdown; + private Button configureAllButton; + private VisualElement clientStatusIndicator; + private Label clientStatusLabel; + private Button configureButton; + private VisualElement claudeCliPathRow; + private TextField claudeCliPath; + private Button browseClaudeButton; + private TextField configPathField; + private Button copyPathButton; + private Button openFileButton; + private TextField configJsonField; + private Button copyJsonButton; + private Label installationStepsLabel; + + // Data + private readonly McpClients mcpClients; + private int selectedClientIndex = 0; + + public VisualElement Root { get; private set; } + + public McpClientConfigSection(VisualElement root, McpClients clients) + { + Root = root; + mcpClients = clients; + CacheUIElements(); + InitializeUI(); + RegisterCallbacks(); + } + + private void CacheUIElements() + { + clientDropdown = Root.Q("client-dropdown"); + configureAllButton = Root.Q