From 34453d1284c76a32a779b24a27ece131a7855a2d Mon Sep 17 00:00:00 2001 From: Kilo Code Date: Sun, 20 Sep 2026 21:23:51 +0300 Subject: [PATCH 1/4] fix: add missing scene resources and improve error handling --- Editor/Resources/CreateSceneResource.cs | 155 +++++++++++++++++ Editor/Resources/GetSceneInfoResource.cs | 84 +++++++++ Editor/UnityBridge/McpUnityServer.cs | 8 + Server~/src/index.ts | 18 +- Server~/src/resources/createSceneResource.ts | 75 ++++++++ Server~/src/resources/getSceneInfoResource.ts | 52 ++++++ Server~/src/tools/createSceneTool.ts | 162 +++++++++--------- 7 files changed, 462 insertions(+), 92 deletions(-) create mode 100644 Editor/Resources/CreateSceneResource.cs create mode 100644 Editor/Resources/GetSceneInfoResource.cs create mode 100644 Server~/src/resources/createSceneResource.ts create mode 100644 Server~/src/resources/getSceneInfoResource.ts diff --git a/Editor/Resources/CreateSceneResource.cs b/Editor/Resources/CreateSceneResource.cs new file mode 100644 index 00000000..8207c3f4 --- /dev/null +++ b/Editor/Resources/CreateSceneResource.cs @@ -0,0 +1,155 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEditor.SceneManagement; +using UnityEditor; +using Newtonsoft.Json.Linq; +using McpUnity.Utils; + +namespace McpUnity.Resources +{ + /// + /// Resource for creating a new Unity scene + /// + public class CreateSceneResource : McpResourceBase + { + public CreateSceneResource() + { + Name = "create_scene"; + Description = "Creates a new scene and saves it to the specified path"; + Uri = "unity://scenes/create"; + } + + /// + /// Create a new scene with the provided parameters + /// + /// Resource parameters as a JObject + /// A JObject containing the result + public override JObject Fetch(JObject parameters) + { + // Parameters + string sceneName = parameters["sceneName"]?.ToObject(); + string folderPath = parameters["folderPath"]?.ToObject(); + bool addToBuildSettings = parameters["addToBuildSettings"]?.ToObject() ?? false; + bool makeActive = parameters["makeActive"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(sceneName)) + { + return new JObject + { + ["success"] = false, + ["message"] = "Required parameter 'sceneName' not provided", + ["error"] = new JObject + { + ["type"] = "validation_error", + ["message"] = "Required parameter 'sceneName' not provided" + } + }; + } + + // Default folder path + if (string.IsNullOrEmpty(folderPath)) + { + folderPath = "Assets"; + } + + // Ensure folder exists + if (!AssetDatabase.IsValidFolder(folderPath)) + { + string[] parts = folderPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + string current = parts.Length > 0 && parts[0] == "Assets" ? "Assets" : "Assets"; + for (int i = 0; i < parts.Length; i++) + { + if (i == 0 && parts[i] == "Assets") continue; + string next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + { + AssetDatabase.CreateFolder(current, parts[i]); + } + current = next; + } + } + + // Create unique path for the scene + string basePath = folderPath.TrimEnd('/'); + string scenePath = AssetDatabase.GenerateUniqueAssetPath($"{basePath}/{sceneName}.unity"); + + try + { + var newScene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); + + bool saved = EditorSceneManager.SaveScene(newScene, scenePath); + if (!saved) + { + return new JObject + { + ["success"] = false, + ["message"] = $"Failed to save scene at '{scenePath}'", + ["error"] = new JObject + { + ["type"] = "save_error", + ["message"] = $"Failed to save scene at '{scenePath}'" + } + }; + } + + AssetDatabase.Refresh(); + + // Make the scene active if requested + if (makeActive) + { + EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); + } + + // Optionally add to build settings + if (addToBuildSettings) + { + AddSceneToBuildSettings(scenePath); + } + + McpLogger.LogInfo($"Created scene '{sceneName}' at path '{scenePath}'"); + + return new JObject + { + ["success"] = true, + ["message"] = $"Successfully created scene '{sceneName}' at path '{scenePath}'", + ["scenePath"] = scenePath + }; + } + catch (Exception ex) + { + return new JObject + { + ["success"] = false, + ["message"] = $"Error creating scene: {ex.Message}", + ["error"] = new JObject + { + ["type"] = "scene_creation_error", + ["message"] = $"Error creating scene: {ex.Message}" + } + }; + } + } + + private void AddSceneToBuildSettings(string scenePath) + { + var scenes = EditorBuildSettings.scenes; + + foreach (var s in scenes) + { + if (s.path == scenePath) + { + return; + } + } + + var newList = new EditorBuildSettingsScene[scenes.Length + 1]; + for (int i = 0; i < scenes.Length; i++) + { + newList[i] = scenes[i]; + } + newList[newList.Length - 1] = new EditorBuildSettingsScene(scenePath, true); + EditorBuildSettings.scenes = newList; + } + } +} \ No newline at end of file diff --git a/Editor/Resources/GetSceneInfoResource.cs b/Editor/Resources/GetSceneInfoResource.cs new file mode 100644 index 00000000..50e1e1dc --- /dev/null +++ b/Editor/Resources/GetSceneInfoResource.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.SceneManagement; +using Newtonsoft.Json.Linq; +using McpUnity.Utils; + +namespace McpUnity.Resources +{ + /// + /// Resource for getting information about the active scene + /// + public class GetSceneInfoResource : McpResourceBase + { + public GetSceneInfoResource() + { + Name = "get_scene_info"; + Description = "Gets information about the active scene and all loaded scenes"; + Uri = "unity://scene/info"; + } + + /// + /// Fetch scene information + /// + /// Resource parameters as a JObject + /// A JObject containing scene information + public override JObject Fetch(JObject parameters) + { + try + { + var sceneInfo = new JObject(); + var activeScene = SceneManager.GetActiveScene(); + + sceneInfo["activeScene"] = new JObject + { + ["name"] = activeScene.name, + ["path"] = activeScene.path, + ["buildIndex"] = activeScene.buildIndex, + ["isLoaded"] = activeScene.isLoaded, + ["isDirty"] = activeScene.isDirty, + ["rootObjectCount"] = activeScene.GetRootGameObjects()?.Length ?? 0 + }; + + var loadedScenes = new JArray(); + int sceneCount = SceneManager.sceneCount; + for (int i = 0; i < sceneCount; i++) + { + Scene scene = SceneManager.GetSceneAt(i); + loadedScenes.Add(new JObject + { + ["name"] = scene.name, + ["path"] = scene.path, + ["buildIndex"] = scene.buildIndex, + ["isLoaded"] = scene.isLoaded, + ["isDirty"] = scene.isDirty + }); + } + + sceneInfo["loadedScenes"] = loadedScenes; + sceneInfo["loadedSceneCount"] = sceneCount; + + return new JObject + { + ["success"] = true, + ["message"] = "Successfully retrieved scene info", + ["sceneInfo"] = sceneInfo + }; + } + catch (Exception ex) + { + return new JObject + { + ["success"] = false, + ["message"] = $"Error getting scene info: {ex.Message}", + ["error"] = new JObject + { + ["type"] = "scene_info_error", + ["message"] = $"Error getting scene info: {ex.Message}" + } + }; + } + } + } +} \ No newline at end of file diff --git a/Editor/UnityBridge/McpUnityServer.cs b/Editor/UnityBridge/McpUnityServer.cs index 1b14e25a..d7a836a4 100644 --- a/Editor/UnityBridge/McpUnityServer.cs +++ b/Editor/UnityBridge/McpUnityServer.cs @@ -915,6 +915,14 @@ private void RegisterResources() GetTestsResource getTestsResource = new GetTestsResource(_testRunnerService); _resources.Add(getTestsResource.Name, getTestsResource); + // Register GetSceneInfoResource + GetSceneInfoResource getSceneInfoResource = new GetSceneInfoResource(); + _resources.Add(getSceneInfoResource.Name, getSceneInfoResource); + + // Register CreateSceneResource + CreateSceneResource createSceneResource = new CreateSceneResource(); + _resources.Add(createSceneResource.Name, createSceneResource); + // Register GetGameObjectResource GetGameObjectResource getGameObjectResource = new GetGameObjectResource(); _resources.Add(getGameObjectResource.Name, getGameObjectResource); diff --git a/Server~/src/index.ts b/Server~/src/index.ts index 3770e538..155f5213 100644 --- a/Server~/src/index.ts +++ b/Server~/src/index.ts @@ -35,6 +35,8 @@ import { registerGetHierarchyResource } from './resources/getScenesHierarchyReso import { registerGetPackagesResource } from './resources/getPackagesResource.js'; import { registerGetAssetsResource } from './resources/getAssetsResource.js'; import { registerGetTestsResource } from './resources/getTestsResource.js'; +import { registerGetSceneInfoResource } from './resources/getSceneInfoResource.js'; +import { registerCreateSceneResource } from './resources/createSceneResource.js'; import { registerGetGameObjectResource } from './resources/getGameObjectResource.js'; import { registerUnityDashboardAppResource } from './resources/unityDashboardAppResource.js'; import { registerGameObjectHandlingPrompt } from './prompts/gameobjectHandlingPrompt.js'; @@ -103,13 +105,15 @@ registerBatchExecuteTool(server, mcpUnity, toolLogger); // Register all resources into the MCP server registerGetTestsResource(server, mcpUnity, resourceLogger); -registerGetGameObjectResource(server, mcpUnity, resourceLogger); -registerGetMenuItemsResource(server, mcpUnity, resourceLogger); -registerGetConsoleLogsResource(server, mcpUnity, resourceLogger); -registerGetHierarchyResource(server, mcpUnity, resourceLogger); -registerGetPackagesResource(server, mcpUnity, resourceLogger); -registerGetAssetsResource(server, mcpUnity, resourceLogger); -registerUnityDashboardAppResource(server, resourceLogger); + registerGetGameObjectResource(server, mcpUnity, resourceLogger); + registerGetMenuItemsResource(server, mcpUnity, resourceLogger); + registerGetConsoleLogsResource(server, mcpUnity, resourceLogger); + registerGetHierarchyResource(server, mcpUnity, resourceLogger); + registerGetPackagesResource(server, mcpUnity, resourceLogger); + registerGetAssetsResource(server, mcpUnity, resourceLogger); + registerUnityDashboardAppResource(server, resourceLogger); + registerGetSceneInfoResource(server, mcpUnity, resourceLogger); + registerCreateSceneResource(server, mcpUnity, resourceLogger); // Register all prompts into the MCP server registerGameObjectHandlingPrompt(server); diff --git a/Server~/src/resources/createSceneResource.ts b/Server~/src/resources/createSceneResource.ts new file mode 100644 index 00000000..b21d210f --- /dev/null +++ b/Server~/src/resources/createSceneResource.ts @@ -0,0 +1,75 @@ +import { Logger } from '../utils/logger.js'; +import { ResourceTemplate, McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { McpUnity } from '../unity/mcpUnity.js'; +import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; +import { McpUnityError, ErrorType } from '../utils/errors.js'; + +export const resourceName = 'create_scene'; +export const resourceUri = 'unity://scenes/create'; +export const resourceMimeType = 'application/json'; + +export function registerCreateSceneResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { + logger.info(`Registering resource: ${resourceName}`); + + server.resource( + resourceName, + resourceUri, + { + description: 'Creates a new Unity scene and saves it to the specified path', + mimeType: resourceMimeType + }, + async (uri, variables) => { + try { + return await resourceHandler(mcpUnity, uri, variables, logger); + } catch (error) { + logger.error(`Error handling resource ${resourceName}: ${error}`); + throw error; + } + } + ); +} + +async function resourceHandler( + mcpUnity: McpUnity, + uri: URL, + variables: Variables, + logger: Logger +): Promise { + const sceneName = variables['sceneName'] as string; + const folderPath = variables['folderPath'] as string || 'Assets'; + const addToBuildSettings = variables['addToBuildSettings'] === 'true'; + const makeActive = variables['makeActive'] !== 'false'; + + if (!sceneName || sceneName.trim() === '') { + throw new McpUnityError( + ErrorType.VALIDATION, + "Required parameter 'sceneName' not provided" + ); + } + + const response = await mcpUnity.sendRequest({ + method: resourceName, + params: { + sceneName, + folderPath, + addToBuildSettings, + makeActive + } + }); + + if (!response.success) { + throw new McpUnityError( + ErrorType.RESOURCE_FETCH, + response.message || 'Failed to create scene' + ); + } + + return { + contents: [{ + uri: resourceUri, + mimeType: resourceMimeType, + text: JSON.stringify(response, null, 2) + }] + }; +} \ No newline at end of file diff --git a/Server~/src/resources/getSceneInfoResource.ts b/Server~/src/resources/getSceneInfoResource.ts new file mode 100644 index 00000000..3709fac1 --- /dev/null +++ b/Server~/src/resources/getSceneInfoResource.ts @@ -0,0 +1,52 @@ +import { Logger } from '../utils/logger.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { McpUnity } from '../unity/mcpUnity.js'; +import { McpUnityError, ErrorType } from '../utils/errors.js'; + +export const resourceName = 'get_scene_info'; +export const resourceUri = 'unity://scene/info'; +export const resourceMimeType = 'application/json'; + +export function registerGetSceneInfoResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { + logger.info(`Registering resource: ${resourceName}`); + + server.resource( + resourceName, + resourceUri, + { + description: 'Gets information about the active scene and all loaded scenes', + mimeType: resourceMimeType + }, + async () => { + try { + return await resourceHandler(mcpUnity); + } catch (error) { + logger.error(`Error handling resource ${resourceName}: ${error}`); + throw error; + } + } + ); +} + +async function resourceHandler(mcpUnity: McpUnity): Promise { + const response = await mcpUnity.sendRequest({ + method: resourceName, + params: {} + }); + + if (!response.success) { + throw new McpUnityError( + ErrorType.RESOURCE_FETCH, + response.message || 'Failed to fetch scene info from Unity' + ); + } + + return { + contents: [{ + uri: resourceUri, + mimeType: resourceMimeType, + text: JSON.stringify(response, null, 2) + }] + }; +} \ No newline at end of file diff --git a/Server~/src/tools/createSceneTool.ts b/Server~/src/tools/createSceneTool.ts index 3c804e69..c2761f2c 100644 --- a/Server~/src/tools/createSceneTool.ts +++ b/Server~/src/tools/createSceneTool.ts @@ -1,85 +1,77 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpUnity } from "../unity/mcpUnity.js"; -import { McpUnityError, ErrorType } from "../utils/errors.js"; -import * as z from "zod"; -import { Logger } from "../utils/logger.js"; - -const toolName = "create_scene"; -const toolDescription = - "Creates a new scene and saves it to the specified path"; - -const paramsSchema = z.object({ - sceneName: z - .string() - .describe("The name of the scene to create (without extension)"), - folderPath: z - .string() - .optional() - .describe("The folder path under 'Assets' to save into (default: Assets)"), - addToBuildSettings: z - .boolean() - .optional() - .describe("Whether to add the scene to Build Settings"), - makeActive: z - .boolean() - .optional() - .describe("Whether to open/make the new scene active after creating it"), -}); - -export function registerCreateSceneTool( - server: McpServer, - mcpUnity: McpUnity, - logger: Logger -) { - logger.info(`Registering tool: ${toolName}`); - - server.tool( - toolName, - toolDescription, - paramsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${toolName}`, params); - const result = await toolHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${toolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${toolName}`, error); - throw error; - } - } - ); -} - -async function toolHandler(mcpUnity: McpUnity, params: any) { - if (!params.sceneName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "'sceneName' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: toolName, - params, - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || "Failed to create scene" - ); - } - - return { - content: [ - { - type: response.type, - text: response.message || "Successfully created scene", - }, - ], - data: { - scenePath: response.scenePath, - }, - }; -} +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpUnity } from "../unity/mcpUnity.js"; +import { Logger } from "../utils/logger.js"; +import * as z from "zod"; + +const toolName = "create_scene"; +const toolDescription = "Creates a new scene and saves it to the specified path"; + +const paramsSchema = z.object({ + sceneName: z + .string() + .describe("The name of the scene to create (without extension)"), + folderPath: z + .string() + .optional() + .describe("The folder path under 'Assets' to save into (default: Assets)"), + addToBuildSettings: z + .boolean() + .optional() + .describe("Whether to add the scene to Build Settings"), + makeActive: z + .boolean() + .optional() + .describe("Whether to open/make the new scene active after creating it"), +}); + +export function registerCreateSceneTool( + server: McpServer, + mcpUnity: McpUnity, + logger: Logger +) { + logger.info(`Registering tool: ${toolName}`); + + server.tool( + toolName, + toolDescription, + paramsSchema.shape, + async (params: any) => { + try { + logger.info(`Executing tool: ${toolName}`, params); + const result = await toolHandler(mcpUnity, params); + logger.info(`Tool execution successful: ${toolName}`); + return result; + } catch (error) { + logger.error(`Tool execution failed: ${toolName}`, error); + throw error; + } + } + ); +} + +async function toolHandler(mcpUnity: McpUnity, params: any) { + if (!params.sceneName) { + throw new Error("'sceneName' must be provided"); + } + + const response = await mcpUnity.sendRequest({ + method: toolName, + params, + }); + + if (!response.success) { + throw new Error(response.message || "Failed to create scene"); + } + + return { + content: [ + { + type: response.type || "text", + text: response.message || "Successfully created scene", + }, + ], + data: { + scenePath: response.scenePath, + }, + }; +} \ No newline at end of file From 8bb79f9a05b03bc4e4c388d4c9e1537fba77dcd9 Mon Sep 17 00:00:00 2001 From: Kilo Code Date: Sun, 20 Sep 2026 23:24:58 +0300 Subject: [PATCH 2/4] fix: standardize error response format with success field for Node compatibility --- Editor/UnityBridge/McpUnitySocketHandler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Editor/UnityBridge/McpUnitySocketHandler.cs b/Editor/UnityBridge/McpUnitySocketHandler.cs index e0c90612..015efad0 100644 --- a/Editor/UnityBridge/McpUnitySocketHandler.cs +++ b/Editor/UnityBridge/McpUnitySocketHandler.cs @@ -75,7 +75,8 @@ public McpUnitySocketHandler(McpUnityServer server, int connectionGeneration) } /// - /// Create a standardized error response + /// Create a standardized error response compatible with both Unity and Node sides. + /// Returns: { "success": false, "type": "text", "message": "...", "error": { "type": "...", "message": "..." } } /// /// Error message /// Type of error @@ -84,6 +85,9 @@ public static JObject CreateErrorResponse(string message, string errorType) { return new JObject { + ["success"] = false, + ["type"] = "text", + ["message"] = message, ["error"] = new JObject { ["type"] = errorType, From f290c8e515ceb432a5491ceef74d9f7c9e776333 Mon Sep 17 00:00:00 2001 From: Kilo Code Date: Mon, 21 Sep 2026 00:09:08 +0300 Subject: [PATCH 3/4] fix: comprehensive fixes for 15 issues - security, performance, architecture, and code quality --- Editor/Services/ConsoleLogsService.cs | 50 +++++++++++++---- Editor/Services/TestRunnerService.cs | 30 ++++++++++- Editor/Tools/BatchExecuteTool.cs | 7 +++ Editor/UnityBridge/McpUnitySettings.cs | 2 +- Editor/Utils/McpBackgroundTick.cs | 16 ++++++ Editor/Utils/McpUtils.cs | 53 +++++++++++++++++-- .../src/resources/getGameObjectResource.ts | 30 ++++++++--- Server~/src/tools/createSceneTool.ts | 8 ++- Server~/src/tools/gameObjectTools.ts | 41 ++++++-------- Server~/src/unity/commandQueue.ts | 8 ++- Server~/src/unity/unityConnectionConfig.ts | 28 +++++++++- Server~/src/utils/logger.ts | 49 +++++++++++++---- 12 files changed, 260 insertions(+), 62 deletions(-) diff --git a/Editor/Services/ConsoleLogsService.cs b/Editor/Services/ConsoleLogsService.cs index 1dbbf01d..b99eb36f 100644 --- a/Editor/Services/ConsoleLogsService.cs +++ b/Editor/Services/ConsoleLogsService.cs @@ -29,6 +29,11 @@ private class LogEntry public DateTime Timestamp { get; set; } } + // Reflection cache for Unity internal API (avoids repeated reflection) + private static MethodInfo _logEntriesGetCountMethod; + private static bool _reflectionInitialized; + private static readonly object _reflectionLock = new object(); + // Constants for log management private const int MaxLogEntries = 1000; private const int CleanupThreshold = 200; // Remove oldest entries when exceeding max @@ -201,20 +206,41 @@ public int GetLogCount() /// /// Check if console was cleared using reflection (for Unity 2022.3) + /// Uses cached reflection to avoid repeated expensive reflection calls /// private void CheckConsoleClearViaReflection() { + // Initialize reflection once + if (!_reflectionInitialized) + { + lock (_reflectionLock) + { + if (!_reflectionInitialized) + { + try + { + var logEntriesType = Type.GetType("UnityEditor.LogEntries,UnityEditor"); + if (logEntriesType != null) + { + _logEntriesGetCountMethod = logEntriesType.GetMethod("GetCount", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[MCP Unity] Failed to initialize LogEntries reflection: {ex.Message}"); + } + _reflectionInitialized = true; + } + } + } + + // If reflection failed to initialize, skip this check + if (_logEntriesGetCountMethod == null) return; + try { - // Get current log counts using LogEntries (internal Unity API) - var logEntriesType = Type.GetType("UnityEditor.LogEntries,UnityEditor"); - if (logEntriesType == null) return; - - var getCountMethod = logEntriesType.GetMethod("GetCount", - BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); - if (getCountMethod == null) return; - - int currentTotalCount = (int)getCountMethod.Invoke(null, null); + int currentTotalCount = (int)_logEntriesGetCountMethod.Invoke(null, null); // If we had logs before, but now we don't, console was likely cleared if (currentTotalCount == 0 && _logEntries.Count > 0) @@ -224,8 +250,10 @@ private void CheckConsoleClearViaReflection() } catch (Exception ex) { - // Just log the error but don't break functionality - Debug.LogError($"[MCP Unity] Error checking console clear: {ex.Message}"); + // Reflection invocation failed - clear cache so it retries next time + _logEntriesGetCountMethod = null; + _reflectionInitialized = false; + Debug.LogWarning($"[MCP Unity] LogEntries reflection failed, will retry: {ex.Message}"); } } diff --git a/Editor/Services/TestRunnerService.cs b/Editor/Services/TestRunnerService.cs index 9af4557a..183c65c5 100644 --- a/Editor/Services/TestRunnerService.cs +++ b/Editor/Services/TestRunnerService.cs @@ -16,13 +16,14 @@ namespace McpUnity.Services /// Service for accessing Unity Test Runner functionality /// Implements ICallbacks for TestRunnerApi. /// - public class TestRunnerService : ITestRunnerService, ICallbacks + public class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable { private readonly TestRunnerApi _testRunnerApi; private TaskCompletionSource _tcs; private bool _returnOnlyFailures; private bool _returnWithLogs; private List _results; + private bool _disposed; /// /// Constructor @@ -34,6 +35,33 @@ public TestRunnerService() _testRunnerApi.RegisterCallbacks(this); } + /// + /// Unregisters callbacks to prevent memory leaks + /// + public void Dispose() + { + if (!_disposed) + { + try + { + _testRunnerApi?.UnregisterCallbacks(this); + } + catch (Exception ex) + { + McpLogger.LogWarning($"[MCP Unity] Error unregistering TestRunner callbacks: {ex.Message}"); + } + _disposed = true; + } + } + + /// + /// Finalizer to ensure cleanup + /// + ~TestRunnerService() + { + Dispose(); + } + /// /// Async retrieval of all tests using TestRunnerApi callbacks /// diff --git a/Editor/Tools/BatchExecuteTool.cs b/Editor/Tools/BatchExecuteTool.cs index 3abef13a..62dd3936 100644 --- a/Editor/Tools/BatchExecuteTool.cs +++ b/Editor/Tools/BatchExecuteTool.cs @@ -14,6 +14,13 @@ namespace McpUnity.Tools /// Tool for executing multiple operations in a single batch request. /// Supports sequential execution, stop-on-error, and atomic rollback. /// + /// + /// Important: Atomic rollback limitation + /// The atomic parameter uses Unity's Undo system for rollback, which only works in the Unity Editor. + /// In Play Mode, builds, or headless/batch mode, the Undo system is not available and atomic rollback will NOT work. + /// When atomic=true is used outside the Editor, operations will still execute sequentially with stop-on-error, + /// but no rollback will occur on failure. Consider this when designing automated workflows. + /// public class BatchExecuteTool : McpToolBase { private readonly Func _getTool; diff --git a/Editor/UnityBridge/McpUnitySettings.cs b/Editor/UnityBridge/McpUnitySettings.cs index 9377cf30..69e63b13 100644 --- a/Editor/UnityBridge/McpUnitySettings.cs +++ b/Editor/UnityBridge/McpUnitySettings.cs @@ -13,7 +13,7 @@ namespace McpUnity.Unity public class McpUnitySettings { // Constants - public const string ServerVersion = "1.2.0"; + public const string ServerVersion = "1.4.0"; public const string PackageName = "com.gamelovers.mcp-unity"; public const int RequestTimeoutMinimum = 10; diff --git a/Editor/Utils/McpBackgroundTick.cs b/Editor/Utils/McpBackgroundTick.cs index 13de6728..b0ddc7fd 100644 --- a/Editor/Utils/McpBackgroundTick.cs +++ b/Editor/Utils/McpBackgroundTick.cs @@ -76,6 +76,22 @@ public static void Stop() #endif } + /// + /// Resets the internal state for testing purposes. + /// Should only be called in test environments. + /// + internal static void ResetForTesting() + { +#if UNITY_EDITOR_WIN + if (_timerId != UIntPtr.Zero) + { + try { KillTimer(IntPtr.Zero, _timerId); } catch { } + _timerId = UIntPtr.Zero; + } + _callbackRunning = 0; +#endif + } + #if UNITY_EDITOR_WIN private static void OnTimer(IntPtr hWnd, uint uMsg, UIntPtr nIDEvent, uint dwTime) { diff --git a/Editor/Utils/McpUtils.cs b/Editor/Utils/McpUtils.cs index 724f5a03..fce800ca 100644 --- a/Editor/Utils/McpUtils.cs +++ b/Editor/Utils/McpUtils.cs @@ -189,6 +189,7 @@ private static string GetIndexJsPath(PathMode mode) /// Gets the absolute path to the Server directory containing package.json (root server dir). /// Works whether MCP Unity is installed via Package Manager or directly in the Assets folder /// + /// The server path, or null if not found public static string GetServerPath() { // First, try to find the package info via Package Manager @@ -218,7 +219,7 @@ public static string GetServerPath() Debug.LogError(errorString); - return errorString; + return null; } /// @@ -622,6 +623,11 @@ private static string GetAntigravityConfigPath() /// /// Gets the path to the GitHub Copilot config file (workspace .vscode/mcp.json) /// + /// + /// GitHub Copilot and VS Code use .vscode/mcp.json in the project root. + /// Other VS Code-based editors (Cursor, Windsurf) may use different paths. + /// This path follows the VS Code workspace settings convention. + /// /// The path to the GitHub Copilot config file private static string GetGitHubCopilotConfigPath() { @@ -892,15 +898,16 @@ public static void RunNpmCommand(string arguments, string workingDirectory) if (useCustomNpmPath) { - // Use the custom path directly + // Use the custom path directly - arguments passed as-is (no shell) startInfo.FileName = npmExecutable; startInfo.Arguments = arguments; } else if (Application.platform == RuntimePlatform.WindowsEditor) { // Fallback to cmd.exe to find 'npm' in PATH + // Escape arguments for cmd.exe startInfo.FileName = "cmd.exe"; - startInfo.Arguments = $"/c npm {arguments}"; + startInfo.Arguments = $"/c npm {EscapeForCmd(arguments)}"; } else // macOS / Linux { @@ -910,8 +917,10 @@ public static void RunNpmCommand(string arguments, string workingDirectory) // Source rc file to init version managers (nvm, fnm, volta) - GUI apps don't inherit shell env string rcFile = shellName == "zsh" ? ".zshrc" : ".bashrc"; + // Escape arguments for shell to prevent injection + string escapedArgs = EscapeForShell(arguments); startInfo.FileName = userShell; - startInfo.Arguments = $"-c \"source ~/{rcFile} 2>/dev/null || true; npm {arguments}\""; + startInfo.Arguments = $"-c \"source ~/{rcFile} 2>/dev/null || true; npm {escapedArgs}\""; // Fallback PATH for common npm locations string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; @@ -956,6 +965,42 @@ public static void RunNpmCommand(string arguments, string workingDirectory) } } + /// + /// Escapes a string for safe use in cmd.exe /c command + /// + private static string EscapeForCmd(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + // Escape special cmd characters: & | < > ^ ( ) % ! " + // Wrap in quotes if contains spaces or special chars + bool needsQuotes = input.Any(c => " &|<>()^%!\"".Contains(c)); + string escaped = input.Replace("^", "^^") + .Replace("&", "^&") + .Replace("|", "^|") + .Replace("<", "^<") + .Replace(">", "^>") + .Replace("(", "^(") + .Replace(")", "^)") + .Replace("%", "^%") + .Replace("!", "^!") + .Replace("\"", "^\""); + + return needsQuotes ? $"\"{escaped}\"" : escaped; + } + + /// + /// Escapes a string for safe use in POSIX shell (bash/zsh) + /// + private static string EscapeForShell(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + // For single-quoted strings in shell, only single quote needs escaping + // Pattern: '...'\''...' (end quote, escaped quote, start quote) + return "'" + input.Replace("'", "'\\''") + "'"; + } + /// /// Returns the appropriate config JObject for merging MCP server settings, /// with special handling for "Claude Code": diff --git a/Server~/src/resources/getGameObjectResource.ts b/Server~/src/resources/getGameObjectResource.ts index 48f14c55..0616cf14 100644 --- a/Server~/src/resources/getGameObjectResource.ts +++ b/Server~/src/resources/getGameObjectResource.ts @@ -11,6 +11,11 @@ const resourceName = 'get_gameobject'; const resourceUri = 'unity://gameobject/{idOrName}'; const resourceMimeType = 'application/json'; +// Cache for GameObject list to avoid repeated hierarchy requests +// TTL: 5 seconds - balance between freshness and performance +const LIST_CACHE_TTL_MS = 5000; +let gameObjectListCache: { data: any; timestamp: number } | null = null; + /** * Creates and registers the GameObject resource with the MCP server * This resource provides access to GameObjects in Unity scenes @@ -24,9 +29,8 @@ export function registerGetGameObjectResource(server: McpServer, mcpUnity: McpUn const resourceTemplate = new ResourceTemplate( resourceUri, { - // This list method is commented because is calling getHierarchyResource every second to the MCP client in the current format. - // TODO: Find a new way to implement this so that it doesn't request the list of game objects so often - list: undefined//async () => listGameObjects(mcpUnity, logger, resourceMimeType) + // Cached list method - fetches hierarchy only when cache expires + list: async () => listGameObjects(mcpUnity, logger, resourceMimeType) } ); logger.info(`Registering resource: ${resourceName}`); @@ -88,13 +92,22 @@ async function resourceHandler(mcpUnity: McpUnity, uri: URL, variables: Variable } /** - * Get a list of all GameObjects in the scene + * Get a list of all GameObjects in the scene (with caching) * @param mcpUnity The McpUnity instance to communicate with Unity * @param logger The logger instance * @param resourceMimeType The MIME type for the resource * @returns A promise that resolves to a list of GameObject resources */ async function listGameObjects(mcpUnity: McpUnity, logger: Logger, resourceMimeType: string) { + const now = Date.now(); + + // Return cached data if still valid + if (gameObjectListCache && (now - gameObjectListCache.timestamp) < LIST_CACHE_TTL_MS) { + logger.debug(`[getGameObjectResource] Returning cached GameObject list (${gameObjectListCache.data.resources.length} resources)`); + return gameObjectListCache.data; + } + + // Fetch fresh hierarchy const hierarchyResponse = await mcpUnity.sendRequest({ method: hierarchyResourceName, params: {} @@ -108,7 +121,7 @@ async function listGameObjects(mcpUnity: McpUnity, logger: Logger, resourceMimeT // Process the hierarchy to create a list of GameObject references const gameObjects = processHierarchyToGameObjectList(hierarchyResponse.hierarchy || []); - logger.info(`[getGameObjectResource] Fetched hierarchy with ${gameObjects.length} GameObjects ${hierarchyResponse.hierarchy}`); + logger.info(`[getGameObjectResource] Fetched hierarchy with ${gameObjects.length} GameObjects`); // Create resources array with both instance ID and path URIs const resources: Array<{ @@ -139,7 +152,12 @@ async function listGameObjects(mcpUnity: McpUnity, logger: Logger, resourceMimeT } }); - return { resources }; + const result = { resources }; + + // Update cache + gameObjectListCache = { data: result, timestamp: now }; + + return result; } /** diff --git a/Server~/src/tools/createSceneTool.ts b/Server~/src/tools/createSceneTool.ts index c2761f2c..81f5c5aa 100644 --- a/Server~/src/tools/createSceneTool.ts +++ b/Server~/src/tools/createSceneTool.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpUnity } from "../unity/mcpUnity.js"; import { Logger } from "../utils/logger.js"; import * as z from "zod"; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; const toolName = "create_scene"; const toolDescription = "Creates a new scene and saves it to the specified path"; @@ -24,6 +25,9 @@ const paramsSchema = z.object({ .describe("Whether to open/make the new scene active after creating it"), }); +// Type inferred from the schema for type-safe handler params +type CreateSceneParams = z.infer; + export function registerCreateSceneTool( server: McpServer, mcpUnity: McpUnity, @@ -35,7 +39,7 @@ export function registerCreateSceneTool( toolName, toolDescription, paramsSchema.shape, - async (params: any) => { + async (params: CreateSceneParams): Promise => { try { logger.info(`Executing tool: ${toolName}`, params); const result = await toolHandler(mcpUnity, params); @@ -49,7 +53,7 @@ export function registerCreateSceneTool( ); } -async function toolHandler(mcpUnity: McpUnity, params: any) { +async function toolHandler(mcpUnity: McpUnity, params: CreateSceneParams): Promise { if (!params.sceneName) { throw new Error("'sceneName' must be provided"); } diff --git a/Server~/src/tools/gameObjectTools.ts b/Server~/src/tools/gameObjectTools.ts index 0dbfdd6b..cc02e1b5 100644 --- a/Server~/src/tools/gameObjectTools.ts +++ b/Server~/src/tools/gameObjectTools.ts @@ -5,6 +5,20 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpUnityError, ErrorType } from '../utils/errors.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +/** + * Validates that either instanceId or objectPath is provided for GameObject operations + * @throws McpUnityError if neither is provided + */ +function validateGameObjectIdentifier(params: any): void { + if ((params.instanceId === undefined || params.instanceId === null) && + (!params.objectPath || params.objectPath.trim() === '')) { + throw new McpUnityError( + ErrorType.VALIDATION, + "Either 'instanceId' or 'objectPath' must be provided" + ); + } +} + // ============================================================================ // Duplicate GameObject Tool // ============================================================================ @@ -45,14 +59,7 @@ export function registerDuplicateGameObjectTool(server: McpServer, mcpUnity: Mcp } async function duplicateHandler(mcpUnity: McpUnity, params: any): Promise { - // Validate parameters - require either instanceId or objectPath - if ((params.instanceId === undefined || params.instanceId === null) && - (!params.objectPath || params.objectPath.trim() === '')) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'instanceId' or 'objectPath' must be provided" - ); - } + validateGameObjectIdentifier(params); const response = await mcpUnity.sendRequest({ method: duplicateToolName, @@ -118,14 +125,7 @@ export function registerDeleteGameObjectTool(server: McpServer, mcpUnity: McpUni } async function deleteHandler(mcpUnity: McpUnity, params: any): Promise { - // Validate parameters - require either instanceId or objectPath - if ((params.instanceId === undefined || params.instanceId === null) && - (!params.objectPath || params.objectPath.trim() === '')) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'instanceId' or 'objectPath' must be provided" - ); - } + validateGameObjectIdentifier(params); const response = await mcpUnity.sendRequest({ method: deleteToolName, @@ -190,14 +190,7 @@ export function registerReparentGameObjectTool(server: McpServer, mcpUnity: McpU } async function reparentHandler(mcpUnity: McpUnity, params: any): Promise { - // Validate parameters - require either instanceId or objectPath - if ((params.instanceId === undefined || params.instanceId === null) && - (!params.objectPath || params.objectPath.trim() === '')) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'instanceId' or 'objectPath' must be provided" - ); - } + validateGameObjectIdentifier(params); const response = await mcpUnity.sendRequest({ method: reparentToolName, diff --git a/Server~/src/unity/commandQueue.ts b/Server~/src/unity/commandQueue.ts index fb4ab389..79df2437 100644 --- a/Server~/src/unity/commandQueue.ts +++ b/Server~/src/unity/commandQueue.ts @@ -67,7 +67,7 @@ export interface EnqueueResult { * Default configuration values */ const DEFAULT_CONFIG = { - maxSize: 100, + maxSize: 1000, defaultTimeout: 60000, // 60 seconds cleanupInterval: 5000 // 5 seconds }; @@ -130,6 +130,12 @@ export class CommandQueue { this.queue.push(queuedCommand); const position = this.queue.length; + // Warn when queue is getting full (80% capacity) + const capacityRatio = this.queue.length / this.config.maxSize; + if (capacityRatio >= 0.8) { + this.logger.warn(`Command queue at ${Math.round(capacityRatio * 100)}% capacity (${this.queue.length}/${this.config.maxSize})`); + } + this.logger.debug(`Queued command ${command.id} (${command.request.method}), position: ${position}/${this.config.maxSize}`); return { diff --git a/Server~/src/unity/unityConnectionConfig.ts b/Server~/src/unity/unityConnectionConfig.ts index 3361fe01..68b187e2 100644 --- a/Server~/src/unity/unityConnectionConfig.ts +++ b/Server~/src/unity/unityConnectionConfig.ts @@ -50,9 +50,35 @@ export async function resolveUnityConnectionConfig( const cwd = path.resolve(options.cwd ?? process.cwd()); const environment = options.environment ?? process.env; const modulePath = path.resolve(options.modulePath ?? process.argv[1] ?? cwd); - const settingsFile = await findSettingsFile(logger, cwd, modulePath, environment); + + // Add retry logic for settings file (handles race condition on first run) + const maxRetries = 30; // 30 seconds max wait + const retryDelayMs = 1000; // 1 second between retries + let settingsFile: SettingsFileResult | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + settingsFile = await findSettingsFile(logger, cwd, modulePath, environment); + const settings = settingsFile?.settings ?? {}; + + // Check if we have a valid port from settings (not just default) + const hasValidPort = settings.Port !== undefined && + typeof settings.Port === 'number' && + settings.Port >= 1 && settings.Port <= 65535; + + if (hasValidPort || attempt === maxRetries) { + // Either we have valid settings, or we've exhausted retries + break; + } + + if (attempt < maxRetries) { + logger.info(`McpUnitySettings.json not found or port not set. Waiting for Unity to create settings... (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + } + const settings = settingsFile?.settings ?? {}; const settingsSource = settingsFile ? `McpUnitySettings.json (${settingsFile.path})` : 'default settings'; + // ... rest of the function const port = resolveIntegerSetting({ environment, diff --git a/Server~/src/utils/logger.ts b/Server~/src/utils/logger.ts index 5c515d8c..19a155ab 100644 --- a/Server~/src/utils/logger.ts +++ b/Server~/src/utils/logger.ts @@ -1,4 +1,4 @@ -import { appendFileSync } from 'fs'; +import { appendFile } from 'fs/promises'; export enum LogLevel { DEBUG = 0, @@ -13,6 +13,35 @@ const isLoggingEnabled = process.env.LOGGING === 'true'; // Check environment variable for logging in a file const isLoggingFileEnabled = process.env.LOGGING_FILE === 'true'; +// Async log queue to avoid blocking the main thread +const logQueue: Array<() => Promise> = []; +let isProcessingQueue = false; + +async function processLogQueue(): Promise { + if (isProcessingQueue || logQueue.length === 0) return; + + isProcessingQueue = true; + + while (logQueue.length > 0) { + const logTask = logQueue.shift(); + if (logTask) { + try { + await logTask(); + } catch (error) { + console.error('Failed to write to log file:', error); + } + } + } + + isProcessingQueue = false; +} + +function enqueueLogWrite(logTask: () => Promise): void { + logQueue.push(logTask); + // Process queue asynchronously without blocking + setImmediate(() => processLogQueue()); +} + export class Logger { private level: LogLevel; private prefix: string; @@ -53,16 +82,14 @@ export class Logger { const levelStr = LogLevel[level]; const logMessage = `[${timestamp}] [${levelStr}] [${this.prefix}] ${message}`; - // Write to file if file logging is enabled + // Write to file if file logging is enabled (async, non-blocking) if (this.isLoggingFileEnabled()) { - try { - appendFileSync('log.txt', logMessage + '\n'); - if (data) { - appendFileSync('log.txt', JSON.stringify(data, null, 2) + '\n'); - } - } catch (error) { - console.error('Failed to write to log file:', error); - } + enqueueLogWrite(async () => { + await appendFile('log.txt', logMessage + '\n'); + if (data) { + await appendFile('log.txt', JSON.stringify(data, null, 2) + '\n'); + } + }); } // Write to console if logging is enabled @@ -74,4 +101,4 @@ export class Logger { } } } -} +} \ No newline at end of file From b7c526dcf2bb8ff8062fb3493b10e39a9cf62a20 Mon Sep 17 00:00:00 2001 From: Kilo Code Date: Mon, 21 Sep 2026 00:28:11 +0300 Subject: [PATCH 4/4] fix: TypeScript build errors + test updates for CommandQueue size and retry logic --- Server~/src/__tests__/commandQueue.test.ts | 2 +- .../__tests__/unityConnectionConfig.test.ts | 3 +- Server~/src/resources/createSceneResource.ts | 12 ++++- Server~/src/unity/unityConnectionConfig.ts | 45 +++++++++++-------- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/Server~/src/__tests__/commandQueue.test.ts b/Server~/src/__tests__/commandQueue.test.ts index c62925c9..d1d46c53 100644 --- a/Server~/src/__tests__/commandQueue.test.ts +++ b/Server~/src/__tests__/commandQueue.test.ts @@ -24,7 +24,7 @@ describe('CommandQueue', () => { describe('constructor', () => { it('should create with default configuration', () => { const stats = queue.getStats(); - expect(stats.maxSize).toBe(100); + expect(stats.maxSize).toBe(1000); expect(stats.size).toBe(0); }); diff --git a/Server~/src/__tests__/unityConnectionConfig.test.ts b/Server~/src/__tests__/unityConnectionConfig.test.ts index 7dd886d3..e1dd75de 100644 --- a/Server~/src/__tests__/unityConnectionConfig.test.ts +++ b/Server~/src/__tests__/unityConnectionConfig.test.ts @@ -109,7 +109,8 @@ describe('Unity connection configuration', () => { const config = await resolveUnityConnectionConfig(logger, { cwd: path.join(temporaryDirectory, 'no-project'), modulePath: path.join(temporaryDirectory, 'global-server', 'build', 'unity', 'mcpUnity.js'), - environment: {} + environment: {}, + disableSettingsRetry: true }); expect(config).toMatchObject({ port: 8090, host: 'localhost', requestTimeout: 10000 }); diff --git a/Server~/src/resources/createSceneResource.ts b/Server~/src/resources/createSceneResource.ts index b21d210f..a755aac4 100644 --- a/Server~/src/resources/createSceneResource.ts +++ b/Server~/src/resources/createSceneResource.ts @@ -12,14 +12,22 @@ export const resourceMimeType = 'application/json'; export function registerCreateSceneResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { logger.info(`Registering resource: ${resourceName}`); + // Use a template with parameters for the create scene resource + const template = new ResourceTemplate( + resourceUri, + { + list: undefined + } + ); + server.resource( resourceName, - resourceUri, + template, { description: 'Creates a new Unity scene and saves it to the specified path', mimeType: resourceMimeType }, - async (uri, variables) => { + async (uri: URL, variables: Variables, _extra: any) => { try { return await resourceHandler(mcpUnity, uri, variables, logger); } catch (error) { diff --git a/Server~/src/unity/unityConnectionConfig.ts b/Server~/src/unity/unityConnectionConfig.ts index 68b187e2..a046326d 100644 --- a/Server~/src/unity/unityConnectionConfig.ts +++ b/Server~/src/unity/unityConnectionConfig.ts @@ -26,6 +26,8 @@ export interface UnityConnectionConfigResolutionOptions { cwd?: string; environment?: NodeJS.ProcessEnv; modulePath?: string; + /** Disable settings file retry logic (for testing) */ + disableSettingsRetry?: boolean; } export interface ResolvedUnityConnectionConfig { @@ -50,29 +52,36 @@ export async function resolveUnityConnectionConfig( const cwd = path.resolve(options.cwd ?? process.cwd()); const environment = options.environment ?? process.env; const modulePath = path.resolve(options.modulePath ?? process.argv[1] ?? cwd); + const disableRetry = options.disableSettingsRetry ?? environment.MCP_UNITY_DISABLE_SETTINGS_RETRY === 'true'; - // Add retry logic for settings file (handles race condition on first run) - const maxRetries = 30; // 30 seconds max wait - const retryDelayMs = 1000; // 1 second between retries let settingsFile: SettingsFileResult | undefined; - for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (disableRetry) { + // Single attempt, no retry - used for testing settingsFile = await findSettingsFile(logger, cwd, modulePath, environment); - const settings = settingsFile?.settings ?? {}; + } else { + // Add retry logic for settings file (handles race condition on first run) + const maxRetries = 30; // 30 seconds max wait + const retryDelayMs = 1000; // 1 second between retries - // Check if we have a valid port from settings (not just default) - const hasValidPort = settings.Port !== undefined && - typeof settings.Port === 'number' && - settings.Port >= 1 && settings.Port <= 65535; - - if (hasValidPort || attempt === maxRetries) { - // Either we have valid settings, or we've exhausted retries - break; - } - - if (attempt < maxRetries) { - logger.info(`McpUnitySettings.json not found or port not set. Waiting for Unity to create settings... (attempt ${attempt + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + for (let attempt = 0; attempt <= maxRetries; attempt++) { + settingsFile = await findSettingsFile(logger, cwd, modulePath, environment); + const settings = settingsFile?.settings ?? {}; + + // Check if we have a valid port from settings (not just default) + const hasValidPort = settings.Port !== undefined && + typeof settings.Port === 'number' && + settings.Port >= 1 && settings.Port <= 65535; + + if (hasValidPort || attempt === maxRetries) { + // Either we have valid settings, or we've exhausted retries + break; + } + + if (attempt < maxRetries) { + logger.info(`McpUnitySettings.json not found or port not set. Waiting for Unity to create settings... (attempt ${attempt + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } } }