diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 30dcd2bb..d117af2f 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -21,6 +21,10 @@ internal static class EditorPrefKeys internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + internal const string AuthToken = "MCPForUnity.AuthToken"; + internal const string AuthEnabled = "MCPForUnity.AuthEnabled"; + internal const string AuthAllowedIps = "MCPForUnity.AuthAllowedIps"; + internal const string ServerSrc = "MCPForUnity.ServerSrc"; internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index a310c6e1..a10e170e 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -170,6 +170,7 @@ public static (string uvxPath, string fromUrl, string packageName) GetUvxCommand { string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string fromUrl = GetMcpServerGitUrl(); + // Default uvx package name string packageName = "mcp-for-unity"; return (uvxPath, fromUrl, packageName); diff --git a/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs new file mode 100644 index 00000000..f7e29ba8 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class AuthPreferencesUtility + { + private static string ApiKeyPrefKey => EditorPrefKeys.AuthToken; + private static string AuthEnabledPrefKey => EditorPrefKeys.AuthEnabled; + private static string AllowedIpsPrefKey => EditorPrefKeys.AuthAllowedIps; + + internal static bool IsAuthEnabled() + { + return EditorPrefs.GetBool(AuthEnabledPrefKey, false); + } + + internal static void SetAuthEnabled(bool enabled) + { + EditorPrefs.SetBool(AuthEnabledPrefKey, enabled); + } + + internal static string GetAllowedIps() + { + string stored = EditorPrefs.GetString(AllowedIpsPrefKey, string.Empty); + return string.IsNullOrWhiteSpace(stored) ? "*" : stored; + } + + internal static void SetAllowedIps(string allowedIps) + { + string value = string.IsNullOrWhiteSpace(allowedIps) ? "*" : allowedIps; + EditorPrefs.SetString(AllowedIpsPrefKey, value); + } + + internal static string GetApiKey(bool ensureExists = true) + { + // Prefer EditorPrefs for quick access + string apiKey = EditorPrefs.GetString(ApiKeyPrefKey, string.Empty); + + if (string.IsNullOrEmpty(apiKey) && ensureExists) + { + apiKey = TryReadApiKeyFromDisk(); + if (string.IsNullOrEmpty(apiKey)) + { + apiKey = GenerateNewApiKey(); + } + + EditorPrefs.SetString(ApiKeyPrefKey, apiKey); + TryPersistApiKey(apiKey); + } + + return apiKey; + } + + internal static void SetApiKey(string apiKey) + { + if (string.IsNullOrEmpty(apiKey)) + { + apiKey = GenerateNewApiKey(); + } + + EditorPrefs.SetString(ApiKeyPrefKey, apiKey); + TryPersistApiKey(apiKey); + } + + internal static string GenerateNewApiKey() + { + // 32 bytes -> ~43 base64url chars without padding; mirrors server token_urlsafe + var bytes = new byte[32]; + using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + string base64 = Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + return base64; + } + + internal static string GetApiKeyFilePath() + { + // Keep UI in lockstep with server path resolution (supports UNITY_MCP_HOME override) + string overrideRoot = Environment.GetEnvironmentVariable("UNITY_MCP_HOME"); + if (!string.IsNullOrEmpty(overrideRoot)) + { + return Path.Combine(overrideRoot, "api_key"); + } + + #if UNITY_EDITOR_WIN + string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "UnityMCP", "api_key"); + #elif UNITY_EDITOR_OSX + string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", "UnityMCP"); + return Path.Combine(root, "api_key"); + #else + string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), ".local", "share", "UnityMCP"); + return Path.Combine(root, "api_key"); + #endif + } + + private static string TryReadApiKeyFromDisk() + { + try + { + string path = GetApiKeyFilePath(); + if (!File.Exists(path)) + { + return string.Empty; + } + + string content = File.ReadAllText(path).Trim(); + return content; + } + catch (Exception) + { + // Fall back to generating a new key if reading fails + return string.Empty; + } + } + + private static void TryPersistApiKey(string apiKey) + { + try + { + string path = GetApiKeyFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, apiKey); + } + catch (Exception) + { + // Non-fatal: user can still copy the key from the UI + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs.meta b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs.meta new file mode 100644 index 00000000..378102c3 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AuthPreferencesUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a8e3b3c33c4f4f3e8c0f6f6a6f0a0c1f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 3c0ba705..29672352 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -15,6 +15,7 @@ namespace MCPForUnity.Editor.Helpers { public static class ConfigJsonBuilder { + private const string ApiKeyInputKey = "UnityMcpApiKey"; public static string BuildManualConfigJson(string uvPath, McpClient client) { var root = new JObject(); @@ -22,7 +23,7 @@ public static string BuildManualConfigJson(string uvPath, McpClient client) JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); var unity = new JObject(); - PopulateUnityNode(unity, uvPath, client, isVSCode); + PopulateUnityNode(root, unity, uvPath, client, isVSCode); container["unityMCP"] = unity; @@ -35,7 +36,7 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa 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); + PopulateUnityNode(root, unity, uvPath, client, isVSCode); container["unityMCP"] = unity; return root; @@ -48,10 +49,11 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa /// - Adds transport configuration (HTTP or stdio) /// - Adds disabled:false for Windsurf/Kiro only when missing /// - private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) + private static void PopulateUnityNode(JObject root, JObject unity, string uvPath, McpClient client, bool isVSCode) { // Get transport preference (default to HTTP) bool useHttpTransport = client?.SupportsHttpTransport != false && EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + bool authEnabled = AuthPreferencesUtility.IsAuthEnabled(); string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; var urlPropsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" }; urlPropsToRemove.Remove(httpProperty); @@ -62,6 +64,72 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); unity[httpProperty] = httpUrl; + var inputs = root["inputs"] as JArray ?? new JArray(); + + if (authEnabled) + { + // Add API key header with input binding and prompt definition + var headers = unity["headers"] as JObject ?? new JObject(); + // Remove legacy auth header if present + if (headers["Authorization"] != null) + { + headers.Remove("Authorization"); + } + headers["X-API-Key"] = "${input:UnityMcpApiKey}"; + unity["headers"] = headers; + + var existing = inputs + .OfType() + .FirstOrDefault(o => string.Equals((string)o["id"], ApiKeyInputKey, StringComparison.Ordinal)); + if (existing == null) + { + existing = new JObject(); + inputs.Add(existing); + } + + // Drop legacy Authorization input if it exists + foreach (var legacy in inputs + .OfType() + .Where(o => string.Equals((string)o["id"], "Authorization", StringComparison.Ordinal)) + .ToList()) + { + inputs.Remove(legacy); + } + + existing["id"] = ApiKeyInputKey; + existing["type"] = "promptString"; + existing["description"] = "Unity MCP API Key"; + existing["password"] = true; + + root["inputs"] = inputs; + } + else + { + // Remove auth inputs and headers when disabled + foreach (var legacy in inputs + .OfType() + .Where(o => string.Equals((string)o["id"], ApiKeyInputKey, StringComparison.Ordinal) || + string.Equals((string)o["id"], "Authorization", StringComparison.Ordinal)) + .ToList()) + { + inputs.Remove(legacy); + } + + if (inputs.Count > 0) + { + root["inputs"] = inputs; + } + else if (root["inputs"] != null) + { + root.Remove("inputs"); + } + + if (unity["headers"] != null) + { + unity.Remove("headers"); + } + } + foreach (var prop in urlPropsToRemove) { if (unity[prop] != null) unity.Remove(prop); @@ -75,6 +143,9 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl { unity["type"] = "http"; } + + // Set version field + unity["version"] = AssetPathUtility.GetPackageVersion(); } else { @@ -106,6 +177,9 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl { unity["type"] = "stdio"; } + + // Set version field + unity["version"] = AssetPathUtility.GetPackageVersion(); } // Remove type for non-VSCode clients diff --git a/MCPForUnity/Editor/Models/MCPConfigServer.cs b/MCPForUnity/Editor/Models/MCPConfigServer.cs index fbffed37..aade7107 100644 --- a/MCPForUnity/Editor/Models/MCPConfigServer.cs +++ b/MCPForUnity/Editor/Models/MCPConfigServer.cs @@ -15,5 +15,8 @@ public class McpConfigServer // VSCode expects a transport type; include only when explicitly set [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] public string type; + + [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] + public string version; } } diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 31927533..53dc298a 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using MCPForUnity.Editor.Constants; @@ -368,11 +369,41 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) return false; } - string args = string.IsNullOrEmpty(fromUrl) - ? $"{packageName} --transport http --http-url {httpUrl}" - : $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; + var args = new List(); - command = $"{uvxPath} {args}"; + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Add("--from"); + args.Add(fromUrl); + } + + args.Add(packageName); + args.Add("--transport"); + args.Add("http"); + args.Add("--http-url"); + args.Add(httpUrl); + + bool authEnabled = AuthPreferencesUtility.IsAuthEnabled(); + if (authEnabled) + { + args.Add("--auth-enabled"); + + string allowedIps = AuthPreferencesUtility.GetAllowedIps(); + if (!string.IsNullOrWhiteSpace(allowedIps)) + { + args.Add("--allowed-ips"); + args.Add(allowedIps); + } + + string apiKey = AuthPreferencesUtility.GetApiKey(); + if (!string.IsNullOrEmpty(apiKey)) + { + args.Add("--auth-token"); + args.Add(apiKey); + } + } + + command = $"{QuoteArgument(uvxPath)} {string.Join(" ", args.Select(QuoteArgument))}".Trim(); return true; } @@ -516,5 +547,17 @@ private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(strin }; #endif } + + private static string QuoteArgument(string arg) + { + if (string.IsNullOrEmpty(arg)) + { + return "\"\""; + } + + bool needsQuotes = arg.IndexOfAny(new[] { ' ', '\"' }) >= 0; + return needsQuotes ? $"\"{arg.Replace("\"", "\\\"")}\"" : arg; + } + } } diff --git a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs index 35011a80..561bcf33 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -190,10 +190,34 @@ private async Task EstablishConnectionAsync(CancellationToken token) _socket = new ClientWebSocket(); _socket.Options.KeepAliveInterval = _socketKeepAliveInterval; + // Send auth headers only when enabled + if (AuthPreferencesUtility.IsAuthEnabled()) + { + string apiKey = AuthPreferencesUtility.GetApiKey(); + if (!string.IsNullOrEmpty(apiKey)) + { + _socket.Options.SetRequestHeader("X-API-Key", apiKey); + _socket.Options.SetRequestHeader("Authorization", $"Bearer {apiKey}"); + } + } + try { await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false); } + catch (WebSocketException wse) + { + string msg = wse.Message ?? string.Empty; + if (msg.Contains("401") || msg.Contains("403")) + { + McpLog.Error("[WebSocket] Connection failed: unauthorized (check API key)"); + } + else + { + McpLog.Error($"[WebSocket] Connection failed: {wse.Message}"); + } + return false; + } catch (Exception ex) { McpLog.Error($"[WebSocket] Connection failed: {ex.Message}"); diff --git a/MCPForUnity/Editor/Windows/Components/Auth.meta b/MCPForUnity/Editor/Windows/Components/Auth.meta new file mode 100644 index 00000000..7b7f9688 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Auth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2f1f5be7b6d740f8a56d5f3a65f6b9a3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 9b2cc933..3d6d5a76 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -26,12 +26,20 @@ private enum TransportProtocol // UI Elements private EnumField transportDropdown; + private Toggle authEnabledToggle; + private VisualElement authToggleRow; + private VisualElement allowedIpsRow; + private TextField allowedIpsField; private VisualElement httpUrlRow; private VisualElement httpServerCommandSection; private TextField httpServerCommandField; private Button copyHttpServerCommandButton; private Label httpServerCommandHint; private TextField httpUrlField; + private VisualElement apiKeyRow; + private TextField apiKeyField; + private Button copyApiKeyButton; + private Button regenerateApiKeyButton; private Button startHttpServerButton; private Button stopHttpServerButton; private VisualElement unitySocketPortRow; @@ -69,12 +77,20 @@ public McpConnectionSection(VisualElement root) private void CacheUIElements() { transportDropdown = Root.Q("transport-dropdown"); + authEnabledToggle = Root.Q("auth-enabled-toggle"); + authToggleRow = Root.Q("auth-toggle-row"); + allowedIpsRow = Root.Q("allowed-ips-row"); + allowedIpsField = Root.Q("allowed-ips-field"); httpUrlRow = Root.Q("http-url-row"); httpServerCommandSection = Root.Q("http-server-command-section"); httpServerCommandField = Root.Q("http-server-command"); copyHttpServerCommandButton = Root.Q