Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 2 additions & 10 deletions .claude/mcp.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
{
"mcpServers": {
"UnityMCP": {
"type": "stdio",
"command": "uv",
"args": [
"run",
"--directory",
"${workspaceFolder}/Server",
"src/main.py",
"--transport",
"stdio"
]
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}
6 changes: 0 additions & 6 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@
"MultiEdit(reports/**)"
],
"deny": [
"WebFetch",
"WebSearch",
"Task",
"TodoWrite",
"NotebookEdit",
"NotebookRead"
]
}
}
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ TestProjects/UnityMCPTests/Assets/Temp/
# CI test reports (generated during test runs)
reports/

# Local Claude configs (not for repo)
.claude/local/

# Local testing harness
scripts/local-test/
.claude/settings.local.json

# Ignore the .claude directory, since it might contain local/project-level setting such as deny and allowlist.
/.claude
200 changes: 181 additions & 19 deletions MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -543,21 +543,47 @@ public ClaudeCliMcpConfigurator(McpClient client) : base(client) { }
public override string GetConfigPath() => "Managed via Claude CLI";

/// <summary>
/// Returns the project directory that CLI-based configurators will use as the working directory
/// for `claude mcp add/remove --scope local`. Checks for an explicit override in EditorPrefs
/// first, then falls back to the current Unity project directory.
/// The override is useful when the Claude Code workspace is at a different path than the Unity project
/// (e.g., plugin developers running CC from the repo root while Unity is open with a test project).
/// MUST be called from the main Unity thread (accesses Application.dataPath and EditorPrefs).
/// </summary>
internal static string GetClientProjectDir()
{
string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty);
if (!string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir))
return overrideDir;
return Path.GetDirectoryName(Application.dataPath);
}

/// <summary>
/// Returns true if a valid client project directory override is set.
/// </summary>
internal static bool HasClientProjectDirOverride
{
get
{
string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty);
return !string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir);
}
}
/// Checks the Claude CLI registration status.
/// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access.
/// </summary>
public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
{
// Capture main-thread-only values before delegating to thread-safe method
string projectDir = Path.GetDirectoryName(Application.dataPath);
string projectDir = GetClientProjectDir();
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
// Resolve claudePath on the main thread (EditorPrefs access)
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
RuntimePlatform platform = Application.platform;
bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
// Get expected package source for the installed package version (matches what Register() would use)
string expectedPackageSource = GetExpectedPackageSourceForValidation();
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite);
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite, HasClientProjectDirOverride);
}

/// <summary>
Expand All @@ -572,7 +598,7 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true)
internal McpStatus CheckStatusWithProjectDir(
string projectDir, bool useHttpTransport, string claudePath, RuntimePlatform platform,
bool isRemoteScope, string expectedPackageSource,
bool attemptAutoRewrite = false)
bool attemptAutoRewrite = false, bool hasProjectDirOverride = false)
{
try
{
Expand Down Expand Up @@ -632,8 +658,12 @@ internal McpStatus CheckStatusWithProjectDir(
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}

// Check for transport mismatch
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
// Check for transport mismatch.
// When a project dir override is active, the local UseHttpTransport
// GUI setting may legitimately differ from the registered transport
// in the overridden project, so skip this check.
bool hasTransportMismatch = !hasProjectDirOverride
&& ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp));

// For stdio transport, also check package version
bool hasVersionMismatch = false;
Expand Down Expand Up @@ -873,7 +903,7 @@ private void Register()
args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}";
}

string projectDir = Path.GetDirectoryName(Application.dataPath);
string projectDir = GetClientProjectDir();

string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
Expand Down Expand Up @@ -925,7 +955,7 @@ private void Unregister()
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}

