diff --git a/MCPForUnity/Editor/Clients.meta b/MCPForUnity/Editor/Clients.meta new file mode 100644 index 000000000..b4105b364 --- /dev/null +++ b/MCPForUnity/Editor/Clients.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9d47f01d06964ee7843765d1bd71205 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators.meta b/MCPForUnity/Editor/Clients/Configurators.meta new file mode 100644 index 000000000..a259c217d --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 59ff83375c2c74c8385c4a22549778dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs new file mode 100644 index 000000000..9a83620f6 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class AntigravityConfigurator : JsonFileMcpConfigurator + { + public AntigravityConfigurator() : base(new McpClient + { + name = "Antigravity", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Antigravity", + "Click the more_horiz menu in the Agent pane > MCP Servers", + "Select 'Install' for Unity MCP or use the Configure button above", + "Restart Antigravity if necessary" + }; + } +} diff --git a/MCPForUnity/Editor/Data/McpClients.cs.meta b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta similarity index 83% rename from MCPForUnity/Editor/Data/McpClients.cs.meta rename to MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta index e5a10813f..76e91a072 100644 --- a/MCPForUnity/Editor/Data/McpClients.cs.meta +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 711b86bbc1f661e4fb2c822e14970e16 +guid: 331b33961513042e3945d0a1d06615b5 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs new file mode 100644 index 000000000..1c8bf734d --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator + { + public ClaudeCodeConfigurator() : base(new McpClient + { + name = "Claude Code", + windowsConfigPath = string.Empty, + macConfigPath = string.Empty, + linuxConfigPath = string.Empty, + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed", + "Use the Register button to register automatically\nOR manually run: claude mcp add UnityMCP", + "Restart Claude Code" + }; + } +} diff --git a/MCPForUnity/Editor/Models/McpTypes.cs.meta b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta similarity index 83% rename from MCPForUnity/Editor/Models/McpTypes.cs.meta rename to MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta index 377a6d0be..b5ceb3fc7 100644 --- a/MCPForUnity/Editor/Models/McpTypes.cs.meta +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 +guid: d0d22681fc594475db1c189f2d9abdf7 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs new file mode 100644 index 000000000..e6cb41b17 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class ClaudeDesktopConfigurator : JsonFileMcpConfigurator + { + public ClaudeDesktopConfigurator() : base(new McpClient + { + name = "Claude Desktop", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json"), + SupportsHttpTransport = false + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Claude Desktop", + "Go to Settings > Developer > Edit Config\nOR open the config path", + "Paste the configuration JSON", + "Save and restart Claude Desktop" + }; + + public override void Configure() + { + bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + if (useHttp) + { + throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring."); + } + + base.Configure(); + } + + public override string GetManualSnippet() + { + bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + if (useHttp) + { + return "# Claude Desktop does not support HTTP transport.\n" + + "# Open Advanced Settings and disable HTTP transport to use stdio, then regenerate."; + } + + return base.GetManualSnippet(); + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta new file mode 100644 index 000000000..905c262d0 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5e5d87c9db57495f842dc366f1ebd65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs new file mode 100644 index 000000000..9337d4cd5 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CodexConfigurator : CodexMcpConfigurator + { + public CodexConfigurator() : base(new McpClient + { + name = "Codex", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' in a terminal\nOR open the config file at the path above", + "Paste the configuration TOML", + "Save and restart Codex" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta new file mode 100644 index 000000000..14bc60e47 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7037ef8b168e49f79247cb31c3be75a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs new file mode 100644 index 000000000..d63b22610 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CursorConfigurator : JsonFileMcpConfigurator + { + public CursorConfigurator() : base(new McpClient + { + name = "Cursor", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Cursor", + "Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Cursor" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta new file mode 100644 index 000000000..578eb0f64 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b708eda314746481fb8f4a1fb0652b03 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs new file mode 100644 index 000000000..445b6e597 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class KiroConfigurator : JsonFileMcpConfigurator + { + public KiroConfigurator() : base(new McpClient + { + name = "Kiro", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + EnsureEnvObject = true, + DefaultUnityFields = { { "disabled", false } } + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Kiro", + "Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Kiro" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta new file mode 100644 index 000000000..dacb04359 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9b73ff071a6043dda1f2ec7d682ef71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs new file mode 100644 index 000000000..f32b68863 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class TraeConfigurator : JsonFileMcpConfigurator + { + public TraeConfigurator() : base(new McpClient + { + name = "Trae", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Trae", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Trae", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Trae", "mcp.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Trae and go to Settings > MCP", + "Select Add Server > Add Manually", + "Paste the JSON or point to the mcp.json file\n"+ + "Windows: %AppData%\\Trae\\mcp.json\n" + + "macOS: ~/Library/Application Support/Trae/mcp.json\n" + + "Linux: ~/.config/Trae/mcp.json\n", + "Save and restart Trae" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta new file mode 100644 index 000000000..09e953c11 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3ab39e22ae0948ab94beae307f9902e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs new file mode 100644 index 000000000..90579304d --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class VSCodeConfigurator : JsonFileMcpConfigurator + { + public VSCodeConfigurator() : base(new McpClient + { + name = "VSCode GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot extension", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart VSCode" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta new file mode 100644 index 000000000..056d0d412 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcc7ead475a4d4ea2978151c217757b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs new file mode 100644 index 000000000..4437170fa --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class WindsurfConfigurator : JsonFileMcpConfigurator + { + public WindsurfConfigurator() : base(new McpClient + { + name = "Windsurf", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + macConfigPath = 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"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Windsurf", + "Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Windsurf" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta new file mode 100644 index 000000000..1b9515663 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b528971e189f141d38db577f155bd222 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs new file mode 100644 index 000000000..02bc41e72 --- /dev/null +++ b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs @@ -0,0 +1,41 @@ +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Contract for MCP client configurators. Each client is responsible for + /// status detection, auto-configure, and manual snippet/steps. + /// + public interface IMcpClientConfigurator + { + /// Stable identifier (e.g., "cursor"). + string Id { get; } + + /// Display name shown in the UI. + string DisplayName { get; } + + /// Current status cached by the configurator. + McpStatus Status { get; } + + /// True if this client supports auto-configure. + bool SupportsAutoConfigure { get; } + + /// Label to show on the configure button for the current state. + string GetConfigureActionLabel(); + + /// Returns the platform-specific config path (or message for CLI-managed clients). + string GetConfigPath(); + + /// Checks and updates status; returns current status. + McpStatus CheckStatus(bool attemptAutoRewrite = true); + + /// Runs auto-configuration (register/write file/CLI etc.). + void Configure(); + + /// Returns the manual configuration snippet (JSON/TOML/commands). + string GetManualSnippet(); + + /// Returns ordered human-readable installation steps. + System.Collections.Generic.IList GetInstallationSteps(); + } +} diff --git a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta new file mode 100644 index 000000000..fc5739664 --- /dev/null +++ b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5a5078d9e6e14027a1abfebf4018634 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs new file mode 100644 index 000000000..f1dc2eb98 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -0,0 +1,555 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// Shared base class for MCP configurators. + public abstract class McpClientConfiguratorBase : IMcpClientConfigurator + { + protected readonly McpClient client; + + protected McpClientConfiguratorBase(McpClient client) + { + this.client = client; + } + + internal McpClient Client => client; + + public string Id => client.name.Replace(" ", "").ToLowerInvariant(); + public virtual string DisplayName => client.name; + public McpStatus Status => client.status; + public virtual bool SupportsAutoConfigure => true; + public virtual string GetConfigureActionLabel() => "Configure"; + + public abstract string GetConfigPath(); + public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true); + public abstract void Configure(); + public abstract string GetManualSnippet(); + public abstract IList GetInstallationSteps(); + + protected string GetUvxPathOrError() + { + string uvx = MCPServiceLocator.Paths.GetUvxPath(); + if (string.IsNullOrEmpty(uvx)) + { + throw new InvalidOperationException("uv not found. Install uv/uvx or set the override in Advanced Settings."); + } + return uvx; + } + + protected string CurrentOsPath() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return client.windowsConfigPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return client.macConfigPath; + return client.linuxConfigPath; + } + + protected bool UrlsEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return false; + } + + if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && + Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) + { + return Uri.Compare( + uriA, + uriB, + UriComponents.HttpRequestUrl, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + string Normalize(string value) => value.Trim().TrimEnd('/'); + return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); + } + } + + /// JSON-file based configurator (Cursor, Windsurf, VS Code, etc.). + public abstract class JsonFileMcpConfigurator : McpClientConfiguratorBase + { + public JsonFileMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + string configJson = File.ReadAllText(path); + string[] args = null; + string configuredUrl = null; + bool configExists = false; + + if (client.IsVsCodeLayout) + { + var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; + if (vsConfig != null) + { + var unityToken = + vsConfig["servers"]?["unityMCP"] + ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; + + if (unityToken is JObject unityObj) + { + configExists = true; + + var argsToken = unityObj["args"]; + if (argsToken is JArray) + { + args = argsToken.ToObject(); + } + + var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } + } + } + } + else + { + McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); + if (standardConfig?.mcpServers?.unityMCP != null) + { + args = standardConfig.mcpServers.unityMCP.args; + configExists = true; + } + } + + if (!configExists) + { + client.SetStatus(McpStatus.MissingConfig); + return client.status; + } + + bool matches = false; + if (args != null && args.Length > 0) + { + string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + + if (attemptAutoRewrite) + { + var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return ConfigJsonBuilder.BuildManualConfigJson(uvx, client); + } + catch (Exception ex) + { + var errorObj = new { error = ex.Message }; + return JsonConvert.SerializeObject(errorObj); + } + } + + public override IList GetInstallationSteps() => new List { "Configuration steps not available for this client." }; + } + + /// Codex (TOML) configurator. + public abstract class CodexMcpConfigurator : McpClientConfiguratorBase + { + public CodexMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + string toml = File.ReadAllText(path); + if (CodexConfigHelper.TryParseCodexServer(toml, out _, out var args, out var url)) + { + bool matches = false; + if (!string.IsNullOrEmpty(url)) + { + matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl()); + } + else if (args != null && args.Length > 0) + { + string expected = AssetPathUtility.GetMcpServerGitUrl(); + string configured = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configured) && + McpConfigurationHelper.PathsEqual(configured, expected); + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + } + + if (attemptAutoRewrite) + { + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return CodexConfigHelper.BuildCodexServerBlock(uvx); + } + catch (Exception ex) + { + return $"# error: {ex.Message}"; + } + } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' or open the config path", + "Paste the TOML", + "Save and restart Codex" + }; + } + + /// CLI-based configurator (Claude Code). + public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase + { + public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } + + public override bool SupportsAutoConfigure => true; + public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Register"; + + public override string GetConfigPath() => "Managed via Claude CLI"; + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) + { + client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); + return client.status; + } + + string args = "mcp list"; + string projectDir = Path.GetDirectoryName(Application.dataPath); + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out _, 10000, pathPrepend)) + { + if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + } + + client.SetStatus(McpStatus.NotConfigured); + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + + return client.status; + } + + public override void Configure() + { + if (client.status == McpStatus.Configured) + { + Unregister(); + } + else + { + Register(); + } + } + + private void Register() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + string args; + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + else + { + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + bool already = false; + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + string combined = ($"{stdout}\n{stderr}") ?? string.Empty; + if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + { + already = true; + } + else + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + } + + if (!already) + { + McpLog.Info("Successfully registered with Claude Code."); + } + + CheckStatus(); + } + + private void Unregister() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + + if (!serverExists) + { + client.SetStatus(McpStatus.NotConfigured); + McpLog.Info("No MCP for Unity server found - already unregistered."); + return; + } + + if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) + { + McpLog.Info("MCP server successfully unregistered from Claude Code."); + } + else + { + throw new InvalidOperationException($"Failed to unregister: {stderr}"); + } + + client.SetStatus(McpStatus.NotConfigured); + CheckStatus(); + } + + public override string GetManualSnippet() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --transport http UnityMCP {httpUrl}\n\n" + + "# Unregister the MCP server:\n" + + "claude mcp remove UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list # Only works when claude is run in the project's directory"; + } + + if (string.IsNullOrEmpty(uvxPath)) + { + return "# Error: Configuration not available - check paths in Advanced Settings"; + } + + string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity\n\n" + + "# Unregister the MCP server:\n" + + "claude mcp remove UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list # Only works when claude is run in the project's directory"; + } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed", + "Use Register to add UnityMCP (or run claude mcp add UnityMCP)", + "Restart Claude Code" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta new file mode 100644 index 000000000..17709d19a --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d408fd7733cb4a1eb80f785307db2ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientRegistry.cs b/MCPForUnity/Editor/Clients/McpClientRegistry.cs new file mode 100644 index 000000000..b29c2f809 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientRegistry.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Central registry that auto-discovers configurators via TypeCache. + /// + public static class McpClientRegistry + { + private static List cached; + + public static IReadOnlyList All + { + get + { + if (cached == null) + { + cached = BuildRegistry(); + } + return cached; + } + } + + private static List BuildRegistry() + { + var configurators = new List(); + + foreach (var type in TypeCache.GetTypesDerivedFrom()) + { + if (type.IsAbstract || !type.IsClass || !type.IsPublic) + continue; + + // Require a public parameterless constructor + if (type.GetConstructor(Type.EmptyTypes) == null) + continue; + + try + { + if (Activator.CreateInstance(type) is IMcpClientConfigurator instance) + { + configurators.Add(instance); + } + } + catch (Exception ex) + { + Debug.LogWarning($"UnityMCP: Failed to instantiate configurator {type.Name}: {ex.Message}"); + } + } + + // Alphabetical order by display name + configurators = configurators.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + return configurators; + } + } +} diff --git a/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta b/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta new file mode 100644 index 000000000..2e0400b55 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4ce08555f995e4e848a826c63f18cb35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Data/McpClients.cs b/MCPForUnity/Editor/Data/McpClients.cs deleted file mode 100644 index 687175105..000000000 --- a/MCPForUnity/Editor/Data/McpClients.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using MCPForUnity.Editor.Models; - -namespace MCPForUnity.Editor.Data -{ - public class McpClients - { - public List clients = new() - { - // 1) Cursor - new() - { - name = "Cursor", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".cursor", - "mcp.json" - ), - macConfigPath = 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" - ), - macConfigPath = 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" - ), - macConfigPath = 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", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Claude", - "claude_desktop_config.json" - ), - - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Claude", - "claude_desktop_config.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Claude", - "claude_desktop_config.json" - ), - - mcpType = McpTypes.ClaudeDesktop, - configStatus = "Not Configured", - }, - // 5) VSCode GitHub Copilot - new() - { - name = "VSCode GitHub Copilot", - // Windows path is canonical under %AppData%\Code\User - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Code", - "User", - "mcp.json" - ), - // macOS: ~/Library/Application Support/Code/User/mcp.json - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Code", - "User", - "mcp.json" - ), - // Linux: ~/.config/Code/User/mcp.json - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Code", - "User", - "mcp.json" - ), - mcpType = McpTypes.VSCode, - configStatus = "Not Configured", - }, - // Trae IDE - new() - { - name = "Trae", - // Windows: %AppData%\Trae\mcp.json - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "Trae", - "mcp.json" - ), - // macOS: ~/Library/Application Support/Trae/mcp.json - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", - "Application Support", - "Trae", - "mcp.json" - ), - // Linux: ~/.config/Trae/mcp.json - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".config", - "Trae", - "mcp.json" - ), - mcpType = McpTypes.Trae, - configStatus = "Not Configured", - }, - // 3) Kiro - new() - { - name = "Kiro", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".kiro", - "settings", - "mcp.json" - ), - mcpType = McpTypes.Kiro, - configStatus = "Not Configured", - }, - // 4) Codex CLI - new() - { - name = "Codex CLI", - windowsConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codex", - "config.toml" - ), - macConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codex", - "config.toml" - ), - linuxConfigPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".codex", - "config.toml" - ), - mcpType = McpTypes.Codex, - configStatus = "Not Configured", - }, - }; - - // Initialize status enums after construction - public McpClients() - { - foreach (var client in clients) - { - if (client.configStatus == "Not Configured") - { - client.status = McpStatus.NotConfigured; - } - } - } - } -} diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 084e2a7ea..294579b49 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using System; using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; @@ -13,16 +14,8 @@ public static class ConfigJsonBuilder public static string BuildManualConfigJson(string uvPath, McpClient client) { var root = new JObject(); - bool isVSCode = client?.mcpType == McpTypes.VSCode; - JObject container; - if (isVSCode) - { - container = EnsureObject(root, "servers"); - } - else - { - container = EnsureObject(root, "mcpServers"); - } + bool isVSCode = client?.IsVsCodeLayout == true; + JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); var unity = new JObject(); PopulateUnityNode(unity, uvPath, client, isVSCode); @@ -35,7 +28,7 @@ public static string BuildManualConfigJson(string uvPath, McpClient client) public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client) { if (root == null) root = new JObject(); - bool isVSCode = client?.mcpType == McpTypes.VSCode; + bool isVSCode = client?.IsVsCodeLayout == true; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); PopulateUnityNode(unity, uvPath, client, isVSCode); @@ -54,21 +47,20 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { // Get transport preference (default to HTTP) - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - bool isWindsurf = client?.mcpType == McpTypes.Windsurf; + bool useHttpTransport = client?.SupportsHttpTransport != false && EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; + var urlPropsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" }; + urlPropsToRemove.Remove(httpProperty); if (useHttpTransport) { // HTTP mode: Use URL, no command string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); - string httpProperty = isWindsurf ? "serverUrl" : "url"; unity[httpProperty] = httpUrl; - // Remove legacy property for Windsurf (or vice versa) - string staleProperty = isWindsurf ? "url" : "serverUrl"; - if (unity[staleProperty] != null) + foreach (var prop in urlPropsToRemove) { - unity.Remove(staleProperty); + if (unity[prop] != null) unity.Remove(prop); } // Remove command/args if they exist from previous config @@ -102,6 +94,10 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl // Remove url/serverUrl if they exist from previous config if (unity["url"] != null) unity.Remove("url"); if (unity["serverUrl"] != null) unity.Remove("serverUrl"); + foreach (var prop in urlPropsToRemove) + { + if (unity[prop] != null) unity.Remove(prop); + } if (isVSCode) { @@ -115,8 +111,8 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl unity.Remove("type"); } - bool requiresEnv = client?.mcpType == McpTypes.Kiro; - bool requiresDisabled = client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro); + bool requiresEnv = client?.EnsureEnvObject == true; + bool stripEnv = client?.StripEnvWhenNotRequired == true; if (requiresEnv) { @@ -125,14 +121,20 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl unity["env"] = new JObject(); } } - else if (isWindsurf && unity["env"] != null) + else if (stripEnv && unity["env"] != null) { unity.Remove("env"); } - if (requiresDisabled && unity["disabled"] == null) + if (client?.DefaultUnityFields != null) { - unity["disabled"] = false; + foreach (var kvp in client.DefaultUnityFields) + { + if (unity[kvp.Key] == null) + { + unity[kvp.Key] = kvp.Value != null ? JToken.FromObject(kvp.Value) : JValue.CreateNull(); + } + } } } diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs index 2552f9a25..9ead6f2aa 100644 --- a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -79,7 +79,7 @@ public static string WriteMcpConfiguration(string configPath, McpClient mcpClien // Determine existing entry references (command/args) string existingCommand = null; string[] existingArgs = null; - bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); + bool isVSCode = (mcpClient?.IsVsCodeLayout == true); try { if (isVSCode) diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs index 9f43734ff..b00a4faf9 100644 --- a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -1,6 +1,6 @@ using System; using System.IO; -using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; @@ -8,6 +8,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Constants; +using System.Linq; namespace MCPForUnity.Editor.Migrations { @@ -48,21 +49,24 @@ private static void RunMigrationIfNeeded() bool hadFailures = false; bool touchedAny = false; - var clients = new McpClients().clients; - foreach (var client in clients) + var configurators = McpClientRegistry.All.OfType().ToList(); + foreach (var configurator in configurators) { try { - if (!ConfigUsesStdIo(client)) + if (!ConfigUsesStdIo(configurator.Client)) continue; - MCPServiceLocator.Client.ConfigureClient(client); + if (!configurator.SupportsAutoConfigure) + continue; + + MCPServiceLocator.Client.ConfigureClient(configurator); touchedAny = true; } catch (Exception ex) { hadFailures = true; - McpLog.Warn($"Failed to refresh stdio config for {client.name}: {ex.Message}"); + McpLog.Warn($"Failed to refresh stdio config for {configurator.DisplayName}: {ex.Message}"); } } @@ -90,13 +94,7 @@ private static void RunMigrationIfNeeded() private static bool ConfigUsesStdIo(McpClient client) { - switch (client.mcpType) - { - case McpTypes.Codex: - return CodexConfigUsesStdIo(client); - default: - return JsonConfigUsesStdIo(client); - } + return JsonConfigUsesStdIo(client); } private static bool JsonConfigUsesStdIo(McpClient client) @@ -112,7 +110,7 @@ private static bool JsonConfigUsesStdIo(McpClient client) var root = JObject.Parse(File.ReadAllText(configPath)); JToken unityNode = null; - if (client.mcpType == McpTypes.VSCode) + if (client.IsVsCodeLayout) { unityNode = root.SelectToken("servers.unityMCP") ?? root.SelectToken("mcp.servers.unityMCP"); @@ -132,24 +130,5 @@ private static bool JsonConfigUsesStdIo(McpClient client) } } - private static bool CodexConfigUsesStdIo(McpClient client) - { - try - { - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) - { - return false; - } - - string toml = File.ReadAllText(configPath); - return CodexConfigHelper.TryParseCodexServer(toml, out var command, out _) - && !string.IsNullOrEmpty(command); - } - catch - { - return false; - } - } } } diff --git a/MCPForUnity/Editor/Models/McpClient.cs b/MCPForUnity/Editor/Models/McpClient.cs index a32f7f596..f09352a0d 100644 --- a/MCPForUnity/Editor/Models/McpClient.cs +++ b/MCPForUnity/Editor/Models/McpClient.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace MCPForUnity.Editor.Models { public class McpClient @@ -6,10 +8,17 @@ public class McpClient public string windowsConfigPath; public string macConfigPath; public string linuxConfigPath; - public McpTypes mcpType; public string configStatus; public McpStatus status = McpStatus.NotConfigured; + // Capability flags/config for JSON-based configurators + public bool IsVsCodeLayout; // Whether the config file follows VS Code layout (env object at root) + public bool SupportsHttpTransport = true; // Whether the MCP server supports HTTP transport + public bool EnsureEnvObject; // Whether to ensure the env object is present in the config + public bool StripEnvWhenNotRequired; // Whether to strip the env object when not required + public string HttpUrlProperty = "url"; // The property name for the HTTP URL in the config + public Dictionary DefaultUnityFields = new(); + // Helper method to convert the enum to a display string public string GetStatusDisplayString() { diff --git a/MCPForUnity/Editor/Models/McpTypes.cs b/MCPForUnity/Editor/Models/McpTypes.cs deleted file mode 100644 index 7c8648979..000000000 --- a/MCPForUnity/Editor/Models/McpTypes.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace MCPForUnity.Editor.Models -{ - public enum McpTypes - { - ClaudeCode, - ClaudeDesktop, - Codex, - Cursor, - Kiro, - VSCode, - Windsurf, - Trae, - } -} diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs index 546ea38e8..3c48abdf5 100644 --- a/MCPForUnity/Editor/Services/ClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -1,15 +1,8 @@ using System; -using System.IO; +using System.Collections.Generic; using System.Linq; -using System.Runtime.InteropServices; -using MCPForUnity.Editor.Constants; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; namespace MCPForUnity.Editor.Services { @@ -18,565 +11,49 @@ namespace MCPForUnity.Editor.Services /// public class ClientConfigurationService : IClientConfigurationService { - private readonly Data.McpClients mcpClients = new(); + private readonly List configurators; - public void ConfigureClient(McpClient client) + public ClientConfigurationService() { - var pathService = MCPServiceLocator.Paths; - string uvxPath = pathService.GetUvxPath(); - - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + configurators = McpClientRegistry.All.ToList(); + } - string result = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); + public IReadOnlyList GetAllClients() => configurators; - if (result == "Configured successfully") - { - client.SetStatus(McpStatus.Configured); - } - else - { - client.SetStatus(McpStatus.NotConfigured); - throw new InvalidOperationException($"Configuration failed: {result}"); - } + public void ConfigureClient(IMcpClientConfigurator configurator) + { + configurator.Configure(); } public ClientConfigurationSummary ConfigureAllDetectedClients() { var summary = new ClientConfigurationSummary(); - var pathService = MCPServiceLocator.Paths; - - foreach (var client in mcpClients.clients) + foreach (var configurator in configurators) { try { // Always re-run configuration so core fields stay current - CheckClientStatus(client, attemptAutoRewrite: false); - - // Check if required tools are available - if (client.mcpType == McpTypes.ClaudeCode) - { - if (!pathService.IsClaudeCliDetected()) - { - summary.SkippedCount++; - summary.Messages.Add($"➜ {client.name}: Claude CLI not found"); - continue; - } - - // Force a fresh registration so transport settings stay current - UnregisterClaudeCode(); - RegisterClaudeCode(); - summary.SuccessCount++; - summary.Messages.Add($"✓ {client.name}: Re-registered successfully"); - } - else - { - ConfigureClient(client); - summary.SuccessCount++; - summary.Messages.Add($"✓ {client.name}: Configured successfully"); - } + configurator.CheckStatus(attemptAutoRewrite: false); + configurator.Configure(); + summary.SuccessCount++; + summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully"); } catch (Exception ex) { summary.FailureCount++; - summary.Messages.Add($"⚠ {client.name}: {ex.Message}"); + summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}"); } } return summary; } - public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) + public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true) { - var previousStatus = client.status; - - try - { - // Special handling for Claude Code - if (client.mcpType == McpTypes.ClaudeCode) - { - CheckClaudeCodeConfiguration(client); - return client.status != previousStatus; - } - - string configPath = McpConfigurationHelper.GetClientConfigPath(client); - - if (!File.Exists(configPath)) - { - client.SetStatus(McpStatus.NotConfigured); - return client.status != previousStatus; - } - - string configJson = File.ReadAllText(configPath); - // Check configuration based on client type - string[] args = null; - string configuredUrl = null; - bool configExists = false; - - switch (client.mcpType) - { - case McpTypes.VSCode: - var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; - if (vsConfig != null) - { - var unityToken = - vsConfig["servers"]?["unityMCP"] - ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; - - if (unityToken is JObject unityObj) - { - configExists = true; - - var argsToken = unityObj["args"]; - if (argsToken is JArray) - { - args = argsToken.ToObject(); - } - - var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; - if (urlToken != null && urlToken.Type != JTokenType.Null) - { - configuredUrl = urlToken.ToString(); - } - } - } - break; - - case McpTypes.Codex: - if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs, out var codexUrl)) - { - args = codexArgs; - configuredUrl = codexUrl; - configExists = true; - } - break; - - default: - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); - if (standardConfig?.mcpServers?.unityMCP != null) - { - args = standardConfig.mcpServers.unityMCP.args; - configExists = true; - } - break; - } - - if (configExists) - { - bool matches = false; - - if (args != null && args.Length > 0) - { - string expectedUvxUrl = AssetPathUtility.GetMcpServerGitUrl(); - string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); - matches = !string.IsNullOrEmpty(configuredUvxUrl) && - McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); - } - else if (!string.IsNullOrEmpty(configuredUrl)) - { - string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); - matches = UrlsEqual(configuredUrl, expectedUrl); - } - - if (matches) - { - client.SetStatus(McpStatus.Configured); - } - else if (attemptAutoRewrite) - { - // Attempt auto-rewrite if path mismatch detected - try - { - string rewriteResult = client.mcpType == McpTypes.Codex - ? McpConfigurationHelper.ConfigureCodexClient(configPath, client) - : McpConfigurationHelper.WriteMcpConfiguration(configPath, client); - - if (rewriteResult == "Configured successfully") - { - bool debugLogsEnabled = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); - if (debugLogsEnabled) - { - string targetDescriptor = args != null && args.Length > 0 - ? AssetPathUtility.GetMcpServerGitUrl() - : HttpEndpointUtility.GetMcpRpcUrl(); - McpLog.Info($"Auto-updated MCP config for '{client.name}' to new version: {targetDescriptor}", always: false); - } - client.SetStatus(McpStatus.Configured); - } - else - { - client.SetStatus(McpStatus.IncorrectPath); - } - } - catch - { - client.SetStatus(McpStatus.IncorrectPath); - } - } - else - { - client.SetStatus(McpStatus.IncorrectPath); - } - } - else - { - client.SetStatus(McpStatus.MissingConfig); - } - } - catch (Exception ex) - { - client.SetStatus(McpStatus.Error, ex.Message); - } - - return client.status != previousStatus; + var previous = configurator.Status; + var current = configurator.CheckStatus(attemptAutoRewrite); + return current != previous; } - public void RegisterClaudeCode() - { - var pathService = MCPServiceLocator.Paths; - string claudePath = pathService.GetClaudeCliPath(); - if (string.IsNullOrEmpty(claudePath)) - { - throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); - } - - // Check transport preference - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - - string args; - if (useHttpTransport) - { - // HTTP mode: Use --transport http with URL - string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); - args = $"mcp add --transport http UnityMCP {httpUrl}"; - } - else - { - // Stdio mode: Use command with uvx - var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); - args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}"; - } - - string projectDir = Path.GetDirectoryName(Application.dataPath); - - string pathPrepend = null; - if (Application.platform == RuntimePlatform.OSXEditor) - { - pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; - } - else if (Application.platform == RuntimePlatform.LinuxEditor) - { - pathPrepend = "/usr/local/bin:/usr/bin:/bin"; - } - - // Add the directory containing Claude CLI to PATH (for node/nvm scenarios) - try - { - string claudeDir = Path.GetDirectoryName(claudePath); - if (!string.IsNullOrEmpty(claudeDir)) - { - pathPrepend = string.IsNullOrEmpty(pathPrepend) - ? claudeDir - : $"{claudeDir}:{pathPrepend}"; - } - } - catch { } - - if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) - { - string combined = ($"{stdout}\n{stderr}") ?? string.Empty; - if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) - { - McpLog.Info("MCP for Unity already registered with Claude Code."); - } - else - { - throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); - } - return; - } - - McpLog.Info("Successfully registered with Claude Code."); - - // Update status - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - CheckClaudeCodeConfiguration(claudeClient); - } - } - - public void UnregisterClaudeCode() - { - var pathService = MCPServiceLocator.Paths; - string claudePath = pathService.GetClaudeCliPath(); - - if (string.IsNullOrEmpty(claudePath)) - { - throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); - } - - string projectDir = Path.GetDirectoryName(Application.dataPath); - string pathPrepend = Application.platform == RuntimePlatform.OSXEditor - ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - : null; - - // Check if UnityMCP server exists (fixed - only check for "UnityMCP") - bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); - - if (!serverExists) - { - // Nothing to unregister - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - claudeClient.SetStatus(McpStatus.NotConfigured); - } - McpLog.Info("No MCP for Unity server found - already unregistered."); - return; - } - - // Remove the server - if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) - { - McpLog.Info("MCP server successfully unregistered from Claude Code."); - } - else - { - throw new InvalidOperationException($"Failed to unregister: {stderr}"); - } - - // Update status - var client = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (client != null) - { - client.SetStatus(McpStatus.NotConfigured); - CheckClaudeCodeConfiguration(client); - } - } - - public string GetConfigPath(McpClient client) - { - // Claude Code is managed via CLI, not config files - if (client.mcpType == McpTypes.ClaudeCode) - { - return "Not applicable (managed via Claude CLI)"; - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return client.windowsConfigPath; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return client.macConfigPath; - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - return client.linuxConfigPath; - - return "Unknown"; - } - - public string GenerateConfigJson(McpClient client) - { - string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); - - // Claude Code uses CLI commands, not JSON config - if (client.mcpType == McpTypes.ClaudeCode) - { - // Check transport preference - bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); - - string registerCommand; - if (useHttpTransport) - { - // HTTP mode - string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); - registerCommand = $"claude mcp add --transport http UnityMCP {httpUrl}"; - } - else - { - // Stdio mode - if (string.IsNullOrEmpty(uvxPath)) - { - return "# Error: Configuration not available - check paths in Advanced Settings"; - } - - string gitUrl = AssetPathUtility.GetMcpServerGitUrl(); - registerCommand = $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity"; - } - - return "# Register the MCP server with Claude Code:\n" + - $"{registerCommand}\n\n" + - "# Unregister the MCP server:\n" + - "claude mcp remove UnityMCP\n\n" + - "# List registered servers:\n" + - "claude mcp list # Only works when claude is run in the project's directory"; - } - - if (string.IsNullOrEmpty(uvxPath)) - return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }"; - - try - { - if (client.mcpType == McpTypes.Codex) - { - return CodexConfigHelper.BuildCodexServerBlock(uvxPath); - } - else - { - return ConfigJsonBuilder.BuildManualConfigJson(uvxPath, client); - } - } - catch (Exception ex) - { - return $"{{ \"error\": \"{ex.Message}\" }}"; - } - } - - public string GetInstallationSteps(McpClient client) - { - string baseSteps = client.mcpType switch - { - McpTypes.ClaudeDesktop => - "1. Open Claude Desktop\n" + - "2. Go to Settings > Developer > Edit Config\n" + - " OR open the config file at the path above\n" + - "3. Paste the configuration JSON\n" + - "4. Save and restart Claude Desktop", - - McpTypes.Cursor => - "1. Open Cursor\n" + - "2. Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\n" + - " OR open the config file at the path above\n" + - "3. Paste the configuration JSON\n" + - "4. Save and restart Cursor", - - McpTypes.Windsurf => - "1. Open Windsurf\n" + - "2. Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\n" + - " OR open the config file at the path above\n" + - "3. Paste the configuration JSON\n" + - "4. Save and restart Windsurf", - - McpTypes.VSCode => - "1. Ensure VSCode and GitHub Copilot extension are installed\n" + - "2. Open or create mcp.json at the path above\n" + - "3. Paste the configuration JSON\n" + - "4. Save and restart VSCode", - - McpTypes.Kiro => - "1. Open Kiro\n" + - "2. Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\n" + - " OR open the config file at the path above\n" + - "3. Paste the configuration JSON\n" + - "4. Save and restart Kiro", - - McpTypes.Codex => - "1. Run 'codex config edit' in a terminal\n" + - " OR open the config file at the path above\n" + - "2. Paste the configuration TOML\n" + - "3. Save and restart Codex", - - McpTypes.ClaudeCode => - "1. Ensure Claude CLI is installed\n" + - "2. Use the Register button to register automatically\n" + - " OR manually run: claude mcp add UnityMCP\n" + - "3. Restart Claude Code", - - McpTypes.Trae => - "1. Open Trae and go to Settings > MCP\n" + - "2. Select Add Server > Add Manually\n" + - "3. Paste the JSON or point to the mcp.json file\n" + - " Windows: %AppData%\\Trae\\mcp.json\n" + - " macOS: ~/Library/Application Support/Trae/mcp.json\n" + - " Linux: ~/.config/Trae/mcp.json\n" + - "4. For local servers, Node.js (npx) or uvx must be installed\n" + - "5. Save and restart Trae", - - _ => "Configuration steps not available for this client." - }; - - return baseSteps; - } - - private void CheckClaudeCodeConfiguration(McpClient client) - { - try - { - var pathService = MCPServiceLocator.Paths; - string claudePath = pathService.GetClaudeCliPath(); - - if (string.IsNullOrEmpty(claudePath)) - { - client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); - return; - } - - // Use 'claude mcp list' to check if UnityMCP is registered - string args = "mcp list"; - string projectDir = Path.GetDirectoryName(Application.dataPath); - - string pathPrepend = null; - if (Application.platform == RuntimePlatform.OSXEditor) - { - pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; - } - else if (Application.platform == RuntimePlatform.LinuxEditor) - { - pathPrepend = "/usr/local/bin:/usr/bin:/bin"; - } - - // Add the directory containing Claude CLI to PATH - try - { - string claudeDir = Path.GetDirectoryName(claudePath); - if (!string.IsNullOrEmpty(claudeDir)) - { - pathPrepend = string.IsNullOrEmpty(pathPrepend) - ? claudeDir - : $"{claudeDir}:{pathPrepend}"; - } - } - catch { } - - if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 10000, pathPrepend)) - { - // Check if UnityMCP is in the output - if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) - { - client.SetStatus(McpStatus.Configured); - return; - } - } - - client.SetStatus(McpStatus.NotConfigured); - } - catch (Exception ex) - { - client.SetStatus(McpStatus.Error, ex.Message); - } - } - - private static bool UrlsEqual(string a, string b) - { - if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) - { - return false; - } - - if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && - Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) - { - return Uri.Compare( - uriA, - uriB, - UriComponents.HttpRequestUrl, - UriFormat.SafeUnescaped, - StringComparison.OrdinalIgnoreCase) == 0; - } - - string Normalize(string value) => value.Trim().TrimEnd('/'); - - return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); - } } } diff --git a/MCPForUnity/Editor/Services/IClientConfigurationService.cs b/MCPForUnity/Editor/Services/IClientConfigurationService.cs index 24b01fad7..6172e8fb3 100644 --- a/MCPForUnity/Editor/Services/IClientConfigurationService.cs +++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Services @@ -11,7 +13,7 @@ public interface IClientConfigurationService /// Configures a specific MCP client /// /// The client to configure - void ConfigureClient(McpClient client); + void ConfigureClient(IMcpClientConfigurator configurator); /// /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) @@ -25,38 +27,10 @@ public interface IClientConfigurationService /// The client to check /// If true, attempts to auto-fix mismatched paths /// True if status changed, false otherwise - bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true); + bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true); - /// - /// Registers MCP for Unity with Claude Code CLI - /// - void RegisterClaudeCode(); - - /// - /// Unregisters MCP for Unity from Claude Code CLI - /// - void UnregisterClaudeCode(); - - /// - /// Gets the configuration file path for a client - /// - /// The client - /// Platform-specific config path - string GetConfigPath(McpClient client); - - /// - /// Generates the configuration JSON for a client - /// - /// The client - /// JSON configuration string - string GenerateConfigJson(McpClient client); - - /// - /// Gets human-readable installation steps for a client - /// - /// The client - /// Installation instructions - string GetInstallationSteps(McpClient client); + /// Gets the registry of discovered configurators. + IReadOnlyList GetAllClients(); } /// diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 8a323aba9..b4fe2f32e 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -2,7 +2,6 @@ using System.IO; using System.Linq; using MCPForUnity.Editor.Constants; -using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 462ed4a2d..883526b41 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -6,7 +7,7 @@ using UnityEditor; using UnityEngine; using UnityEngine.UIElements; -using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Clients; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; @@ -36,15 +37,15 @@ public class McpClientConfigSection private Label installationStepsLabel; // Data - private readonly McpClients mcpClients; + private readonly List configurators; private int selectedClientIndex = 0; public VisualElement Root { get; private set; } - public McpClientConfigSection(VisualElement root, McpClients clients) + public McpClientConfigSection(VisualElement root) { Root = root; - mcpClients = clients; + configurators = MCPServiceLocator.Client.GetAllClients().ToList(); CacheUIElements(); InitializeUI(); RegisterCallbacks(); @@ -70,7 +71,7 @@ private void CacheUIElements() private void InitializeUI() { - var clientNames = mcpClients.clients.Select(c => c.name).ToList(); + var clientNames = configurators.Select(c => c.DisplayName).ToList(); clientDropdown.choices = clientNames; if (clientNames.Count > 0) { @@ -100,20 +101,20 @@ private void RegisterCallbacks() public void UpdateClientStatus() { - if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) + if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) return; - var client = mcpClients.clients[selectedClientIndex]; + var client = configurators[selectedClientIndex]; MCPServiceLocator.Client.CheckClientStatus(client); - clientStatusLabel.text = client.GetStatusDisplayString(); + clientStatusLabel.text = GetStatusDisplayString(client.Status); clientStatusLabel.style.color = StyleKeyword.Null; clientStatusIndicator.RemoveFromClassList("configured"); clientStatusIndicator.RemoveFromClassList("not-configured"); clientStatusIndicator.RemoveFromClassList("warning"); - switch (client.status) + switch (client.Status) { case McpStatus.Configured: case McpStatus.Running: @@ -130,42 +131,60 @@ public void UpdateClientStatus() break; } - if (client.mcpType == McpTypes.ClaudeCode) - { - bool isConfigured = client.status == McpStatus.Configured; - configureButton.text = isConfigured ? "Unregister" : "Register"; - } - else + configureButton.text = client.GetConfigureActionLabel(); + } + + private string GetStatusDisplayString(McpStatus status) + { + return status switch { - configureButton.text = "Configure"; - } + McpStatus.NotConfigured => "Not Configured", + McpStatus.Configured => "Configured", + McpStatus.Running => "Running", + McpStatus.Connected => "Connected", + McpStatus.IncorrectPath => "Incorrect Path", + McpStatus.CommunicationError => "Communication Error", + McpStatus.NoResponse => "No Response", + McpStatus.UnsupportedOS => "Unsupported OS", + McpStatus.MissingConfig => "Missing MCPForUnity Config", + McpStatus.Error => "Error", + _ => "Unknown", + }; } public void UpdateManualConfiguration() { - if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) + if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) return; - var client = mcpClients.clients[selectedClientIndex]; + var client = configurators[selectedClientIndex]; - string configPath = MCPServiceLocator.Client.GetConfigPath(client); + string configPath = client.GetConfigPath(); configPathField.value = configPath; - string configJson = MCPServiceLocator.Client.GenerateConfigJson(client); + string configJson = client.GetManualSnippet(); configJsonField.value = configJson; - string steps = MCPServiceLocator.Client.GetInstallationSteps(client); - installationStepsLabel.text = steps; + var steps = client.GetInstallationSteps(); + if (steps != null && steps.Count > 0) + { + var numbered = steps.Select((s, i) => $"{i + 1}. {s}"); + installationStepsLabel.text = string.Join("\n", numbered); + } + else + { + installationStepsLabel.text = "Configuration steps not available for this client."; + } } private void UpdateClaudeCliPathVisibility() { - if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) + if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) return; - var client = mcpClients.clients[selectedClientIndex]; + var client = configurators[selectedClientIndex]; - if (client.mcpType == McpTypes.ClaudeCode) + if (client is ClaudeCliMcpConfigurator) { string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) @@ -199,7 +218,7 @@ private void OnConfigureAllClientsClicked() EditorUtility.DisplayDialog("Configure All Clients", message, "OK"); - if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count) + if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { UpdateClientStatus(); UpdateManualConfiguration(); @@ -213,30 +232,14 @@ private void OnConfigureAllClientsClicked() private void OnConfigureClicked() { - if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) + if (selectedClientIndex < 0 || selectedClientIndex >= configurators.Count) return; - var client = mcpClients.clients[selectedClientIndex]; + var client = configurators[selectedClientIndex]; try { - if (client.mcpType == McpTypes.ClaudeCode) - { - bool isConfigured = client.status == McpStatus.Configured; - if (isConfigured) - { - MCPServiceLocator.Client.UnregisterClaudeCode(); - } - else - { - MCPServiceLocator.Client.RegisterClaudeCode(); - } - } - else - { - MCPServiceLocator.Client.ConfigureClient(client); - } - + MCPServiceLocator.Client.ConfigureClient(client); UpdateClientStatus(); UpdateManualConfiguration(); } @@ -308,9 +311,9 @@ private void OnCopyJsonClicked() public void RefreshSelectedClient() { - if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count) + if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { - var client = mcpClients.clients[selectedClientIndex]; + var client = configurators[selectedClientIndex]; MCPServiceLocator.Client.CheckClientStatus(client); UpdateClientStatus(); UpdateManualConfiguration(); diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index bef7dd7ae..926ce4cb1 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -4,9 +4,8 @@ using UnityEditor; using UnityEngine; using UnityEngine.UIElements; -using MCPForUnity.Editor.Data; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Windows.Components.Settings; using MCPForUnity.Editor.Windows.Components.Connection; using MCPForUnity.Editor.Windows.Components.ClientConfig; @@ -20,9 +19,7 @@ public class MCPForUnityEditorWindow : EditorWindow private McpConnectionSection connectionSection; private McpClientConfigSection clientConfigSection; - // Data - private readonly McpClients mcpClients = new(); - private static readonly HashSet OpenWindows = new(); + private static readonly HashSet OpenWindows = new(); public static void ShowWindow() { @@ -105,8 +102,8 @@ public void CreateGUI() { var clientConfigRoot = clientConfigTree.Instantiate(); sectionsContainer.Add(clientConfigRoot); - clientConfigSection = new McpClientConfigSection(clientConfigRoot, mcpClients); - } + clientConfigSection = new McpClientConfigSection(clientConfigRoot); + } // Initial updates RefreshAllData(); diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index fe7de5bf6..2143106f8 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -14,9 +14,11 @@ import glob import json import logging +import os from datetime import datetime from pathlib import Path import socket +import struct from models.models import UnityInstanceInfo diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs index fff2740d2..65ebc879e 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs @@ -76,7 +76,13 @@ public void AddsDisabledFalseAndServerUrl_ForWindsurf() var configPath = Path.Combine(_tempRoot, "windsurf.json"); WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); - var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + var client = new McpClient + { + name = "Windsurf", + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -84,7 +90,7 @@ public void AddsDisabledFalseAndServerUrl_ForWindsurf() Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); Assert.IsNull(unity["env"], "Windsurf configs should not include an env block"); Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing"); - AssertTransportConfiguration(unity, McpTypes.Windsurf); + AssertTransportConfiguration(unity, client); } [Test] @@ -93,7 +99,12 @@ public void AddsEnvAndDisabledFalse_ForKiro() var configPath = Path.Combine(_tempRoot, "kiro.json"); WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); - var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro }; + var client = new McpClient + { + name = "Kiro", + EnsureEnvObject = true, + DefaultUnityFields = { { "disabled", false } } + }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -102,7 +113,7 @@ public void AddsEnvAndDisabledFalse_ForKiro() Assert.NotNull(unity["env"], "env should be present for all clients"); Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object"); Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing"); - AssertTransportConfiguration(unity, McpTypes.Kiro); + AssertTransportConfiguration(unity, client); } [Test] @@ -111,7 +122,7 @@ public void DoesNotAddEnvOrDisabled_ForCursor() var configPath = Path.Combine(_tempRoot, "cursor.json"); WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); - var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; + var client = new McpClient { name = "Cursor" }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -119,7 +130,7 @@ public void DoesNotAddEnvOrDisabled_ForCursor() Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients"); Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients"); - AssertTransportConfiguration(unity, McpTypes.Cursor); + AssertTransportConfiguration(unity, client); } [Test] @@ -128,7 +139,7 @@ public void DoesNotAddEnvOrDisabled_ForVSCode() var configPath = Path.Combine(_tempRoot, "vscode.json"); WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path"); - var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; + var client = new McpClient { name = "VSCode", IsVsCodeLayout = true }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -136,7 +147,7 @@ public void DoesNotAddEnvOrDisabled_ForVSCode() Assert.NotNull(unity, "Expected servers.unityMCP node"); Assert.IsNull(unity["env"], "env should not be added for VSCode client"); Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client"); - AssertTransportConfiguration(unity, McpTypes.VSCode); + AssertTransportConfiguration(unity, client); } [Test] @@ -145,12 +156,7 @@ public void DoesNotAddEnvOrDisabled_ForTrae() var configPath = Path.Combine(_tempRoot, "trae.json"); WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); - if (!Enum.TryParse("Trae", out var traeValue)) - { - Assert.Ignore("McpTypes.Trae not available in this package version; skipping test."); - } - - var client = new McpClient { name = "Trae", mcpType = traeValue }; + var client = new McpClient { name = "Trae" }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -158,7 +164,7 @@ public void DoesNotAddEnvOrDisabled_ForTrae() Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); Assert.IsNull(unity["env"], "env should not be added for Trae client"); Assert.IsNull(unity["disabled"], "disabled should not be added for Trae client"); - AssertTransportConfiguration(unity, traeValue); + AssertTransportConfiguration(unity, client); } [Test] @@ -182,7 +188,12 @@ public void PreservesExistingEnvAndDisabled_ForKiro() }; File.WriteAllText(configPath, json.ToString()); - var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro }; + var client = new McpClient + { + name = "Kiro", + EnsureEnvObject = true, + DefaultUnityFields = { { "disabled", false } } + }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -190,7 +201,7 @@ public void PreservesExistingEnvAndDisabled_ForKiro() Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved"); Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved"); - AssertTransportConfiguration(unity, McpTypes.Kiro); + AssertTransportConfiguration(unity, client); } [Test] @@ -213,7 +224,13 @@ public void RemovesEnvBlock_ForWindsurf() }; File.WriteAllText(configPath, json.ToString()); - var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + var client = new McpClient + { + name = "Windsurf", + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); @@ -221,7 +238,7 @@ public void RemovesEnvBlock_ForWindsurf() Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); Assert.IsNull(unity["env"], "Windsurf config should strip any existing env block"); Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved"); - AssertTransportConfiguration(unity, McpTypes.Windsurf); + AssertTransportConfiguration(unity, client); } [Test] @@ -232,13 +249,19 @@ public void UsesStdioTransport_ForNonVSCodeClients_WhenPreferenceDisabled() WithTransportPreference(false, () => { - var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; + var client = new McpClient + { + name = "Windsurf", + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); - AssertTransportConfiguration(unity, McpTypes.Windsurf); + AssertTransportConfiguration(unity, client); }); } @@ -250,13 +273,13 @@ public void UsesStdioTransport_ForVSCode_WhenPreferenceDisabled() WithTransportPreference(false, () => { - var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; + var client = new McpClient { name = "VSCode", IsVsCodeLayout = true }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); var unity = (JObject)root.SelectToken("servers.unityMCP"); Assert.NotNull(unity, "Expected servers.unityMCP node"); - AssertTransportConfiguration(unity, McpTypes.VSCode); + AssertTransportConfiguration(unity, client); }); } @@ -324,11 +347,11 @@ private static void InvokeWriteToConfig(string configPath, McpClient client) Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success"); } - private static void AssertTransportConfiguration(JObject unity, McpTypes clientType) + private static void AssertTransportConfiguration(JObject unity, McpClient client) { bool useHttp = EditorPrefs.GetBool(UseHttpTransportPrefKey, true); - bool isVSCode = clientType == McpTypes.VSCode; - bool isWindsurf = clientType == McpTypes.Windsurf; + bool isVSCode = client.IsVsCodeLayout; + bool isWindsurf = string.Equals(client.HttpUrlProperty, "serverUrl", StringComparison.OrdinalIgnoreCase); if (useHttp) { diff --git a/docs/MCP_CLIENT_CONFIGURATORS.md b/docs/MCP_CLIENT_CONFIGURATORS.md new file mode 100644 index 000000000..097aee62e --- /dev/null +++ b/docs/MCP_CLIENT_CONFIGURATORS.md @@ -0,0 +1,290 @@ +# MCP Client Configurators + +This guide explains how MCP client configurators work in this repo and how to add a new one. + +It covers: + +- **Typical JSON-file clients** (Cursor, VSCode GitHub Copilot, Windsurf, Kiro, Trae, Antigravity, etc.). +- **Special clients** like **Claude CLI** and **Codex** that require custom logic. +- **How to add a new configurator class** so it shows up automatically in the MCP for Unity window. + +## Quick example: JSON-file configurator + +For most clients you just need a small class like this: + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class MyClientConfigurator : JsonFileMcpConfigurator + { + public MyClientConfigurator() : base(new McpClient + { + name = "My Client", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".myclient", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".myclient", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".myclient", "mcp.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open My Client and go to MCP settings", + "Open or create the mcp.json file at the path above", + "Click Configure in MCP for Unity (or paste the manual JSON snippet)", + "Restart My Client" + }; + } +} +``` + +--- + +## How the configurator system works + +At a high level: + +- **`IMcpClientConfigurator`** (`MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs`) + - Contract for all MCP client configurators. + - Handles status detection, auto-configure, manual snippet, and installation steps. + +- **Base classes** (`MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs`) + - **`McpClientConfiguratorBase`** + - Common properties and helpers. + - **`JsonFileMcpConfigurator`** + - For JSON-based config files (most clients). + - Implements `CheckStatus`, `Configure`, and `GetManualSnippet` using `ConfigJsonBuilder`. + - **`CodexMcpConfigurator`** + - For Codex-style TOML config files. + - **`ClaudeCliMcpConfigurator`** + - For CLI-driven clients like Claude Code (register/unregister via CLI, not JSON files). + +- **`McpClient` model** (`MCPForUnity/Editor/Models/McpClient.cs`) + - Holds the per-client configuration: + - `name` + - `windowsConfigPath`, `macConfigPath`, `linuxConfigPath` + - Status and several **JSON-config flags** (used by `JsonFileMcpConfigurator`): + - `IsVsCodeLayout` – VS Code-style layout (`servers` root, `type` field, etc.). + - `SupportsHttpTransport` – whether the client supports HTTP transport. + - `EnsureEnvObject` – ensure an `env` object exists. + - `StripEnvWhenNotRequired` – remove `env` when not needed. + - `HttpUrlProperty` – which property holds the HTTP URL (e.g. `"url"` vs `"serverUrl"`). + - `DefaultUnityFields` – key/value pairs like `{ "disabled": false }` applied when missing. + +- **Auto-discovery** (`McpClientRegistry`) + - `McpClientRegistry.All` uses `TypeCache.GetTypesDerivedFrom()` to find configurators. + - A configurator appears automatically if: + - It is a **public, non-abstract class**. + - It has a **public parameterless constructor**. + - No extra registration list is required. + +--- + +## Typical JSON-file clients + +Most MCP clients use a JSON config file that defines one or more MCP servers. Examples: + +- **Cursor** – `JsonFileMcpConfigurator` (global `~/.cursor/mcp.json`). +- **VSCode GitHub Copilot** – `JsonFileMcpConfigurator` with `IsVsCodeLayout = true`. +- **Windsurf** – `JsonFileMcpConfigurator` with Windsurf-specific flags (`HttpUrlProperty = "serverUrl"`, `DefaultUnityFields["disabled"] = false`, etc.). +- **Kiro**, **Trae**, **Antigravity (Gemini)** – JSON configs with project-specific paths and flags. + +All of these follow the same pattern: + +1. **Subclass `JsonFileMcpConfigurator`.** +2. **Provide a `McpClient` instance** in the constructor with: + - A user-friendly `name`. + - OS-specific config paths. + - Any JSON behavior flags as needed. +3. **Override `GetInstallationSteps`** to describe how users open or edit the config. +4. Rely on **base implementations** for: + - `CheckStatus` – reads and validates the JSON config; can auto-rewrite to match Unity MCP. + - `Configure` – writes/rewrites the config file. + - `GetManualSnippet` – builds a JSON snippet using `ConfigJsonBuilder`. + +### JSON behavior controlled by `McpClient` + +`JsonFileMcpConfigurator` relies on the fields on `McpClient`: + +- **HTTP vs stdio** + - `SupportsHttpTransport` + `EditorPrefs.UseHttpTransport` decide whether to configure + - `url` / `serverUrl` (HTTP), or + - `command` + `args` (stdio with `uvx`). +- **URL property name** + - `HttpUrlProperty` (default `"url"`) selects which JSON property to use for HTTP urls. + - Example: Windsurf and Antigravity use `"serverUrl"`. +- **VS Code layout** + - `IsVsCodeLayout = true` switches config structure to a VS Code compatible layout. +- **Env object and default fields** + - `EnsureEnvObject` / `StripEnvWhenNotRequired` control an `env` block. + - `DefaultUnityFields` adds client-specific fields if they are missing (e.g. `disabled: false`). + +All of this logic is centralized in **`ConfigJsonBuilder`**, so most JSON-based clients **do not need to override** `GetManualSnippet`. + +--- + +## Special clients + +Some clients cannot be handled by the generic JSON configurator alone. + +### Codex (TOML-based) + +- Uses **`CodexMcpConfigurator`**. +- Reads and writes a **TOML** config (usually `~/.codex/config.toml`). +- Uses `CodexConfigHelper` to: + - Parse the existing TOML. + - Check for a matching Unity MCP server configuration. + - Write/patch the Codex server block. +- The `CodexConfigurator` class: + - Only needs to supply a `McpClient` with TOML config paths. + - Inherits the Codex-specific status and configure behavior from `CodexMcpConfigurator`. + +### Claude Code (CLI-based) + +- Uses **`ClaudeCliMcpConfigurator`**. +- Configuration is stored **internally by the Claude CLI**, not in a JSON file. +- `CheckStatus` and `Configure` are implemented in the base class using `claude mcp ...` commands: + - `CheckStatus` calls `claude mcp list` to detect if `UnityMCP` is registered. + - `Configure` toggles register/unregister via `claude mcp add/remove UnityMCP`. +- The `ClaudeCodeConfigurator` class: + - Only needs a `McpClient` with a `name`. + - Overrides `GetInstallationSteps` with CLI-specific instructions. + +### Claude Desktop (JSON with restrictions) + +- Uses **`JsonFileMcpConfigurator`**, but only supports **stdio transport**. +- `ClaudeDesktopConfigurator`: + - Sets `SupportsHttpTransport = false` in `McpClient`. + - Overrides `Configure` / `GetManualSnippet` to: + - Guard against HTTP mode. + - Provide clear error text if HTTP is enabled. + +--- + +## Adding a new MCP client (typical JSON case) + +This is the most common scenario: your MCP client uses a JSON file to configure servers. + +### 1. Choose the base class + +- Use **`JsonFileMcpConfigurator`** if your client reads a JSON config file. +- Consider **`CodexMcpConfigurator`** only if you are integrating a TOML-based client like Codex. +- Consider **`ClaudeCliMcpConfigurator`** only if your client exposes a CLI command to manage MCP servers. + +### 2. Create the configurator class + +Create a new file under: + +```text +MCPForUnity/Editor/Clients/Configurators +``` + +Name it something like: + +```text +MyClientConfigurator.cs +``` + +Inside, follow the existing pattern (e.g. `CursorConfigurator`, `WindsurfConfigurator`, `KiroConfigurator`): + +- **Namespace** must be: + - `MCPForUnity.Editor.Clients.Configurators` +- **Class**: + - `public class MyClientConfigurator : JsonFileMcpConfigurator` +- **Constructor**: + - Public, **parameterless**, and call `base(new McpClient { ... })`. + - Set at least: + - `name = "My Client"` + - `windowsConfigPath = ...` + - `macConfigPath = ...` + - `linuxConfigPath = ...` + - Optionally set flags: + - `IsVsCodeLayout = true` for VS Code-style config. + - `HttpUrlProperty = "serverUrl"` if your client expects `serverUrl`. + - `EnsureEnvObject` / `StripEnvWhenNotRequired` based on env handling. + - `DefaultUnityFields = { { "disabled", false }, ... }` for client-specific defaults. + +Because the constructor is parameterless and public, **`McpClientRegistry` will auto-discover this configurator** with no extra registration. + +### 3. Add installation steps + +Override `GetInstallationSteps` to tell users how to configure the client: + +- Where to find or create the JSON config file. +- Which menu path opens the MCP settings. +- Whether they should rely on the **Configure** button or copy-paste the manual JSON. + +Look at `CursorConfigurator`, `VSCodeConfigurator`, `KiroConfigurator`, `TraeConfigurator`, or `AntigravityConfigurator` for phrasing. + +### 4. Rely on the base JSON logic + +Unless your client has very unusual behavior, you typically **do not need to override**: + +- `CheckStatus` +- `Configure` +- `GetManualSnippet` + +The base `JsonFileMcpConfigurator`: + +- Detects missing or mismatched config. +- Optionally rewrites config to match Unity MCP. +- Builds a JSON snippet with **correct HTTP vs stdio settings**, using `ConfigJsonBuilder`. + +Only override these methods if your client has constraints that cannot be expressed via `McpClient` flags. + +### 5. Verify in Unity + +After adding your configurator class: + +1. Open Unity and the **MCP for Unity** window. +2. Your client should appear in the list, sorted by display name (`McpClient.name`). +3. Use **Check Status** to verify: + - Missing config files show as `Not Configured`. + - Existing files with matching server settings show as `Configured`. +4. Click **Configure** to auto-write the config file. +5. Restart your MCP client and confirm it connects to Unity. + +--- + +## Adding a custom (non-JSON) client + +If your MCP client doesnt store configuration as a JSON file, you likely need a custom base class. + +### Codex-style TOML client + +- Subclass **`CodexMcpConfigurator`**. +- Provide TOML paths via `McpClient` (similar to `CodexConfigurator`). +- Override `GetInstallationSteps` to describe how to open/edit the TOML. + +The Codex-specific status and configure logic is already implemented in the base class. + +### CLI-managed client (Claude-style) + +- Subclass **`ClaudeCliMcpConfigurator`**. +- Provide a `McpClient` with a `name`. +- Override `GetInstallationSteps` with the CLI flow. + +The base class: + +- Locates the CLI binary using `MCPServiceLocator.Paths`. +- Uses `ExecPath.TryRun` to call `mcp list`, `mcp add`, and `mcp remove`. +- Implements `Configure` as a toggle between register and unregister. + +Use this only if the client exposes an official CLI for managing MCP servers. + +--- + +## Summary + +- **For most MCP clients**, you only need to: + - Create a `JsonFileMcpConfigurator` subclass in `Editor/Clients/Configurators`. + - Provide a `McpClient` with paths and flags. + - Override `GetInstallationSteps`. +- **Special cases** like Codex (TOML) and Claude Code (CLI) have dedicated base classes. +- **No manual registration** is needed: `McpClientRegistry` auto-discovers all configurators with a public parameterless constructor. + +Following these patterns keeps all MCP client integrations consistent and lets users configure everything from the MCP for Unity window with minimal friction.