Merge pull request #192 from dsarno/feat/bridge-stability

Harden MCP Bridge: reliable reloads, sticky port, embedded server, cleaner Auto‑Setup UI
main
dsarno 2025-08-11 11:46:01 -07:00 committed by GitHub
commit bbbc26a17f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 2386 additions and 702 deletions

View File

@ -46,16 +46,23 @@ Restores original files from backup.
## Finding Unity Package Cache Path
Unity package cache is typically located at:
Unity stores Git packages under a version-or-hash folder. Expect something like:
```
X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@1.0.0
X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@<version-or-hash>
```
Example (hash):
```
X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e
```
To find it:
To find it reliably:
1. Open Unity Package Manager
2. Select "Unity MCP" package
3. Right click on the package and "Show in Explorer"
4. Navigate to the path above with your username and version
3. Right click the package and choose "Show in Explorer"
4. That opens the exact cache folder Unity is using for your project
Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server.
## Workflow

View File

@ -118,17 +118,18 @@ Unity MCP connects your tools using two components:
Connect your MCP Client (Claude, Cursor, etc.) to the Python server you installed in Step 1.
<img width="609" alt="image" src="https://github.com/user-attachments/assets/cef3a639-4677-4fd8-84e7-2d82a04d55bb" />
<img width="648" height="599" alt="UnityMCP-Readme-Image" src="https://github.com/user-attachments/assets/b4a725da-5c43-4bd6-80d6-ee2e3cca9596" />
**Option A: Auto-Configure (Recommended for Claude/Cursor/VSC Copilot)**
**Option A: Auto-Setup (Recommended for Claude/Cursor/VSC Copilot)**
1. In Unity, go to `Window > Unity MCP`.
2. Click `Auto Configure` on the IDE you uses.
3. Look for a green status indicator 🟢 and "Connected". *(This attempts to modify the MCP Client\'s config file automatically)*.
2. Click `Auto-Setup`.
3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically)*.
**Option B: Manual Configuration**
If Auto-Configure fails or you use a different client:
If Auto-Setup fails or you use a different client:
1. **Find your MCP Client\'s configuration file.** (Check client documentation).
* *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json`

View File

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

View File

@ -9,6 +9,58 @@ namespace UnityMcpBridge.Editor.Data
{
public List<McpClient> clients = new()
{
// 1) Cursor
new()
{
name = "Cursor",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
"mcp.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
"mcp.json"
),
mcpType = McpTypes.Cursor,
configStatus = "Not Configured",
},
// 2) Claude Code
new()
{
name = "Claude Code",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
mcpType = McpTypes.ClaudeCode,
configStatus = "Not Configured",
},
// 3) Windsurf
new()
{
name = "Windsurf",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
"windsurf",
"mcp_config.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
"windsurf",
"mcp_config.json"
),
mcpType = McpTypes.Windsurf,
configStatus = "Not Configured",
},
// 4) Claude Desktop
new()
{
name = "Claude Desktop",
@ -27,36 +79,7 @@ namespace UnityMcpBridge.Editor.Data
mcpType = McpTypes.ClaudeDesktop,
configStatus = "Not Configured",
},
new()
{
name = "Claude Code",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
mcpType = McpTypes.ClaudeCode,
configStatus = "Not Configured",
},
new()
{
name = "Cursor",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
"mcp.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
"mcp.json"
),
mcpType = McpTypes.Cursor,
configStatus = "Not Configured",
},
// 5) VSCode GitHub Copilot
new()
{
name = "VSCode GitHub Copilot",
@ -77,24 +100,6 @@ namespace UnityMcpBridge.Editor.Data
mcpType = McpTypes.VSCode,
configStatus = "Not Configured",
},
new()
{
name = "Windsurf",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
"windsurf",
"mcp_config.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
"windsurf",
"mcp_config.json"
),
mcpType = McpTypes.Windsurf,
configStatus = "Not Configured",
},
};
// Initialize status enums after construction

View File

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

View File

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

View File

@ -0,0 +1,43 @@
using UnityEditor;
using UnityEngine;
namespace UnityMcpBridge.Editor.Helpers
{
/// <summary>
/// Handles automatic installation of the Python server when the package is first installed.
/// </summary>
[InitializeOnLoad]
public static class PackageInstaller
{
private const string InstallationFlagKey = "UnityMCP.ServerInstalled";
static PackageInstaller()
{
// Check if this is the first time the package is loaded
if (!EditorPrefs.GetBool(InstallationFlagKey, false))
{
// Schedule the installation for after Unity is fully loaded
EditorApplication.delayCall += InstallServerOnFirstLoad;
}
}
private static void InstallServerOnFirstLoad()
{
try
{
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Installing Python server...");
ServerInstaller.EnsureServerInstalled();
// Mark as installed
EditorPrefs.SetBool(InstallationFlagKey, true);
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Python server installation completed successfully.");
}
catch (System.Exception ex)
{
Debug.LogError($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Failed to install Python server: {ex.Message}");
Debug.LogWarning("<b><color=#2EA3FF>UNITY-MCP</color></b>: You may need to manually install the Python server. Check the Unity MCP Editor Window for instructions.");
}
}
}
}

View File

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

View File

@ -1,7 +1,11 @@
using System;
using System.IO;
using UnityEditor;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using UnityEngine;
@ -12,6 +16,12 @@ namespace UnityMcpBridge.Editor.Helpers
/// </summary>
public static class PortManager
{
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); }
catch { return false; }
}
private const int DefaultPort = 6400;
private const int MaxPortAttempts = 100;
private const string RegistryFileName = "unity-mcp-port.json";
@ -31,15 +41,30 @@ namespace UnityMcpBridge.Editor.Helpers
/// <returns>Port number to use</returns>
public static int GetPortWithFallback()
{
// Try to load stored port first
int storedPort = LoadStoredPort();
if (storedPort > 0 && IsPortAvailable(storedPort))
// 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))
{
Debug.Log($"Using stored port {storedPort}");
return storedPort;
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Using stored port {storedConfig.unity_port} for current project");
return storedConfig.unity_port;
}
// If no stored port or stored port is unavailable, find a new one
// 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($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Stored port {storedConfig.unity_port} became available after short wait");
return storedConfig.unity_port;
}
// Prefer sticking to the same port; let the caller handle bind retries/fallbacks
return storedConfig.unity_port;
}
// If no valid stored port, find a new one and save it
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
@ -53,7 +78,7 @@ namespace UnityMcpBridge.Editor.Helpers
{
int newPort = FindAvailablePort();
SavePort(newPort);
Debug.Log($"Discovered and saved new port: {newPort}");
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Discovered and saved new port: {newPort}");
return newPort;
}
@ -66,18 +91,18 @@ namespace UnityMcpBridge.Editor.Helpers
// Always try default port first
if (IsPortAvailable(DefaultPort))
{
Debug.Log($"Using default port {DefaultPort}");
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Using default port {DefaultPort}");
return DefaultPort;
}
Debug.Log($"Default port {DefaultPort} is in use, searching for alternative...");
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Default port {DefaultPort} is in use, searching for alternative...");
// Search for alternatives
for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++)
{
if (IsPortAvailable(port))
{
Debug.Log($"Found available port {port}");
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Found available port {port}");
return port;
}
}
@ -86,7 +111,7 @@ namespace UnityMcpBridge.Editor.Helpers
}
/// <summary>
/// Check if a specific port is available
/// Check if a specific port is available for binding
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port is available</returns>
@ -105,6 +130,61 @@ namespace UnityMcpBridge.Editor.Helpers
}
}
/// <summary>
/// Check if a port is currently being used by Unity MCP Bridge
/// This helps avoid unnecessary port changes when Unity itself is using the port
/// </summary>
/// <param name="port">Port to check</param>
/// <returns>True if port appears to be used by Unity MCP</returns>
public static bool IsPortUsedByUnityMcp(int port)
{
try
{
// Try to make a quick connection to see if it's a Unity MCP server
using var client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
if (connectTask.Wait(100)) // 100ms timeout
{
// If connection succeeded, it's likely the Unity MCP server
return client.Connected;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Wait for a port to become available for a limited amount of time.
/// Used to bridge the gap during domain reload when the old listener
/// hasn't released the socket yet.
/// </summary>
private static bool WaitForPortRelease(int port, int timeoutMs)
{
int waited = 0;
const int step = 100;
while (waited < timeoutMs)
{
if (IsPortAvailable(port))
{
return true;
}
// If the port is in use by an MCP instance, continue waiting briefly
if (!IsPortUsedByUnityMcp(port))
{
// In use by something else; don't keep waiting
return false;
}
Thread.Sleep(step);
waited += step;
}
return IsPortAvailable(port);
}
/// <summary>
/// Save port to persistent storage
/// </summary>
@ -123,11 +203,15 @@ namespace UnityMcpBridge.Editor.Helpers
string registryDir = GetRegistryDirectory();
Directory.CreateDirectory(registryDir);
string registryFile = Path.Combine(registryDir, RegistryFileName);
string registryFile = GetRegistryFilePath();
string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented);
// Write to hashed, project-scoped file
File.WriteAllText(registryFile, json);
// Also write to legacy stable filename to avoid hash/case drift across reloads
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
File.WriteAllText(legacy, json);
Debug.Log($"Saved port {port} to storage");
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Saved port {port} to storage");
}
catch (Exception ex)
{
@ -143,12 +227,18 @@ namespace UnityMcpBridge.Editor.Helpers
{
try
{
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file name
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return 0;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
var portConfig = JsonConvert.DeserializeObject<PortConfig>(json);
@ -170,12 +260,18 @@ namespace UnityMcpBridge.Editor.Helpers
{
try
{
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
string registryFile = GetRegistryFilePath();
if (!File.Exists(registryFile))
{
// Backwards compatibility: try the legacy file
string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName);
if (!File.Exists(legacy))
{
return null;
}
registryFile = legacy;
}
string json = File.ReadAllText(registryFile);
return JsonConvert.DeserializeObject<PortConfig>(json);
@ -191,5 +287,33 @@ namespace UnityMcpBridge.Editor.Helpers
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
private static string GetRegistryFilePath()
{
string dir = GetRegistryDirectory();
string hash = ComputeProjectHash(Application.dataPath);
string fileName = $"unity-mcp-port-{hash}.json";
return Path.Combine(dir, fileName);
}
private static string ComputeProjectHash(string input)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8]; // short, sufficient for filenames
}
catch
{
return "default";
}
}
}
}

View File

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

View File

@ -1,8 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using UnityEditor;
using UnityEngine;
namespace UnityMcpBridge.Editor.Helpers
@ -11,37 +10,35 @@ namespace UnityMcpBridge.Editor.Helpers
{
private const string RootFolder = "UnityMCP";
private const string ServerFolder = "UnityMcpServer";
private const string BranchName = "main";
private const string GitUrl = "https://github.com/CoplayDev/unity-mcp.git";
private const string PyprojectUrl =
"https://raw.githubusercontent.com/CoplayDev/unity-mcp/refs/heads/"
+ BranchName
+ "/UnityMcpServer/src/pyproject.toml";
/// <summary>
/// Ensures the unity-mcp-server is installed and up to date.
/// Ensures the unity-mcp-server is installed locally by copying from the embedded package source.
/// No network calls or Git operations are performed.
/// </summary>
public static void EnsureServerInstalled()
{
try
{
string saveLocation = GetSaveLocation();
string destRoot = Path.Combine(saveLocation, ServerFolder);
string destSrc = Path.Combine(destRoot, "src");
if (!IsServerInstalled(saveLocation))
if (File.Exists(Path.Combine(destSrc, "server.py")))
{
InstallServer(saveLocation);
return; // Already installed
}
else
{
string installedVersion = GetInstalledVersion();
string latestVersion = GetLatestVersion();
if (IsNewerVersion(latestVersion, installedVersion))
if (!TryGetEmbeddedServerSource(out string embeddedSrc))
{
UpdateServer(saveLocation);
}
else { }
throw new Exception("Could not find embedded UnityMcpServer/src in the package.");
}
// Ensure destination exists
Directory.CreateDirectory(destRoot);
// Copy the entire UnityMcpServer folder (parent of src)
string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
CopyDirectoryRecursive(embeddedRoot, destRoot);
}
catch (Exception ex)
{
@ -79,14 +76,11 @@ namespace UnityMcpBridge.Editor.Helpers
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
string path = "/usr/local/bin";
return !Directory.Exists(path) || !IsDirectoryWritable(path)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Applications",
RootFolder
)
: Path.Combine(path, RootFolder);
// Use Application Support for a stable, user-writable location
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"UnityMCP"
);
}
throw new Exception("Unsupported operating system.");
}
@ -111,140 +105,372 @@ namespace UnityMcpBridge.Editor.Helpers
private static bool IsServerInstalled(string location)
{
return Directory.Exists(location)
&& File.Exists(Path.Combine(location, ServerFolder, "src", "pyproject.toml"));
&& File.Exists(Path.Combine(location, ServerFolder, "src", "server.py"));
}
/// <summary>
/// Installs the server by cloning only the UnityMcpServer folder from the repository and setting up dependencies.
/// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package
/// or common development locations.
/// </summary>
private static void InstallServer(string location)
private static bool TryGetEmbeddedServerSource(out string srcPath)
{
// Create the src directory where the server code will reside
Directory.CreateDirectory(location);
// Initialize git repo in the src directory
RunCommand("git", $"init", workingDirectory: location);
// Add remote
RunCommand("git", $"remote add origin {GitUrl}", workingDirectory: location);
// Configure sparse checkout
RunCommand("git", "config core.sparseCheckout true", workingDirectory: location);
// Set sparse checkout path to only include UnityMcpServer folder
string sparseCheckoutPath = Path.Combine(location, ".git", "info", "sparse-checkout");
File.WriteAllText(sparseCheckoutPath, $"{ServerFolder}/");
// Fetch and checkout the branch
RunCommand("git", $"fetch --depth=1 origin {BranchName}", workingDirectory: location);
RunCommand("git", $"checkout {BranchName}", workingDirectory: location);
// 1) Development mode: common repo layouts
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 */ }
/// <summary>
/// Fetches the currently installed version from the local pyproject.toml file.
/// </summary>
public static string GetInstalledVersion()
// 2) Installed package: resolve via Package Manager
// 2) Installed package: resolve via Package Manager (support new + legacy IDs, warn on legacy)
try
{
var list = UnityEditor.PackageManager.Client.List();
while (!list.IsCompleted) { }
if (list.Status == UnityEditor.PackageManager.StatusCode.Success)
{
string pyprojectPath = Path.Combine(
GetSaveLocation(),
ServerFolder,
"src",
"pyproject.toml"
const string CurrentId = "com.coplaydev.unity-mcp";
const string LegacyId = "com.justinpbarnett.unity-mcp";
foreach (var pkg in list.Result)
{
if (pkg.name == CurrentId || pkg.name == LegacyId)
{
if (pkg.name == LegacyId)
{
Debug.LogWarning(
"UnityMCP: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " +
"Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage."
);
return ParseVersionFromPyproject(File.ReadAllText(pyprojectPath));
}
/// <summary>
/// Fetches the latest version from the GitHub pyproject.toml file.
/// </summary>
public static string GetLatestVersion()
{
using WebClient webClient = new();
string pyprojectContent = webClient.DownloadString(PyprojectUrl);
return ParseVersionFromPyproject(pyprojectContent);
}
string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path
/// <summary>
/// Updates the server by pulling the latest changes for the UnityMcpServer folder only.
/// </summary>
private static void UpdateServer(string location)
{
RunCommand("git", $"pull origin {BranchName}", workingDirectory: location);
}
/// <summary>
/// Parses the version number from pyproject.toml content.
/// </summary>
private static string ParseVersionFromPyproject(string content)
{
foreach (string line in content.Split('\n'))
{
if (line.Trim().StartsWith("version ="))
{
string[] parts = line.Split('=');
if (parts.Length == 2)
{
return parts[1].Trim().Trim('"');
}
}
}
throw new Exception("Version not found in pyproject.toml");
}
/// <summary>
/// Compares two version strings to determine if the latest is newer.
/// </summary>
public static bool IsNewerVersion(string latest, string installed)
{
int[] latestParts = latest.Split('.').Select(int.Parse).ToArray();
int[] installedParts = installed.Split('.').Select(int.Parse).ToArray();
for (int i = 0; i < Math.Min(latestParts.Length, installedParts.Length); i++)
{
if (latestParts[i] > installedParts[i])
// Preferred: tilde folder embedded alongside Editor/Runtime within the package
string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src");
if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py")))
{
srcPath = embeddedTilde;
return true;
}
if (latestParts[i] < installedParts[i])
// Fallback: legacy non-tilde folder name inside the package
string embedded = Path.Combine(packagePath, "UnityMcpServer", "src");
if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py")))
{
return false;
}
}
return latestParts.Length > installedParts.Length;
srcPath = embedded;
return true;
}
/// <summary>
/// Runs a command-line process and handles output/errors.
/// </summary>
private static void RunCommand(
string command,
string arguments,
string workingDirectory = null
)
// Legacy: sibling of the package folder (dev-linked). Only valid when present on disk.
string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src");
if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py")))
{
System.Diagnostics.Process process = new()
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory ?? string.Empty,
},
};
process.Start();
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new Exception(
$"Command failed: {command} {arguments}\nOutput: {output}\nError: {error}"
);
srcPath = sibling;
return true;
}
}
}
}
}
catch { /* ignore */ }
// 3) Fallback to previous common install locations
try
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
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 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);
string destSubDir = Path.Combine(destinationDir, dirName);
CopyDirectoryRecursive(dirPath, destSubDir);
}
}
public static bool RepairPythonEnvironment()
{
try
{
string serverSrc = GetServerPath();
bool hasServer = File.Exists(Path.Combine(serverSrc, "server.py"));
if (!hasServer)
{
// In dev mode or if not installed yet, try the embedded/dev source
if (TryGetEmbeddedServerSource(out string embeddedSrc) && File.Exists(Path.Combine(embeddedSrc, "server.py")))
{
serverSrc = embeddedSrc;
hasServer = true;
}
else
{
// Attempt to install then retry
EnsureServerInstalled();
serverSrc = GetServerPath();
hasServer = File.Exists(Path.Combine(serverSrc, "server.py"));
}
}
if (!hasServer)
{
Debug.LogWarning("RepairPythonEnvironment: server.py not found; ensure server is installed first.");
return false;
}
// Remove stale venv and pinned version file if present
string venvPath = Path.Combine(serverSrc, ".venv");
if (Directory.Exists(venvPath))
{
try { Directory.Delete(venvPath, recursive: true); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .venv: {ex.Message}"); }
}
string pyPin = Path.Combine(serverSrc, ".python-version");
if (File.Exists(pyPin))
{
try { File.Delete(pyPin); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .python-version: {ex.Message}"); }
}
string uvPath = FindUvPath();
if (uvPath == null)
{
Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." );
return false;
}
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = uvPath,
Arguments = "sync",
WorkingDirectory = serverSrc,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var p = System.Diagnostics.Process.Start(psi);
string stdout = p.StandardOutput.ReadToEnd();
string stderr = p.StandardError.ReadToEnd();
p.WaitForExit(60000);
if (p.ExitCode != 0)
{
Debug.LogError($"uv sync failed: {stderr}\n{stdout}");
return false;
}
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: Python environment repaired successfully.");
return true;
}
catch (Exception ex)
{
Debug.LogError($"RepairPythonEnvironment failed: {ex.Message}");
return false;
}
}
private static string FindUvPath()
{
// Allow user override via EditorPrefs
try
{
string overridePath = EditorPrefs.GetString("UnityMCP.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))
{
candidates = new[]
{
// Common per-user installs
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python313\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python312\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python311\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty, @"Programs\Python\Python310\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python313\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python312\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python311\Scripts\uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty, @"Python\Python310\Scripts\uv.exe"),
// Program Files style installs (if a native installer was used)
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty, @"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.13/bin/uv",
"/Library/Frameworks/Python.framework/Versions/3.12/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
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var wherePsi = new System.Diagnostics.ProcessStartInfo
{
FileName = "where",
Arguments = "uv.exe",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var wp = System.Diagnostics.Process.Start(wherePsi);
string output = wp.StandardOutput.ReadToEnd().Trim();
wp.WaitForExit(3000);
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;
}
}
}
else
{
var whichPsi = new System.Diagnostics.ProcessStartInfo
{
FileName = "/usr/bin/which",
Arguments = "uv",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var wp = System.Diagnostics.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 System.Diagnostics.ProcessStartInfo
{
FileName = uvPath,
Arguments = "--version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var p = System.Diagnostics.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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -21,6 +22,13 @@ namespace UnityMcpBridge.Editor
private static TcpListener listener;
private static bool isRunning = false;
private static readonly object lockObj = new();
private static readonly object startStopLock = new();
private static bool initScheduled = false;
private static bool ensureUpdateHooked = false;
private static bool isStarting = false;
private static double nextStartAt = 0.0f;
private static double nextHeartbeatAt = 0.0f;
private static int heartbeatSeq = 0;
private static Dictionary<
string,
(string commandJson, TaskCompletionSource<string> tcs)
@ -28,6 +36,20 @@ namespace UnityMcpBridge.Editor
private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false;
// Debug helpers
private static bool IsDebugEnabled()
{
try { return EditorPrefs.GetBool("UnityMCP.DebugLogs", false); } catch { return false; }
}
private static void LogBreadcrumb(string stage)
{
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: [{stage}]");
}
}
public static bool IsRunning => isRunning;
public static int GetCurrentPort() => currentUnityPort;
public static bool IsAutoConnectMode() => isAutoConnectMode;
@ -41,17 +63,10 @@ namespace UnityMcpBridge.Editor
try
{
// Discover new port and save it
currentUnityPort = PortManager.DiscoverNewPort();
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Start();
isRunning = true;
// Prefer stored project port and start using the robust Start() path (with retries/options)
currentUnityPort = PortManager.GetPortWithFallback();
Start();
isAutoConnectMode = true;
Debug.Log($"UnityMcpBridge auto-connected on port {currentUnityPort}");
Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands;
}
catch (Exception ex)
{
@ -81,51 +96,229 @@ namespace UnityMcpBridge.Editor
static UnityMcpBridge()
{
Start();
// Skip bridge in headless/batch environments (CI/builds)
if (Application.isBatchMode)
{
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)
{
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();
}
public static void Start()
/// <summary>
/// Initialize the MCP bridge after Unity is fully loaded and compilation is complete.
/// This prevents repeated restarts during script compilation that cause port hopping.
/// </summary>
private static void InitializeAfterCompilation()
{
Stop();
initScheduled = false;
try
// Play-mode friendly: allow starting in play mode; only defer while compiling
if (IsCompiling())
{
ServerInstaller.EnsureServerInstalled();
}
catch (Exception ex)
{
Debug.LogError($"Failed to ensure UnityMcpServer is installed: {ex.Message}");
ScheduleInitRetry();
return;
}
if (isRunning)
if (!isRunning)
{
Start();
if (!isRunning)
{
// If a race prevented start, retry later
ScheduleInitRetry();
}
}
}
private static void ScheduleInitRetry()
{
if (initScheduled)
{
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 void EnsureStartedOnEditorIdle()
{
// Do nothing while compiling
if (IsCompiling())
{
return;
}
// If already running, remove the hook
if (isRunning)
{
EditorApplication.update -= EnsureStartedOnEditorIdle;
ensureUpdateHooked = false;
return;
}
// Debounced start: wait until the scheduled time
if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
{
return;
}
if (isStarting)
{
return;
}
isStarting = true;
// Attempt start; if it succeeds, remove the hook to avoid overhead
Start();
isStarting = false;
if (isRunning)
{
EditorApplication.update -= EnsureStartedOnEditorIdle;
ensureUpdateHooked = false;
}
}
// Helper to check compilation status across Unity versions
private static bool IsCompiling()
{
if (EditorApplication.isCompiling)
{
return true;
}
try
{
// Use PortManager to get available port with automatic fallback
System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop != null)
{
return (bool)prop.GetValue(null);
}
}
catch { }
return false;
}
public static void Start()
{
lock (startStopLock)
{
// Don't restart if already running on a working port
if (isRunning && listener != null)
{
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: UnityMcpBridge 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;
const int retrySleepMs = 75;
int attempt = 0;
for (;;)
{
try
{
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Server.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress,
true
);
#if UNITY_EDITOR_WIN
try
{
listener.ExclusiveAddressUse = false;
}
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;
}
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries)
{
attempt++;
Thread.Sleep(retrySleepMs);
continue;
}
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
{
currentUnityPort = PortManager.GetPortWithFallback();
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Server.SetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress,
true
);
#if UNITY_EDITOR_WIN
try
{
listener.ExclusiveAddressUse = false;
}
catch { }
#endif
try
{
listener.Server.LingerState = new LingerOption(true, 0);
}
catch (Exception)
{
}
listener.Start();
break;
}
}
isRunning = true;
isAutoConnectMode = false; // Normal startup mode
Debug.Log($"UnityMcpBridge started on port {currentUnityPort}.");
// Assuming ListenerLoop and ProcessCommands are defined elsewhere
isAutoConnectMode = false;
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: UnityMcpBridge started on port {currentUnityPort}.");
Task.Run(ListenerLoop);
EditorApplication.update += ProcessCommands;
// Write initial heartbeat immediately
heartbeatSeq++;
WriteHeartbeat(false, "ready");
nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f;
}
catch (SocketException ex)
{
if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
{
Debug.LogError(
$"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation."
);
}
else
{
Debug.LogError($"Failed to start TCP listener: {ex.Message}");
}
@ -133,6 +326,8 @@ namespace UnityMcpBridge.Editor
}
public static void Stop()
{
lock (startStopLock)
{
if (!isRunning)
{
@ -141,17 +336,21 @@ namespace UnityMcpBridge.Editor
try
{
// Mark as stopping early to avoid accept logging during disposal
isRunning = false;
// Mark heartbeat one last time before stopping
WriteHeartbeat(false);
listener?.Stop();
listener = null;
isRunning = false;
EditorApplication.update -= ProcessCommands;
Debug.Log("UnityMcpBridge stopped.");
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: UnityMcpBridge stopped.");
}
catch (Exception ex)
{
Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}");
}
}
}
private static async Task ListenerLoop()
{
@ -173,6 +372,14 @@ namespace UnityMcpBridge.Editor
// Fire and forget each client connection
_ = HandleClientAsync(client);
}
catch (ObjectDisposedException)
{
// Listener was disposed during stop/reload; exit quietly
if (!isRunning)
{
break;
}
}
catch (Exception ex)
{
if (isRunning)
@ -242,6 +449,14 @@ namespace UnityMcpBridge.Editor
List<string> processedIds = new();
lock (lockObj)
{
// Periodic heartbeat while editor is idle/processing
double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt)
{
WriteHeartbeat(false);
nextHeartbeatAt = now + 0.5f;
}
foreach (
KeyValuePair<
string,
@ -469,5 +684,67 @@ namespace UnityMcpBridge.Editor
return "Could not summarize parameters";
}
}
// Heartbeat/status helpers
private static void OnBeforeAssemblyReload()
{
// Stop cleanly before reload so sockets close and clients see 'reloading'
try { Stop(); } catch { }
WriteHeartbeat(true, "reloading");
LogBreadcrumb("Reload");
}
private static void OnAfterAssemblyReload()
{
// Will be overwritten by Start(), but mark as alive quickly
WriteHeartbeat(false, "idle");
LogBreadcrumb("Idle");
// Schedule a safe restart after reload to avoid races during compilation
ScheduleInitRetry();
}
private static void WriteHeartbeat(bool reloading, string reason = null)
{
try
{
string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
var payload = new
{
unity_port = currentUnityPort,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
project_path = Application.dataPath,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(filePath, JsonConvert.SerializeObject(payload));
}
catch (Exception)
{
// Best-effort only
}
}
private static string ComputeProjectHash(string input)
{
try
{
using var sha1 = System.Security.Cryptography.SHA1.Create();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hashBytes = sha1.ComputeHash(bytes);
var sb = new System.Text.StringBuilder();
foreach (byte b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString()[..8];
}
catch
{
return "default";
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Net.Sockets;
using System.Net;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
@ -21,6 +25,11 @@ namespace UnityMcpBridge.Editor.Windows
private Color pythonServerInstallationStatusColor = Color.red;
private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server)
private readonly McpClients mcpClients = new();
private bool autoRegisterEnabled;
private bool lastClientRegisteredOk;
private bool lastBridgeVerifiedOk;
private string pythonDirOverride = null;
private bool debugLogsEnabled;
// Script validation settings
private int validationLevelIndex = 1; // Default to Standard
@ -47,6 +56,8 @@ namespace UnityMcpBridge.Editor.Windows
// Refresh bridge status
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
autoRegisterEnabled = EditorPrefs.GetBool("UnityMCP.AutoRegisterEnabled", true);
debugLogsEnabled = EditorPrefs.GetBool("UnityMCP.DebugLogs", false);
foreach (McpClient mcpClient in mcpClients.clients)
{
CheckMcpConfiguration(mcpClient);
@ -54,10 +65,18 @@ namespace UnityMcpBridge.Editor.Windows
// Load validation level setting
LoadValidationLevelSetting();
// First-run auto-setup (register client(s) and ensure bridge is listening)
if (autoRegisterEnabled)
{
AutoFirstRunSetup();
}
}
private void OnFocus()
{
// Refresh bridge running state on focus in case initialization completed after domain reload
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count)
{
McpClient selectedClient = mcpClients.clients[selectedClientIndex];
@ -83,30 +102,37 @@ namespace UnityMcpBridge.Editor.Windows
private void UpdatePythonServerInstallationStatus()
{
string serverPath = ServerInstaller.GetServerPath();
if (File.Exists(Path.Combine(serverPath, "server.py")))
try
{
string installedVersion = ServerInstaller.GetInstalledVersion();
string latestVersion = ServerInstaller.GetLatestVersion();
if (ServerInstaller.IsNewerVersion(latestVersion, installedVersion))
string installedPath = ServerInstaller.GetServerPath();
bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py"));
if (installedOk)
{
pythonServerInstallationStatus = "Newer Version Available";
pythonServerInstallationStatusColor = Color.yellow;
pythonServerInstallationStatus = "Installed";
pythonServerInstallationStatusColor = Color.green;
return;
}
else
// Fall back to embedded/dev source via our existing resolution logic
string embeddedPath = FindPackagePythonDirectory();
bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py"));
if (embeddedOk)
{
pythonServerInstallationStatus = "Up to Date";
pythonServerInstallationStatus = "Installed (Embedded)";
pythonServerInstallationStatusColor = Color.green;
}
}
else
{
pythonServerInstallationStatus = "Not Installed";
pythonServerInstallationStatusColor = Color.red;
}
}
catch
{
pythonServerInstallationStatus = "Not Installed";
pythonServerInstallationStatusColor = Color.red;
}
}
private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12)
@ -142,27 +168,50 @@ namespace UnityMcpBridge.Editor.Windows
// Header
DrawHeader();
// Main sections in a more compact layout
EditorGUILayout.BeginHorizontal();
// Compute equal column widths for uniform layout
float horizontalSpacing = 2f;
float outerPadding = 20f; // approximate padding
// Make columns a bit less wide for a tighter layout
float computed = (position.width - outerPadding - horizontalSpacing) / 2f;
float colWidth = Mathf.Clamp(computed, 220f, 340f);
// Use fixed heights per row so paired panels match exactly
float topPanelHeight = 190f;
float bottomPanelHeight = 230f;
// Left column - Status and Bridge
EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.5f));
// Top row: Server Status (left) and Unity Bridge (right)
EditorGUILayout.BeginHorizontal();
{
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight));
DrawServerStatusSection();
EditorGUILayout.Space(5);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(horizontalSpacing);
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight));
DrawBridgeSection();
EditorGUILayout.EndVertical();
// Right column - Validation Settings
EditorGUILayout.BeginVertical();
DrawValidationSection();
EditorGUILayout.EndVertical();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(10);
// Unified MCP Client Configuration
// Second row: MCP Client Configuration (left) and Script Validation (right)
EditorGUILayout.BeginHorizontal();
{
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight));
DrawUnifiedClientConfiguration();
EditorGUILayout.EndVertical();
EditorGUILayout.Space(horizontalSpacing);
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight));
DrawValidationSection();
EditorGUILayout.EndVertical();
}
EditorGUILayout.EndHorizontal();
// Minimal bottom padding
EditorGUILayout.Space(2);
EditorGUILayout.EndScrollView();
}
@ -184,6 +233,16 @@ namespace UnityMcpBridge.Editor.Windows
"Unity MCP Editor",
titleStyle
);
// Place the Show Debug Logs toggle on the same header row, right-aligned
float toggleWidth = 160f;
Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f);
bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs");
if (newDebug != debugLogsEnabled)
{
debugLogsEnabled = newDebug;
EditorPrefs.SetBool("UnityMCP.DebugLogs", debugLogsEnabled);
}
EditorGUILayout.Space(15);
}
@ -212,35 +271,13 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.Space(5);
// Connection mode and Auto-Connect button
EditorGUILayout.BeginHorizontal();
bool isAutoMode = UnityMcpBridge.IsAutoConnectMode();
GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 };
EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle);
// Auto-Connect button
if (GUILayout.Button(isAutoMode ? "Connected ✓" : "Auto-Connect", GUILayout.Width(100), GUILayout.Height(24)))
{
if (!isAutoMode)
{
try
{
UnityMcpBridge.StartAutoConnect();
// Update UI state
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
Repaint();
}
catch (Exception ex)
{
EditorUtility.DisplayDialog("Auto-Connect Failed", ex.Message, "OK");
}
}
}
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
// Current ports display
int currentUnityPort = UnityMcpBridge.GetCurrentPort();
GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel)
{
@ -248,6 +285,82 @@ namespace UnityMcpBridge.Editor.Windows
};
EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle);
EditorGUILayout.Space(5);
/// Auto-Setup button below ports
string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup";
if (GUILayout.Button(setupButtonText, GUILayout.Height(24)))
{
RunSetupNow();
}
EditorGUILayout.Space(4);
// Repair Python Env button with tooltip tag
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
GUIContent repairLabel = new GUIContent(
"Repair Python Env",
"Deletes the server's .venv and runs 'uv sync' to rebuild a clean environment. Use this if modules are missing or Python upgraded."
);
if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22)))
{
bool ok = global::UnityMcpBridge.Editor.Helpers.ServerInstaller.RepairPythonEnvironment();
if (ok)
{
EditorUtility.DisplayDialog("Unity MCP", "Python environment repaired.", "OK");
UpdatePythonServerInstallationStatus();
}
else
{
EditorUtility.DisplayDialog("Unity MCP", "Repair failed. Please check Console for details.", "OK");
}
}
}
// (Removed descriptive tool tag under the Repair button)
// (Show Debug Logs toggle moved to header)
EditorGUILayout.Space(2);
// Python detection warning with link
if (!IsPythonDetected())
{
GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true };
EditorGUILayout.LabelField("<color=#cc3333><b>Warning:</b></color> No Python installation found.", warnStyle);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Open install instructions", GUILayout.Width(200)))
{
Application.OpenURL("https://www.python.org/downloads/");
}
}
EditorGUILayout.Space(4);
}
// Troubleshooting helpers
if (pythonServerInstallationStatusColor != Color.green)
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Select server folder…", GUILayout.Width(160)))
{
string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, "");
if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py")))
{
pythonDirOverride = picked;
EditorPrefs.SetString("UnityMCP.PythonDirOverride", pythonDirOverride);
UpdatePythonServerInstallationStatus();
}
else if (!string.IsNullOrEmpty(picked))
{
EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK");
}
}
if (GUILayout.Button("Verify again", GUILayout.Width(120)))
{
UpdatePythonServerInstallationStatus();
}
}
}
EditorGUILayout.EndVertical();
}
@ -255,6 +368,9 @@ namespace UnityMcpBridge.Editor.Windows
{
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
// Always reflect the live state each repaint to avoid stale UI after recompiles
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel)
{
fontSize = 14
@ -305,7 +421,9 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.Space(8);
string description = GetValidationLevelDescription(validationLevelIndex);
EditorGUILayout.HelpBox(description, MessageType.Info);
EditorGUILayout.Space(5);
EditorGUILayout.Space(4);
// (Show Debug Logs toggle moved to header)
EditorGUILayout.Space(2);
EditorGUILayout.EndVertical();
}
@ -320,6 +438,15 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle);
EditorGUILayout.Space(10);
// Auto-connect toggle (moved from Server Status)
bool newAuto = EditorGUILayout.ToggleLeft("Auto-connect to MCP Clients", autoRegisterEnabled);
if (newAuto != autoRegisterEnabled)
{
autoRegisterEnabled = newAuto;
EditorPrefs.SetBool("UnityMCP.AutoRegisterEnabled", autoRegisterEnabled);
}
EditorGUILayout.Space(6);
// Client selector
string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray();
EditorGUI.BeginChangeCheck();
@ -341,6 +468,222 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.EndVertical();
}
private void AutoFirstRunSetup()
{
try
{
// Project-scoped one-time flag
string projectPath = Application.dataPath ?? string.Empty;
string key = $"UnityMCP.AutoRegistered.{ComputeSha1(projectPath)}";
if (EditorPrefs.GetBool(key, false))
{
return;
}
// Attempt client registration using discovered Python server dir
pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null);
string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory();
if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py")))
{
bool anyRegistered = false;
foreach (McpClient client in mcpClients.clients)
{
try
{
if (client.mcpType == McpTypes.ClaudeCode)
{
if (!IsClaudeConfigured())
{
RegisterWithClaudeCode(pythonDir);
anyRegistered = true;
}
}
else
{
// For Cursor/others, skip if already configured
if (!IsCursorConfigured(pythonDir))
{
ConfigureMcpClient(client);
anyRegistered = true;
}
}
}
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}");
}
}
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured();
}
// Ensure the bridge is listening and has a fresh saved port
if (!UnityMcpBridge.IsRunning)
{
try
{
UnityMcpBridge.StartAutoConnect();
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
Repaint();
}
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}");
}
}
// Verify bridge with a quick ping
lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort());
EditorPrefs.SetBool(key, true);
}
catch (Exception e)
{
UnityEngine.Debug.LogWarning($"Unity MCP auto-setup skipped: {e.Message}");
}
}
private static string ComputeSha1(string input)
{
try
{
using SHA1 sha1 = SHA1.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty);
byte[] hash = sha1.ComputeHash(bytes);
StringBuilder sb = new StringBuilder(hash.Length * 2);
foreach (byte b in hash)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString();
}
catch
{
return "";
}
}
private void RunSetupNow()
{
// Force a one-shot setup regardless of first-run flag
try
{
pythonDirOverride ??= EditorPrefs.GetString("UnityMCP.PythonDirOverride", null);
string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory();
if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py")))
{
EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK");
return;
}
bool anyRegistered = false;
foreach (McpClient client in mcpClients.clients)
{
try
{
if (client.mcpType == McpTypes.ClaudeCode)
{
if (!IsClaudeConfigured())
{
RegisterWithClaudeCode(pythonDir);
anyRegistered = true;
}
}
else
{
if (!IsCursorConfigured(pythonDir))
{
ConfigureMcpClient(client);
anyRegistered = true;
}
}
}
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}");
}
}
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured();
// Restart/ensure bridge
UnityMcpBridge.StartAutoConnect();
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
// Verify
lastBridgeVerifiedOk = VerifyBridgePing(UnityMcpBridge.GetCurrentPort());
Repaint();
}
catch (Exception e)
{
EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK");
}
}
private static bool IsCursorConfigured(string pythonDir)
{
try
{
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor", "mcp.json")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor", "mcp.json");
if (!File.Exists(configPath)) return false;
string json = File.ReadAllText(configPath);
dynamic cfg = JsonConvert.DeserializeObject(json);
var servers = cfg?.mcpServers;
if (servers == null) return false;
var unity = servers.unityMCP ?? servers.UnityMCP;
if (unity == null) return false;
var args = unity.args;
if (args == null) return false;
foreach (var a in args)
{
string s = (string)a;
if (!string.IsNullOrEmpty(s) && s.Contains(pythonDir, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
catch { return false; }
}
private static bool IsClaudeConfigured()
{
try
{
string command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "claude" : "/usr/local/bin/claude";
var psi = new ProcessStartInfo { FileName = command, Arguments = "mcp list", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
using var p = Process.Start(psi);
string output = p.StandardOutput.ReadToEnd();
p.WaitForExit(3000);
if (p.ExitCode != 0) return false;
return output.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0;
}
catch { return false; }
}
private static bool VerifyBridgePing(int port)
{
try
{
using TcpClient c = new TcpClient();
var task = c.ConnectAsync(IPAddress.Loopback, port);
if (!task.Wait(500)) return false;
using NetworkStream s = c.GetStream();
byte[] ping = Encoding.UTF8.GetBytes("ping");
s.Write(ping, 0, ping.Length);
s.ReadTimeout = 1000;
byte[] buf = new byte[256];
int n = s.Read(buf, 0, buf.Length);
if (n <= 0) return false;
string resp = Encoding.UTF8.GetString(buf, 0, n);
return resp.Contains("pong", StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
private void DrawClientConfigurationCompact(McpClient mcpClient)
{
// Status display
@ -458,8 +801,9 @@ namespace UnityMcpBridge.Editor.Windows
{
UnityMcpBridge.Start();
}
isUnityBridgeRunning = !isUnityBridgeRunning;
// Reflect the actual state post-operation (avoid optimistic toggle)
isUnityBridgeRunning = UnityMcpBridge.IsRunning;
Repaint();
}
private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null)
@ -545,7 +889,10 @@ namespace UnityMcpBridge.Editor.Windows
return "Configured successfully";
}
private void ShowManualConfigurationInstructions(string configPath, McpClient mcpClient)
private void ShowManualConfigurationInstructions(
string configPath,
McpClient mcpClient
)
{
mcpClient.SetStatus(McpStatus.Error, "Manual configuration required");
@ -617,6 +964,29 @@ namespace UnityMcpBridge.Editor.Windows
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)
{
UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}");
}
return devPath;
}
}
}
// Try to find the package using Package Manager API
UnityEditor.PackageManager.Requests.ListRequest request =
UnityEditor.PackageManager.Client.List();
@ -630,7 +1000,14 @@ namespace UnityMcpBridge.Editor.Windows
{
string packagePath = package.resolvedPath;
// Check for local package structure (UnityMcpServer/src)
// Preferred: check for tilde folder inside package
string packagedTildeDir = Path.Combine(packagePath, "UnityMcpServer~", "src");
if (Directory.Exists(packagedTildeDir) && File.Exists(Path.Combine(packagedTildeDir, "server.py")))
{
return packagedTildeDir;
}
// Fallback: legacy local package structure (UnityMcpServer/src)
string localPythonDir = Path.Combine(Path.GetDirectoryName(packagePath), "UnityMcpServer", "src");
if (Directory.Exists(localPythonDir) && File.Exists(Path.Combine(localPythonDir, "server.py")))
{
@ -655,10 +1032,6 @@ namespace UnityMcpBridge.Editor.Windows
// Check for local development structure
string[] possibleDirs =
{
// Check in the Unity project's Packages folder (for local package development)
Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Packages", "unity-mcp", "UnityMcpServer", "src")),
// Check relative to the Unity project (for development)
Path.GetFullPath(Path.Combine(Application.dataPath, "..", "unity-mcp", "UnityMcpServer", "src")),
// Check in user's home directory (common installation location)
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "unity-mcp", "UnityMcpServer", "src"),
// Check in Applications folder (macOS/Linux common location)
@ -676,8 +1049,11 @@ namespace UnityMcpBridge.Editor.Windows
}
// If still not found, return the placeholder path
if (debugLogsEnabled)
{
UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path");
}
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Error finding package path: {e.Message}");
@ -686,6 +1062,35 @@ namespace UnityMcpBridge.Editor.Windows
return pythonDir;
}
private 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.justinpbarnett.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0)
{
int idx = manifestContent.IndexOf("com.justinpbarnett.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;
}
}
private string ConfigureMcpClient(McpClient mcpClient)
{
try
@ -712,8 +1117,8 @@ namespace UnityMcpBridge.Editor.Windows
// Create directory if it doesn't exist
Directory.CreateDirectory(Path.GetDirectoryName(configPath));
// Find the server.py file location
string pythonDir = ServerInstaller.GetServerPath();
// Find the server.py file location using the same logic as FindPackagePythonDirectory
string pythonDir = FindPackagePythonDirectory();
if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
{
@ -871,7 +1276,8 @@ namespace UnityMcpBridge.Editor.Windows
}
string configJson = File.ReadAllText(configPath);
string pythonDir = ServerInstaller.GetServerPath();
// Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode
string pythonDir = FindPackagePythonDirectory();
// Use switch statement to handle different client types, extracting common logic
string[] args = null;
@ -905,16 +1311,40 @@ namespace UnityMcpBridge.Editor.Windows
// Common logic for checking configuration status
if (configExists)
{
if (pythonDir != null &&
Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)))
bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal));
if (matches)
{
mcpClient.SetStatus(McpStatus.Configured);
}
else
{
// Attempt auto-rewrite once if the package path changed
try
{
string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient);
if (rewriteResult == "Configured successfully")
{
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"UnityMCP: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}");
}
mcpClient.SetStatus(McpStatus.Configured);
}
else
{
mcpClient.SetStatus(McpStatus.IncorrectPath);
}
}
catch (Exception ex)
{
mcpClient.SetStatus(McpStatus.IncorrectPath);
if (debugLogsEnabled)
{
UnityEngine.Debug.LogWarning($"UnityMCP: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}");
}
}
}
}
else
{
mcpClient.SetStatus(McpStatus.MissingConfig);
@ -1033,10 +1463,13 @@ namespace UnityMcpBridge.Editor.Windows
}
else if (!string.IsNullOrEmpty(errors))
{
if (debugLogsEnabled)
{
UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}");
}
}
}
catch (Exception e)
{
UnityEngine.Debug.LogError($"Claude CLI registration failed: {e.Message}");
@ -1521,7 +1954,10 @@ namespace UnityMcpBridge.Editor.Windows
? mcpClient.windowsConfigPath
: mcpClient.linuxConfigPath;
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"Checking Claude config at: {configPath}");
}
if (!File.Exists(configPath))
{
@ -1580,5 +2016,99 @@ namespace UnityMcpBridge.Editor.Windows
mcpClient.SetStatus(McpStatus.Error, e.Message);
}
}
private bool IsPythonDetected()
{
try
{
// Windows-specific Python detection
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Common Windows Python installation paths
string[] windowsCandidates =
{
@"C:\Python313\python.exe",
@"C:\Python312\python.exe",
@"C:\Python311\python.exe",
@"C:\Python310\python.exe",
@"C:\Python39\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.LocalApplicationData), @"Programs\Python\Python39\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"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\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 (existing code)
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.13/bin/python3",
"/Library/Frameworks/Python.framework/Versions/3.12/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;
}
}
catch { }
return false;
}
}
}

View File

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

View File

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

View File

@ -15,7 +15,7 @@ class ServerConfig:
mcp_port: int = 6500
# Connection settings
connection_timeout: float = 600.0 # 10 minutes timeout
connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Logging settings
@ -23,8 +23,13 @@ class ServerConfig:
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Server settings
max_retries: int = 3
retry_delay: float = 1.0
max_retries: int = 10
retry_delay: float = 0.25
# Backoff hint returned to clients when Unity is reloading (milliseconds)
reload_retry_ms: int = 250
# Number of polite retries when Unity reports reloading
# 40 × 250ms ≈ 10s default window
reload_max_retries: int = 40
# Create a global config instance
config = ServerConfig()

View File

@ -0,0 +1,155 @@
"""
Port discovery utility for Unity MCP Server.
What changed and why:
- Unity now writes a per-project port file named like
`~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting
each other's saved port. The legacy file `unity-mcp-port.json` may still
exist.
- This module now scans for both patterns, prefers the most recently
modified file, and verifies that the port is actually a Unity MCP listener
(quick socket connect + ping) before choosing it.
"""
import json
import os
import logging
from pathlib import Path
from typing import Optional, List
import glob
import socket
logger = logging.getLogger("unity-mcp-server")
class PortDiscovery:
"""Handles port discovery from Unity Bridge registry"""
REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file
DEFAULT_PORT = 6400
CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery
@staticmethod
def get_registry_path() -> Path:
"""Get the path to the port registry file"""
return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE
@staticmethod
def get_registry_dir() -> Path:
return Path.home() / ".unity-mcp"
@staticmethod
def list_candidate_files() -> List[Path]:
"""Return candidate registry files, newest first.
Includes hashed per-project files and the legacy file (if present).
"""
base = PortDiscovery.get_registry_dir()
hashed = sorted(
(Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
legacy = PortDiscovery.get_registry_path()
if legacy.exists():
# Put legacy at the end so hashed, per-project files win
hashed.append(legacy)
return hashed
@staticmethod
def _try_probe_unity_mcp(port: int) -> bool:
"""Quickly check if a Unity MCP listener is on this port.
Tries a short TCP connect, sends 'ping', expects a JSON 'pong'.
"""
try:
with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s:
s.settimeout(PortDiscovery.CONNECT_TIMEOUT)
try:
s.sendall(b"ping")
data = s.recv(512)
# Minimal validation: look for a success pong response
if data and b'"message":"pong"' in data:
return True
except Exception:
return False
except Exception:
return False
return False
@staticmethod
def _read_latest_status() -> Optional[dict]:
try:
base = PortDiscovery.get_registry_dir()
status_files = sorted(
(Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not status_files:
return None
with status_files[0].open('r') as f:
return json.load(f)
except Exception:
return None
@staticmethod
def discover_unity_port() -> int:
"""
Discover Unity port by scanning per-project and legacy registry files.
Prefer the newest file whose port responds; fall back to first parsed
value; finally default to 6400.
Returns:
Port number to connect to
"""
# Prefer the latest heartbeat status if it points to a responsive port
status = PortDiscovery._read_latest_status()
if status:
port = status.get('unity_port')
if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port):
logger.info(f"Using Unity port from status: {port}")
return port
candidates = PortDiscovery.list_candidate_files()
first_seen_port: Optional[int] = None
for path in candidates:
try:
with open(path, 'r') as f:
cfg = json.load(f)
unity_port = cfg.get('unity_port')
if isinstance(unity_port, int):
if first_seen_port is None:
first_seen_port = unity_port
if PortDiscovery._try_probe_unity_mcp(unity_port):
logger.info(f"Using Unity port from {path.name}: {unity_port}")
return unity_port
except Exception as e:
logger.warning(f"Could not read port registry {path}: {e}")
if first_seen_port is not None:
logger.info(f"No responsive port found; using first seen value {first_seen_port}")
return first_seen_port
# Fallback to default port
logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}")
return PortDiscovery.DEFAULT_PORT
@staticmethod
def get_port_config() -> Optional[dict]:
"""
Get the most relevant port configuration from registry.
Returns the most recent hashed file's config if present,
otherwise the legacy file's config. Returns None if nothing exists.
Returns:
Port configuration dict or None if not found
"""
candidates = PortDiscovery.list_candidate_files()
if not candidates:
return None
for path in candidates:
try:
with open(path, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Could not read port configuration {path}: {e}")
return None

View File

@ -3,7 +3,7 @@ name = "UnityMcpServer"
version = "2.0.0"
description = "Unity MCP Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"]
[build-system]

View File

@ -3,7 +3,9 @@ Defines the execute_menu_item tool for running Unity Editor menu commands.
"""
from typing import Dict, Any
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import get_unity_connection # Import unity_connection module
from unity_connection import get_unity_connection, send_command_with_retry # Import retry helper
from config import config
import time
def register_execute_menu_item_tools(mcp: FastMCP):
"""Registers the execute_menu_item tool with the MCP server."""
@ -42,10 +44,6 @@ def register_execute_menu_item_tools(mcp: FastMCP):
if "parameters" not in params_dict:
params_dict["parameters"] = {} # Ensure parameters dict exists
# Get Unity connection and send the command
# We use the unity_connection module to communicate with Unity
unity_conn = get_unity_connection()
# Send command to the ExecuteMenuItem C# handler
# The command type should match what the Unity side expects
return unity_conn.send_command("execute_menu_item", params_dict)
# Use centralized retry helper
resp = send_command_with_retry("execute_menu_item", params_dict)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}

View File

@ -5,7 +5,9 @@ import asyncio # Added: Import asyncio for running sync code in async
from typing import Dict, Any
from mcp.server.fastmcp import FastMCP, Context
# from ..unity_connection import get_unity_connection # Original line that caused error
from unity_connection import get_unity_connection # Use absolute import relative to Python dir
from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper
from config import config
import time
def register_manage_asset_tools(mcp: FastMCP):
"""Registers the manage_asset tool with the MCP server."""
@ -71,13 +73,7 @@ def register_manage_asset_tools(mcp: FastMCP):
# Get the Unity connection instance
connection = get_unity_connection()
# Run the synchronous send_command in the default executor (thread pool)
# This prevents blocking the main async event loop.
result = await loop.run_in_executor(
None, # Use default executor
connection.send_command, # The function to call
"manage_asset", # First argument for send_command
params_dict # Second argument for send_command
)
# Use centralized async retry helper to avoid blocking the event loop
result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity
return result
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -1,6 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context
import time
from typing import Dict, Any
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
def register_manage_editor_tools(mcp: FastMCP):
"""Register all editor management tools with the MCP server."""
@ -40,14 +42,13 @@ def register_manage_editor_tools(mcp: FastMCP):
}
params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity
response = get_unity_connection().send_command("manage_editor", params)
# Send command using centralized retry helper
response = send_command_with_retry("manage_editor", params)
# Process response
if response.get("success"):
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred during editor management.")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Python error managing editor: {str(e)}"}

