Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions MCPForUnity/Editor/Helpers/PortManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ public static int GetPortWithFallback()
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</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;
// Port is still busy after waiting - find a new available port instead
if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} is occupied by another instance, finding alternative...");
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
}

// If no valid stored port, find a new one and save it
int newPort = FindAvailablePort();
SavePort(newPort);
return newPort;
int foundPort = FindAvailablePort();
SavePort(foundPort);
return foundPort;
}

/// <summary>
Expand Down
56 changes: 56 additions & 0 deletions MCPForUnity/Editor/MCPForUnityBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,22 @@ public static void Start()
}
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
{
// Port is occupied by another instance, get a new available port
int oldPort = currentUnityPort;
currentUnityPort = PortManager.GetPortWithFallback();

// Safety check: ensure we got a different port
if (currentUnityPort == oldPort)
{
McpLog.Error($"Port {oldPort} is occupied and no alternative port available");
throw;
}

if (IsDebugEnabled())
{
McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}");
}

Comment on lines 365 to 382
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Verify safety check logic when port becomes available.

The safety check at lines 370-374 throws an error if GetPortWithFallback() returns the same port as oldPort. However, GetPortWithFallback() internally calls WaitForPortRelease(), which might successfully reclaim the stored port if it becomes available during the wait. In that scenario, returning the same port is valid and should be used, not treated as an error.

Consider revising the logic to distinguish between:

  1. Port became available during wait → use it
  2. All ports exhausted → error
-                            // Safety check: ensure we got a different port
-                            if (currentUnityPort == oldPort)
-                            {
-                                McpLog.Error($"Port {oldPort} is occupied and no alternative port available");
-                                throw;
-                            }
+                            // If GetPortWithFallback returned the same port, it became available - proceed
+                            // If it returned a different port, we're switching to an alternative

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In MCPForUnity/Editor/MCPForUnityBridge.cs around lines 365-380, the safety
check treats getting the same port as an error even though GetPortWithFallback()
may wait and legitimately return the reclaimed port; update the logic so
same-port is allowed and only treat the call as failure when no alternative port
is available. Concretely: change PortManager.GetPortWithFallback() to signal a
genuine failure (e.g., return -1/null or throw a specific exception) when no
ports remain, or add an out/bool indicator (e.g., succeeded/wasExhausted); then
here, remove the current equality-based throw and instead only log+throw when
the sentinel/failure indicator is returned, otherwise proceed and use the
returned port (and keep the existing debug/info logging).

listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
listener.Server.SetSocketOption(
SocketOptionLevel.Socket,
Expand Down Expand Up @@ -474,6 +489,22 @@ public static void Stop()
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }

// Clean up status file when Unity stops
try
{
string statusDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
string statusFile = Path.Combine(statusDir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
if (File.Exists(statusFile))
{
File.Delete(statusFile);
if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}");
}
}
catch (Exception ex)
{
if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}");
}

if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped.");
}

Expand Down Expand Up @@ -1184,13 +1215,38 @@ private static void WriteHeartbeat(bool reloading, string reason = null)
}
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");

// Extract project name from path
string projectName = "Unknown";
try
{
string projectPath = Application.dataPath;
if (!string.IsNullOrEmpty(projectPath))
{
// Remove trailing /Assets or \Assets
projectPath = projectPath.TrimEnd('/', '\\');
if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase))
{
projectPath = projectPath.Substring(0, projectPath.Length - 6).TrimEnd('/', '\\');
}
projectName = Path.GetFileName(projectPath);
if (string.IsNullOrEmpty(projectName))
{
projectName = "Unknown";
}
}
}
catch { }

var payload = new
{
unity_port = currentUnityPort,
reloading,
reason = reason ?? (reloading ? "reloading" : "ready"),
seq = heartbeatSeq,
project_path = Application.dataPath,
project_name = projectName,
unity_version = Application.unityVersion,
last_heartbeat = DateTime.UtcNow.ToString("O")
};
File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false));
Expand Down
58 changes: 50 additions & 8 deletions MCPForUnity/Editor/Tools/ManageAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties)
// Example: Set color property
if (properties["color"] is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); // Auto-detect if not specified
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
Expand All @@ -922,10 +922,20 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties)
colArr[2].ToObject<float>(),
colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
if (mat.HasProperty(propName))
{
mat.SetColor(propName, newColor);
modified = true;
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
catch (Exception ex)
Expand All @@ -938,7 +948,8 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties)
}
else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
string propName = "_Color";
// Auto-detect the main color property for the shader
string propName = GetMainColorPropertyName(mat);
try
{
if (colorArr.Count >= 3)
Expand All @@ -949,10 +960,20 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties)
colorArr[2].ToObject<float>(),
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
if (mat.HasProperty(propName))
{
mat.SetColor(propName, newColor);
modified = true;
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
}
Expand Down Expand Up @@ -1140,6 +1161,27 @@ string ResolvePropertyName(string name)
return modified;
}

/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc.
/// </summary>
private static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";

// Try common color property names in order of likelihood
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}

// Fallback to _Color if none found
return "_Color";
}

/// <summary>
/// Applies properties from JObject to a PhysicsMaterial.
/// </summary>
Expand Down
26 changes: 26 additions & 0 deletions MCPForUnity/UnityMcpServer~/src/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any
from datetime import datetime
from pydantic import BaseModel


Expand All @@ -7,3 +8,28 @@ class MCPResponse(BaseModel):
message: str | None = None
error: str | None = None
data: Any | None = None


class UnityInstanceInfo(BaseModel):
"""Information about a Unity Editor instance"""
id: str # "ProjectName@hash" or fallback to hash
name: str # Project name extracted from path
path: str # Full project path (Assets folder)
hash: str # 8-char hash of project path
port: int # TCP port
status: str # "running", "reloading", "offline"
last_heartbeat: datetime | None = None
unity_version: str | None = None

def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
"id": self.id,
"name": self.name,
"path": self.path,
"hash": self.hash,
"port": self.port,
"status": self.status,
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
"unity_version": self.unity_version
}
Loading