string projectDir = Path.GetDirectoryName(Application.dataPath);
string projectDir = GetClientProjectDir();
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
{
Expand Down Expand Up @@ -997,6 +1027,7 @@ public override string GetManualSnippet()

/// <summary>
/// Removes UnityMCP registration from all Claude Code configuration scopes (local, user, project).
/// Also removes legacy entries from ~/.claude.json that the CLI scoped removal can't touch.
/// This ensures no stale or conflicting configurations remain across different scopes.
/// Also handles legacy "unityMCP" naming convention.
/// </summary>
Expand All @@ -1015,6 +1046,81 @@ private static void RemoveFromAllScopes(string claudePath, string projectDir, st
ExecPath.TryRun(claudePath, $"mcp remove --scope {scope} {name}", projectDir, out _, out _, 5000, pathPrepend);
}
}

// Also remove legacy entries directly from ~/.claude.json.
// Older versions and manual CLI commands without --scope wrote mcpServers entries
// into the projects section of ~/.claude.json. The scoped `claude mcp remove` commands
// above won't touch these, leaving stale/conflicting configs behind.
RemoveLegacyUserConfigEntries(projectDir);
}

/// <summary>
/// Removes UnityMCP entries from the projects section of ~/.claude.json.
/// These are legacy entries that were created by older versions or manual commands
/// that didn't use --scope. The scoped `claude mcp remove` commands don't clean these up.
/// </summary>
private static void RemoveLegacyUserConfigEntries(string projectDir)
{
try
{
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configPath = Path.Combine(homeDir, ".claude.json");
if (!File.Exists(configPath))
return;

string json = File.ReadAllText(configPath);
var config = JObject.Parse(json);
var projects = config["projects"] as JObject;
if (projects == null)
return;

string normalizedProjectDir = NormalizePath(projectDir);
bool modified = false;

// Walk all project entries looking for ones that match our project path
foreach (var project in projects.Properties())
{
string normalizedKey = NormalizePath(project.Name);

// Match exact path or parent paths (same logic as ReadUserScopeConfig)
if (!string.Equals(normalizedKey, normalizedProjectDir, StringComparison.OrdinalIgnoreCase))
{
// Also check if projectDir is a child of this config entry
if (!normalizedProjectDir.StartsWith(normalizedKey + "/", StringComparison.OrdinalIgnoreCase))
continue;
}

var mcpServers = project.Value?["mcpServers"] as JObject;
if (mcpServers == null)
continue;

// Remove UnityMCP/unityMCP entries (case-insensitive)
var toRemove = new List<string>();
foreach (var server in mcpServers.Properties())
{
if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase))
{
toRemove.Add(server.Name);
}
}

foreach (var name in toRemove)
{
mcpServers.Remove(name);
modified = true;
McpLog.Info($"Removed legacy '{name}' entry from ~/.claude.json for project '{project.Name}'");
}
}

if (modified)
{
File.WriteAllText(configPath, config.ToString(Formatting.Indented));
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to clean up legacy ~/.claude.json entries: {ex.Message}");
}
}

/// <summary>
Expand Down Expand Up @@ -1095,32 +1201,89 @@ private static string ExtractPackageSourceFromCliOutput(string cliOutput)
}

/// <summary>
/// Reads Claude Code configuration directly from ~/.claude.json file.
/// Reads Claude Code configuration from both local-scope (.claude/mcp.json in the project)
/// and user-scope (~/.claude.json). Local scope takes precedence, matching Claude Code's
/// own config resolution order.
/// This is much faster than running `claude mcp list` which does health checks on all servers.
/// </summary>
private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string projectDir)
{
try
{
// Find the Claude config file
// 1. Check local-scope config first: {projectDir}/.claude/mcp.json
// This is where `claude mcp add --scope local` writes.
var localResult = ReadLocalScopeConfig(projectDir);
if (localResult.serverConfig != null)
return localResult;
if (localResult.error != null)
return localResult;

// 2. Fall back to user-scope config: ~/.claude.json
return ReadUserScopeConfig(projectDir);
}
catch (Exception ex)
{
return (null, $"Error reading Claude config: {ex.Message}");
}
}

/// <summary>
/// Reads UnityMCP config from the local-scope file: {projectDir}/.claude/mcp.json.
/// This is where `claude mcp add --scope local` stores registrations.
/// </summary>
private static (JObject serverConfig, string error) ReadLocalScopeConfig(string projectDir)
{
try
{
if (string.IsNullOrEmpty(projectDir))
return (null, null);

string localConfigPath = Path.Combine(projectDir, ".claude", "mcp.json");
if (!File.Exists(localConfigPath))
return (null, null);

string json = File.ReadAllText(localConfigPath);
var config = JObject.Parse(json);
var mcpServers = config["mcpServers"] as JObject;
if (mcpServers == null)
return (null, null);

foreach (var server in mcpServers.Properties())
{
if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase))
{
return (server.Value as JObject, null);
}
}

return (null, null);
}
catch (Exception ex)
{
return (null, $"Error reading local Claude config: {ex.Message}");
}
}

/// <summary>
/// Reads UnityMCP config from the user-scope file: ~/.claude.json (projects section).
/// This handles legacy configurations and direct user-level entries.
/// </summary>
private static (JObject serverConfig, string error) ReadUserScopeConfig(string projectDir)
{
try
{
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configPath = Path.Combine(homeDir, ".claude.json");

if (!File.Exists(configPath))
{
// Missing config file is "not configured", not an error
// (Claude Code may not be installed or just hasn't been configured yet)
return (null, null);
}

string configJson = File.ReadAllText(configPath);
var config = JObject.Parse(configJson);

var projects = config["projects"] as JObject;
if (projects == null)
{
return (null, null); // No projects configured
}
return (null, null);

// Build a dictionary of normalized paths for quick lookup
// Use last entry for duplicates (forward/backslash variants) as it's typically more recent
Expand All @@ -1142,7 +1305,6 @@ private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string
var mcpServers = projectConfig?["mcpServers"] as JObject;
if (mcpServers != null)
{
// Look for UnityMCP (case-insensitive)
foreach (var server in mcpServers.Properties())
{
if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase))
Expand All @@ -1162,11 +1324,11 @@ private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string
currentDir = currentDir.Substring(0, lastSlash);
}

return (null, null); // Project not found in config
return (null, null);
}
catch (Exception ex)
{
return (null, $"Error reading Claude config: {ex.Message}");
return (null, $"Error reading user Claude config: {ex.Message}");
}
}

Expand Down
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal static class EditorPrefKeys

internal const string UvxPathOverride = "MCPForUnity.UvxPath";
internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath";
internal const string ClientProjectDirOverride = "MCPForUnity.ClientProjectDir";

internal const string HttpBaseUrl = "MCPForUnity.HttpUrl";
internal const string HttpRemoteBaseUrl = "MCPForUnity.HttpRemoteUrl";
Expand Down
9 changes: 7 additions & 2 deletions MCPForUnity/Editor/Helpers/ExecPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,14 @@ internal static string ResolveClaude()
// Common npm global locations
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
string[] candidates =
{
// Prefer .cmd (most reliable from non-interactive processes)
// Native installer locations
Path.Combine(localAppData, "Programs", "claude", "claude.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"),
Path.Combine(home, ".local", "bin", "claude.exe"),
// npm global locations (.cmd preferred for non-interactive processes)
Path.Combine(appData, "npm", "claude.cmd"),
Path.Combine(localAppData, "npm", "claude.cmd"),
// Fall back to PowerShell shim if only .ps1 is present
Expand Down Expand Up @@ -277,7 +282,7 @@ private static string Which(string exe, string prependPath)
#endif

#if UNITY_EDITOR_WIN
private static string FindInPathWindows(string exe, string extraPathPrepend = null)
internal static string FindInPathWindows(string exe, string extraPathPrepend = null)
{
try
{
Expand Down
Loading