/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Collections.Generic; using com.IvanMurzak.McpPlugin.Common.Utils; using com.IvanMurzak.Unity.MCP.Utils; using Microsoft.Extensions.Logging; using static com.IvanMurzak.McpPlugin.Common.Consts.MCP.Server; namespace com.IvanMurzak.Unity.MCP.Runtime.Utils { /// /// Loads MCP plugin configuration from layered sources and (optionally) applies /// runtime overrides from environment variables and process command-line arguments. /// /// Priority (highest wins): /// 1. Process command-line flags (e.g. --url, --token, --auth, /// or any --UNITY_MCP_* variant) /// 2. Process environment variables (UNITY_MCP_*) /// 3. On-disk config (UserSettings/AI-Game-Developer-Config.json) /// 4. Built-in defaults /// /// Overrides applied via env vars / flags are NEVER persisted back to disk — /// they are runtime-only. The returned by /// captures both the disk-baseline values /// and the override values so that the editor's Save() flow can restore the /// baseline before serializing. See UnityMcpPluginEditor.Save. /// public static class EnvironmentUtils { static readonly ILogger _logger = UnityLoggerFactory.LoggerFactory.CreateLogger(nameof(EnvironmentUtils)); // Environment variable names for MCP connection overrides. public const string EnvHost = "UNITY_MCP_HOST"; public const string EnvKeepConnected = "UNITY_MCP_KEEP_CONNECTED"; public const string EnvAuthOption = "UNITY_MCP_AUTH_OPTION"; public const string EnvToken = "UNITY_MCP_TOKEN"; public const string EnvTools = "UNITY_MCP_TOOLS"; public const string EnvStartServer = "UNITY_MCP_START_SERVER"; public const string EnvTransport = "UNITY_MCP_TRANSPORT"; public const string EnvCloudUrl = "UNITY_MCP_CLOUD_URL"; public const string EnvConnectionMode = "UNITY_MCP_CONNECTION_MODE"; // Short flag aliases recognised by the in-plugin command-line parser. // The CLI tool already translates these to the equivalent env vars before // launching Unity, but the plugin still parses them directly so // out-of-band invocations (manual editor launch, IDE config, etc.) work. public const string FlagUrl = "url"; public const string FlagToken = "token"; public const string FlagAuth = "auth"; // Field-name keys used by OverrideRecord to track which fields were overridden // by env/flags so the Save flow can restore disk-baseline values before writing. public const string FieldHost = nameof(UnityMcpPlugin.UnityConnectionConfig.LocalHost); public const string FieldKeepConnected = nameof(UnityMcpPlugin.UnityConnectionConfig.KeepConnected); public const string FieldAuthOption = nameof(UnityMcpPlugin.UnityConnectionConfig.AuthOption); public const string FieldLocalToken = nameof(UnityMcpPlugin.UnityConnectionConfig.LocalToken); public const string FieldCloudToken = nameof(UnityMcpPlugin.UnityConnectionConfig.CloudToken); public const string FieldTools = nameof(UnityMcpPlugin.UnityConnectionConfig.EnabledToolsOverride); public const string FieldStartServer = nameof(UnityMcpPlugin.UnityConnectionConfig.KeepServerRunning); public const string FieldTransport = nameof(UnityMcpPlugin.UnityConnectionConfig.TransportMethod); public const string FieldConnectionMode = nameof(UnityMcpPlugin.UnityConnectionConfig.ConnectionMode); /// /// Captures, per overridden field, the disk-baseline value (what was read from JSON) /// and the runtime override value applied on top. Allows Save() to round-trip /// the baseline to disk while keeping the runtime override on the in-memory config. /// Empty when no env/flag overrides were detected. /// public sealed class OverrideRecord { readonly Dictionary _baselineValues = new(); readonly Dictionary _overrideValues = new(); public IReadOnlyDictionary BaselineValues => _baselineValues; public IReadOnlyDictionary OverrideValues => _overrideValues; public bool HasAny => _baselineValues.Count > 0; public bool Contains(string fieldName) => _baselineValues.ContainsKey(fieldName); internal void Track(string fieldName, object? baselineValue, object? overrideValue) { _baselineValues[fieldName] = baselineValue; _overrideValues[fieldName] = overrideValue; } } /// /// Checks if the current environment is a CI environment. /// public static bool IsCi() { var commandLineArgs = ArgsUtils.ParseCommandLineArguments(); var ci = commandLineArgs.GetValueOrDefault("CI") ?? Environment.GetEnvironmentVariable("CI"); var gha = commandLineArgs.GetValueOrDefault("GITHUB_ACTIONS") ?? Environment.GetEnvironmentVariable("GITHUB_ACTIONS"); var az = commandLineArgs.GetValueOrDefault("TF_BUILD") ?? Environment.GetEnvironmentVariable("TF_BUILD"); // Azure Pipelines return string.Equals(ci?.Trim()?.Trim('"'), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(gha?.Trim()?.Trim('"'), "true", StringComparison.OrdinalIgnoreCase) || string.Equals(az?.Trim()?.Trim('"'), "true", StringComparison.OrdinalIgnoreCase); } /// /// Applies environment-variable and command-line-argument overrides to the given config. /// Args (highest priority) override env vars, env vars override the disk-baseline values /// already present on . Returns an /// that captures the disk-baseline value for each field that was overridden. /// public static OverrideRecord ApplyEnvironmentOverrides(UnityMcpPlugin.UnityConnectionConfig config) { var args = ArgsUtils.ParseCommandLineArguments(); return ApplyEnvironmentOverrides(config, args, Environment.GetEnvironmentVariable); } /// /// Test-friendly overload. is the parsed command-line /// arg dictionary (keys without leading dashes); resolves /// environment variables. Either may be empty / always-null to disable that source. /// public static OverrideRecord ApplyEnvironmentOverrides( UnityMcpPlugin.UnityConnectionConfig config, IReadOnlyDictionary argReader, Func envReader) { if (config == null) throw new ArgumentNullException(nameof(config)); if (argReader == null) throw new ArgumentNullException(nameof(argReader)); if (envReader == null) throw new ArgumentNullException(nameof(envReader)); var record = new OverrideRecord(); // Resolve a value with priority: short-flag-alias (highest) > UNITY_MCP_* env-style flag > env var. // Returns null if no source provided a non-empty value. string? Resolve(string envKey, string? flagAlias) { if (!string.IsNullOrEmpty(flagAlias) && argReader.TryGetValue(flagAlias, out var flagValue) && !string.IsNullOrWhiteSpace(flagValue)) { return flagValue; } if (argReader.TryGetValue(envKey, out var envFlagValue) && !string.IsNullOrWhiteSpace(envFlagValue)) { return envFlagValue; } var envValue = envReader(envKey); if (!string.IsNullOrWhiteSpace(envValue)) return envValue; return null; } string Sanitize(string raw) => raw.Trim().Trim('"'); // UNITY_MCP_HOST is the legacy alias for UNITY_MCP_CLOUD_URL. string? sanitizedHost = null; var rawHost = Resolve(EnvCloudUrl, FlagUrl) ?? Resolve(EnvHost, flagAlias: null); if (rawHost != null) { var host = Sanitize(rawHost).TrimEnd('/'); if (host.Length > 0) { sanitizedHost = host; if (!string.Equals(host, config.LocalHost, StringComparison.Ordinal)) { record.Track(FieldHost, config.LocalHost, host); config.LocalHost = host; _logger.LogInformation("[MCP] Override: {Key}={Value}", FieldHost, host); } } } // Loopback URLs without an explicit mode infer Custom (worktree / local-dev). // Remote URLs without an explicit mode leave the disk-baseline ConnectionMode untouched. var rawMode = Resolve(EnvConnectionMode, flagAlias: null); ConnectionMode? targetMode = null; if (rawMode != null && Enum.TryParse(Sanitize(rawMode), ignoreCase: true, out var explicitMode)) { targetMode = explicitMode; } else if (sanitizedHost != null && IsLoopbackUrl(sanitizedHost)) { targetMode = ConnectionMode.Custom; } if (targetMode.HasValue && targetMode.Value != config.ConnectionMode) { record.Track(FieldConnectionMode, config.ConnectionMode, targetMode.Value); config.ConnectionMode = targetMode.Value; _logger.LogInformation("[MCP] Override: {Key}={Value}", FieldConnectionMode, targetMode.Value); } var rawKeep = Resolve(EnvKeepConnected, flagAlias: null); if (rawKeep != null && bool.TryParse(Sanitize(rawKeep), out var keep) && keep != config.KeepConnected) { record.Track(FieldKeepConnected, config.KeepConnected, keep); config.KeepConnected = keep; _logger.LogInformation("[MCP] Override: {Key}={Value}", EnvKeepConnected, keep); } var rawAuth = Resolve(EnvAuthOption, FlagAuth); if (rawAuth != null && Enum.TryParse(Sanitize(rawAuth), ignoreCase: true, out var ao) && ao != config.AuthOption) { record.Track(FieldAuthOption, config.AuthOption, ao); config.AuthOption = ao; _logger.LogInformation("[MCP] Override: {Key}={Value}", EnvAuthOption, ao); } // Resolved AFTER ConnectionMode so we route to the correct underlying field // (LocalToken in Custom mode, CloudToken in Cloud mode). We track the specific // backing field rather than the abstract Token property because only the backing // fields are serialised — restoring the baseline must target the same field that // was clobbered. var rawToken = Resolve(EnvToken, FlagToken); if (rawToken != null) { var token = Sanitize(rawToken); if (config.ConnectionMode == ConnectionMode.Cloud) { if (!string.Equals(token, config.CloudToken, StringComparison.Ordinal)) { record.Track(FieldCloudToken, config.CloudToken, token); config.CloudToken = token; _logger.LogInformation("[MCP] Override: {Key}=*** (CloudToken)", EnvToken); } } else { if (!string.Equals(token, config.LocalToken, StringComparison.Ordinal)) { record.Track(FieldLocalToken, config.LocalToken, token); config.LocalToken = token; _logger.LogInformation("[MCP] Override: {Key}=*** (LocalToken)", EnvToken); } } } var rawTransport = Resolve(EnvTransport, flagAlias: null); if (rawTransport != null && Enum.TryParse(Sanitize(rawTransport), ignoreCase: true, out var tm) && tm != config.TransportMethod) { record.Track(FieldTransport, config.TransportMethod, tm); config.TransportMethod = tm; _logger.LogInformation("[MCP] Override: {Key}={Value}", EnvTransport, tm); } var rawStart = Resolve(EnvStartServer, flagAlias: null); if (rawStart != null && bool.TryParse(Sanitize(rawStart), out var ss) && ss != config.KeepServerRunning) { record.Track(FieldStartServer, config.KeepServerRunning, ss); config.KeepServerRunning = ss; _logger.LogInformation("[MCP] Override: {Key}={Value}", EnvStartServer, ss); } // EnabledToolsOverride is [JsonIgnore] so it is never persisted regardless of the // baseline-restoration logic; we still track it for completeness. var rawTools = Resolve(EnvTools, flagAlias: null); if (rawTools != null) { var ids = rawTools.Split(',', StringSplitOptions.RemoveEmptyEntries); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var trimmed = new List(ids.Length); foreach (var id in ids) { var value = Sanitize(id); if (!string.IsNullOrWhiteSpace(value) && seen.Add(value)) trimmed.Add(value); } record.Track(FieldTools, config.EnabledToolsOverride, trimmed); config.EnabledToolsOverride = trimmed; _logger.LogInformation("[MCP] Override: {Key}={Value}", EnvTools, rawTools.Trim()); } return record; } /// /// Applies the BaselineValues from to /// (i.e. reverses runtime overrides so the in-memory config reflects what is on disk). /// Used by the editor's Save() flow to ensure runtime-only overrides are not persisted. /// public static void ApplyBaseline(UnityMcpPlugin.UnityConnectionConfig config, OverrideRecord record) { if (config == null) throw new ArgumentNullException(nameof(config)); if (record == null) throw new ArgumentNullException(nameof(record)); ApplyValues(config, record.BaselineValues); } /// /// Re-applies the OverrideValues from to /// (i.e. restores runtime overrides after a temporary baseline-restore). /// public static void ApplyOverrides(UnityMcpPlugin.UnityConnectionConfig config, OverrideRecord record) { if (config == null) throw new ArgumentNullException(nameof(config)); if (record == null) throw new ArgumentNullException(nameof(record)); ApplyValues(config, record.OverrideValues); } static void ApplyValues(UnityMcpPlugin.UnityConnectionConfig config, IReadOnlyDictionary values) { foreach (var kvp in values) { switch (kvp.Key) { case FieldHost: config.LocalHost = (string?)kvp.Value ?? UnityMcpPlugin.UnityConnectionConfig.DefaultHost; break; case FieldKeepConnected: if (kvp.Value is bool kc) config.KeepConnected = kc; break; case FieldAuthOption: if (kvp.Value is AuthOption ao) config.AuthOption = ao; break; case FieldLocalToken: config.LocalToken = (string?)kvp.Value; break; case FieldCloudToken: config.CloudToken = (string?)kvp.Value; break; case FieldTools: config.EnabledToolsOverride = (List?)kvp.Value; break; case FieldStartServer: if (kvp.Value is bool ss) config.KeepServerRunning = ss; break; case FieldTransport: if (kvp.Value is TransportMethod tm) config.TransportMethod = tm; break; case FieldConnectionMode: if (kvp.Value is ConnectionMode cm) config.ConnectionMode = cm; break; default: // Fail loudly if a new Field* constant is added to Track but forgotten here — // a silent miss would let runtime overrides leak to disk on Save. throw new InvalidOperationException($"Unhandled override field: {kvp.Key}"); } } } /// /// Returns true if the given URL targets a loopback host /// (localhost, 127.0.0.0/8, or IPv6 ::1). /// Used to infer when a worktree-style local URL is supplied. /// public static bool IsLoopbackUrl(string url) { if (string.IsNullOrWhiteSpace(url)) return false; if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; var host = uri.Host; if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) return true; if (System.Net.IPAddress.TryParse(host, out var ip)) return System.Net.IPAddress.IsLoopback(ip); return false; } } }