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