View File

@ -1,6 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
def register_manage_gameobject_tools(mcp: FastMCP):
"""Register all GameObject management tools with the MCP server."""
@ -122,17 +124,14 @@ def register_manage_gameobject_tools(mcp: FastMCP):
params.pop("prefab_folder", None)
# --------------------------------
# Send the command to Unity via the established connection
# Use the get_unity_connection function to retrieve the active connection instance
# Changed "MANAGE_GAMEOBJECT" to "manage_gameobject" to potentially match Unity expectation
response = get_unity_connection().send_command("manage_gameobject", params)
# Use centralized retry helper
response = send_command_with_retry("manage_gameobject", params)
# Check if the response indicates success
# If the response is not successful, raise an exception with the error message
if response.get("success"):
if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred during GameObject management.")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Python error managing GameObject: {str(e)}"}

View File

@ -1,6 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
def register_manage_scene_tools(mcp: FastMCP):
"""Register all scene management tools with the MCP server."""
@ -34,14 +36,13 @@ def register_manage_scene_tools(mcp: FastMCP):
}
params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity
response = get_unity_connection().send_command("manage_scene", params)
# Use centralized retry helper
response = send_command_with_retry("manage_scene", params)
# Process response
if response.get("success"):
# Preserve structured failure data; unwrap success into a friendlier shape
if isinstance(response, dict) and response.get("success"):
return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred during scene management.")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Python error managing scene: {str(e)}"}

