Merge pull request #208 from dsarno/feature/claude-cli-detection-ui

UX and Fix: Claude Code detection + UV gating (Cursor/Windsurf) + VSCode mcp.json schema
main
dsarno 2025-08-12 21:49:33 -07:00 committed by GitHub
commit ce8ab83256
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 291 additions and 71 deletions

View File

@ -87,7 +87,7 @@ namespace UnityMcpBridge.Editor.Data
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Code", "Code",
"User", "User",
"settings.json" "mcp.json"
), ),
linuxConfigPath = Path.Combine( linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
@ -95,7 +95,7 @@ namespace UnityMcpBridge.Editor.Data
"Application Support", "Application Support",
"Code", "Code",
"User", "User",
"settings.json" "mcp.json"
), ),
mcpType = McpTypes.VSCode, mcpType = McpTypes.VSCode,
configStatus = "Not Configured", configStatus = "Not Configured",

View File

@ -35,6 +35,9 @@ namespace UnityMcpBridge.Editor.Helpers
Path.Combine(home, ".local", "bin", "claude"), Path.Combine(home, ".local", "bin", "claude"),
}; };
foreach (string c in candidates) { if (File.Exists(c)) return c; } foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
#else #else
@ -70,6 +73,9 @@ namespace UnityMcpBridge.Editor.Helpers
Path.Combine(home, ".local", "bin", "claude"), Path.Combine(home, ".local", "bin", "claude"),
}; };
foreach (string c in candidates) { if (File.Exists(c)) return c; } foreach (string c in candidates) { if (File.Exists(c)) return c; }
// Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude
string nvmClaude = ResolveClaudeFromNvm(home);
if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude;
#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
return Which("claude", "/usr/local/bin:/usr/bin:/bin"); return Which("claude", "/usr/local/bin:/usr/bin:/bin");
#else #else
@ -78,6 +84,75 @@ namespace UnityMcpBridge.Editor.Helpers
} }
} }
// Attempt to resolve claude from NVM-managed Node installations, choosing the newest version
private static string ResolveClaudeFromNvm(string home)
{
try
{
if (string.IsNullOrEmpty(home)) return null;
string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node");
if (!Directory.Exists(nvmNodeDir)) return null;
string bestPath = null;
Version bestVersion = null;
foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir))
{
string name = Path.GetFileName(versionDir);
if (string.IsNullOrEmpty(name)) continue;
if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
// Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0
string versionStr = name.Substring(1);
int dashIndex = versionStr.IndexOf('-');
if (dashIndex > 0)
{
versionStr = versionStr.Substring(0, dashIndex);
}
if (Version.TryParse(versionStr, out Version parsed))
{
string candidate = Path.Combine(versionDir, "bin", "claude");
if (File.Exists(candidate))
{
if (bestVersion == null || parsed > bestVersion)
{
bestVersion = parsed;
bestPath = candidate;
}
}
}
}
}
return bestPath;
}
catch { return null; }
}
// Explicitly set the Claude CLI absolute path override in EditorPrefs
internal static void SetClaudeCliPath(string absolutePath)
{
try
{
if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath))
{
EditorPrefs.SetString(PrefClaude, absolutePath);
}
}
catch { }
}
// Clear any previously set Claude CLI override path
internal static void ClearClaudeCliPath()
{
try
{
if (EditorPrefs.HasKey(PrefClaude))
{
EditorPrefs.DeleteKey(PrefClaude);
}
}
catch { }
}
// Use existing UV resolver; returns absolute path or null. // Use existing UV resolver; returns absolute path or null.
internal static string ResolveUv() internal static string ResolveUv()
{ {

View File

@ -11,5 +11,9 @@ namespace UnityMcpBridge.Editor.Models
[JsonProperty("args")] [JsonProperty("args")]
public string[] args; public string[] args;
// VSCode expects a transport type; include only when explicitly set
[JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)]
public string type;
} }
} }

View File

@ -438,14 +438,7 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle);
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
// Auto-connect toggle (moved from Server Status) // (Auto-connect toggle removed per design)
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 // Client selector
string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray();
@ -697,6 +690,31 @@ namespace UnityMcpBridge.Editor.Windows
private void DrawClientConfigurationCompact(McpClient mcpClient) private void DrawClientConfigurationCompact(McpClient mcpClient)
{ {
// Special pre-check for Claude Code: if CLI missing, reflect in status UI
if (mcpClient.mcpType == McpTypes.ClaudeCode)
{
string claudeCheck = ExecPath.ResolveClaude();
if (string.IsNullOrEmpty(claudeCheck))
{
mcpClient.configStatus = "Claude Not Found";
mcpClient.status = McpStatus.NotConfigured;
}
}
// Pre-check for clients that require uv (all except Claude Code)
bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode;
bool uvMissingEarly = false;
if (uvRequired)
{
string uvPathEarly = FindUvPath();
if (string.IsNullOrEmpty(uvPathEarly))
{
uvMissingEarly = true;
mcpClient.configStatus = "uv Not Found";
mcpClient.status = McpStatus.NotConfigured;
}
}
// Status display // Status display
EditorGUILayout.BeginHorizontal(); EditorGUILayout.BeginHorizontal();
Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24));
@ -710,9 +728,65 @@ namespace UnityMcpBridge.Editor.Windows
}; };
EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28));
EditorGUILayout.EndHorizontal(); EditorGUILayout.EndHorizontal();
// When Claude CLI is missing, show a clear install hint directly below status
if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
{
GUIStyle installHintStyle = new GUIStyle(clientStatusStyle);
installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange
EditorGUILayout.BeginHorizontal();
GUIContent installText = new GUIContent("Make sure Claude Code is installed!");
Vector2 textSize = installHintStyle.CalcSize(installText);
EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false));
GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
GUILayout.Space(6);
if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code");
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
// If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls
if (uvRequired && uvMissingEarly)
{
GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label)
{
fontSize = 12,
fontStyle = FontStyle.Bold,
wordWrap = false
};
installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f);
EditorGUILayout.BeginHorizontal();
GUIContent installText2 = new GUIContent("Make sure uv is installed!");
Vector2 sz = installHintStyle2.CalcSize(installText2);
EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false));
GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold };
GUILayout.Space(6);
if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false)))
{
Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf");
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Choose UV Install Location", GUILayout.Width(260), GUILayout.Height(22)))
{
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, "");
if (!string.IsNullOrEmpty(picked))
{
EditorPrefs.SetString("UnityMCP.UvPath", picked);
ConfigureMcpClient(mcpClient);
Repaint();
}
}
EditorGUILayout.EndHorizontal();
return;
}
// Action buttons in horizontal layout // Action buttons in horizontal layout
EditorGUILayout.BeginHorizontal(); EditorGUILayout.BeginHorizontal();
@ -724,6 +798,9 @@ namespace UnityMcpBridge.Editor.Windows
} }
} }
else if (mcpClient.mcpType == McpTypes.ClaudeCode) else if (mcpClient.mcpType == McpTypes.ClaudeCode)
{
bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude());
if (claudeAvailable)
{ {
bool isConfigured = mcpClient.status == McpStatus.Configured; bool isConfigured = mcpClient.status == McpStatus.Configured;
string buttonText = isConfigured ? "Unregister UnityMCP with Claude Code" : "Register with Claude Code"; string buttonText = isConfigured ? "Unregister UnityMCP with Claude Code" : "Register with Claude Code";
@ -739,6 +816,37 @@ namespace UnityMcpBridge.Editor.Windows
RegisterWithClaudeCode(pythonDir); RegisterWithClaudeCode(pythonDir);
} }
} }
// Hide the picker once a valid binary is available
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true };
string resolvedClaude = ExecPath.ResolveClaude();
EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle);
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
}
// CLI picker row (only when not found)
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
if (!claudeAvailable)
{
// Only show the picker button in not-found state (no redundant "not found" label)
if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22)))
{
string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, "");
if (!string.IsNullOrEmpty(picked))
{
ExecPath.SetClaudeCliPath(picked);
// Auto-register after setting a valid path
string pythonDir = FindPackagePythonDirectory();
RegisterWithClaudeCode(pythonDir);
Repaint();
}
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
} }
else else
{ {
@ -794,13 +902,19 @@ namespace UnityMcpBridge.Editor.Windows
EditorGUILayout.EndHorizontal(); EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8); EditorGUILayout.Space(8);
// Quick info // Quick info (hide when Claude is not found to avoid confusion)
bool hideConfigInfo =
(mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude()))
|| ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath()));
if (!hideConfigInfo)
{
GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel)
{ {
fontSize = 10 fontSize = 10
}; };
EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle);
} }
}
private void ToggleUnityBridge() private void ToggleUnityBridge()
{ {
@ -831,6 +945,10 @@ namespace UnityMcpBridge.Editor.Windows
command = uvPath, command = uvPath,
args = new[] { "--directory", pythonDir, "run", "server.py" }, args = new[] { "--directory", pythonDir, "run", "server.py" },
}; };
if (mcpClient?.mcpType == McpTypes.VSCode)
{
unityMCPConfig.type = "stdio";
}
JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented };
@ -849,29 +967,41 @@ namespace UnityMcpBridge.Editor.Windows
} }
// Parse the existing JSON while preserving all properties // Parse the existing JSON while preserving all properties
dynamic existingConfig = JsonConvert.DeserializeObject(existingJson); dynamic existingConfig;
existingConfig ??= new Newtonsoft.Json.Linq.JObject(); try
{
if (string.IsNullOrWhiteSpace(existingJson))
{
existingConfig = new Newtonsoft.Json.Linq.JObject();
}
else
{
existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new Newtonsoft.Json.Linq.JObject();
}
}
catch
{
// If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object
if (!string.IsNullOrWhiteSpace(existingJson))
{
UnityEngine.Debug.LogWarning("UnityMCP: VSCode mcp.json could not be parsed; rewriting servers block.");
}
existingConfig = new Newtonsoft.Json.Linq.JObject();
}
// Handle different client types with a switch statement // Handle different client types with a switch statement
//Comments: Interestingly, VSCode has mcp.servers.unityMCP while others have mcpServers.unityMCP, which is why we need to prevent this //Comments: Interestingly, VSCode has mcp.servers.unityMCP while others have mcpServers.unityMCP, which is why we need to prevent this
switch (mcpClient?.mcpType) switch (mcpClient?.mcpType)
{ {
case McpTypes.VSCode: case McpTypes.VSCode:
// VSCode specific configuration // VSCode-specific configuration (top-level "servers")
// Ensure mcp object exists if (existingConfig.servers == null)
if (existingConfig.mcp == null)
{ {
existingConfig.mcp = new Newtonsoft.Json.Linq.JObject(); existingConfig.servers = new Newtonsoft.Json.Linq.JObject();
} }
// Ensure mcp.servers object exists // Add/update UnityMCP server in VSCode mcp.json
if (existingConfig.mcp.servers == null) existingConfig.servers.unityMCP =
{
existingConfig.mcp.servers = new Newtonsoft.Json.Linq.JObject();
}
// Add/update UnityMCP server in VSCode settings
existingConfig.mcp.servers.unityMCP =
JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>( JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JToken>(
JsonConvert.SerializeObject(unityMCPConfig) JsonConvert.SerializeObject(unityMCPConfig)
); );
@ -924,18 +1054,23 @@ namespace UnityMcpBridge.Editor.Windows
switch (mcpClient.mcpType) switch (mcpClient.mcpType)
{ {
case McpTypes.VSCode: case McpTypes.VSCode:
// Resolve uv so VSCode launches the correct executable even if not on PATH
string uvPathManual = FindUvPath();
if (uvPathManual == null)
{
UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
return;
}
// Create VSCode-specific configuration with proper format // Create VSCode-specific configuration with proper format
var vscodeConfig = new var vscodeConfig = new
{
mcp = new
{ {
servers = new servers = new
{ {
unityMCP = new unityMCP = new
{ {
command = "uv", command = uvPathManual,
args = new[] { "--directory", pythonDir, "run", "server.py" } args = new[] { "--directory", pythonDir, "run", "server.py" },
} type = "stdio"
} }
} }
}; };
@ -1244,9 +1379,15 @@ namespace UnityMcpBridge.Editor.Windows
case McpTypes.VSCode: case McpTypes.VSCode:
dynamic config = JsonConvert.DeserializeObject(configJson); dynamic config = JsonConvert.DeserializeObject(configJson);
if (config?.mcp?.servers?.unityMCP != null) // New schema: top-level servers
if (config?.servers?.unityMCP != null)
{
args = config.servers.unityMCP.args.ToObject<string[]>();
configExists = true;
}
// Back-compat: legacy mcp.servers
else if (config?.mcp?.servers?.unityMCP != null)
{ {
// Extract args from VSCode config format
args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); args = config.mcp.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "com.coplaydev.unity-mcp", "name": "com.coplaydev.unity-mcp",
"version": "2.0.1", "version": "2.0.2",
"displayName": "Unity MCP Bridge", "displayName": "Unity MCP Bridge",
"description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.", "description": "A bridge that manages and communicates with the sister application, Unity MCP Server, which allows for communications with MCP Clients like Claude Desktop or Cursor.",
"unity": "2020.3", "unity": "2020.3",