diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5bc1e90
--- /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 9cb8e1c..e99be17 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 513dfb7..49c6f7f 100644
--- a/.github/workflows/claude-nl-suite.yml
+++ b/.github/workflows/claude-nl-suite.yml
@@ -55,14 +55,13 @@ 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
+ else
+ echo "No MCP Python deps found (skipping)"
+ fi
else
echo "No MCP Python deps found (skipping)"
fi
@@ -217,7 +216,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 +284,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 8364d1b..2269758 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 d56cf6c..81f0d66 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 3d24208..7c23235 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 0000000..ffaa31c
--- /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 SessionId = "MCPForUnity.SessionId";
+ 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 f1e14f7..9c923da 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 22719a5..0000000
--- 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 ce6efef..3f7b154 100644
--- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs
+++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs
@@ -56,14 +56,10 @@ namespace MCPForUnity.Editor.Dependencies
var pythonStatus = detector.DetectPython();
result.Dependencies.Add(pythonStatus);
- // Check UV
- var uvStatus = detector.DetectUV();
+ // Check uv
+ 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);
@@ -104,7 +100,7 @@ namespace MCPForUnity.Editor.Dependencies
try
{
var detector = GetCurrentPlatformDetector();
- return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl());
+ return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl());
}
catch
{
@@ -128,9 +124,9 @@ namespace MCPForUnity.Editor.Dependencies
{
result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}");
}
- else if (dep.Name == "UV Package Manager")
+ else if (dep.Name == "uv Package Manager")
{
- result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}");
+ result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}");
}
else if (dep.Name == "MCP Server")
{
@@ -140,7 +136,7 @@ namespace MCPForUnity.Editor.Dependencies
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 7fba58f..3231105 100644
--- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs
+++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs
@@ -23,14 +23,9 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
DependencyStatus DetectPython();
///
- /// Detect UV package manager on this platform
+ /// Detect uv package manager on this platform
///
- DependencyStatus DetectUV();
-
- ///
- /// Detect MCP server installation on this platform
- ///
- DependencyStatus DetectMCPServer();
+ DependencyStatus DetectUv();
///
/// Get platform-specific installation recommendations
@@ -43,8 +38,8 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
string GetPythonInstallUrl();
///
- /// Get platform-specific UV installation URL
+ /// Get platform-specific uv installation URL
///
- string GetUVInstallUrl();
+ string GetUvInstallUrl();
}
}
diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs
index f654612..1c5bf45 100644
--- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs
+++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs
@@ -25,45 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
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)
{
@@ -78,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://www.python.org/downloads/source/";
}
- public override string GetUVInstallUrl()
+ public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#linux";
}
@@ -93,7 +81,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
- Arch: sudo pacman -S python python-pip
- Or use pyenv: https://github.com/pyenv/pyenv
-2. UV Package Manager: Install via curl
+2. uv Package Manager: Install via curl
- Run: curl -LsSf https://astral.sh/uv/install.sh | sh
- Or download from: https://github.com/astral-sh/uv/releases
@@ -102,6 +90,51 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
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 @@ Note: Make sure ~/.local/bin is in your PATH for user-local installations.";
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 7d54d23..a3ced1b 100644
--- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs
+++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs
@@ -25,49 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
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)
{
@@ -82,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://www.python.org/downloads/macos/";
}
- public override string GetUVInstallUrl()
+ public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#macos";
}
@@ -95,7 +79,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
- Homebrew: brew install python3
- Direct download: https://python.org/downloads/macos/
-2. UV Package Manager: Install via curl or Homebrew
+2. uv Package Manager: Install via curl or Homebrew
- Curl: curl -LsSf https://astral.sh/uv/install.sh | sh
- Homebrew: brew install uv
@@ -104,6 +88,51 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
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 @@ Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH.";
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 98044f1..c64881c 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
{
@@ -16,122 +14,78 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
public abstract DependencyStatus DetectPython();
public abstract string GetPythonInstallUrl();
- public abstract string GetUVInstallUrl();
+ public abstract string GetUvInstallUrl();
public abstract string GetInstallationRecommendations();
- public virtual DependencyStatus DetectUV()
+ public virtual DependencyStatus DetectUv()
{
- var status = new DependencyStatus("UV Package Manager", isRequired: true)
+ var status = new DependencyStatus("uv Package Manager", isRequired: true)
{
- InstallationHint = GetUVInstallUrl()
+ InstallationHint = GetUvInstallUrl()
};
try
{
- // Use existing UV detection from ServerInstaller
- string uvPath = ServerInstaller.FindUvPath();
- if (!string.IsNullOrEmpty(uvPath))
- {
- 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.ErrorMessage = "UV package manager not found. Please install UV.";
- status.Details = "UV is required for managing Python dependencies.";
- }
- catch (Exception ex)
- {
- status.ErrorMessage = $"Error detecting UV: {ex.Message}";
- }
-
- return status;
- }
-
- public virtual DependencyStatus DetectMCPServer()
- {
- var status = new DependencyStatus("MCP Server", isRequired: false);
-
- try
- {
- // Check if server is installed
- string serverPath = ServerInstaller.GetServerPath();
- string serverPy = Path.Combine(serverPath, "server.py");
-
- if (File.Exists(serverPy))
+ // Try to find uv/uvx in PATH
+ if (TryFindUvInPath(out string uvPath, out string version))
{
status.IsAvailable = true;
- status.Path = serverPath;
-
- // Try to get version
- string versionFile = Path.Combine(serverPath, "server_version.txt");
- if (File.Exists(versionFile))
- {
- status.Version = File.ReadAllText(versionFile).Trim();
- }
-
- status.Details = $"MCP Server found at {serverPath}";
- }
- else
- {
- // Check for embedded server
- if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath))
- {
- status.IsAvailable = true;
- status.Path = embeddedPath;
- status.Details = "MCP Server available (embedded in package)";
- }
- else
- {
- status.ErrorMessage = "MCP Server not found";
- status.Details = "Server will be installed automatically when needed";
- }
+ status.Version = version;
+ status.Path = uvPath;
+ 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 MCP Server: {ex.Message}";
+ status.ErrorMessage = $"Error detecting uv: {ex.Message}";
}
return status;
}
- protected bool TryValidateUV(string uvPath, out string version)
+ protected bool TryFindUvInPath(out string uvPath, out string version)
{
+ uvPath = null;
version = null;
- try
+ // Try common uv command names
+ var commands = new[] { "uvx", "uv" };
+
+ foreach (var cmd in commands)
{
- var psi = new ProcessStartInfo
+ try
{
- FileName = uvPath,
- Arguments = "--version",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
+ var psi = new ProcessStartInfo
+ {
+ FileName = cmd,
+ Arguments = "--version",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
- using var process = Process.Start(psi);
- if (process == null) return false;
+ using var process = Process.Start(psi);
+ if (process == null) continue;
- string output = process.StandardOutput.ReadToEnd().Trim();
- process.WaitForExit(5000);
+ string output = process.StandardOutput.ReadToEnd().Trim();
+ process.WaitForExit(5000);
- if (process.ExitCode == 0 && output.StartsWith("uv "))
- {
- version = output.Substring(3); // Remove "uv " prefix
- return true;
+ if (process.ExitCode == 0 && output.StartsWith("uv "))
+ {
+ version = output.Substring(3).Trim();
+ uvPath = cmd;
+ return true;
+ }
+ }
+ catch
+ {
+ // 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 6a534eb..e4d7b92 100644
--- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs
+++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs
@@ -25,61 +25,33 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
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")
- };
+ status.IsAvailable = true;
+ status.Version = version;
+ status.Path = fullPath;
+ status.Details = $"Found Python {version} in PATH";
+ return status;
+ }
- foreach (var candidate in candidates)
+ // Fallback: try 'where' command
+ if (TryFindInPath("python3.exe", out string pathResult) ||
+ TryFindInPath("python.exe", out pathResult))
{
- if (TryValidatePython(candidate, 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} at {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))
- {
- if (TryValidatePython(pathResult, out string version, out string fullPath))
- {
- status.IsAvailable = true;
- status.Version = version;
- status.Path = fullPath;
- status.Details = $"Found Python {version} in PATH at {fullPath}";
- 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)
{
@@ -94,7 +66,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP";
}
- public override string GetUVInstallUrl()
+ public override string GetUvInstallUrl()
{
return "https://docs.astral.sh/uv/getting-started/installation/#windows";
}
@@ -107,7 +79,7 @@ namespace MCPForUnity.Editor.Dependencies.PlatformDetectors
- Microsoft Store: Search for 'Python 3.10' or higher
- Direct download: https://python.org/downloads/windows/
-2. UV Package Manager: Install via PowerShell
+2. uv Package Manager: Install via PowerShell
- Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex""
- Or download from: https://github.com/astral-sh/uv/releases
diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
index dac1fac..a310c6e 100644
--- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
+++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs
@@ -1,5 +1,7 @@
using System;
using System.IO;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
@@ -49,7 +51,7 @@ namespace MCPForUnity.Editor.Helpers
// 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 @@ namespace MCPForUnity.Editor.Helpers
}
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,45 @@ namespace MCPForUnity.Editor.Helpers
}
///
- /// Gets the version string from the package.json file.
+ /// 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();
+ 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 a472890..b4f786e 100644
--- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
+++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
@@ -4,6 +4,7 @@ using System.IO;
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)
- {
- try
- {
- string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- if (string.IsNullOrEmpty(basePath)) return false;
-
- string configPath = Path.Combine(basePath, ".codex", "config.toml");
- if (!File.Exists(configPath)) return false;
-
- string toml = File.ReadAllText(configPath);
- if (!TryParseCodexServer(toml, out _, out var args)) return false;
-
- string dir = McpConfigurationHelper.ExtractDirectoryArg(args);
- if (string.IsNullOrEmpty(dir)) return false;
-
- return McpConfigurationHelper.PathsEqual(dir, pythonDir);
- }
- catch
- {
- return false;
- }
- }
-
- public static string BuildCodexServerBlock(string uvPath, string serverSrc)
+ public static string BuildCodexServerBlock(string uvPath)
{
var table = new TomlTable();
var mcpServers = new TomlTable();
+ var unityMCP = new TomlTable();
- mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc);
+ // 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"] = uvxPath;
+
+ 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;
+
+ // 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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
}
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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
///
/// 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;
+ // Check transport preference
+ bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true);
- // Add Windows-specific environment configuration, see: https://github.com/CoplayDev/unity-mcp/issues/315
- var platformService = MCPServiceLocator.Platform;
- if (platformService.IsWindows())
+ if (useHttpTransport)
{
- var envTable = new TomlTable { IsInline = true };
- envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() };
- unityMCP["env"] = envTable;
+ // 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 5889e4f..084e2a7 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 @@ namespace MCPForUnity.Editor.Helpers
}
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 @@ namespace MCPForUnity.Editor.Helpers
///
/// 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";
+ }
}
- if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro))
+ // Remove type for non-VSCode clients
+ if (!isVSCode && unity["type"] != null)
+ {
+ unity.Remove("type");
+ }
+
+ 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/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs
index 20c1200..9190ec3 100644
--- a/MCPForUnity/Editor/Helpers/ExecPath.cs
+++ b/MCPForUnity/Editor/Helpers/ExecPath.cs
@@ -5,12 +5,13 @@ using System.Linq;
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 @@ namespace MCPForUnity.Editor.Helpers
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 f9abf1f..05d1b8b 100644
--- a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs
+++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs
@@ -255,25 +255,25 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
// --- 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 @@ namespace MCPForUnity.Editor.Helpers
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 0000000..bda33cb
--- /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/Data/PythonToolsAsset.cs.meta b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta
similarity index 83%
rename from MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta
rename to MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta
index bfe30d9..55d67cb 100644
--- a/MCPForUnity/Editor/Data/PythonToolsAsset.cs.meta
+++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 1ad9865b38bcc4efe85d4970c6d3a997
+guid: 2051d90316ea345c09240c80c7138e3b
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
index 96ad7ec..2552f9a 100644
--- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
+++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs
@@ -3,13 +3,15 @@ using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Dependencies;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Models;
+using MCPForUnity.Editor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
-using MCPForUnity.Editor.Dependencies;
-using MCPForUnity.Editor.Helpers;
-using MCPForUnity.Editor.Models;
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 @@ namespace MCPForUnity.Editor.Helpers
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();
+ 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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
}
- string uvPath = ServerInstaller.FindUvPath();
- try
+ string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
+ if (uvxPath == null)
{
- 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.";
+ 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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
}
}
- ///
- /// 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 @@ namespace MCPForUnity.Editor.Helpers
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 0000000..5db093b
--- /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/PythonToolSyncProcessor.cs.meta b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta
similarity index 83%
rename from MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta
rename to MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta
index d3a3719..df45ef5 100644
--- a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs.meta
+++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 4bdcf382960c842aab0a08c90411ab43
+guid: 28912085dd68342f8a9fda8a43c83a59
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs
index 8d31c55..2b0a314 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("MCPForUnity.DebugLogs", false); } catch { return false; }
+ 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)
+ {
+ 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 04082a9..0000000
--- 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 02e482c..0000000
--- 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 e7c4891..1de6f02 100644
--- a/MCPForUnity/Editor/Helpers/PortManager.cs
+++ b/MCPForUnity/Editor/Helpers/PortManager.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using Newtonsoft.Json;
using UnityEngine;
+using MCPForUnity.Editor.Constants;
namespace MCPForUnity.Editor.Helpers
{
@@ -18,7 +19,7 @@ namespace MCPForUnity.Editor.Helpers
{
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 @@ namespace MCPForUnity.Editor.Helpers
}
///
- /// 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 @@ namespace MCPForUnity.Editor.Helpers
{
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 @@ namespace MCPForUnity.Editor.Helpers
// 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 @@ namespace MCPForUnity.Editor.Helpers
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 @@ namespace MCPForUnity.Editor.Helpers
}
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 @@ namespace MCPForUnity.Editor.Helpers
}
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 0000000..34a5391
--- /dev/null
+++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs
@@ -0,0 +1,260 @@
+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.SessionId;
+ private static bool _legacyKeyCleared;
+ private static string _cachedProjectName = "Unknown";
+ private static string _cachedProjectHash = "default";
+ private static string _fallbackSessionId;
+ 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 16 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,
+ /// or "Unknown" if the name cannot be determined.
+ ///
+ public static string GetProjectName()
+ {
+ EnsureIdentityCache();
+ 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)).ToLowerInvariant();
+ }
+ 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.
+ if (string.IsNullOrEmpty(_fallbackSessionId))
+ {
+ _fallbackSessionId = Guid.NewGuid().ToString();
+ }
+
+ return _fallbackSessionId;
+ }
+ }
+
+ ///
+ /// 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;
+ }
+
+ _fallbackSessionId = null;
+ }
+ 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/McpPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta
similarity index 83%
rename from MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta
rename to MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta
index 38f1997..b7879e1 100644
--- a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta
+++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 2c76f0c7ff138ba4a952481e04bc3974
+guid: 936e878ce1275453bae5e0cf03bd9d30
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs b/MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs
deleted file mode 100644
index de6167a..0000000
--- 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 cfcd2ef..39d7f6c 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
- {
- ///
- /// 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)
- {
- if (data != null)
- {
- return new
- {
- success = true,
- message = message,
- data = data,
- };
- }
- else
- {
- return new { success = true, message = message };
- }
- }
+public interface IMcpResponse
+{
+ [JsonProperty("success")]
+ bool Success { get; }
+ }
- ///
- /// 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 sealed class SuccessResponse : IMcpResponse
+ {
+ [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)
- {
- // 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 = 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;
+
+ public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null)
+ {
+ 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 2e149bb..0000000
--- 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 dfd9023..0000000
--- 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 0e46294..0000000
--- 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 d02df60..0000000
--- 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 0f43623..953fa79 100644
--- a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs
+++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs
@@ -2,6 +2,8 @@ using System;
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 @@ namespace MCPForUnity.Editor.Helpers
{
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 @@ namespace MCPForUnity.Editor.Helpers
{
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 8c60a1c..0000000
--- 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 7e2edb2..0000000
--- 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 8844892..47621bd 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 f8d1f46..0000000
--- 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 714e485..0000000
--- 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 30148f2..ad5fb5e 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/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs
new file mode 100644
index 0000000..32fde72
--- /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 16c8bb6..62d67f0 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 0000000..186f62f
--- /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 0000000..ddc85c0
--- /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 0000000..9f43734
--- /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 0000000..872a357
--- /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 0a3fa86..13a5564 100644
--- a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs
+++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs
@@ -40,11 +40,11 @@ namespace MCPForUnity.Editor.Resources.Editor
}
};
- 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 fdcff7e..57f70f7 100644
--- a/MCPForUnity/Editor/Resources/Editor/EditorState.cs
+++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs
@@ -28,12 +28,12 @@ namespace MCPForUnity.Editor.Resources.Editor
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 2f66a01..ee47d6f 100644
--- a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs
+++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs
@@ -16,10 +16,10 @@ namespace MCPForUnity.Editor.Resources.Editor
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 @@ namespace MCPForUnity.Editor.Resources.Editor
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 07bb34d..022d9c4 100644
--- a/MCPForUnity/Editor/Resources/Editor/Selection.cs
+++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs
@@ -41,11 +41,11 @@ namespace MCPForUnity.Editor.Resources.Editor
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 a637c1e..5719088 100644
--- a/MCPForUnity/Editor/Resources/Editor/Windows.cs
+++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs
@@ -48,11 +48,11 @@ namespace MCPForUnity.Editor.Resources.Editor
}
}
- 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 c554be2..f6a8428 100644
--- a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs
+++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs
@@ -33,7 +33,7 @@ namespace MCPForUnity.Editor.Resources.MenuItems
}
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 eb7f1a3..9e9ef7d 100644
--- a/MCPForUnity/Editor/Resources/Project/Layers.cs
+++ b/MCPForUnity/Editor/Resources/Project/Layers.cs
@@ -27,12 +27,12 @@ namespace MCPForUnity.Editor.Resources.Project
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 3306983..6e6d12f 100644
--- a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs
+++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs
@@ -20,7 +20,7 @@ namespace MCPForUnity.Editor.Resources.Project
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 @@ namespace MCPForUnity.Editor.Resources.Project
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 665e8d7..756f00d 100644
--- a/MCPForUnity/Editor/Resources/Project/Tags.cs
+++ b/MCPForUnity/Editor/Resources/Project/Tags.cs
@@ -16,11 +16,11 @@ namespace MCPForUnity.Editor.Resources.Project
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 3efb1c6..f7eeda9 100644
--- a/MCPForUnity/Editor/Resources/Tests/GetTests.cs
+++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs
@@ -27,12 +27,12 @@ namespace MCPForUnity.Editor.Resources.Tests
catch (Exception ex)
{
McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}");
- return Response.Error("Failed to retrieve tests");
+ return new ErrorResponse("Failed to retrieve tests");
}
string message = $"Retrieved {result.Count} tests";
- return Response.Success(message, result);
+ return new SuccessResponse(message, result);
}
}
@@ -49,12 +49,12 @@ namespace MCPForUnity.Editor.Resources.Tests
string modeStr = @params["mode"]?.ToString();
if (string.IsNullOrEmpty(modeStr))
{
- return Response.Error("'mode' parameter is required");
+ return new ErrorResponse("'mode' parameter is required");
}
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{
- return Response.Error(parseError);
+ return new ErrorResponse(parseError);
}
McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}");
@@ -66,11 +66,11 @@ namespace MCPForUnity.Editor.Resources.Tests
catch (Exception ex)
{
McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}");
- return Response.Error("Failed to retrieve tests");
+ return new ErrorResponse("Failed to retrieve tests");
}
string message = $"Retrieved {result.Count} {parsedMode.Value} tests";
- return Response.Success(message, result);
+ return new SuccessResponse(message, result);
}
}
diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs
index a462e68..c67efd1 100644
--- a/MCPForUnity/Editor/Services/BridgeControlService.cs
+++ b/MCPForUnity/Editor/Services/BridgeControlService.cs
@@ -1,174 +1,130 @@
+
using System;
-using System.IO;
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
+using System.Threading.Tasks;
+using UnityEditor;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Services.Transport;
+using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnity.Editor.Services
{
///
- /// Implementation of bridge control service
+ /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
///
public class BridgeControlService : IBridgeControlService
{
- public bool IsRunning => MCPForUnityBridge.IsRunning;
- public int CurrentPort => MCPForUnityBridge.GetCurrentPort();
- public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode();
+ private readonly TransportManager _transportManager;
+ private TransportMode _preferredMode = TransportMode.Http;
- public void Start()
+ public BridgeControlService()
{
- // If server is installed, use auto-connect mode
- // Otherwise use standard mode
- string serverPath = MCPServiceLocator.Paths.GetMcpServerPath();
- if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py")))
+ _transportManager = MCPServiceLocator.TransportManager;
+ }
+
+ private TransportMode ResolvePreferredMode()
+ {
+ bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
+ _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio;
+ return _preferredMode;
+ }
+
+ private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null)
+ {
+ bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true);
+ string transportLabel = string.IsNullOrWhiteSpace(state.TransportName)
+ ? mode.ToString().ToLowerInvariant()
+ : state.TransportName;
+ string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]";
+ string message = messageOverride
+ ?? state.Error
+ ?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}");
+
+ return new BridgeVerificationResult
{
- MCPForUnityBridge.StartAutoConnect();
- }
- else
+ Success = pingSucceeded && handshakeValid,
+ HandshakeValid = handshakeValid,
+ PingSucceeded = pingSucceeded,
+ Message = message
+ };
+ }
+
+ public bool IsRunning => _transportManager.GetState().IsConnected;
+
+ public int CurrentPort
+ {
+ get
{
- MCPForUnityBridge.Start();
+ var state = _transportManager.GetState();
+ if (state.Port.HasValue)
+ {
+ return state.Port.Value;
+ }
+
+ // Legacy fallback while the stdio bridge is still in play
+ return StdioBridgeHost.GetCurrentPort();
}
}
- public void Stop()
+ public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode();
+ public TransportMode? ActiveMode => _transportManager.ActiveMode;
+
+ public async Task StartAsync()
{
- MCPForUnityBridge.Stop();
+ var mode = ResolvePreferredMode();
+ try
+ {
+ bool started = await _transportManager.StartAsync(mode);
+ if (!started)
+ {
+ McpLog.Warn($"Failed to start MCP transport: {mode}");
+ }
+ return started;
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}");
+ return false;
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ try
+ {
+ await _transportManager.StopAsync();
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Error stopping MCP transport: {ex.Message}");
+ }
+ }
+
+ public async Task VerifyAsync()
+ {
+ var mode = _transportManager.ActiveMode ?? ResolvePreferredMode();
+ bool pingSucceeded = await _transportManager.VerifyAsync();
+ var state = _transportManager.GetState();
+ return BuildVerificationResult(state, mode, pingSucceeded);
}
public BridgeVerificationResult Verify(int port)
{
- var result = new BridgeVerificationResult
+ var mode = _transportManager.ActiveMode ?? ResolvePreferredMode();
+ bool pingSucceeded = _transportManager.VerifyAsync().GetAwaiter().GetResult();
+ var state = _transportManager.GetState();
+
+ if (mode == TransportMode.Stdio)
{
- Success = false,
- HandshakeValid = false,
- PingSucceeded = false,
- Message = "Verification not started"
- };
-
- const int ConnectTimeoutMs = 1000;
- const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout
-
- try
- {
- using (var client = new TcpClient())
- {
- // Attempt connection
- var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
- if (!connectTask.Wait(ConnectTimeoutMs))
- {
- result.Message = "Connection timeout";
- return result;
- }
-
- using (var stream = client.GetStream())
- {
- try { client.NoDelay = true; } catch { }
-
- // 1) Read handshake line (ASCII, newline-terminated)
- string handshake = ReadLineAscii(stream, 2000);
- if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0)
- {
- result.Message = "Bridge handshake missing FRAMING=1";
- return result;
- }
-
- result.HandshakeValid = true;
-
- // 2) Send framed "ping"
- byte[] payload = Encoding.UTF8.GetBytes("ping");
- WriteFrame(stream, payload, FrameTimeoutMs);
-
- // 3) Read framed response and check for pong
- string response = ReadFrameUtf8(stream, FrameTimeoutMs);
- if (!string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0)
- {
- result.PingSucceeded = true;
- result.Success = true;
- result.Message = "Bridge verified successfully";
- }
- else
- {
- result.Message = $"Ping failed; response='{response}'";
- }
- }
- }
- }
- catch (Exception ex)
- {
- result.Message = $"Verification error: {ex.Message}";
+ bool handshakeValid = state.IsConnected && port == CurrentPort;
+ string message = handshakeValid
+ ? $"STDIO transport listening on port {CurrentPort}"
+ : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})";
+ return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid);
}
- return result;
+ return BuildVerificationResult(state, mode, pingSucceeded);
}
- // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts
- private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs)
- {
- if (payload == null) throw new ArgumentNullException(nameof(payload));
- if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed");
-
- byte[] header = new byte[8];
- ulong len = (ulong)payload.LongLength;
- header[0] = (byte)(len >> 56);
- header[1] = (byte)(len >> 48);
- header[2] = (byte)(len >> 40);
- header[3] = (byte)(len >> 32);
- header[4] = (byte)(len >> 24);
- header[5] = (byte)(len >> 16);
- header[6] = (byte)(len >> 8);
- header[7] = (byte)(len);
-
- stream.WriteTimeout = timeoutMs;
- stream.Write(header, 0, header.Length);
- stream.Write(payload, 0, payload.Length);
- }
-
- private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs)
- {
- byte[] header = ReadExact(stream, 8, timeoutMs);
- ulong len = ((ulong)header[0] << 56)
- | ((ulong)header[1] << 48)
- | ((ulong)header[2] << 40)
- | ((ulong)header[3] << 32)
- | ((ulong)header[4] << 24)
- | ((ulong)header[5] << 16)
- | ((ulong)header[6] << 8)
- | header[7];
- if (len == 0UL) throw new IOException("Zero-length frames are not allowed");
- if (len > int.MaxValue) throw new IOException("Frame too large");
- byte[] payload = ReadExact(stream, (int)len, timeoutMs);
- return Encoding.UTF8.GetString(payload);
- }
-
- private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs)
- {
- byte[] buffer = new byte[count];
- int offset = 0;
- stream.ReadTimeout = timeoutMs;
- while (offset < count)
- {
- int read = stream.Read(buffer, offset, count - offset);
- if (read <= 0) throw new IOException("Connection closed before reading expected bytes");
- offset += read;
- }
- return buffer;
- }
-
- private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512)
- {
- stream.ReadTimeout = timeoutMs;
- using (var ms = new MemoryStream())
- {
- byte[] one = new byte[1];
- while (ms.Length < maxLen)
- {
- int n = stream.Read(one, 0, 1);
- if (n <= 0) break;
- if (one[0] == (byte)'\n') break;
- ms.WriteByte(one[0]);
- }
- return Encoding.ASCII.GetString(ms.ToArray());
- }
- }
}
}
diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs
index 8a9c4ca..546ea38 100644
--- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs
+++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs
@@ -2,10 +2,12 @@ using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
+using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Data;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
@@ -20,38 +22,24 @@ namespace MCPForUnity.Editor.Services
public void ConfigureClient(McpClient client)
{
- try
+ var pathService = MCPServiceLocator.Paths;
+ string uvxPath = pathService.GetUvxPath();
+
+ string configPath = McpConfigurationHelper.GetClientConfigPath(client);
+ McpConfigurationHelper.EnsureConfigDirectoryExists(configPath);
+
+ string result = client.mcpType == McpTypes.Codex
+ ? McpConfigurationHelper.ConfigureCodexClient(configPath, client)
+ : McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
+
+ if (result == "Configured successfully")
{
- string configPath = McpConfigurationHelper.GetClientConfigPath(client);
- McpConfigurationHelper.EnsureConfigDirectoryExists(configPath);
-
- string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath();
-
- if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
- {
- throw new InvalidOperationException("Server not found. Please use manual configuration or set server path in Advanced Settings.");
- }
-
- string result = client.mcpType == McpTypes.Codex
- ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client)
- : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client);
-
- if (result == "Configured successfully")
- {
- client.SetStatus(McpStatus.Configured);
- Debug.Log($"MCP-FOR-UNITY: {client.name} configured successfully");
- }
- else
- {
- Debug.LogWarning($"Configuration completed with message: {result}");
- }
-
- CheckClientStatus(client);
+ client.SetStatus(McpStatus.Configured);
}
- catch (Exception ex)
+ else
{
- Debug.LogError($"Failed to configure {client.name}: {ex.Message}");
- throw;
+ client.SetStatus(McpStatus.NotConfigured);
+ throw new InvalidOperationException($"Configuration failed: {result}");
}
}
@@ -64,14 +52,8 @@ namespace MCPForUnity.Editor.Services
{
try
{
- // Skip if already configured
+ // Always re-run configuration so core fields stay current
CheckClientStatus(client, attemptAutoRewrite: false);
- if (client.status == McpStatus.Configured)
- {
- summary.SkippedCount++;
- summary.Messages.Add($"✓ {client.name}: Already configured");
- continue;
- }
// Check if required tools are available
if (client.mcpType == McpTypes.ClaudeCode)
@@ -83,20 +65,14 @@ namespace MCPForUnity.Editor.Services
continue;
}
+ // Force a fresh registration so transport settings stay current
+ UnregisterClaudeCode();
RegisterClaudeCode();
summary.SuccessCount++;
- summary.Messages.Add($"✓ {client.name}: Registered successfully");
+ summary.Messages.Add($"✓ {client.name}: Re-registered successfully");
}
else
{
- // Other clients require UV
- if (!pathService.IsUvDetected())
- {
- summary.SkippedCount++;
- summary.Messages.Add($"➜ {client.name}: UV not found");
- continue;
- }
-
ConfigureClient(client);
summary.SuccessCount++;
summary.Messages.Add($"✓ {client.name}: Configured successfully");
@@ -134,32 +110,45 @@ namespace MCPForUnity.Editor.Services
}
string configJson = File.ReadAllText(configPath);
- string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath();
-
// Check configuration based on client type
string[] args = null;
+ string configuredUrl = null;
bool configExists = false;
switch (client.mcpType)
{
case McpTypes.VSCode:
- dynamic vsConfig = JsonConvert.DeserializeObject(configJson);
- if (vsConfig?.servers?.unityMCP != null)
+ var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject;
+ if (vsConfig != null)
{
- args = vsConfig.servers.unityMCP.args.ToObject();
- configExists = true;
- }
- else if (vsConfig?.mcp?.servers?.unityMCP != null)
- {
- args = vsConfig.mcp.servers.unityMCP.args.ToObject();
- configExists = true;
+ var unityToken =
+ vsConfig["servers"]?["unityMCP"]
+ ?? vsConfig["mcp"]?["servers"]?["unityMCP"];
+
+ if (unityToken is JObject unityObj)
+ {
+ configExists = true;
+
+ var argsToken = unityObj["args"];
+ if (argsToken is JArray)
+ {
+ args = argsToken.ToObject();
+ }
+
+ var urlToken = unityObj["url"] ?? unityObj["serverUrl"];
+ if (urlToken != null && urlToken.Type != JTokenType.Null)
+ {
+ configuredUrl = urlToken.ToString();
+ }
+ }
}
break;
case McpTypes.Codex:
- if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
+ if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs, out var codexUrl))
{
args = codexArgs;
+ configuredUrl = codexUrl;
configExists = true;
}
break;
@@ -176,9 +165,20 @@ namespace MCPForUnity.Editor.Services
if (configExists)
{
- string configuredDir = McpConfigurationHelper.ExtractDirectoryArg(args);
- bool matches = !string.IsNullOrEmpty(configuredDir) &&
- McpConfigurationHelper.PathsEqual(configuredDir, pythonDir);
+ bool matches = false;
+
+ if (args != null && args.Length > 0)
+ {
+ string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl();
+ string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args);
+ matches = !string.IsNullOrEmpty(configuredUvxUrl) &&
+ McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl);
+ }
+ else if (!string.IsNullOrEmpty(configuredUrl))
+ {
+ string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ matches = UrlsEqual(configuredUrl, expectedUrl);
+ }
if (matches)
{
@@ -190,15 +190,18 @@ namespace MCPForUnity.Editor.Services
try
{
string rewriteResult = client.mcpType == McpTypes.Codex
- ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client)
- : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client);
+ ? McpConfigurationHelper.ConfigureCodexClient(configPath, client)
+ : McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
if (rewriteResult == "Configured successfully")
{
- bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
+ bool debugLogsEnabled = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
if (debugLogsEnabled)
{
- McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false);
+ string targetDescriptor = args != null && args.Length > 0
+ ? AssetPathUtility.GetMcpServerGitUrl()
+ : HttpEndpointUtility.GetMcpRpcUrl();
+ McpLog.Info($"Auto-updated MCP config for '{client.name}' to new version: {targetDescriptor}", always: false);
}
client.SetStatus(McpStatus.Configured);
}
@@ -233,21 +236,29 @@ namespace MCPForUnity.Editor.Services
public void RegisterClaudeCode()
{
var pathService = MCPServiceLocator.Paths;
- string pythonDir = pathService.GetMcpServerPath();
-
- if (string.IsNullOrEmpty(pythonDir))
- {
- throw new InvalidOperationException("Cannot register: Python directory not found");
- }
-
string claudePath = pathService.GetClaudeCliPath();
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
- string uvPath = pathService.GetUvPath() ?? "uv";
- string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py";
+ // Check transport preference
+ bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
+
+ string args;
+ if (useHttpTransport)
+ {
+ // HTTP mode: Use --transport http with URL
+ string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ args = $"mcp add --transport http UnityMCP {httpUrl}";
+ }
+ else
+ {
+ // Stdio mode: Use command with uvx
+ var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
+ args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}";
+ }
+
string projectDir = Path.GetDirectoryName(Application.dataPath);
string pathPrepend = null;
@@ -278,7 +289,7 @@ namespace MCPForUnity.Editor.Services
string combined = ($"{stdout}\n{stderr}") ?? string.Empty;
if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0)
{
- Debug.Log("MCP-FOR-UNITY: MCP for Unity already registered with Claude Code.");
+ McpLog.Info("MCP for Unity already registered with Claude Code.");
}
else
{
@@ -287,7 +298,7 @@ namespace MCPForUnity.Editor.Services
return;
}
- Debug.Log("MCP-FOR-UNITY: Successfully registered with Claude Code.");
+ McpLog.Info("Successfully registered with Claude Code.");
// Update status
var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode);
@@ -301,7 +312,7 @@ namespace MCPForUnity.Editor.Services
{
var pathService = MCPServiceLocator.Paths;
string claudePath = pathService.GetClaudeCliPath();
-
+
if (string.IsNullOrEmpty(claudePath))
{
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
@@ -323,14 +334,14 @@ namespace MCPForUnity.Editor.Services
{
claudeClient.SetStatus(McpStatus.NotConfigured);
}
- Debug.Log("MCP-FOR-UNITY: No MCP for Unity server found - already unregistered.");
+ McpLog.Info("No MCP for Unity server found - already unregistered.");
return;
}
// Remove the server
if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend))
{
- Debug.Log("MCP-FOR-UNITY: MCP server successfully unregistered from Claude Code.");
+ McpLog.Info("MCP server successfully unregistered from Claude Code.");
}
else
{
@@ -366,19 +377,32 @@ namespace MCPForUnity.Editor.Services
public string GenerateConfigJson(McpClient client)
{
- string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath();
- string uvPath = MCPServiceLocator.Paths.GetUvPath();
+ string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
// Claude Code uses CLI commands, not JSON config
if (client.mcpType == McpTypes.ClaudeCode)
{
- if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath))
- {
- return "# Error: Configuration not available - check paths in Advanced Settings";
- }
+ // Check transport preference
+ bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
- // Show the actual command that RegisterClaudeCode() uses
- string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py";
+ string registerCommand;
+ if (useHttpTransport)
+ {
+ // HTTP mode
+ string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
+ registerCommand = $"claude mcp add --transport http UnityMCP {httpUrl}";
+ }
+ else
+ {
+ // Stdio mode
+ if (string.IsNullOrEmpty(uvxPath))
+ {
+ return "# Error: Configuration not available - check paths in Advanced Settings";
+ }
+
+ string gitUrl = AssetPathUtility.GetMcpServerGitUrl();
+ registerCommand = $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity";
+ }
return "# Register the MCP server with Claude Code:\n" +
$"{registerCommand}\n\n" +
@@ -388,19 +412,18 @@ namespace MCPForUnity.Editor.Services
"claude mcp list # Only works when claude is run in the project's directory";
}
- if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath))
+ if (string.IsNullOrEmpty(uvxPath))
return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }";
try
{
if (client.mcpType == McpTypes.Codex)
{
- return CodexConfigHelper.BuildCodexServerBlock(uvPath,
- McpConfigurationHelper.ResolveServerDirectory(pythonDir, null));
+ return CodexConfigHelper.BuildCodexServerBlock(uvxPath);
}
else
{
- return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client);
+ return ConfigJsonBuilder.BuildManualConfigJson(uvxPath, client);
}
}
catch (Exception ex)
@@ -479,22 +502,46 @@ namespace MCPForUnity.Editor.Services
{
try
{
- string configPath = McpConfigurationHelper.GetClientConfigPath(client);
+ var pathService = MCPServiceLocator.Paths;
+ string claudePath = pathService.GetClaudeCliPath();
- if (!File.Exists(configPath))
+ if (string.IsNullOrEmpty(claudePath))
{
- client.SetStatus(McpStatus.NotConfigured);
+ client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found");
return;
}
- string configJson = File.ReadAllText(configPath);
- dynamic claudeConfig = JsonConvert.DeserializeObject(configJson);
+ // Use 'claude mcp list' to check if UnityMCP is registered
+ string args = "mcp list";
+ string projectDir = Path.GetDirectoryName(Application.dataPath);
- if (claudeConfig?.mcpServers != null)
+ string pathPrepend = null;
+ if (Application.platform == RuntimePlatform.OSXEditor)
{
- var servers = claudeConfig.mcpServers;
- // Only check for UnityMCP (fixed - removed candidate hacks)
- if (servers.UnityMCP != null)
+ pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
+ }
+ else if (Application.platform == RuntimePlatform.LinuxEditor)
+ {
+ pathPrepend = "/usr/local/bin:/usr/bin:/bin";
+ }
+
+ // Add the directory containing Claude CLI to PATH
+ try
+ {
+ string claudeDir = Path.GetDirectoryName(claudePath);
+ if (!string.IsNullOrEmpty(claudeDir))
+ {
+ pathPrepend = string.IsNullOrEmpty(pathPrepend)
+ ? claudeDir
+ : $"{claudeDir}:{pathPrepend}";
+ }
+ }
+ catch { }
+
+ if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 10000, pathPrepend))
+ {
+ // Check if UnityMCP is in the output
+ if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
{
client.SetStatus(McpStatus.Configured);
return;
@@ -508,5 +555,28 @@ namespace MCPForUnity.Editor.Services
client.SetStatus(McpStatus.Error, ex.Message);
}
}
+
+ private static bool UrlsEqual(string a, string b)
+ {
+ if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
+ {
+ return false;
+ }
+
+ if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) &&
+ Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB))
+ {
+ return Uri.Compare(
+ uriA,
+ uriB,
+ UriComponents.HttpRequestUrl,
+ UriFormat.SafeUnescaped,
+ StringComparison.OrdinalIgnoreCase) == 0;
+ }
+
+ string Normalize(string value) => value.Trim().TrimEnd('/');
+
+ return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase);
+ }
}
}
diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs
new file mode 100644
index 0000000..16b8bd8
--- /dev/null
+++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Threading.Tasks;
+using UnityEditor;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Helpers;
+using MCPForUnity.Editor.Services.Transport;
+using MCPForUnity.Editor.Windows;
+
+namespace MCPForUnity.Editor.Services
+{
+ ///
+ /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge.
+ ///
+ [InitializeOnLoad]
+ internal static class HttpBridgeReloadHandler
+ {
+ static HttpBridgeReloadHandler()
+ {
+ AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
+ AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
+ }
+
+ private static void OnBeforeAssemblyReload()
+ {
+ try
+ {
+ var bridge = MCPServiceLocator.Bridge;
+ bool shouldResume = bridge.IsRunning && bridge.ActiveMode == TransportMode.Http;
+
+ if (shouldResume)
+ {
+ EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true);
+ }
+ else
+ {
+ EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload);
+ }
+
+ if (bridge.IsRunning)
+ {
+ var stopTask = bridge.StopAsync();
+ stopTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted && t.Exception != null)
+ {
+ McpLog.Warn($"Error stopping MCP bridge before reload: {t.Exception.GetBaseException().Message}");
+ }
+ }, TaskScheduler.Default);
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}");
+ }
+ }
+
+ private static void OnAfterAssemblyReload()
+ {
+ bool resume = false;
+ try
+ {
+ resume = EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false);
+ if (resume)
+ {
+ EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload);
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}");
+ resume = false;
+ }
+
+ if (!resume)
+ {
+ return;
+ }
+
+ // If the editor is not compiling, attempt an immediate restart without relying on editor focus.
+ bool isCompiling = EditorApplication.isCompiling;
+ try
+ {
+ var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
+ var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
+ if (prop != null) isCompiling |= (bool)prop.GetValue(null);
+ }
+ catch { }
+
+ if (!isCompiling)
+ {
+ try
+ {
+ var startTask = MCPServiceLocator.Bridge.StartAsync();
+ startTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted)
+ {
+ var baseEx = t.Exception?.GetBaseException();
+ McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {baseEx?.Message}");
+ return;
+ }
+ bool started = t.Result;
+ if (!started)
+ {
+ McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
+ }
+ else
+ {
+ MCPForUnityEditorWindow.RequestHealthVerification();
+ }
+ }, TaskScheduler.Default);
+ return;
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}");
+ return;
+ }
+ }
+
+ // Fallback when compiling: schedule on the editor loop
+ EditorApplication.delayCall += async () =>
+ {
+ try
+ {
+ bool started = await MCPServiceLocator.Bridge.StartAsync();
+ if (!started)
+ {
+ McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
+ }
+ else
+ {
+ MCPForUnityEditorWindow.RequestHealthVerification();
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}");
+ }
+ };
+ }
+ }
+}
diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta
new file mode 100644
index 0000000..ae5e9ed
--- /dev/null
+++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4c0cf970a7b494a659be151dc0124296
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/MCPForUnity/Editor/Services/IBridgeControlService.cs b/MCPForUnity/Editor/Services/IBridgeControlService.cs
index 6233ed7..7cc593e 100644
--- a/MCPForUnity/Editor/Services/IBridgeControlService.cs
+++ b/MCPForUnity/Editor/Services/IBridgeControlService.cs
@@ -1,3 +1,6 @@
+using System.Threading.Tasks;
+using MCPForUnity.Editor.Services.Transport;
+
namespace MCPForUnity.Editor.Services
{
///
@@ -9,35 +12,48 @@ namespace MCPForUnity.Editor.Services
/// Gets whether the bridge is currently running
///
bool IsRunning { get; }
-
+
///
/// Gets the current port the bridge is listening on
///
int CurrentPort { get; }
-
+
///
/// Gets whether the bridge is in auto-connect mode
///
bool IsAutoConnectMode { get; }
-
+
///
- /// Starts the MCP for Unity Bridge
+ /// Gets the currently active transport mode, if any
///
- void Start();
-
+ TransportMode? ActiveMode { get; }
+
///
- /// Stops the MCP for Unity Bridge
+ /// Starts the MCP for Unity Bridge asynchronously
///
- void Stop();
-
+ /// True if the bridge started successfully
+ Task StartAsync();
+
+ ///
+ /// Stops the MCP for Unity Bridge asynchronously
+ ///
+ Task StopAsync();
+
///
/// Verifies the bridge connection by sending a ping and waiting for a pong response
///
/// The port to verify
/// Verification result with detailed status
BridgeVerificationResult Verify(int port);
+
+ ///
+ /// Verifies the connection asynchronously (works for both HTTP and stdio transports)
+ ///
+ /// Verification result with detailed status
+ Task VerifyAsync();
+
}
-
+
///
/// Result of a bridge verification attempt
///
@@ -47,17 +63,17 @@ namespace MCPForUnity.Editor.Services
/// Whether the verification was successful
///
public bool Success { get; set; }
-
+
///
/// Human-readable message about the verification result
///
public string Message { get; set; }
-
+
///
/// Whether the handshake was valid (FRAMING=1 protocol)
///
public bool HandshakeValid { get; set; }
-
+
///
/// Whether the ping/pong exchange succeeded
///
diff --git a/MCPForUnity/Editor/Services/IClientConfigurationService.cs b/MCPForUnity/Editor/Services/IClientConfigurationService.cs
index e647424..24b01fa 100644
--- a/MCPForUnity/Editor/Services/IClientConfigurationService.cs
+++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs
@@ -12,13 +12,13 @@ namespace MCPForUnity.Editor.Services
///
/// 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 @@ namespace MCPForUnity.Editor.Services
/// 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 @@ namespace MCPForUnity.Editor.Services
/// Installation instructions
string GetInstallationSteps(McpClient client);
}
-
+
///
/// Summary of configuration results for multiple clients
///
@@ -68,22 +68,22 @@ namespace MCPForUnity.Editor.Services
/// 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/IPackageUpdateService.cs b/MCPForUnity/Editor/Services/IPackageUpdateService.cs
index a9a1491..9d4d2e4 100644
--- a/MCPForUnity/Editor/Services/IPackageUpdateService.cs
+++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs
@@ -11,7 +11,7 @@ namespace MCPForUnity.Editor.Services
/// 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 @@ namespace MCPForUnity.Editor.Services
/// 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 @@ namespace MCPForUnity.Editor.Services
/// 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 9968af6..104c311 100644
--- a/MCPForUnity/Editor/Services/IPathResolverService.cs
+++ b/MCPForUnity/Editor/Services/IPathResolverService.cs
@@ -6,84 +6,56 @@ 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();
+
///
/// 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
- ///
- /// True if UV is found
- bool IsUvDetected();
-
+
///
/// 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
+ /// Sets an override for the uvx path
///
/// Path to override with
- void SetMcpServerOverride(string path);
-
- ///
- /// Sets an override for the UV 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
+ /// Gets whether a uvx path override is active
///
- bool HasMcpServerOverride { get; }
-
- ///
- /// Gets whether a UV path override is active
- ///
- bool HasUvPathOverride { get; }
-
+ bool HasUvxPathOverride { get; }
+
///
/// Gets whether a Claude CLI path override is active
///
diff --git a/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs b/MCPForUnity/Editor/Services/IPythonToolRegistryService.cs
deleted file mode 100644
index dde40d1..0000000
--- 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 3f4835f..0000000
--- 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 0000000..54c7b9c
--- /dev/null
+++ b/MCPForUnity/Editor/Services/IServerManagementService.cs
@@ -0,0 +1,46 @@
+namespace MCPForUnity.Editor.Services
+{
+ ///
+ /// Interface for server management operations
+ ///
+ public interface IServerManagementService
+ {
+ ///
+ /// Clear the local uvx cache for the MCP server package
+ ///
+ /// True if successful, false otherwise
+ bool ClearUvxCache();
+
+ ///
+ /// Start the local HTTP server in a new terminal window.
+ /// Stops any existing server on the port and clears the uvx cache first.
+ ///
+ /// True if server was started successfully, false otherwise
+ bool StartLocalHttpServer();
+
+ ///
+ /// Stop the local HTTP server by finding the process listening on the configured port
+ ///
+ bool StopLocalHttpServer();
+
+ ///
+ /// 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 0000000..9f12dc3
--- /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 0000000..01ceb2b
--- /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 0000000..a25b749
--- /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 3a62fdf..0000000
--- 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 0282828..0000000
--- 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 a743d4c..d537182 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,22 @@ namespace MCPForUnity.Editor.Services
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 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 IServerManagementService Server => _serverManagementService ??= new ServerManagementService();
+ public static TransportManager TransportManager => _transportManager ??= new TransportManager();
///
/// Registers a custom implementation for a service (useful for testing)
@@ -38,16 +43,18 @@ namespace MCPForUnity.Editor.Services
_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 IServerManagementService sm)
+ _serverManagementService = sm;
+ else if (implementation is TransportManager tm)
+ _transportManager = tm;
}
///
@@ -58,20 +65,22 @@ namespace MCPForUnity.Editor.Services
(_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();
+ (_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;
+ _serverManagementService = null;
+ _transportManager = null;
}
}
}
diff --git a/MCPForUnity/Editor/Services/PackageUpdateService.cs b/MCPForUnity/Editor/Services/PackageUpdateService.cs
index 7a5bc9f..b4384d9 100644
--- a/MCPForUnity/Editor/Services/PackageUpdateService.cs
+++ b/MCPForUnity/Editor/Services/PackageUpdateService.cs
@@ -3,6 +3,7 @@ using System.Net;
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 083115f..4b6b07f 100644
--- a/MCPForUnity/Editor/Services/PathResolverService.cs
+++ b/MCPForUnity/Editor/Services/PathResolverService.cs
@@ -1,6 +1,9 @@
using System;
using System.Diagnostics;
using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
@@ -12,164 +15,107 @@ 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()
{
- // Check for override first
- string overridePath = EditorPrefs.GetString(PythonDirOverrideKey, null);
- if (!string.IsNullOrEmpty(overridePath) && File.Exists(Path.Combine(overridePath, "server.py")))
- {
- return overridePath;
- }
-
- // Fall back to automatic detection
- return McpPathResolver.FindPackagePythonDirectory(false);
- }
-
- public string GetUvPath()
- {
- // Check for override first
- string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null);
- if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath))
- {
- return overridePath;
- }
-
- // Fall back to automatic detection
try
{
- return ServerInstaller.FindUvPath();
+ string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
+ if (!string.IsNullOrEmpty(overridePath))
+ {
+ return overridePath;
+ }
}
catch
{
- return null;
+ // ignore EditorPrefs read errors and fall back to default command
+ McpLog.Debug("No uvx path override found, falling back to default command");
}
+
+ return "uvx";
}
public string GetClaudeCliPath()
{
- // Check for override first
- string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, 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 */ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ string[] candidates = new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"),
+ "claude.exe"
+ };
+
+ foreach (var c in candidates)
+ {
+ if (File.Exists(c)) return c;
+ }
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ string[] candidates = new[]
+ {
+ "/opt/homebrew/bin/claude",
+ "/usr/local/bin/claude",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude")
+ };
+
+ foreach (var c in candidates)
+ {
+ if (File.Exists(c)) return c;
+ }
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ string[] candidates = new[]
+ {
+ "/usr/bin/claude",
+ "/usr/local/bin/claude",
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "bin", "claude")
+ };
+
+ foreach (var c in candidates)
+ {
+ if (File.Exists(c)) return c;
+ }
}
- // Fall back to automatic detection
- return ExecPath.ResolveClaude();
+ return null;
}
public bool IsPythonDetected()
{
try
{
- // Windows-specific Python detection
- if (Application.platform == RuntimePlatform.WindowsEditor)
+ var psi = 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;
- }
-
- // 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))
- {
- string outp = p.StandardOutput.ReadToEnd().Trim();
- p.WaitForExit(2000);
- if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp))
- {
- string[] lines = outp.Split('\n');
- foreach (string line in lines)
- {
- string trimmed = line.Trim();
- if (File.Exists(trimmed)) return true;
- }
- }
- }
- }
- 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;
- }
- }
+ FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3",
+ Arguments = "--version",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ using var p = Process.Start(psi);
+ p.WaitForExit(2000);
+ return p.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
}
- catch { }
- return false;
- }
-
- public bool IsUvDetected()
- {
- return !string.IsNullOrEmpty(GetUvPath());
}
public bool IsClaudeCliDetected()
@@ -177,36 +123,20 @@ namespace MCPForUnity.Editor.Services
return !string.IsNullOrEmpty(GetClaudeCliPath());
}
- public void SetMcpServerOverride(string path)
+ public void SetUvxPathOverride(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)
- {
- 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 +152,17 @@ namespace MCPForUnity.Editor.Services
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);
+ EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path);
}
- public void ClearMcpServerOverride()
+ public void ClearUvxPathOverride()
{
- EditorPrefs.DeleteKey(PythonDirOverrideKey);
- }
-
- public void ClearUvPathOverride()
- {
- 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 1fab20c..0000000
--- 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 9fba1e9..0000000
--- 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 0000000..8a323ab
--- /dev/null
+++ b/MCPForUnity/Editor/Services/ServerManagementService.cs
@@ -0,0 +1,496 @@
+using System;
+using System.IO;
+using System.Linq;
+using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Data;
+using MCPForUnity.Editor.Helpers;
+using UnityEditor;
+using UnityEngine;
+
+namespace MCPForUnity.Editor.Services
+{
+ ///
+ /// Service for managing MCP server lifecycle
+ ///
+ public class ServerManagementService : IServerManagementService
+ {
+ ///
+ /// Clear the local uvx cache for the MCP server package
+ ///
+ /// True if successful, false otherwise
+ public bool ClearUvxCache()
+ {
+ try
+ {
+ string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
+ string uvCommand = uvxPath.Remove(uvxPath.Length - 1, 1);
+
+ // Get the package name
+ string packageName = "mcp-for-unity";
+
+ // Run uvx cache clean command
+ string args = $"cache clean {packageName}";
+
+ bool success;
+ string stdout;
+ string stderr;
+
+ success = ExecuteUvCommand(uvCommand, args, out stdout, out stderr);
+
+ if (success)
+ {
+ McpLog.Debug($"uv cache cleared successfully: {stdout}");
+ return true;
+ }
+ else
+ {
+ string errorMessage = string.IsNullOrEmpty(stderr)
+ ? "Unknown error"
+ : stderr;
+
+ McpLog.Error($"Failed to clear uv cache using '{uvCommand} {args}': {errorMessage}. Ensure uv is installed, available on PATH, or set an override in Advanced Settings.");
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Error clearing uv cache: {ex.Message}");
+ return false;
+ }
+ }
+
+ private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, out string stderr)
+ {
+ stdout = null;
+ stderr = null;
+
+ string uvxPath = MCPServiceLocator.Paths.GetUvxPath();
+ string uvPath = uvxPath.Remove(uvxPath.Length - 1, 1);
+
+ if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000);
+ }
+
+ string command = $"{uvPath} {args}";
+ string extraPathPrepend = GetPlatformSpecificPathPrepend();
+
+ if (Application.platform == RuntimePlatform.WindowsEditor)
+ {
+ return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
+ }
+
+ string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh";
+
+ if (!string.IsNullOrEmpty(shell) && File.Exists(shell))
+ {
+ string escaped = command.Replace("\"", "\\\"");
+ return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
+ }
+
+ return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend);
+ }
+
+ private string GetPlatformSpecificPathPrepend()
+ {
+ if (Application.platform == RuntimePlatform.OSXEditor)
+ {
+ return string.Join(Path.PathSeparator.ToString(), new[]
+ {
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/usr/bin",
+ "/bin"
+ });
+ }
+
+ if (Application.platform == RuntimePlatform.LinuxEditor)
+ {
+ return string.Join(Path.PathSeparator.ToString(), new[]
+ {
+ "/usr/local/bin",
+ "/usr/bin",
+ "/bin"
+ });
+ }
+
+ if (Application.platform == RuntimePlatform.WindowsEditor)
+ {
+ string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+
+ return string.Join(Path.PathSeparator.ToString(), new[]
+ {
+ !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null,
+ !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null
+ }.Where(p => !string.IsNullOrEmpty(p)).ToArray());
+ }
+
+ return null;
+ }
+
+ ///
+ /// Start the local HTTP server in a new terminal window.
+ /// Stops any existing server on the port and clears the uvx cache first.
+ ///
+ 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;
+ }
+
+ // First, try to stop any existing server
+ StopLocalHttpServer();
+
+ // Clear the cache to ensure we get a fresh version
+ try
+ {
+ ClearUvxCache();
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Failed to clear cache before starting server: {ex.Message}");
+ }
+
+ 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;
+ }
+
+ ///
+ /// Stop the local HTTP server by finding the process listening on the configured port
+ ///
+ public bool StopLocalHttpServer()
+ {
+ string httpUrl = HttpEndpointUtility.GetBaseUrl();
+ if (!IsLocalUrl(httpUrl))
+ {
+ McpLog.Warn("Cannot stop server: URL is not local.");
+ return false;
+ }
+
+ try
+ {
+ var uri = new Uri(httpUrl);
+ int port = uri.Port;
+
+ if (port <= 0)
+ {
+ McpLog.Warn("Cannot stop server: Invalid port.");
+ return false;
+ }
+
+ McpLog.Info($"Attempting to stop any process listening on local port {port}. This will terminate the owning process even if it is not the MCP server.");
+
+ int pid = GetProcessIdForPort(port);
+ if (pid > 0)
+ {
+ KillProcess(pid);
+ McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})");
+ return true;
+ }
+ else
+ {
+ McpLog.Info($"No process found listening on port {port}");
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Failed to stop server: {ex.Message}");
+ return false;
+ }
+ }
+
+ private int GetProcessIdForPort(int port)
+ {
+ try
+ {
+ string stdout, stderr;
+ bool success;
+
+ if (Application.platform == RuntimePlatform.WindowsEditor)
+ {
+ // netstat -ano | findstr :
+ success = ExecPath.TryRun("cmd.exe", $"/c netstat -ano | findstr :{port}", Application.dataPath, out stdout, out stderr);
+ if (success && !string.IsNullOrEmpty(stdout))
+ {
+ var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var line in lines)
+ {
+ if (line.Contains("LISTENING"))
+ {
+ var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length > 0 && int.TryParse(parts[parts.Length - 1], out int pid))
+ {
+ return pid;
+ }
+ }
+ }
+ }
+ }
+ else
+ {
+ // lsof -i : -t
+ // Use /usr/sbin/lsof directly as it might not be in PATH for Unity
+ string lsofPath = "/usr/sbin/lsof";
+ if (!System.IO.File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback
+
+ success = ExecPath.TryRun(lsofPath, $"-i :{port} -t", Application.dataPath, out stdout, out stderr);
+ if (success && !string.IsNullOrWhiteSpace(stdout))
+ {
+ var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var pidString in pidStrings)
+ {
+ if (int.TryParse(pidString.Trim(), out int pid))
+ {
+ if (pidStrings.Length > 1)
+ {
+ McpLog.Debug($"Multiple processes found on port {port}; attempting to stop PID {pid} returned by lsof -t.");
+ }
+
+ return pid;
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Warn($"Error checking port {port}: {ex.Message}");
+ }
+ return -1;
+ }
+
+ private void KillProcess(int pid)
+ {
+ try
+ {
+ string stdout, stderr;
+ if (Application.platform == RuntimePlatform.WindowsEditor)
+ {
+ ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr);
+ }
+ else
+ {
+ ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr);
+ }
+ }
+ catch (Exception ex)
+ {
+ McpLog.Error($"Error killing process {pid}: {ex.Message}");
+ }
+ }
+
+ ///
+ /// 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 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 (string.IsNullOrWhiteSpace(command))
+ throw new ArgumentException("Command cannot be empty", nameof(command));
+
+ command = command.Replace("\r", "").Replace("\n", "");
+
+#if UNITY_EDITOR_OSX
+ // macOS: Use osascript directly to avoid shell metacharacter injection via bash
+ // Escape for AppleScript: backslash and double quotes
+ string escapedCommand = command.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ return new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "/usr/bin/osascript",
+ Arguments = $"-e \"tell application \\\"Terminal\\\" to do script \\\"{escapedCommand}\\\" activate\"",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+#elif UNITY_EDITOR_WIN
+ // Windows: Use cmd.exe with start command to open new window
+ // Wrap in quotes for /k and escape internal quotes
+ string escapedCommandWin = command.Replace("\"", "\\\"");
+ return new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{escapedCommandWin}\"",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+#else
+ // Linux: Try common terminal emulators
+ // We use bash -c to execute the command, so we must properly quote/escape for bash
+ // Escape single quotes for the inner bash string
+ string escapedCommandLinux = command.Replace("'", "'\\''");
+ // Wrap the command in single quotes for bash -c
+ string script = $"'{escapedCommandLinux}; exec bash'";
+ // Escape double quotes for the outer Process argument string
+ string escapedScriptForArg = script.Replace("\"", "\\\"");
+ string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\"";
+
+ 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(5000); // Wait for up to 5 seconds, the command is typically instantaneous
+ 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 = $"-- {bashCmdArgs}";
+ }
+ else if (terminalCmd == "konsole")
+ {
+ args = $"-e {bashCmdArgs}";
+ }
+ else if (terminalCmd == "xfce4-terminal")
+ {
+ // xfce4-terminal expects -e "command string" or -e command arg
+ args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\"";
+ }
+ else // xterm and others
+ {
+ args = $"-hold -e {bashCmdArgs}";
+ }
+
+ 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 0000000..8b0fea0
--- /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 0000000..0f3406a
--- /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 0000000..46b7403
--- /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 bd17f99..0000000
--- 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 31db439..0000000
--- 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 fd2be78..58fe0d7 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 0000000..3d8584f
--- /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 0000000..2bdf095
--- /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 0000000..5490508
--- /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 0000000..494c010
--- /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 0000000..7a6afe9
--- /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(MCPServiceLocator.ToolDiscovery),
+ () => 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 0000000..7adde46
--- /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 0000000..7fb6f20
--- /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 0000000..5c592ce
--- /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 0000000..878b705
--- /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 23537b8..5cc1585 100644
--- a/MCPForUnity/Editor/MCPForUnityBridge.cs
+++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
@@ -11,17 +11,15 @@ using Newtonsoft.Json;
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
}
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 @@ namespace MCPForUnity.Editor
Start();
if (!isRunning)
{
- // If a race prevented start, retry later
ScheduleInitRetry();
}
}
@@ -220,28 +200,35 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
return;
}
- // Debounced start: wait until the scheduled time
if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
{
return;
@@ -263,7 +249,6 @@ namespace MCPForUnity.Editor
isStarting = true;
try
{
- // Attempt start; if it succeeds, remove the hook to avoid overhead
Start();
}
finally
@@ -277,7 +262,6 @@ namespace MCPForUnity.Editor
}
}
- // Helper to check compilation status across Unity versions
private static bool IsCompiling()
{
if (EditorApplication.isCompiling)
@@ -286,7 +270,7 @@ namespace MCPForUnity.Editor
}
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 @@ namespace MCPForUnity.Editor
{
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 @@ namespace MCPForUnity.Editor
}
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 @@ namespace MCPForUnity.Editor
}
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
lock (clientsLock) { activeClients.Add(client); }
try
{
- // Framed I/O only; legacy mode removed
try
{
if (IsDebugEnabled())
@@ -570,7 +547,6 @@ namespace MCPForUnity.Editor
}
}
catch { }
- // Strict framing: always require FRAMING=1 and frame all I/O
try
{
client.NoDelay = true;
@@ -584,21 +560,20 @@ namespace MCPForUnity.Editor
#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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
};
}
- // Wait for the handler to produce a response, but do not block indefinitely
string response;
try
{
@@ -643,13 +614,11 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
{
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 @@ namespace MCPForUnity.Editor
}
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 @@ namespace MCPForUnity.Editor
}
}
- // 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 @@ namespace MCPForUnity.Editor
? 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 @@ namespace MCPForUnity.Editor
#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 @@ namespace MCPForUnity.Editor
#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 @@ namespace MCPForUnity.Editor
{
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 @@ namespace MCPForUnity.Editor
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 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);
-
- 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
- {
- // 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));
- }
- }
- catch (Exception ex)
- {
- McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
-
- var response = new
+ var emptyResponse = new
{
status = "error",
- error = ex.Message,
- commandType = "Unknown (error during processing)",
- receivedText = commandText?.Length > 50
+ error = "Empty command received",
+ };
+ tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
+ lock (lockObj) { commandQueue.Remove(id); }
+ continue;
+ }
+
+ commandText = commandText.Trim();
+ if (commandText == "ping")
+ {
+ var pingResponse = new
+ {
+ status = "success",
+ result = new { message = "pong" },
+ };
+ tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
+ lock (lockObj) { commandQueue.Remove(id); }
+ continue;
+ }
+
+ if (!IsValidJson(commandText))
+ {
+ var invalidJsonResponse = new
+ {
+ status = "error",
+ 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 @@ namespace MCPForUnity.Editor
}
}
- // 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