View File

@ -1,6 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
import os
import base64
@ -53,11 +55,11 @@ def register_manage_script_tools(mcp: FastMCP):
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity
response = get_unity_connection().send_command("manage_script", params)
# Send command via centralized retry helper
response = send_command_with_retry("manage_script", params)
# Process response from Unity
if response.get("success"):
if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
@ -66,8 +68,7 @@ def register_manage_script_tools(mcp: FastMCP):
del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred.")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
# Handle Python-side errors (e.g., connection issues)

View File

@ -1,6 +1,8 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
import os
import base64
@ -46,11 +48,11 @@ def register_manage_shader_tools(mcp: FastMCP):
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
# Send command to Unity
response = get_unity_connection().send_command("manage_shader", params)
# Send command via centralized retry helper
response = send_command_with_retry("manage_shader", params)
# Process response from Unity
if response.get("success"):
if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
@ -59,8 +61,7 @@ def register_manage_shader_tools(mcp: FastMCP):
del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
else:
return {"success": False, "message": response.get("error", "An unknown error occurred.")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
# Handle Python-side errors (e.g., connection issues)

View File

@ -2,8 +2,10 @@
Defines the read_console tool for accessing Unity Editor console messages.
"""
from typing import List, Dict, Any
import time
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import get_unity_connection
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
def register_read_console_tools(mcp: FastMCP):
"""Registers the read_console tool with the MCP server."""
@ -66,5 +68,6 @@ def register_read_console_tools(mcp: FastMCP):
if 'count' not in params_dict:
params_dict['count'] = None
# Forward the command using the bridge's send_command method
return bridge.send_command("read_console", params_dict)
# Use centralized retry helper
resp = send_command_with_retry("read_console", params_dict)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}

View File

@ -0,0 +1,331 @@
import socket
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import time
import random
import errno
from typing import Dict, Any
from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config
logging.basicConfig(
level=getattr(logging, config.log_level),
format=config.log_format
)
logger = logging.getLogger("unity-mcp-server")
@dataclass
class UnityConnection:
"""Manages the socket connection to the Unity Editor."""
host: str = config.unity_host
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
def __post_init__(self):
"""Set port from discovery if not explicitly provided"""
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
def connect(self) -> bool:
"""Establish a connection to the Unity Editor."""
if self.sock:
return True
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
logger.info(f"Connected to Unity at {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Unity: {str(e)}")
self.sock = None
return False
def disconnect(self):
"""Close the connection to the Unity Editor."""
if self.sock:
try:
self.sock.close()
except Exception as e:
logger.error(f"Error disconnecting from Unity: {str(e)}")
finally:
self.sock = None
def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
"""Receive a complete response from Unity, handling chunked data."""
chunks = []
sock.settimeout(config.connection_timeout) # Use timeout from config
try:
while True:
chunk = sock.recv(buffer_size)
if not chunk:
if not chunks:
raise Exception("Connection closed before receiving data")
break
chunks.append(chunk)
# Process the data received so far
data = b''.join(chunks)
decoded_data = data.decode('utf-8')
# Check if we've received a complete response
try:
# Special case for ping-pong
if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'):
logger.debug("Received ping response")
return data
# Handle escaped quotes in the content
if '"content":' in decoded_data:
# Find the content field and its value
content_start = decoded_data.find('"content":') + 9
content_end = decoded_data.rfind('"', content_start)
if content_end > content_start:
# Replace escaped quotes in content with regular quotes
content = decoded_data[content_start:content_end]
content = content.replace('\\"', '"')
decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:]
# Validate JSON format
json.loads(decoded_data)
# If we get here, we have valid JSON
logger.info(f"Received complete response ({len(data)} bytes)")
return data
except json.JSONDecodeError:
# We haven't received a complete valid JSON response yet
continue
except Exception as e:
logger.warning(f"Error processing response chunk: {str(e)}")
# Continue reading more chunks as this might not be the complete response
continue
except socket.timeout:
logger.warning("Socket timeout during receive")
raise Exception("Timeout receiving Unity response")
except Exception as e:
logger.error(f"Error during receive: {str(e)}")
raise
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
"""Send a command with retry/backoff and port rediscovery. Pings only when requested."""
# Defensive guard: catch empty/placeholder invocations early
if not command_type:
raise ValueError("MCP call missing command_type")
if params is None:
# Return a fast, structured error that clients can display without hanging
return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"}
attempts = max(config.max_retries, 5)
base_backoff = max(0.5, config.retry_delay)
def read_status_file() -> dict | None:
try:
status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
if not status_files:
return None
latest = status_files[0]
with latest.open('r') as f:
return json.load(f)
except Exception:
return None
last_short_timeout = None
# Preflight: if Unity reports reloading, return a structured hint so clients can retry politely
try:
status = read_status_file()
if status and (status.get('reloading') or status.get('reason') == 'reloading'):
return {
"success": False,
"state": "reloading",
"retry_after_ms": int(config.reload_retry_ms),
"error": "Unity domain reload in progress",
"message": "Unity is reloading scripts; please retry shortly"
}
except Exception:
pass
for attempt in range(attempts + 1):
try:
# Ensure connected
if not self.sock:
# During retries use short connect timeout
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(1.0)
self.sock.connect((self.host, self.port))
# restore steady-state timeout for receive
self.sock.settimeout(config.connection_timeout)
logger.info(f"Connected to Unity at {self.host}:{self.port}")
# Build payload
if command_type == 'ping':
payload = b'ping'
else:
command = {"type": command_type, "params": params or {}}
payload = json.dumps(command, ensure_ascii=False).encode('utf-8')
# Send
self.sock.sendall(payload)
# During retry bursts use a short receive timeout
if attempt > 0 and last_short_timeout is None:
last_short_timeout = self.sock.gettimeout()
self.sock.settimeout(1.0)
response_data = self.receive_full_response(self.sock)
# restore steady-state timeout if changed
if last_short_timeout is not None:
self.sock.settimeout(config.connection_timeout)
last_short_timeout = None
# Parse
if command_type == 'ping':
resp = json.loads(response_data.decode('utf-8'))
if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong':
return {"message": "pong"}
raise Exception("Ping unsuccessful")
resp = json.loads(response_data.decode('utf-8'))
if resp.get('status') == 'error':
err = resp.get('error') or resp.get('message', 'Unknown Unity error')
raise Exception(err)
return resp.get('result', {})
except Exception as e:
logger.warning(f"Unity communication attempt {attempt+1} failed: {e}")
try:
if self.sock:
self.sock.close()
finally:
self.sock = None
# Re-discover port each time
try:
new_port = PortDiscovery.discover_unity_port()
if new_port != self.port:
logger.info(f"Unity port changed {self.port} -> {new_port}")
self.port = new_port
except Exception as de:
logger.debug(f"Port discovery failed: {de}")
if attempt < attempts:
# Heartbeat-aware, jittered backoff
status = read_status_file()
# Base exponential backoff
backoff = base_backoff * (2 ** attempt)
# Decorrelated jitter multiplier
jitter = random.uniform(0.1, 0.3)
# Fastretry for transient socket failures
fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
if not fast_error:
try:
err_no = getattr(e, 'errno', None)
fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT)
except Exception:
pass
# Cap backoff depending on state
if status and status.get('reloading'):
cap = 0.8
elif fast_error:
cap = 0.25
else:
cap = 3.0
sleep_s = min(cap, jitter * (2 ** attempt))
time.sleep(sleep_s)
continue
raise
# Global Unity connection
_unity_connection = None
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection."""
global _unity_connection
if _unity_connection is not None:
try:
# Try to ping with a short timeout to verify connection
result = _unity_connection.send_command("ping")
# If we get here, the connection is still valid
logger.debug("Reusing existing Unity connection")
return _unity_connection
except Exception as e:
logger.warning(f"Existing connection failed: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
# Create a new connection
logger.info("Creating new Unity connection")
_unity_connection = UnityConnection()
if not _unity_connection.connect():
_unity_connection = None
raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
try:
# Verify the new connection works
_unity_connection.send_command("ping")
logger.info("Successfully established new Unity connection")
return _unity_connection
except Exception as e:
logger.error(f"Could not verify new connection: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}")
# -----------------------------
# Centralized retry helpers
# -----------------------------
def _is_reloading_response(resp: dict) -> bool:
"""Return True if the Unity response indicates the editor is reloading."""
if not isinstance(resp, dict):
return False
if resp.get("state") == "reloading":
return True
message_text = (resp.get("message") or resp.get("error") or "").lower()
return "reload" in message_text
def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
"""Send a command via the shared connection, waiting politely through Unity reloads.
Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the
structured failure if retries are exhausted.
"""
conn = get_unity_connection()
if max_retries is None:
max_retries = getattr(config, "reload_max_retries", 40)
if retry_ms is None:
retry_ms = getattr(config, "reload_retry_ms", 250)
response = conn.send_command(command_type, params)
retries = 0
while _is_reloading_response(response) and retries < max_retries:
delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms
time.sleep(max(0.0, delay_ms / 1000.0))
retries += 1
response = conn.send_command(command_type, params)
return response
async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]:
"""Async wrapper that runs the blocking retry helper in a thread pool."""
try:
import asyncio # local import to avoid mandatory asyncio dependency for sync callers
if loop is None:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms),
)
except Exception as e:
# Return a structured error dict for consistency with other responses
return {"success": False, "error": f"Python async retry helper failed: {str(e)}"}

View File

@ -1 +0,0 @@
3.12

View File

@ -1,69 +0,0 @@
"""
Port discovery utility for Unity MCP Server.
Reads port configuration saved by Unity Bridge.
"""
import json
import os
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger("unity-mcp-server")
class PortDiscovery:
"""Handles port discovery from Unity Bridge registry"""
REGISTRY_FILE = "unity-mcp-port.json"
@staticmethod
def get_registry_path() -> Path:
"""Get the path to the port registry file"""
return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE
@staticmethod
def discover_unity_port() -> int:
"""
Discover Unity port from registry file with fallback to default
Returns:
Port number to connect to
"""
registry_file = PortDiscovery.get_registry_path()
if registry_file.exists():
try:
with open(registry_file, 'r') as f:
port_config = json.load(f)
unity_port = port_config.get('unity_port')
if unity_port and isinstance(unity_port, int):
logger.info(f"Discovered Unity port from registry: {unity_port}")
return unity_port
except Exception as e:
logger.warning(f"Could not read port registry: {e}")
# Fallback to default port
logger.info("No port registry found, using default port 6400")
return 6400
@staticmethod
def get_port_config() -> Optional[dict]:
"""
Get the full port configuration from registry
Returns:
Port configuration dict or None if not found
"""
registry_file = PortDiscovery.get_registry_path()
if not registry_file.exists():
return None
try:
with open(registry_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Could not read port configuration: {e}")
return None

View File

@ -1,207 +0,0 @@
import socket
import json
import logging
from dataclasses import dataclass
from typing import Dict, Any
from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config
logging.basicConfig(
level=getattr(logging, config.log_level),
format=config.log_format
)
logger = logging.getLogger("unity-mcp-server")
@dataclass
class UnityConnection:
"""Manages the socket connection to the Unity Editor."""
host: str = config.unity_host
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
def __post_init__(self):
"""Set port from discovery if not explicitly provided"""
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
def connect(self) -> bool:
"""Establish a connection to the Unity Editor."""
if self.sock:
return True
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
logger.info(f"Connected to Unity at {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Unity: {str(e)}")
self.sock = None
return False
def disconnect(self):
"""Close the connection to the Unity Editor."""
if self.sock:
try:
self.sock.close()
except Exception as e:
logger.error(f"Error disconnecting from Unity: {str(e)}")
finally:
self.sock = None
def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
"""Receive a complete response from Unity, handling chunked data."""
chunks = []
sock.settimeout(config.connection_timeout) # Use timeout from config
try:
while True:
chunk = sock.recv(buffer_size)
if not chunk:
if not chunks:
raise Exception("Connection closed before receiving data")
break
chunks.append(chunk)
# Process the data received so far
data = b''.join(chunks)
decoded_data = data.decode('utf-8')
# Check if we've received a complete response
try:
# Special case for ping-pong
if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'):
logger.debug("Received ping response")
return data
# Handle escaped quotes in the content
if '"content":' in decoded_data:
# Find the content field and its value
content_start = decoded_data.find('"content":') + 9
content_end = decoded_data.rfind('"', content_start)
if content_end > content_start:
# Replace escaped quotes in content with regular quotes
content = decoded_data[content_start:content_end]
content = content.replace('\\"', '"')
decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:]
# Validate JSON format
json.loads(decoded_data)
# If we get here, we have valid JSON
logger.info(f"Received complete response ({len(data)} bytes)")
return data
except json.JSONDecodeError:
# We haven't received a complete valid JSON response yet
continue
except Exception as e:
logger.warning(f"Error processing response chunk: {str(e)}")
# Continue reading more chunks as this might not be the complete response
continue
except socket.timeout:
logger.warning("Socket timeout during receive")
raise Exception("Timeout receiving Unity response")
except Exception as e:
logger.error(f"Error during receive: {str(e)}")
raise
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
"""Send a command to Unity and return its response."""
if not self.sock and not self.connect():
raise ConnectionError("Not connected to Unity")
# Special handling for ping command
if command_type == "ping":
try:
logger.debug("Sending ping to verify connection")
self.sock.sendall(b"ping")
response_data = self.receive_full_response(self.sock)
response = json.loads(response_data.decode('utf-8'))
if response.get("status") != "success":
logger.warning("Ping response was not successful")
self.sock = None
raise ConnectionError("Connection verification failed")
return {"message": "pong"}
except Exception as e:
logger.error(f"Ping error: {str(e)}")
self.sock = None
raise ConnectionError(f"Connection verification failed: {str(e)}")
# Normal command handling
command = {"type": command_type, "params": params or {}}
try:
# Check for very large content that might cause JSON issues
command_size = len(json.dumps(command))
if command_size > config.buffer_size / 2:
logger.warning(f"Large command detected ({command_size} bytes). This might cause issues.")
logger.info(f"Sending command: {command_type} with params size: {command_size} bytes")
# Ensure we have a valid JSON string before sending
command_json = json.dumps(command, ensure_ascii=False)
self.sock.sendall(command_json.encode('utf-8'))
response_data = self.receive_full_response(self.sock)
try:
response = json.loads(response_data.decode('utf-8'))
except json.JSONDecodeError as je:
logger.error(f"JSON decode error: {str(je)}")
# Log partial response for debugging
partial_response = response_data.decode('utf-8')[:500] + "..." if len(response_data) > 500 else response_data.decode('utf-8')
logger.error(f"Partial response: {partial_response}")
raise Exception(f"Invalid JSON response from Unity: {str(je)}")
if response.get("status") == "error":
error_message = response.get("error") or response.get("message", "Unknown Unity error")
logger.error(f"Unity error: {error_message}")
raise Exception(error_message)
return response.get("result", {})
except Exception as e:
logger.error(f"Communication error with Unity: {str(e)}")
self.sock = None
raise Exception(f"Failed to communicate with Unity: {str(e)}")
# Global Unity connection
_unity_connection = None
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection."""
global _unity_connection
if _unity_connection is not None:
try:
# Try to ping with a short timeout to verify connection
result = _unity_connection.send_command("ping")
# If we get here, the connection is still valid
logger.debug("Reusing existing Unity connection")
return _unity_connection
except Exception as e:
logger.warning(f"Existing connection failed: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
# Create a new connection
logger.info("Creating new Unity connection")
_unity_connection = UnityConnection()
if not _unity_connection.connect():
_unity_connection = None
raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
try:
# Verify the new connection works
_unity_connection.send_command("ping")
logger.info("Successfully established new Unity connection")
return _unity_connection
except Exception as e:
logger.error(f"Could not verify new connection: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}")

View File

@ -9,7 +9,7 @@ echo.
:: Configuration
set "SCRIPT_DIR=%~dp0"
set "BRIDGE_SOURCE=%SCRIPT_DIR%UnityMcpBridge"
set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpServer\src"
set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpBridge\UnityMcpServer~\src"
set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup"
set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src"

View File

@ -5,6 +5,9 @@ echo ===============================================
echo Unity MCP Development Restore Script
echo ===============================================
echo.
echo Note: The Python server is bundled under UnityMcpBridge\UnityMcpServer~ in the package.
echo This script restores your installed server path from backups, not the repo copy.
echo.
:: Configuration
set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup"