diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 021c14ce4..5708626ab 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -741,7 +741,7 @@ public override void Configure() public void ConfigureWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, - string uvxPath, string fromArgs, string packageName, bool shouldForceRefresh, + string uvxPath, string fromArgs, string packageName, string uvxDevFlags, string apiKey, Models.ConfiguredTransport serverTransport) { @@ -752,7 +752,7 @@ public void ConfigureWithCapturedValues( else { RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, - useHttpTransport, httpUrl, uvxPath, fromArgs, packageName, shouldForceRefresh, + useHttpTransport, httpUrl, uvxPath, fromArgs, packageName, uvxDevFlags, apiKey, serverTransport); } } @@ -763,7 +763,7 @@ public void ConfigureWithCapturedValues( private void RegisterWithCapturedValues( string projectDir, string claudePath, string pathPrepend, bool useHttpTransport, string httpUrl, - string uvxPath, string fromArgs, string packageName, bool shouldForceRefresh, + string uvxPath, string fromArgs, string packageName, string uvxDevFlags, string apiKey, Models.ConfiguredTransport serverTransport) { @@ -789,10 +789,8 @@ private void RegisterWithCapturedValues( } else { - // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead - string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty; // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) - args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}"; + args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {uvxDevFlags}{fromArgs} {packageName}"; } // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) @@ -867,9 +865,7 @@ private void Register() else { var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); - // Use central helper that checks both DevModeForceServerRefresh AND local path detection. - // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead - string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + string devFlags = AssetPathUtility.GetUvxDevFlags(); string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}"; @@ -977,9 +973,7 @@ public override string GetManualSnippet() return "# Error: Configuration not available - check paths in Advanced Settings"; } - // Use central helper that checks both DevModeForceServerRefresh AND local path detection. - // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead - string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + string devFlags = AssetPathUtility.GetUvxDevFlags(); string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); return "# Register the MCP server with Claude Code:\n" + diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 1af9ea236..c648c19e4 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Services; @@ -438,6 +439,95 @@ public static bool ShouldForceUvxRefresh() return IsLocalServerPath(); } + private static bool _offlineCacheResult; + private static double _offlineCacheTimestamp = -999; + private const double OfflineCacheTtlSeconds = 30.0; + + /// + /// Determines whether uvx should use --offline mode for faster startup. + /// Runs a lightweight probe (uvx --offline ... mcp-for-unity --help) with a 3-second timeout + /// to check if the package is already cached. If cached, --offline skips the network + /// dependency check that can hang for 30+ seconds on poor connections. + /// Returns false if force refresh is enabled (new download needed). + /// The result is cached for 30 seconds to avoid redundant subprocess spawns. + /// Must be called on the main thread (reads EditorPrefs). + /// + public static bool ShouldUseUvxOffline() + { + if (ShouldForceUvxRefresh()) + return false; + return GetCachedOfflineProbeResult(); + } + + private static bool GetCachedOfflineProbeResult() + { + double now = EditorApplication.timeSinceStartup; + if (now - _offlineCacheTimestamp < OfflineCacheTtlSeconds) + return _offlineCacheResult; + + bool result = RunOfflineProbe(); + _offlineCacheResult = result; + _offlineCacheTimestamp = now; + return result; + } + + private static bool RunOfflineProbe() + { + try + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (string.IsNullOrEmpty(uvxPath)) + return false; + + string fromArgs = GetBetaServerFromArgs(quoteFromPath: false); + string probeArgs = string.IsNullOrEmpty(fromArgs) + ? "--offline mcp-for-unity --help" + : $"--offline {fromArgs} mcp-for-unity --help"; + + return ExecPath.TryRun(uvxPath, probeArgs, null, out _, out _, timeoutMs: 3000); + } + catch + { + return false; + } + } + + /// + /// Returns the uvx dev-mode flags as a single string for command-line builders. + /// Returns "--no-cache --refresh " if force refresh is enabled, + /// "--offline " if the cache is warm, or string.Empty otherwise. + /// Must be called on the main thread (reads EditorPrefs). + /// + public static string GetUvxDevFlags() + { + bool forceRefresh = ShouldForceUvxRefresh(); + return GetUvxDevFlags(forceRefresh, !forceRefresh && GetCachedOfflineProbeResult()); + } + + /// + /// Returns the uvx dev-mode flags from pre-captured bool values. + /// Use this overload when values were captured on the main thread for background use. + /// + public static string GetUvxDevFlags(bool forceRefresh, bool useOffline) + { + if (forceRefresh) return "--no-cache --refresh "; + if (useOffline) return "--offline "; + return string.Empty; + } + + /// + /// Returns the uvx dev-mode flags as a list of individual arguments. + /// Suitable for callers that build argument lists (ConfigJsonBuilder, CodexConfigHelper). + /// Must be called on the main thread (reads EditorPrefs). + /// + public static IReadOnlyList GetUvxDevFlagsList() + { + bool forceRefresh = ShouldForceUvxRefresh(); + if (forceRefresh) return new[] { "--no-cache", "--refresh" }; + if (GetCachedOfflineProbeResult()) return new[] { "--offline" }; + return Array.Empty(); + } + /// /// Returns true if the server URL is a local path (file:// or absolute path). /// diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs index a68d47ed7..45ae5a39e 100644 --- a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -17,14 +17,11 @@ namespace MCPForUnity.Editor.Helpers /// public static class CodexConfigHelper { - private static void AddDevModeArgs(TomlArray args) + private static void AddUvxModeFlags(TomlArray args) { if (args == null) return; - // Use central helper that checks both DevModeForceServerRefresh AND local path detection. - // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead - if (!AssetPathUtility.ShouldForceUvxRefresh()) return; - args.Add(new TomlString { Value = "--no-cache" }); - args.Add(new TomlString { Value = "--refresh" }); + foreach (var flag in AssetPathUtility.GetUvxDevFlagsList()) + args.Add(new TomlString { Value = flag }); } public static string BuildCodexServerBlock(string uvPath) @@ -53,7 +50,7 @@ public static string BuildCodexServerBlock(string uvPath) unityMCP["command"] = uvxPath; var args = new TomlArray(); - AddDevModeArgs(args); + AddUvxModeFlags(args); // Use centralized helper for beta server / prerelease args foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) { @@ -205,7 +202,7 @@ private static TomlTable CreateUnityMcpTable(string uvPath) unityMCP["command"] = new TomlString { Value = uvxPath }; var argsArray = new TomlArray(); - AddDevModeArgs(argsArray); + AddUvxModeFlags(argsArray); // Use centralized helper for beta server / prerelease args foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) { diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs index 815477232..8405068dd 100644 --- a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -180,12 +180,8 @@ private static IList BuildUvxArgs(string fromUrl, string packageName) // Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name. var args = new List(); - // Use central helper that checks both DevModeForceServerRefresh AND local path detection. - if (AssetPathUtility.ShouldForceUvxRefresh()) - { - args.Add("--no-cache"); - args.Add("--refresh"); - } + foreach (var flag in AssetPathUtility.GetUvxDevFlagsList()) + args.Add(flag); // Use centralized helper for beta server / prerelease args foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs index 850e27337..ee1050958 100644 --- a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -74,6 +74,12 @@ private static void RunMigrationIfNeeded() if (!ConfigUsesStdIo(configurator.Client)) continue; + // Skip clients that don't support the current transport setting — + // Configure() would throw (e.g., Claude Desktop when HTTP is enabled). + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (useHttp && !configurator.Client.SupportsHttpTransport) + continue; + MCPServiceLocator.Client.ConfigureClient(configurator); touchedAny = true; } diff --git a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs index 47ba7186d..3e505634a 100644 --- a/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs +++ b/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -46,9 +46,7 @@ public bool TryBuildCommand(out string fileName, out string arguments, out strin return false; } - // Use central helper that checks both DevModeForceServerRefresh AND local path detection. - // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead - string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + string devFlags = AssetPathUtility.GetUvxDevFlags(); bool projectScopedTools = EditorPrefs.GetBool( EditorPrefKeys.ProjectScopedToolsLocalHttp, true diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 33ffc8a79..31069022e 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -304,7 +304,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client) string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); - bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh(); + string uvxDevFlags = AssetPathUtility.GetUvxDevFlags(); string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); // Compute pathPrepend on main thread @@ -331,7 +331,7 @@ private void ConfigureClaudeCliAsync(IMcpClientConfigurator client) cliConfigurator.ConfigureWithCapturedValues( projectDir, claudePath, pathPrepend, useHttpTransport, httpUrl, - uvxPath, fromArgs, packageName, shouldForceRefresh, + uvxPath, fromArgs, packageName, uvxDevFlags, apiKey, serverTransport); } return (success: true, error: (string)null); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs new file mode 100644 index 000000000..2d7679513 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnityTests.Editor.Helpers +{ + public class AssetPathUtilityOfflineTests + { + private bool _originalForceRefresh; + + [SetUp] + public void SetUp() + { + _originalForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + } + + [TearDown] + public void TearDown() + { + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalForceRefresh); + } + + [Test] + public void ShouldUseUvxOffline_WhenForceRefreshEnabled_ReturnsFalse() + { + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, true); + Assert.IsFalse(AssetPathUtility.ShouldUseUvxOffline()); + } + + [Test] + public void ShouldUseUvxOffline_DoesNotThrow() + { + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + Assert.DoesNotThrow(() => AssetPathUtility.ShouldUseUvxOffline()); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs.meta new file mode 100644 index 000000000..328664e45 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39ea6f0fc573340d689bf01ef5510153 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs index ec89eda32..8a9039ec2 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -323,10 +323,11 @@ public void SetComponentProperties_ContinuesAfterException() }; // Expect the error logs from the invalid property - // Note: PropertyConversion logs "Error converting token to..." when conversion fails + // Note: PropertyConversion logs "Error converting token to..." when conversion fails, + // then ComponentOps catches the exception and returns an error string (no second Error log). + // GameObjectComponentHelpers logs the failure as a warning. LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Error converting token to UnityEngine.Vector3")); - LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex(@"\[SetProperty\].*Failed to set 'velocity'")); - LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found")); + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex(@"\[ManageGameObject\].*Failed to set property 'velocity'")); // Act var result = ManageGameObject.HandleCommand(setPropertiesParams);