Merge pull request #192 from dsarno/feat/bridge-stability
Harden MCP Bridge: reliable reloads, sticky port, embedded server, cleaner Auto‑Setup UImain
commit
bbbc26a17f
|
|
@ -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
|
||||
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de8f5721c34f7194392e9d8c7d0226c0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 711b86bbc1f661e4fb2c822e14970e16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 64b8ff807bc9a401c82015cbafccffac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 19e6eaa637484e9fa19f9a0459809de2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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,11 +227,17 @@ namespace UnityMcpBridge.Editor.Helpers
|
|||
{
|
||||
try
|
||||
{
|
||||
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
|
||||
string registryFile = GetRegistryFilePath();
|
||||
|
||||
if (!File.Exists(registryFile))
|
||||
{
|
||||
return 0;
|
||||
// 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);
|
||||
|
|
@ -170,11 +260,17 @@ namespace UnityMcpBridge.Editor.Helpers
|
|||
{
|
||||
try
|
||||
{
|
||||
string registryFile = Path.Combine(GetRegistryDirectory(), RegistryFileName);
|
||||
string registryFile = GetRegistryFilePath();
|
||||
|
||||
if (!File.Exists(registryFile))
|
||||
{
|
||||
return null;
|
||||
// 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);
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 80c09a76b944f8c4691e06c4d76c4be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
UpdateServer(saveLocation);
|
||||
}
|
||||
else { }
|
||||
if (!TryGetEmbeddedServerSource(out string embeddedSrc))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the currently installed version from the local pyproject.toml file.
|
||||
/// </summary>
|
||||
public static string GetInstalledVersion()
|
||||
{
|
||||
string pyprojectPath = Path.Combine(
|
||||
GetSaveLocation(),
|
||||
ServerFolder,
|
||||
"src",
|
||||
"pyproject.toml"
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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'))
|
||||
// 1) Development mode: common repo layouts
|
||||
try
|
||||
{
|
||||
if (line.Trim().StartsWith("version ="))
|
||||
string projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
string[] devCandidates =
|
||||
{
|
||||
string[] parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
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")))
|
||||
{
|
||||
return parts[1].Trim().Trim('"');
|
||||
srcPath = full;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception("Version not found in pyproject.toml");
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings to determine if the latest is newer.
|
||||
/// </summary>
|
||||
public static bool IsNewerVersion(string latest, string installed)
|
||||
// 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)
|
||||
{
|
||||
const string CurrentId = "com.coplaydev.unity-mcp";
|
||||
const string LegacyId = "com.justinpbarnett.unity-mcp";
|
||||
|
||||
foreach (var pkg in list.Result)
|
||||
{
|
||||
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 (pkg.name == CurrentId || pkg.name == LegacyId)
|
||||
{
|
||||
if (latestParts[i] > installedParts[i])
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
string packagePath = pkg.resolvedPath; // e.g., Library/PackageCache/... or local path
|
||||
|
||||
// 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;
|
||||
srcPath = embedded;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return latestParts.Length > installedParts.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a command-line process and handles output/errors.
|
||||
/// </summary>
|
||||
private static void RunCommand(
|
||||
string command,
|
||||
string arguments,
|
||||
string workingDirectory = null
|
||||
)
|
||||
{
|
||||
System.Diagnostics.Process process = new()
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
// 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")))
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5862c6a6d0a914f4d83224f8d039cf7b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f8514fd42f23cb641a36e52550825b35
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6754c84e5deb74749bc3a19e0c9aa280
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5fae9d995f514e9498e9613e2cdbeca9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bcb583553e8173b49be71a5c43bd9502
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b1afa56984aec0d41808edcebf805e6a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c17c09908f0c1524daa8b6957ce1f7f5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: aa63057c9e5282d4887352578bf49971
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e4e45386fcc282249907c2e3c7e5d9c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 5b61b5a84813b5749a5c64422694a0fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 896e8045986eb0d449ee68395479f1d6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: de90a1d9743a2874cb235cf0b83444b1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 43ac60aa36b361b4dbe4a038ae9f35c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7641d7388f0f6634b9d83d34de87b2ee
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b6ddda47f4077e74fbb5092388cefcc2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 626d2d44668019a45ae52e9ee066b7ec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 46c4f3614ed61f547ba823f0b2790267
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
currentUnityPort = PortManager.GetPortWithFallback();
|
||||
|
||||
listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
|
||||
listener.Start();
|
||||
isRunning = true;
|
||||
isAutoConnectMode = false; // Normal startup mode
|
||||
Debug.Log($"UnityMcpBridge started on port {currentUnityPort}.");
|
||||
// Assuming ListenerLoop and ProcessCommands are defined elsewhere
|
||||
Task.Run(ListenerLoop);
|
||||
EditorApplication.update += ProcessCommands;
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
if (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
|
||||
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)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"Port {currentUnityPort} is already in use. This should not happen with dynamic port allocation."
|
||||
);
|
||||
return (bool)prop.GetValue(null);
|
||||
}
|
||||
else
|
||||
}
|
||||
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;
|
||||
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)
|
||||
{
|
||||
Debug.LogError($"Failed to start TCP listener: {ex.Message}");
|
||||
}
|
||||
|
|
@ -134,22 +327,28 @@ namespace UnityMcpBridge.Editor
|
|||
|
||||
public static void Stop()
|
||||
{
|
||||
if (!isRunning)
|
||||
lock (startStopLock)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
listener?.Stop();
|
||||
listener = null;
|
||||
isRunning = false;
|
||||
EditorApplication.update -= ProcessCommands;
|
||||
Debug.Log("UnityMcpBridge stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}");
|
||||
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;
|
||||
EditorApplication.update -= ProcessCommands;
|
||||
Debug.Log("<b><color=#2EA3FF>UNITY-MCP</color></b>: UnityMcpBridge stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Error stopping UnityMcpBridge: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1e0fb0e418dd19345a8236c44078972b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 36798bd7b867b8e43ac86885e94f928f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4283e255b343c4546b843cd22214ac93
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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,25 +102,32 @@ 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;
|
||||
}
|
||||
|
||||
// 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 = "Installed (Embedded)";
|
||||
pythonServerInstallationStatusColor = Color.green;
|
||||
}
|
||||
else
|
||||
{
|
||||
pythonServerInstallationStatus = "Up to Date";
|
||||
pythonServerInstallationStatusColor = Color.green;
|
||||
pythonServerInstallationStatus = "Not Installed";
|
||||
pythonServerInstallationStatusColor = Color.red;
|
||||
}
|
||||
}
|
||||
else
|
||||
catch
|
||||
{
|
||||
pythonServerInstallationStatus = "Not Installed";
|
||||
pythonServerInstallationStatusColor = Color.red;
|
||||
|
|
@ -142,27 +168,50 @@ namespace UnityMcpBridge.Editor.Windows
|
|||
// Header
|
||||
DrawHeader();
|
||||
|
||||
// Main sections in a more compact layout
|
||||
// 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;
|
||||
|
||||
// Top row: Server Status (left) and Unity Bridge (right)
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
{
|
||||
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight));
|
||||
DrawServerStatusSection();
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
// Left column - Status and Bridge
|
||||
EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.5f));
|
||||
DrawServerStatusSection();
|
||||
EditorGUILayout.Space(5);
|
||||
DrawBridgeSection();
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
// Right column - Validation Settings
|
||||
EditorGUILayout.BeginVertical();
|
||||
DrawValidationSection();
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(horizontalSpacing);
|
||||
|
||||
EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight));
|
||||
DrawBridgeSection();
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// Unified MCP Client Configuration
|
||||
DrawUnifiedClientConfiguration();
|
||||
// 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,7 +1049,10 @@ namespace UnityMcpBridge.Editor.Windows
|
|||
}
|
||||
|
||||
// If still not found, return the placeholder path
|
||||
UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path");
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("Could not find Python directory, using placeholder path");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -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,14 +1311,38 @@ 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
|
||||
{
|
||||
mcpClient.SetStatus(McpStatus.IncorrectPath);
|
||||
// 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
|
||||
|
|
@ -1034,7 +1464,10 @@ namespace UnityMcpBridge.Editor.Windows
|
|||
}
|
||||
else if (!string.IsNullOrEmpty(errors))
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}");
|
||||
if (debugLogsEnabled)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Claude MCP errors: {errors}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -1521,7 +1954,10 @@ namespace UnityMcpBridge.Editor.Windows
|
|||
? mcpClient.windowsConfigPath
|
||||
: mcpClient.linuxConfigPath;
|
||||
|
||||
UnityEngine.Debug.Log($"Checking Claude config at: {configPath}");
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 377fe73d52cf0435fabead5f50a0d204
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e65311c160f0d41d4a1b45a3dba8dd5a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)}"}
|
||||
|
|
@ -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)}"}
|
||||
|
|
@ -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)}"}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)}
|
||||
|
|
@ -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)
|
||||
|
||||
# Fast‑retry 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)}"}
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.12
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}")
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue