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/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/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/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/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,
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/__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/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..a755aac4
--- /dev/null
+++ b/Server~/src/resources/createSceneResource.ts
@@ -0,0 +1,83 @@
+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}`);
+
+ // Use a template with parameters for the create scene resource
+ const template = new ResourceTemplate(
+ resourceUri,
+ {
+ list: undefined
+ }
+ );
+
+ server.resource(
+ resourceName,
+ template,
+ {
+ description: 'Creates a new Unity scene and saves it to the specified path',
+ mimeType: resourceMimeType
+ },
+ async (uri: URL, variables: Variables, _extra: any) => {
+ 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/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/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..81f5c5aa 100644
--- a/Server~/src/tools/createSceneTool.ts
+++ b/Server~/src/tools/createSceneTool.ts
@@ -1,85 +1,81 @@
-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";
+import { CallToolResult } from '@modelcontextprotocol/sdk/types.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"),
+});
+
+// Type inferred from the schema for type-safe handler params
+type CreateSceneParams = z.infer;
+
+export function registerCreateSceneTool(
+ server: McpServer,
+ mcpUnity: McpUnity,
+ logger: Logger
+) {
+ logger.info(`Registering tool: ${toolName}`);
+
+ server.tool(
+ toolName,
+ toolDescription,
+ paramsSchema.shape,
+ async (params: CreateSceneParams): Promise => {
+ 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: CreateSceneParams): Promise {
+ 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
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..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,9 +52,42 @@ 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);
+ const disableRetry = options.disableSettingsRetry ?? environment.MCP_UNITY_DISABLE_SETTINGS_RETRY === 'true';
+
+ let settingsFile: SettingsFileResult | undefined;
+
+ if (disableRetry) {
+ // Single attempt, no retry - used for testing
+ settingsFile = await findSettingsFile(logger, cwd, modulePath, environment);
+ } 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
+
+ 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