From c697469c25d58a48ae0f8ce4f60ff438e585e23a Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 24 Jul 2026 01:54:18 +0100 Subject: [PATCH 01/20] feat: replace Unity bridge with Pipeline extensions --- Editor/Commands.meta | 8 + Editor/Commands/AssignMaterialCommand.cs | 50 + .../AssignMaterialCommand.cs.meta} | 2 +- Editor/Commands/CommandObjectResolver.cs | 28 + .../CommandObjectResolver.cs.meta} | 2 +- Editor/Commands/CommandResults.cs | 94 ++ .../CommandResults.cs.meta} | 3 +- Editor/Commands/DuplicateGameObjectCommand.cs | 55 + .../DuplicateGameObjectCommand.cs.meta} | 8 +- Editor/Commands/EditorStepCommand.cs | 22 + Editor/Commands/EditorStepCommand.cs.meta | 11 + Editor/Commands/InspectGameObjectCommand.cs | 226 ++++ .../Commands/InspectGameObjectCommand.cs.meta | 11 + .../Commands/SerializedPropertyValueReader.cs | 126 ++ .../SerializedPropertyValueReader.cs.meta | 11 + Editor/Commands/UnloadSceneCommand.cs | 84 ++ Editor/Commands/UnloadSceneCommand.cs.meta | 11 + Editor/Lib.meta | 8 - Editor/Lib/websocket-sharp.dll | Bin 250368 -> 0 bytes Editor/Lib/websocket-sharp.dll.meta | 33 - Editor/McpUnity.Editor.asmdef | 15 +- Editor/Models.meta | 8 - Editor/Models/UpdateGameObjectRequest.cs | 30 - Editor/Models/UpdateGameObjectRequest.cs.meta | 11 - Editor/Resources.meta | 8 - Editor/Resources/GetAssetsResource.cs | 114 -- Editor/Resources/GetAssetsResource.cs.meta | 11 - Editor/Resources/GetConsoleLogsResource.cs | 89 -- .../Resources/GetConsoleLogsResource.cs.meta | 11 - Editor/Resources/GetGameObjectResource.cs | 605 --------- Editor/Resources/GetMenuItemsResource.cs | 83 -- Editor/Resources/GetMenuItemsResource.cs.meta | 11 - Editor/Resources/GetPackagesResource.cs | 151 --- Editor/Resources/GetPackagesResource.cs.meta | 11 - .../Resources/GetScenesHierarchyResource.cs | 89 -- .../GetScenesHierarchyResource.cs.meta | 11 - Editor/Resources/GetTestsResource.cs | 59 - Editor/Resources/GetTestsResource.cs.meta | 11 - Editor/Resources/McpResourceBase.cs | 64 - Editor/Resources/McpResourceBase.cs.meta | 11 - Editor/Services.meta | 8 - Editor/Services/ConsoleLogsService.cs | 273 ---- Editor/Services/ConsoleLogsService.cs.meta | 11 - Editor/Services/IConsoleLogsService.cs | 45 - Editor/Services/IConsoleLogsService.cs.meta | 11 - Editor/Services/ITestRunnerService.cs | 30 - Editor/Services/ITestRunnerService.cs.meta | 11 - Editor/Services/TestRunnerService.cs | 228 ---- Editor/Services/TestRunnerService.cs.meta | 11 - Editor/Tests/AssignMaterialCommandTests.cs | 157 +++ .../Tests/AssignMaterialCommandTests.cs.meta | 11 + Editor/Tests/BatchExecuteToolTests.cs | 501 ------- Editor/Tests/BatchExecuteToolTests.cs.meta | 2 - Editor/Tests/CommandDiscoveryTests.cs | 90 ++ Editor/Tests/CommandDiscoveryTests.cs.meta | 11 + .../Tests/DuplicateGameObjectCommandTests.cs | 121 ++ .../DuplicateGameObjectCommandTests.cs.meta | 11 + Editor/Tests/EditorStepCommandTests.cs | 17 + Editor/Tests/EditorStepCommandTests.cs.meta | 11 + Editor/Tests/GetGameObjectResourceTests.cs | 176 --- .../Tests/GetGameObjectResourceTests.cs.meta | 2 - Editor/Tests/InspectionCommandTests.cs | 165 +++ Editor/Tests/InspectionCommandTests.cs.meta | 11 + Editor/Tests/MaterialToolsTests.cs | 670 ---------- .../Tests/McpBackgroundTickLifecycleTests.cs | 104 -- .../McpBackgroundTickLifecycleTests.cs.meta | 2 - Editor/Tests/McpUnity.Editor.Tests.asmdef | 12 +- Editor/Tests/McpUnityServerRetryTests.cs | 150 --- Editor/Tests/McpUnityServerRetryTests.cs.meta | 11 - Editor/Tests/PackageContractTests.cs | 53 + Editor/Tests/PackageContractTests.cs.meta | 11 + Editor/Tests/PathHandlingTests.cs | 167 --- Editor/Tests/PathHandlingTests.cs.meta | 2 - Editor/Tests/UnloadSceneCommandTests.cs | 113 ++ Editor/Tests/UnloadSceneCommandTests.cs.meta | 11 + Editor/Tests/UpdateComponentToolTests.cs | 149 --- Editor/Tests/UpdateComponentToolTests.cs.meta | 2 - Editor/Tools.meta | 3 - Editor/Tools/AddAssetToSceneTool.cs | 146 --- Editor/Tools/AddAssetToSceneTool.cs.meta | 11 - Editor/Tools/AddPackageTool.cs | 342 ----- Editor/Tools/AddPackageTool.cs.meta | 11 - Editor/Tools/BatchExecuteTool.cs | 312 ----- Editor/Tools/BatchExecuteTool.cs.meta | 2 - Editor/Tools/CreatePrefabTool.cs | 175 --- Editor/Tools/CreatePrefabTool.cs.meta | 2 - Editor/Tools/CreateSceneTool.cs | 139 -- Editor/Tools/CreateSceneTool.cs.meta | 2 - Editor/Tools/DeleteSceneTool.cs | 133 -- Editor/Tools/DeleteSceneTool.cs.meta | 2 - Editor/Tools/GameObjectTools.cs | 430 ------ Editor/Tools/GameObjectTools.cs.meta | 2 - Editor/Tools/GetConsoleLogsTool.cs | 79 -- Editor/Tools/GetConsoleLogsTool.cs.meta | 2 - Editor/Tools/GetGameObjectTool.cs | 94 -- Editor/Tools/GetPlayModeStatusTool.cs | 49 - Editor/Tools/GetPlayModeStatusTool.cs.meta | 2 - Editor/Tools/GetSceneInfoTool.cs | 89 -- Editor/Tools/GetSceneInfoTool.cs.meta | 2 - Editor/Tools/LoadSceneTool.cs | 114 -- Editor/Tools/LoadSceneTool.cs.meta | 2 - Editor/Tools/MaterialTools.cs | 814 ------------ Editor/Tools/McpToolBase.cs | 62 - Editor/Tools/McpToolBase.cs.meta | 11 - Editor/Tools/MenuItemTool.cs | 55 - Editor/Tools/MenuItemTool.cs.meta | 11 - Editor/Tools/RecompileScriptsTool.cs | 225 ---- Editor/Tools/RecompileScriptsTool.cs.meta | 3 - Editor/Tools/RunTestsTool.cs | 56 - Editor/Tools/RunTestsTool.cs.meta | 11 - Editor/Tools/SaveSceneTool.cs | 144 -- Editor/Tools/SaveSceneTool.cs.meta | 2 - Editor/Tools/SelectGameObjectTool.cs | 75 -- Editor/Tools/SelectGameObjectTool.cs.meta | 11 - Editor/Tools/SendConsoleLogTool.cs | 60 - Editor/Tools/SendConsoleLogTool.cs.meta | 11 - Editor/Tools/SetPlayModeStatusTool.cs | 123 -- Editor/Tools/SetPlayModeStatusTool.cs.meta | 2 - Editor/Tools/TransformTools.cs | 484 ------- Editor/Tools/TransformTools.cs.meta | 2 - Editor/Tools/UnloadSceneTool.cs | 118 -- Editor/Tools/UnloadSceneTool.cs.meta | 2 - Editor/Tools/UpdateComponentTool.cs | 858 ------------ Editor/Tools/UpdateComponentTool.cs.meta | 11 - Editor/Tools/UpdateGameObjectTool.cs | 161 --- Editor/Tools/UpdateGameObjectTool.cs.meta | 11 - Editor/UnityBridge.meta | 8 - Editor/UnityBridge/McpUnityEditorWindow.cs | 690 ---------- .../UnityBridge/McpUnityEditorWindow.cs.meta | 11 - Editor/UnityBridge/McpUnityServer.cs | 795 ----------- Editor/UnityBridge/McpUnityServer.cs.meta | 11 - Editor/UnityBridge/McpUnitySettings.cs | 113 -- Editor/UnityBridge/McpUnitySettings.cs.meta | 11 - Editor/UnityBridge/McpUnitySocketHandler.cs | 376 ------ .../UnityBridge/McpUnitySocketHandler.cs.meta | 11 - Editor/Utils.meta | 3 - Editor/Utils/GameObjectHierarchyCreator.cs | 71 - .../Utils/GameObjectHierarchyCreator.cs.meta | 11 - Editor/Utils/Logger.cs | 43 - Editor/Utils/Logger.cs.meta | 11 - Editor/Utils/McpBackgroundTick.cs | 110 -- Editor/Utils/McpBackgroundTick.cs.meta | 2 - Editor/Utils/McpUtils.cs | 1165 ----------------- Editor/Utils/McpUtils.cs.meta | 11 - Editor/Utils/UnityObjectId.cs | 48 - Editor/Utils/UnityObjectId.cs.meta | 11 - Editor/Utils/VsCodeWorkspaceUtils.cs | 140 -- Editor/Utils/VsCodeWorkspaceUtils.cs.meta | 11 - package.json | 11 +- 149 files changed, 1554 insertions(+), 13155 deletions(-) create mode 100644 Editor/Commands.meta create mode 100644 Editor/Commands/AssignMaterialCommand.cs rename Editor/{Tests/MaterialToolsTests.cs.meta => Commands/AssignMaterialCommand.cs.meta} (83%) create mode 100644 Editor/Commands/CommandObjectResolver.cs rename Editor/{Tools/MaterialTools.cs.meta => Commands/CommandObjectResolver.cs.meta} (83%) create mode 100644 Editor/Commands/CommandResults.cs rename Editor/{Tools/GetGameObjectTool.cs.meta => Commands/CommandResults.cs.meta} (83%) create mode 100644 Editor/Commands/DuplicateGameObjectCommand.cs rename Editor/{Resources/GetGameObjectResource.cs.meta => Commands/DuplicateGameObjectCommand.cs.meta} (60%) create mode 100644 Editor/Commands/EditorStepCommand.cs create mode 100644 Editor/Commands/EditorStepCommand.cs.meta create mode 100644 Editor/Commands/InspectGameObjectCommand.cs create mode 100644 Editor/Commands/InspectGameObjectCommand.cs.meta create mode 100644 Editor/Commands/SerializedPropertyValueReader.cs create mode 100644 Editor/Commands/SerializedPropertyValueReader.cs.meta create mode 100644 Editor/Commands/UnloadSceneCommand.cs create mode 100644 Editor/Commands/UnloadSceneCommand.cs.meta delete mode 100644 Editor/Lib.meta delete mode 100644 Editor/Lib/websocket-sharp.dll delete mode 100644 Editor/Lib/websocket-sharp.dll.meta delete mode 100644 Editor/Models.meta delete mode 100644 Editor/Models/UpdateGameObjectRequest.cs delete mode 100644 Editor/Models/UpdateGameObjectRequest.cs.meta delete mode 100644 Editor/Resources.meta delete mode 100644 Editor/Resources/GetAssetsResource.cs delete mode 100644 Editor/Resources/GetAssetsResource.cs.meta delete mode 100644 Editor/Resources/GetConsoleLogsResource.cs delete mode 100644 Editor/Resources/GetConsoleLogsResource.cs.meta delete mode 100644 Editor/Resources/GetGameObjectResource.cs delete mode 100644 Editor/Resources/GetMenuItemsResource.cs delete mode 100644 Editor/Resources/GetMenuItemsResource.cs.meta delete mode 100644 Editor/Resources/GetPackagesResource.cs delete mode 100644 Editor/Resources/GetPackagesResource.cs.meta delete mode 100644 Editor/Resources/GetScenesHierarchyResource.cs delete mode 100644 Editor/Resources/GetScenesHierarchyResource.cs.meta delete mode 100644 Editor/Resources/GetTestsResource.cs delete mode 100644 Editor/Resources/GetTestsResource.cs.meta delete mode 100644 Editor/Resources/McpResourceBase.cs delete mode 100644 Editor/Resources/McpResourceBase.cs.meta delete mode 100644 Editor/Services.meta delete mode 100644 Editor/Services/ConsoleLogsService.cs delete mode 100644 Editor/Services/ConsoleLogsService.cs.meta delete mode 100644 Editor/Services/IConsoleLogsService.cs delete mode 100644 Editor/Services/IConsoleLogsService.cs.meta delete mode 100644 Editor/Services/ITestRunnerService.cs delete mode 100644 Editor/Services/ITestRunnerService.cs.meta delete mode 100644 Editor/Services/TestRunnerService.cs delete mode 100644 Editor/Services/TestRunnerService.cs.meta create mode 100644 Editor/Tests/AssignMaterialCommandTests.cs create mode 100644 Editor/Tests/AssignMaterialCommandTests.cs.meta delete mode 100644 Editor/Tests/BatchExecuteToolTests.cs delete mode 100644 Editor/Tests/BatchExecuteToolTests.cs.meta create mode 100644 Editor/Tests/CommandDiscoveryTests.cs create mode 100644 Editor/Tests/CommandDiscoveryTests.cs.meta create mode 100644 Editor/Tests/DuplicateGameObjectCommandTests.cs create mode 100644 Editor/Tests/DuplicateGameObjectCommandTests.cs.meta create mode 100644 Editor/Tests/EditorStepCommandTests.cs create mode 100644 Editor/Tests/EditorStepCommandTests.cs.meta delete mode 100644 Editor/Tests/GetGameObjectResourceTests.cs delete mode 100644 Editor/Tests/GetGameObjectResourceTests.cs.meta create mode 100644 Editor/Tests/InspectionCommandTests.cs create mode 100644 Editor/Tests/InspectionCommandTests.cs.meta delete mode 100644 Editor/Tests/MaterialToolsTests.cs delete mode 100644 Editor/Tests/McpBackgroundTickLifecycleTests.cs delete mode 100644 Editor/Tests/McpBackgroundTickLifecycleTests.cs.meta delete mode 100644 Editor/Tests/McpUnityServerRetryTests.cs delete mode 100644 Editor/Tests/McpUnityServerRetryTests.cs.meta create mode 100644 Editor/Tests/PackageContractTests.cs create mode 100644 Editor/Tests/PackageContractTests.cs.meta delete mode 100644 Editor/Tests/PathHandlingTests.cs delete mode 100644 Editor/Tests/PathHandlingTests.cs.meta create mode 100644 Editor/Tests/UnloadSceneCommandTests.cs create mode 100644 Editor/Tests/UnloadSceneCommandTests.cs.meta delete mode 100644 Editor/Tests/UpdateComponentToolTests.cs delete mode 100644 Editor/Tests/UpdateComponentToolTests.cs.meta delete mode 100644 Editor/Tools.meta delete mode 100644 Editor/Tools/AddAssetToSceneTool.cs delete mode 100644 Editor/Tools/AddAssetToSceneTool.cs.meta delete mode 100644 Editor/Tools/AddPackageTool.cs delete mode 100644 Editor/Tools/AddPackageTool.cs.meta delete mode 100644 Editor/Tools/BatchExecuteTool.cs delete mode 100644 Editor/Tools/BatchExecuteTool.cs.meta delete mode 100644 Editor/Tools/CreatePrefabTool.cs delete mode 100644 Editor/Tools/CreatePrefabTool.cs.meta delete mode 100644 Editor/Tools/CreateSceneTool.cs delete mode 100644 Editor/Tools/CreateSceneTool.cs.meta delete mode 100644 Editor/Tools/DeleteSceneTool.cs delete mode 100644 Editor/Tools/DeleteSceneTool.cs.meta delete mode 100644 Editor/Tools/GameObjectTools.cs delete mode 100644 Editor/Tools/GameObjectTools.cs.meta delete mode 100644 Editor/Tools/GetConsoleLogsTool.cs delete mode 100644 Editor/Tools/GetConsoleLogsTool.cs.meta delete mode 100644 Editor/Tools/GetGameObjectTool.cs delete mode 100644 Editor/Tools/GetPlayModeStatusTool.cs delete mode 100644 Editor/Tools/GetPlayModeStatusTool.cs.meta delete mode 100644 Editor/Tools/GetSceneInfoTool.cs delete mode 100644 Editor/Tools/GetSceneInfoTool.cs.meta delete mode 100644 Editor/Tools/LoadSceneTool.cs delete mode 100644 Editor/Tools/LoadSceneTool.cs.meta delete mode 100644 Editor/Tools/MaterialTools.cs delete mode 100644 Editor/Tools/McpToolBase.cs delete mode 100644 Editor/Tools/McpToolBase.cs.meta delete mode 100644 Editor/Tools/MenuItemTool.cs delete mode 100644 Editor/Tools/MenuItemTool.cs.meta delete mode 100644 Editor/Tools/RecompileScriptsTool.cs delete mode 100644 Editor/Tools/RecompileScriptsTool.cs.meta delete mode 100644 Editor/Tools/RunTestsTool.cs delete mode 100644 Editor/Tools/RunTestsTool.cs.meta delete mode 100644 Editor/Tools/SaveSceneTool.cs delete mode 100644 Editor/Tools/SaveSceneTool.cs.meta delete mode 100644 Editor/Tools/SelectGameObjectTool.cs delete mode 100644 Editor/Tools/SelectGameObjectTool.cs.meta delete mode 100644 Editor/Tools/SendConsoleLogTool.cs delete mode 100644 Editor/Tools/SendConsoleLogTool.cs.meta delete mode 100644 Editor/Tools/SetPlayModeStatusTool.cs delete mode 100644 Editor/Tools/SetPlayModeStatusTool.cs.meta delete mode 100644 Editor/Tools/TransformTools.cs delete mode 100644 Editor/Tools/TransformTools.cs.meta delete mode 100644 Editor/Tools/UnloadSceneTool.cs delete mode 100644 Editor/Tools/UnloadSceneTool.cs.meta delete mode 100644 Editor/Tools/UpdateComponentTool.cs delete mode 100644 Editor/Tools/UpdateComponentTool.cs.meta delete mode 100644 Editor/Tools/UpdateGameObjectTool.cs delete mode 100644 Editor/Tools/UpdateGameObjectTool.cs.meta delete mode 100644 Editor/UnityBridge.meta delete mode 100644 Editor/UnityBridge/McpUnityEditorWindow.cs delete mode 100644 Editor/UnityBridge/McpUnityEditorWindow.cs.meta delete mode 100644 Editor/UnityBridge/McpUnityServer.cs delete mode 100644 Editor/UnityBridge/McpUnityServer.cs.meta delete mode 100644 Editor/UnityBridge/McpUnitySettings.cs delete mode 100644 Editor/UnityBridge/McpUnitySettings.cs.meta delete mode 100644 Editor/UnityBridge/McpUnitySocketHandler.cs delete mode 100644 Editor/UnityBridge/McpUnitySocketHandler.cs.meta delete mode 100644 Editor/Utils.meta delete mode 100644 Editor/Utils/GameObjectHierarchyCreator.cs delete mode 100644 Editor/Utils/GameObjectHierarchyCreator.cs.meta delete mode 100644 Editor/Utils/Logger.cs delete mode 100644 Editor/Utils/Logger.cs.meta delete mode 100644 Editor/Utils/McpBackgroundTick.cs delete mode 100644 Editor/Utils/McpBackgroundTick.cs.meta delete mode 100644 Editor/Utils/McpUtils.cs delete mode 100644 Editor/Utils/McpUtils.cs.meta delete mode 100644 Editor/Utils/UnityObjectId.cs delete mode 100644 Editor/Utils/UnityObjectId.cs.meta delete mode 100644 Editor/Utils/VsCodeWorkspaceUtils.cs delete mode 100644 Editor/Utils/VsCodeWorkspaceUtils.cs.meta diff --git a/Editor/Commands.meta b/Editor/Commands.meta new file mode 100644 index 00000000..94467340 --- /dev/null +++ b/Editor/Commands.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0a731b41c2154a8296cfd67165517aac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Commands/AssignMaterialCommand.cs b/Editor/Commands/AssignMaterialCommand.cs new file mode 100644 index 00000000..9c7be8d3 --- /dev/null +++ b/Editor/Commands/AssignMaterialCommand.cs @@ -0,0 +1,50 @@ +using System; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace McpUnity.Extensions.Commands +{ + public static class AssignMaterialCommand + { + [CliCommand("assign_material", "Assign a Material to a Renderer shared-material slot.")] + public static AssignMaterialResult Assign( + [CliArg("game_object", "GameObject reference whose Renderer will be edited.", Required = true)] ObjectRef gameObject, + [CliArg("material", "Material reference to assign.", Required = true)] ObjectRef material, + [CliArg("slot", "Renderer shared-material slot index.")] int slot = 0) + { + var target = CommandObjectResolver.Resolve(gameObject, "game_object"); + var assignedMaterial = CommandObjectResolver.Resolve(material, "material"); + var renderer = target.GetComponent(); + if (renderer == null) + throw new InvalidOperationException($"GameObject '{target.name}' does not have a Renderer."); + + var sharedMaterials = renderer.sharedMaterials; + if (slot < 0 || slot >= sharedMaterials.Length) + { + throw new ArgumentOutOfRangeException( + nameof(slot), + slot, + $"Slot must be between 0 and {sharedMaterials.Length - 1} for Renderer '{renderer.name}'."); + } + + Undo.RecordObject(renderer, "Assign Material"); + sharedMaterials[slot] = assignedMaterial; + renderer.sharedMaterials = sharedMaterials; + EditorUtility.SetDirty(renderer); + if (target.scene.IsValid()) + EditorSceneManager.MarkSceneDirty(target.scene); + PrefabUtility.RecordPrefabInstancePropertyModifications(renderer); + + return new AssignMaterialResult + { + GameObject = ObjectResolver.Describe(target), + Material = ObjectResolver.Describe(assignedMaterial), + Slot = slot + }; + } + } +} diff --git a/Editor/Tests/MaterialToolsTests.cs.meta b/Editor/Commands/AssignMaterialCommand.cs.meta similarity index 83% rename from Editor/Tests/MaterialToolsTests.cs.meta rename to Editor/Commands/AssignMaterialCommand.cs.meta index 068fdb3e..a540357e 100644 --- a/Editor/Tests/MaterialToolsTests.cs.meta +++ b/Editor/Commands/AssignMaterialCommand.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 805928c97ab04ebeb48861191f722287 +guid: 1e03020ef7f6484b8e70a4f5f0908cdb MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Editor/Commands/CommandObjectResolver.cs b/Editor/Commands/CommandObjectResolver.cs new file mode 100644 index 00000000..fde3e149 --- /dev/null +++ b/Editor/Commands/CommandObjectResolver.cs @@ -0,0 +1,28 @@ +using System; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEngine; + +namespace McpUnity.Extensions.Commands +{ + internal static class CommandObjectResolver + { + public static T Resolve(ObjectRef reference, string argumentName) + where T : UnityEngine.Object + { + if (reference == null || reference.IsEmpty) + throw new ArgumentException($"'{argumentName}' is required."); + + if (!ObjectResolver.TryResolve(reference, out var resolved, out var error)) + throw new ArgumentException($"Could not resolve '{argumentName}': {error}"); + + if (!(resolved is T typed)) + { + throw new ArgumentException( + $"'{argumentName}' resolved to {resolved.GetType().Name}, not {typeof(T).Name}."); + } + + return typed; + } + } +} diff --git a/Editor/Tools/MaterialTools.cs.meta b/Editor/Commands/CommandObjectResolver.cs.meta similarity index 83% rename from Editor/Tools/MaterialTools.cs.meta rename to Editor/Commands/CommandObjectResolver.cs.meta index 74c237a8..08d965ae 100644 --- a/Editor/Tools/MaterialTools.cs.meta +++ b/Editor/Commands/CommandObjectResolver.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c66563b66ef943c78ce4eaabe4ddf0fa +guid: 0c28ab6ef485433cab1afba28d4b4658 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Editor/Commands/CommandResults.cs b/Editor/Commands/CommandResults.cs new file mode 100644 index 00000000..48f493d5 --- /dev/null +++ b/Editor/Commands/CommandResults.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using Unity.Pipeline.Models; + +namespace McpUnity.Extensions.Commands +{ + public sealed class InspectGameObjectResult + { + public GameObjectInspection Root { get; set; } + public int MaxDepth { get; set; } + public int MaxNodes { get; set; } + public int MaxPropertiesPerComponent { get; set; } + public int NodesReturned { get; set; } + public bool NodeLimitReached { get; set; } + } + + public sealed class GameObjectInspection + { + public AuthoringResult Identity { get; set; } + public string Name { get; set; } + public string Path { get; set; } + public string ScenePath { get; set; } + public bool ActiveSelf { get; set; } + public bool ActiveInHierarchy { get; set; } + public int Layer { get; set; } + public string LayerName { get; set; } + public string Tag { get; set; } + public bool IsStatic { get; set; } + public TransformInspection Transform { get; set; } + public int ChildCount { get; set; } + public List Children { get; set; } = new List(); + public bool ChildrenTruncated { get; set; } + public bool ComponentsIncluded { get; set; } + public int ComponentCount { get; set; } + public List Components { get; set; } = new List(); + } + + public sealed class TransformInspection + { + public Vector3Inspection LocalPosition { get; set; } + public Vector3Inspection LocalEulerAngles { get; set; } + public Vector3Inspection LocalScale { get; set; } + public Vector3Inspection WorldPosition { get; set; } + public Vector3Inspection WorldEulerAngles { get; set; } + } + + public sealed class Vector3Inspection + { + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + } + + public sealed class ComponentInspection + { + public AuthoringResult Identity { get; set; } + public string Type { get; set; } + public bool Missing { get; set; } + public bool? Enabled { get; set; } + public bool PropertiesIncluded { get; set; } + public int SerializedPropertyCount { get; set; } + public List Properties { get; set; } = + new List(); + public bool PropertiesTruncated { get; set; } + public string PropertiesError { get; set; } + } + + public sealed class SerializedPropertyInspection + { + public string Name { get; set; } + public string Path { get; set; } + public string Type { get; set; } + public object Value { get; set; } + } + + public sealed class UnloadSceneResult + { + public string UnloadedPath { get; set; } + public string ActiveSceneName { get; set; } + public string ActiveScenePath { get; set; } + } + + public sealed class EditorStepResult + { + public bool IsPlaying { get; set; } + public bool IsPaused { get; set; } + } + + public sealed class AssignMaterialResult + { + public AuthoringResult GameObject { get; set; } + public AuthoringResult Material { get; set; } + public int Slot { get; set; } + } +} diff --git a/Editor/Tools/GetGameObjectTool.cs.meta b/Editor/Commands/CommandResults.cs.meta similarity index 83% rename from Editor/Tools/GetGameObjectTool.cs.meta rename to Editor/Commands/CommandResults.cs.meta index 3597710d..89e93b07 100644 --- a/Editor/Tools/GetGameObjectTool.cs.meta +++ b/Editor/Commands/CommandResults.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 405fbb4324c64fb0942faed640b82da2 +guid: 30dca5dbf19e4132a171136acdfd1d02 MonoImporter: externalObjects: {} serializedVersion: 2 @@ -9,4 +9,3 @@ MonoImporter: userData: assetBundleName: assetBundleVariant: - diff --git a/Editor/Commands/DuplicateGameObjectCommand.cs b/Editor/Commands/DuplicateGameObjectCommand.cs new file mode 100644 index 00000000..ae35ed31 --- /dev/null +++ b/Editor/Commands/DuplicateGameObjectCommand.cs @@ -0,0 +1,55 @@ +using System; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +namespace McpUnity.Extensions.Commands +{ + public static class DuplicateGameObjectCommand + { + [CliCommand("duplicate_gameobject", "Duplicate a GameObject in its loaded scene with optional parenting and renaming.")] + public static AuthoringResult Duplicate( + [CliArg("source", "GameObject reference to duplicate.", Required = true)] ObjectRef source, + [CliArg("parent", "Optional GameObject parent for the duplicate.")] ObjectRef parent = null, + [CliArg("name", "Optional name for the duplicate.")] string name = null, + [CliArg("world_position_stays", "Preserve world transform when applying the optional parent.")] bool worldPositionStays = false) + { + var sourceObject = CommandObjectResolver.Resolve(source, "source"); + if (!sourceObject.scene.IsValid() || !sourceObject.scene.isLoaded) + throw new ArgumentException("'source' must be a GameObject in a loaded scene."); + + GameObject parentObject = null; + if (parent != null && !parent.IsEmpty) + { + parentObject = CommandObjectResolver.Resolve(parent, "parent"); + if (!parentObject.scene.IsValid() || !parentObject.scene.isLoaded) + throw new ArgumentException("'parent' must be a GameObject in a loaded scene."); + } + + var duplicate = Object.Instantiate(sourceObject); + duplicate.name = string.IsNullOrEmpty(name) ? sourceObject.name : name; + Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate GameObject"); + + if (duplicate.scene != sourceObject.scene) + SceneManager.MoveGameObjectToScene(duplicate, sourceObject.scene); + + if (parentObject != null) + { + duplicate.transform.SetParent(parentObject.transform, worldPositionStays); + } + else if (sourceObject.transform.parent != null) + { + duplicate.transform.SetParent(sourceObject.transform.parent, true); + } + + EditorUtility.SetDirty(duplicate); + EditorSceneManager.MarkSceneDirty(duplicate.scene); + return ObjectResolver.Describe(duplicate); + } + } +} diff --git a/Editor/Resources/GetGameObjectResource.cs.meta b/Editor/Commands/DuplicateGameObjectCommand.cs.meta similarity index 60% rename from Editor/Resources/GetGameObjectResource.cs.meta rename to Editor/Commands/DuplicateGameObjectCommand.cs.meta index 03befb96..0e930fa9 100644 --- a/Editor/Resources/GetGameObjectResource.cs.meta +++ b/Editor/Commands/DuplicateGameObjectCommand.cs.meta @@ -1,11 +1,11 @@ fileFormatVersion: 2 -guid: 120d85b6d23e56448b1fa3b9afedd7ee +guid: b1ef3ef19dcc4cc581a47c43dbcac787 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Commands/EditorStepCommand.cs b/Editor/Commands/EditorStepCommand.cs new file mode 100644 index 00000000..d796963b --- /dev/null +++ b/Editor/Commands/EditorStepCommand.cs @@ -0,0 +1,22 @@ +using System; +using Unity.Pipeline.Commands; + +namespace McpUnity.Extensions.Commands +{ + public static class EditorStepCommand + { + [CliCommand("editor_step", "Advance play mode by one frame.")] + public static EditorStepResult Step() + { + if (!UnityEditor.EditorApplication.isPlaying) + throw new InvalidOperationException("'editor_step' requires the editor to be in play mode."); + + UnityEditor.EditorApplication.Step(); + return new EditorStepResult + { + IsPlaying = UnityEditor.EditorApplication.isPlaying, + IsPaused = UnityEditor.EditorApplication.isPaused + }; + } + } +} diff --git a/Editor/Commands/EditorStepCommand.cs.meta b/Editor/Commands/EditorStepCommand.cs.meta new file mode 100644 index 00000000..da7efe26 --- /dev/null +++ b/Editor/Commands/EditorStepCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b71a60b9e0414cdba358997461747e57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Commands/InspectGameObjectCommand.cs b/Editor/Commands/InspectGameObjectCommand.cs new file mode 100644 index 00000000..407e7783 --- /dev/null +++ b/Editor/Commands/InspectGameObjectCommand.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEngine; + +namespace McpUnity.Extensions.Commands +{ + public static class InspectGameObjectCommand + { + [CliCommand("inspect_gameobject", "Inspect a bounded GameObject hierarchy with optional component and serialized-property details.")] + public static InspectGameObjectResult Inspect( + [CliArg("target", "GameObject reference to inspect.", Required = true)] ObjectRef target, + [CliArg("max_depth", "Maximum child depth to include.")] int maxDepth = 2, + [CliArg("max_nodes", "Maximum number of GameObjects to include.")] int maxNodes = 200, + [CliArg("include_components", "Include component summaries.")] bool includeComponents = true, + [CliArg("include_properties", "Include serialized component properties.")] bool includeProperties = false, + [CliArg("max_properties_per_component", "Maximum serialized properties per component.")] int maxPropertiesPerComponent = 100) + { + var gameObject = CommandObjectResolver.Resolve(target, "target"); + var context = new InspectionContext( + Mathf.Clamp(maxDepth, 0, 8), + Mathf.Clamp(maxNodes, 1, 1000), + includeComponents, + includeProperties, + Mathf.Clamp(maxPropertiesPerComponent, 1, 200)); + + var root = BuildNode(gameObject, 0, context); + return new InspectGameObjectResult + { + Root = root, + MaxDepth = context.MaxDepth, + MaxNodes = context.MaxNodes, + MaxPropertiesPerComponent = context.MaxPropertiesPerComponent, + NodesReturned = context.NodesReturned, + NodeLimitReached = context.NodeLimitReached + }; + } + + private static GameObjectInspection BuildNode( + GameObject gameObject, + int depth, + InspectionContext context) + { + if (context.NodesReturned >= context.MaxNodes) + { + context.NodeLimitReached = true; + return null; + } + + context.NodesReturned++; + var transform = gameObject.transform; + var node = new GameObjectInspection + { + Identity = ObjectResolver.Describe(gameObject), + Name = gameObject.name, + Path = ObjectResolver.Describe(gameObject)?.HierarchyPath, + ScenePath = gameObject.scene.IsValid() ? gameObject.scene.path : null, + ActiveSelf = gameObject.activeSelf, + ActiveInHierarchy = gameObject.activeInHierarchy, + Layer = gameObject.layer, + LayerName = LayerMask.LayerToName(gameObject.layer), + Tag = gameObject.tag, + IsStatic = gameObject.isStatic, + Transform = new TransformInspection + { + LocalPosition = Vector(transform.localPosition), + LocalEulerAngles = Vector(transform.localEulerAngles), + LocalScale = Vector(transform.localScale), + WorldPosition = Vector(transform.position), + WorldEulerAngles = Vector(transform.eulerAngles) + }, + ChildCount = transform.childCount, + ComponentsIncluded = context.IncludeComponents + }; + + AddComponents(gameObject, node, context); + + if (depth >= context.MaxDepth) + { + node.ChildrenTruncated = transform.childCount > 0; + return node; + } + + for (var index = 0; index < transform.childCount; index++) + { + if (context.NodesReturned >= context.MaxNodes) + { + context.NodeLimitReached = true; + node.ChildrenTruncated = true; + break; + } + + var child = BuildNode(transform.GetChild(index).gameObject, depth + 1, context); + if (child == null) + { + node.ChildrenTruncated = true; + break; + } + + node.Children.Add(child); + } + + return node; + } + + private static void AddComponents( + GameObject gameObject, + GameObjectInspection node, + InspectionContext context) + { + var components = gameObject.GetComponents(); + node.ComponentCount = components.Length; + if (!context.IncludeComponents) + return; + + foreach (var component in components) + { + if (component == null) + { + node.Components.Add(new ComponentInspection + { + Type = "", + Missing = true + }); + continue; + } + + var summary = new ComponentInspection + { + Identity = ObjectResolver.Describe(component), + Type = component.GetType().Name, + Enabled = GetEnabled(component), + PropertiesIncluded = context.IncludeProperties + }; + + if (context.IncludeProperties) + ReadProperties(component, summary, context.MaxPropertiesPerComponent); + + node.Components.Add(summary); + } + } + + private static void ReadProperties( + Component component, + ComponentInspection summary, + int maxProperties) + { + try + { + var serializedObject = new SerializedObject(component); + var iterator = serializedObject.GetIterator(); + var enterChildren = true; + while (iterator.NextVisible(enterChildren)) + { + enterChildren = false; + if (iterator.propertyPath == "m_Script") + continue; + + if (!SerializedPropertyValueReader.TryRead(iterator, out var value)) + continue; + + summary.SerializedPropertyCount++; + if (summary.Properties.Count >= maxProperties) + { + summary.PropertiesTruncated = true; + continue; + } + + summary.Properties.Add(new SerializedPropertyInspection + { + Name = iterator.displayName, + Path = iterator.propertyPath, + Type = iterator.propertyType.ToString(), + Value = value + }); + } + } + catch (Exception exception) + { + summary.PropertiesError = exception.Message; + } + } + + private static bool? GetEnabled(Component component) + { + if (component is Behaviour behaviour) + return behaviour.enabled; + if (component is Renderer renderer) + return renderer.enabled; + if (component is Collider collider) + return collider.enabled; + return null; + } + + private static Vector3Inspection Vector(Vector3 value) => + new Vector3Inspection { X = value.x, Y = value.y, Z = value.z }; + + private sealed class InspectionContext + { + public InspectionContext( + int maxDepth, + int maxNodes, + bool includeComponents, + bool includeProperties, + int maxPropertiesPerComponent) + { + MaxDepth = maxDepth; + MaxNodes = maxNodes; + IncludeComponents = includeComponents; + IncludeProperties = includeProperties; + MaxPropertiesPerComponent = maxPropertiesPerComponent; + } + + public int MaxDepth { get; } + public int MaxNodes { get; } + public bool IncludeComponents { get; } + public bool IncludeProperties { get; } + public int MaxPropertiesPerComponent { get; } + public int NodesReturned { get; set; } + public bool NodeLimitReached { get; set; } + } + } +} diff --git a/Editor/Commands/InspectGameObjectCommand.cs.meta b/Editor/Commands/InspectGameObjectCommand.cs.meta new file mode 100644 index 00000000..88970771 --- /dev/null +++ b/Editor/Commands/InspectGameObjectCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1fc54e949b7f4e5795930e35b9c749ac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Commands/SerializedPropertyValueReader.cs b/Editor/Commands/SerializedPropertyValueReader.cs new file mode 100644 index 00000000..e5efd6be --- /dev/null +++ b/Editor/Commands/SerializedPropertyValueReader.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using Unity.Pipeline.Editor.Authoring; +using UnityEditor; +using UnityEngine; + +namespace McpUnity.Extensions.Commands +{ + internal static class SerializedPropertyValueReader + { + public static bool TryRead(SerializedProperty property, out object value) + { + value = null; + switch (property.propertyType) + { + case SerializedPropertyType.Boolean: + value = property.boolValue; + return true; + case SerializedPropertyType.Integer: + value = property.longValue; + return true; + case SerializedPropertyType.Float: + value = property.doubleValue; + return true; + case SerializedPropertyType.String: + value = property.stringValue; + return true; + case SerializedPropertyType.Enum: + value = property.enumValueIndex >= 0 && + property.enumValueIndex < property.enumNames.Length + ? property.enumNames[property.enumValueIndex] + : property.intValue.ToString(); + return true; + case SerializedPropertyType.Vector2: + value = Values(property.vector2Value.x, property.vector2Value.y); + return true; + case SerializedPropertyType.Vector3: + value = Values( + property.vector3Value.x, + property.vector3Value.y, + property.vector3Value.z); + return true; + case SerializedPropertyType.Vector4: + value = Values( + property.vector4Value.x, + property.vector4Value.y, + property.vector4Value.z, + property.vector4Value.w); + return true; + case SerializedPropertyType.Vector2Int: + value = Values(property.vector2IntValue.x, property.vector2IntValue.y); + return true; + case SerializedPropertyType.Vector3Int: + value = Values( + property.vector3IntValue.x, + property.vector3IntValue.y, + property.vector3IntValue.z); + return true; + case SerializedPropertyType.Color: + var color = property.colorValue; + value = Values(color.r, color.g, color.b, color.a); + return true; + case SerializedPropertyType.Rect: + var rect = property.rectValue; + value = NamedValues( + ("x", rect.x), + ("y", rect.y), + ("width", rect.width), + ("height", rect.height)); + return true; + case SerializedPropertyType.RectInt: + var rectInt = property.rectIntValue; + value = NamedValues( + ("x", rectInt.x), + ("y", rectInt.y), + ("width", rectInt.width), + ("height", rectInt.height)); + return true; + case SerializedPropertyType.Bounds: + var bounds = property.boundsValue; + value = new Dictionary + { + ["center"] = Values(bounds.center.x, bounds.center.y, bounds.center.z), + ["size"] = Values(bounds.size.x, bounds.size.y, bounds.size.z) + }; + return true; + case SerializedPropertyType.BoundsInt: + var boundsInt = property.boundsIntValue; + value = new Dictionary + { + ["position"] = Values( + boundsInt.position.x, + boundsInt.position.y, + boundsInt.position.z), + ["size"] = Values(boundsInt.size.x, boundsInt.size.y, boundsInt.size.z) + }; + return true; + case SerializedPropertyType.Quaternion: + var quaternion = property.quaternionValue; + value = Values(quaternion.x, quaternion.y, quaternion.z, quaternion.w); + return true; + case SerializedPropertyType.Hash128: + value = property.hash128Value.ToString(); + return true; + case SerializedPropertyType.ObjectReference: + var referenced = property.objectReferenceValue; + if (referenced is MonoScript) + return false; + value = referenced == null ? null : ObjectResolver.Describe(referenced); + return true; + default: + return false; + } + } + + private static object[] Values(params object[] values) => values; + + private static Dictionary NamedValues( + params (string Name, object Value)[] values) + { + var result = new Dictionary(); + foreach (var value in values) + result[value.Name] = value.Value; + return result; + } + } +} diff --git a/Editor/Commands/SerializedPropertyValueReader.cs.meta b/Editor/Commands/SerializedPropertyValueReader.cs.meta new file mode 100644 index 00000000..79f90b45 --- /dev/null +++ b/Editor/Commands/SerializedPropertyValueReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 301181933041459aa6170004a140b7d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Commands/UnloadSceneCommand.cs b/Editor/Commands/UnloadSceneCommand.cs new file mode 100644 index 00000000..f5d184b7 --- /dev/null +++ b/Editor/Commands/UnloadSceneCommand.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Pipeline.Commands; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; + +namespace McpUnity.Extensions.Commands +{ + public static class UnloadSceneCommand + { + [CliCommand("unload_scene", "Unload an already-loaded scene while protecting dirty and active scene state.")] + public static UnloadSceneResult Unload( + [CliArg("path", "Path of the already-loaded scene to unload.", Required = true)] string path, + [CliArg("force", "Discard unsaved scene changes when true.")] bool force = false) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("'path' is required."); + + var normalizedPath = path.Trim().Replace('\\', '/'); + var loadedScenes = GetLoadedScenes(); + var target = loadedScenes.FirstOrDefault(scene => + string.Equals(scene.path, normalizedPath, StringComparison.OrdinalIgnoreCase)); + + if (!target.IsValid() || !target.isLoaded) + throw new InvalidOperationException($"Scene '{normalizedPath}' is not loaded."); + + if (target.isDirty && !force) + { + throw new InvalidOperationException( + $"Scene '{target.path}' has unsaved changes. Pass force=true to discard them."); + } + + var active = SceneManager.GetActiveScene(); + if (target.handle == active.handle && loadedScenes.Count == 1) + { + throw new InvalidOperationException( + $"Cannot unload '{target.path}' because it is the sole loaded active scene."); + } + + if (target.handle == active.handle) + { + var alternative = loadedScenes + .Where(scene => scene.handle != target.handle) + .OrderBy(scene => scene.path, StringComparer.Ordinal) + .ThenBy(scene => scene.name, StringComparer.Ordinal) + .ThenBy(scene => scene.handle) + .First(); + + if (!SceneManager.SetActiveScene(alternative)) + throw new InvalidOperationException( + $"Could not make '{alternative.path}' active before unloading '{target.path}'."); + } + + if (!EditorSceneManager.CloseScene(target, true)) + { + if (target.IsValid() && target.isLoaded) + SceneManager.SetActiveScene(target); + throw new InvalidOperationException($"Failed to unload scene '{normalizedPath}'."); + } + + var remainingActive = SceneManager.GetActiveScene(); + return new UnloadSceneResult + { + UnloadedPath = normalizedPath, + ActiveSceneName = remainingActive.name, + ActiveScenePath = remainingActive.path + }; + } + + private static List GetLoadedScenes() + { + var scenes = new List(); + for (var index = 0; index < SceneManager.sceneCount; index++) + { + var scene = SceneManager.GetSceneAt(index); + if (scene.isLoaded) + scenes.Add(scene); + } + + return scenes; + } + } +} diff --git a/Editor/Commands/UnloadSceneCommand.cs.meta b/Editor/Commands/UnloadSceneCommand.cs.meta new file mode 100644 index 00000000..22b8e925 --- /dev/null +++ b/Editor/Commands/UnloadSceneCommand.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fcbd971b228d4107bdaf3a1becacc64f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Lib.meta b/Editor/Lib.meta deleted file mode 100644 index 94188fb4..00000000 --- a/Editor/Lib.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 05cb8bd640a00d942af19e6ce940a2d7 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Lib/websocket-sharp.dll b/Editor/Lib/websocket-sharp.dll deleted file mode 100644 index 6ec719f7356aa2a40eee323a5a3c881b1eef4575..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 250368 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 W{_}cKJ&UooE&llbm;S$vf&UA?8>iy{ diff --git a/Editor/Lib/websocket-sharp.dll.meta b/Editor/Lib/websocket-sharp.dll.meta deleted file mode 100644 index 8a56b91a..00000000 --- a/Editor/Lib/websocket-sharp.dll.meta +++ /dev/null @@ -1,33 +0,0 @@ -fileFormatVersion: 2 -guid: c8939e1023ebcf048bb5f16cdbb51310 -PluginImporter: - externalObjects: {} - serializedVersion: 2 - iconMap: {} - executionOrder: {} - defineConstraints: [] - isPreloaded: 0 - isOverridable: 1 - isExplicitlyReferenced: 0 - validateReferences: 1 - platformData: - - first: - Any: - second: - enabled: 0 - settings: {} - - first: - Editor: Editor - second: - enabled: 1 - settings: - DefaultValueInitialized: true - - first: - Windows Store Apps: WindowsStoreApps - second: - enabled: 0 - settings: - CPU: AnyCPU - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/McpUnity.Editor.asmdef b/Editor/McpUnity.Editor.asmdef index 2f4156cb..c0521524 100644 --- a/Editor/McpUnity.Editor.asmdef +++ b/Editor/McpUnity.Editor.asmdef @@ -1,8 +1,9 @@ { - "name": "McpUnity.Editor", - "rootNamespace": "", + "name": "McpUnity.Extensions", + "rootNamespace": "McpUnity.Extensions", "references": [ - "GUID:478a2357cc57436488a56e564b08d223" + "Unity.Pipeline", + "Unity.Pipeline.Editor" ], "includePlatforms": [ "Editor" @@ -13,12 +14,6 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [ - { - "name": "Unity", - "expression": "6000.3", - "define": "MCP_UNITY_ENTITY_ID_API" - } - ], + "versionDefines": [], "noEngineReferences": false } diff --git a/Editor/Models.meta b/Editor/Models.meta deleted file mode 100644 index 14b7d099..00000000 --- a/Editor/Models.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e5573ac08a5d13d45a7c8b26d6052563 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Models/UpdateGameObjectRequest.cs b/Editor/Models/UpdateGameObjectRequest.cs deleted file mode 100644 index ebc71d2d..00000000 --- a/Editor/Models/UpdateGameObjectRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace McpUnity.Models -{ - [Serializable] - public class UpdateGameObjectRequest - { - [JsonProperty("instanceId")] - public int? InstanceId { get; set; } - - [JsonProperty("objectPath")] - public string ObjectPath { get; set; } - - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("tag")] - public string Tag { get; set; } - - [JsonProperty("layer")] - public int? Layer { get; set; } - - [JsonProperty("isActiveSelf")] - public bool? IsActiveSelf { get; set; } - - [JsonProperty("isStatic")] - public bool? IsStatic { get; set; } - } -} diff --git a/Editor/Models/UpdateGameObjectRequest.cs.meta b/Editor/Models/UpdateGameObjectRequest.cs.meta deleted file mode 100644 index b9949bbd..00000000 --- a/Editor/Models/UpdateGameObjectRequest.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2eb0c35b41a79274faeabeac50452871 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources.meta b/Editor/Resources.meta deleted file mode 100644 index 55f77af4..00000000 --- a/Editor/Resources.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: fbd154b4035adf24eaaf8011cfae5db8 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetAssetsResource.cs b/Editor/Resources/GetAssetsResource.cs deleted file mode 100644 index 4aa9254c..00000000 --- a/Editor/Resources/GetAssetsResource.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.IO; -using System.Collections.Generic; -using UnityEngine; -using UnityEditor; -using McpUnity.Unity; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Resources -{ - /// - /// Resource for getting asset information from the Unity Asset Database - /// - public class GetAssetsResource : McpResourceBase - { - public GetAssetsResource() - { - Name = "get_assets"; - Description = "Retrieves assets from the Unity Asset Database"; - Uri = "unity://assets"; - } - - /// - /// Execute the resource to get asset information - /// - /// Optional parameters for filtering - /// JObject containing asset information - public override JObject Fetch(JObject parameters) - { - // Extract optional filter parameters - string assetType = parameters?["assetType"]?.ToObject(); - string searchPattern = parameters?["searchPattern"]?.ToObject(); - - // Get all assets from the project - JArray assets = GetAllAssets(assetType, searchPattern); - - // Return result - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved {assets.Count} assets", - ["assets"] = assets - }; - } - - /// - /// Get all assets from the project, optionally filtered by type and search pattern - /// - /// Optional filter by asset type - /// Optional search pattern for asset names - /// JArray containing asset information - private JArray GetAllAssets(string assetType, string searchPattern) - { - JArray result = new JArray(); - - // Find all assets - string[] assetGuids = AssetDatabase.FindAssets(string.IsNullOrEmpty(searchPattern) ? "" : searchPattern); - - foreach (string guid in assetGuids) - { - string assetPath = AssetDatabase.GUIDToAssetPath(guid); - - // Skip folders - if (AssetDatabase.IsValidFolder(assetPath)) - { - continue; - } - - // Get asset type - var asset = AssetDatabase.LoadAssetAtPath(assetPath); - if (asset == null) - { - continue; - } - - string fileType = asset.GetType().Name; - - // Filter by asset type if specified - if (!string.IsNullOrEmpty(assetType) && !fileType.Equals(assetType, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Create asset information - JObject assetInfo = new JObject - { - ["name"] = Path.GetFileNameWithoutExtension(assetPath), - ["filename"] = Path.GetFileName(assetPath), - ["path"] = assetPath, - ["type"] = fileType, - ["extension"] = Path.GetExtension(assetPath).TrimStart('.'), - ["guid"] = guid, - ["size"] = GetAssetSize(assetPath) - }; - - result.Add(assetInfo); - } - - return result; - } - - /// - /// Get the size of an asset file - /// - /// Path to the asset - /// Size in bytes, or -1 if the file cannot be found - private long GetAssetSize(string assetPath) - { - string fullPath = Path.Combine(Application.dataPath, "..", assetPath); - FileInfo fileInfo = new FileInfo(fullPath); - return fileInfo.Exists ? fileInfo.Length : -1; - } - } -} diff --git a/Editor/Resources/GetAssetsResource.cs.meta b/Editor/Resources/GetAssetsResource.cs.meta deleted file mode 100644 index f1ba1f64..00000000 --- a/Editor/Resources/GetAssetsResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a4e564d8f8bab33449d5207e1df951f2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetConsoleLogsResource.cs b/Editor/Resources/GetConsoleLogsResource.cs deleted file mode 100644 index 0c7c71fd..00000000 --- a/Editor/Resources/GetConsoleLogsResource.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using Newtonsoft.Json.Linq; -using McpUnity.Services; - -namespace McpUnity.Resources -{ - /// - /// Resource for retrieving all logs from the Unity console - /// - public class GetConsoleLogsResource : McpResourceBase - { - private readonly IConsoleLogsService _consoleLogsService; - - public GetConsoleLogsResource(IConsoleLogsService consoleLogsService) - { - Name = "get_console_logs"; - Description = "Retrieves logs from the Unity console (newest first), optionally filtered by type (error, warning, info). Use pagination parameters (offset, limit) to avoid LLM token limits. Set includeStackTrace=false to exclude stack traces and reduce token usage. Recommended: limit=20-50 for optimal performance."; - Uri = "unity://logs/{logType}"; - - _consoleLogsService = consoleLogsService; - } - - /// - /// Fetch logs from the Unity console, optionally filtered by type with pagination support - /// - /// Resource parameters as a JObject (may include 'logType', 'offset', 'limit') - /// A JObject containing the list of logs with pagination info - public override JObject Fetch(JObject parameters) - { - string logType = parameters?["logType"]?.ToString(); - if (string.IsNullOrWhiteSpace(logType)) logType = null; - - int offset = Math.Max(0, GetIntParameter(parameters, "offset", 0)); - int limit = Math.Max(1, Math.Min(1000, GetIntParameter(parameters, "limit", 100))); - bool includeStackTrace = GetBoolParameter(parameters, "includeStackTrace", true); - - // Debug logging - temporarily remove to avoid console clutter - - // Use the new paginated method with stack trace option - JObject result = _consoleLogsService.GetLogsAsJson(logType, offset, limit, includeStackTrace); - - // Add formatted message with pagination info - string typeFilter = logType != null ? $" of type '{logType}'" : ""; - int returnedCount = result["_returnedCount"]?.Value() ?? 0; - int filteredCount = result["_filteredCount"]?.Value() ?? 0; - int totalCount = result["_totalCount"]?.Value() ?? 0; - - result["message"] = $"Retrieved {returnedCount} of {filteredCount} log entries{typeFilter} (offset: {offset}, limit: {limit}, includeStackTrace: {includeStackTrace}, total: {totalCount})"; - result["success"] = true; - - // Remove internal count fields (they're now in the message) - result.Remove("_totalCount"); - result.Remove("_filteredCount"); - result.Remove("_returnedCount"); - - return result; - } - - /// - /// Helper method to safely extract integer parameters with default values - /// - /// JObject containing parameters - /// Parameter key to extract - /// Default value if parameter is missing or invalid - /// Extracted integer value or default - private static int GetIntParameter(JObject parameters, string key, int defaultValue) - { - if (parameters?[key] != null && int.TryParse(parameters[key].ToString(), out int value)) - return value; - return defaultValue; - } - - /// - /// Helper method to safely extract boolean parameters with default values - /// - /// JObject containing parameters - /// Parameter key to extract - /// Default value if parameter is missing or invalid - /// Extracted boolean value or default - private static bool GetBoolParameter(JObject parameters, string key, bool defaultValue) - { - if (parameters?[key] != null && bool.TryParse(parameters[key].ToString(), out bool value)) - return value; - return defaultValue; - } - - - } -} diff --git a/Editor/Resources/GetConsoleLogsResource.cs.meta b/Editor/Resources/GetConsoleLogsResource.cs.meta deleted file mode 100644 index 4baeb7ff..00000000 --- a/Editor/Resources/GetConsoleLogsResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5527fc71c2a481f428816932a15a9a2a -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetGameObjectResource.cs b/Editor/Resources/GetGameObjectResource.cs deleted file mode 100644 index f5278b5f..00000000 --- a/Editor/Resources/GetGameObjectResource.cs +++ /dev/null @@ -1,605 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using UnityEngine; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using UnityEditor; -using McpUnity.Utils; - -namespace McpUnity.Resources -{ - /// - /// Resource for retrieving detailed information about a specific GameObject - /// - public class GetGameObjectResource : McpResourceBase - { - public GetGameObjectResource() - { - Name = "get_gameobject"; - Description = "Retrieves detailed information about a specific GameObject by instance ID or object name or path"; - Uri = "unity://gameobject/{idOrName}"; - } - - /// - /// Default maximum hierarchy depth when serializing children. Prevents oversized responses that - /// would otherwise exceed the MCP framework's response size limit and drop the WebSocket - /// connection. See: https://github.com/CoderGamester/mcp-unity/issues/134 - /// - public const int DefaultMaxChildDepth = 2; - - /// - /// Hard cap on serialized response size, in bytes. Once recursion accumulates this much JSON - /// the remaining children are replaced with a truncation marker so the caller can drill in - /// instead of losing the connection. - /// - public const int MaxResponseBytes = 5 * 1024 * 1024; - - /// - /// Fetch information about a specific GameObject - /// - /// Resource parameters as a JObject. Should include 'objectPathId' which can be either an instance ID or a path - /// A JObject containing the GameObject data - public override JObject Fetch(JObject parameters) - { - // Validate parameters - if (parameters == null || !parameters.ContainsKey("idOrName")) - { - return new JObject - { - ["success"] = false, - ["message"] = "Missing required parameter: idOrName" - }; - } - - string idOrName = parameters["idOrName"]?.ToObject(); - - if (string.IsNullOrEmpty(idOrName)) - { - return new JObject - { - ["success"] = false, - ["message"] = "Parameter 'objectPathId' cannot be null or empty" - }; - } - - GameObject gameObject = null; - - // Try to parse as an instance ID first - if (int.TryParse(idOrName, out int instanceId)) - { - // Unity Instance IDs are typically negative, but we'll accept any integer - UnityEngine.Object unityObject = UnityObjectId.ObjectFromId(instanceId); - gameObject = unityObject as GameObject; - } - else - { - // Otherwise, treat it as a name or hierarchical path - gameObject = GameObject.Find(idOrName); - } - - // Check if the GameObject was found - if (gameObject == null) - { - return new JObject - { - ["success"] = false, - ["message"] = $"GameObject with '{idOrName}' reference not found. Make sure the GameObject exists and is loaded in the current scene(s)." - }; - } - - int maxDepth = parameters["maxDepth"]?.ToObject() ?? DefaultMaxChildDepth; - bool includeComponents = parameters["includeComponents"]?.ToObject() ?? true; - bool includeComponentProperties = parameters["includeComponentProperties"]?.ToObject() ?? true; - - // Convert the GameObject to a JObject - JObject gameObjectData = GameObjectToJObject( - gameObject, true, maxDepth, includeComponents, includeComponentProperties); - - // Create the response - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved GameObject data for '{gameObject.name}'", - ["gameObject"] = gameObjectData, - ["instanceId"] = UnityObjectId.GetObjectId(gameObject) - }; - } - - /// - /// Convert a GameObject to a JObject with its hierarchy, scoped by depth, component toggles, - /// and a hard byte cap. The byte cap guards against responses exceeding the MCP framework's - /// 15 MB ceiling, which would otherwise drop the WebSocket connection (issue #134). - /// - /// The GameObject to convert - /// Whether to include detailed component information - /// Maximum child levels to recurse. 0 = no children. Default: - /// When false, the components array is omitted entirely - /// When false, components are listed without their reflected properties - /// A JObject representing the GameObject - public static JObject GameObjectToJObject( - GameObject gameObject, - bool includeDetailedComponents, - int maxDepth = DefaultMaxChildDepth, - bool includeComponents = true, - bool includeComponentProperties = true) - { - int[] bytesUsed = { 0 }; - return GameObjectToJObjectInternal( - gameObject, - includeDetailedComponents, - maxDepth, - includeComponents, - includeComponentProperties, - currentDepth: 0, - bytesUsed); - } - - private static JObject GameObjectToJObjectInternal( - GameObject gameObject, - bool includeDetailedComponents, - int maxDepth, - bool includeComponents, - bool includeComponentProperties, - int currentDepth, - int[] bytesUsed) - { - if (gameObject == null) return null; - - JObject gameObjectJson = new JObject - { - ["name"] = gameObject.name, - ["activeSelf"] = gameObject.activeSelf, - ["activeInHierarchy"] = gameObject.activeInHierarchy, - ["tag"] = gameObject.tag, - ["layer"] = gameObject.layer, - ["layerName"] = LayerMask.LayerToName(gameObject.layer), - ["instanceId"] = UnityObjectId.GetObjectId(gameObject) - }; - - if (includeComponents) - { - gameObjectJson["components"] = GetComponentsInfo( - gameObject, includeDetailedComponents, includeComponentProperties); - } - - int childCount = gameObject.transform.childCount; - JArray childrenArray = new JArray(); - gameObjectJson["children"] = childrenArray; - - // Account for this node's own JSON before deciding whether to recurse further. - bytesUsed[0] += gameObjectJson.ToString(Formatting.None).Length; - - if (childCount > 0 && currentDepth >= maxDepth) - { - gameObjectJson["_truncated"] = true; - gameObjectJson["_truncatedReason"] = "depth_limit"; - gameObjectJson["_childCount"] = childCount; - } - else if (childCount > 0 && bytesUsed[0] > MaxResponseBytes) - { - gameObjectJson["_truncated"] = true; - gameObjectJson["_truncatedReason"] = "size_limit_exceeded"; - gameObjectJson["_childCount"] = childCount; - gameObjectJson["_hint"] = "Response exceeded 5MB cap. Re-query specific children with maxDepth=0 or includeComponentProperties=false."; - } - else - { - foreach (Transform child in gameObject.transform) - { - if (bytesUsed[0] > MaxResponseBytes) - { - gameObjectJson["_truncated"] = true; - gameObjectJson["_truncatedReason"] = "size_limit_exceeded"; - gameObjectJson["_childCount"] = childCount; - gameObjectJson["_serializedChildCount"] = childrenArray.Count; - gameObjectJson["_hint"] = "Response exceeded 5MB cap. Re-query specific children with maxDepth=0 or includeComponentProperties=false."; - break; - } - - childrenArray.Add(GameObjectToJObjectInternal( - child.gameObject, - includeDetailedComponents, - maxDepth, - includeComponents, - includeComponentProperties, - currentDepth + 1, - bytesUsed)); - } - } - - return gameObjectJson; - } - - /// - /// Namespace prefixes for components with native/C++ code that crash when accessed via reflection. - /// These components will only have basic info (type, enabled) serialized, not detailed properties. - /// - private static readonly string[] UnsafeNamespacePrefixes = new string[] - { - "Pathfinding", // A* Pathfinding Project - "FMOD", // FMOD audio - "FMODUnity", // FMOD Unity integration - }; - - /// - /// Component base types that should never have public properties reflected because some native-backed getters - /// can crash the Unity editor before a managed exception is thrown. - /// - private static readonly Type[] UnsafeDetailedInspectionBaseTypes = new Type[] - { - typeof(Collider) - }; - - /// - /// Common expensive or unsafe properties that should be skipped for all component types. - /// - private static readonly HashSet GloballySkippedPropertyNames = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "mesh", - "sharedMesh", - "material", - "materials", - "sharedMaterial", - "sharedMaterials", - "sprite", - "mainTexture", - "mainTextureOffset", - "mainTextureScale" - }; - - /// - /// Per-component property denylist for getters known to be unsafe via reflection. - /// Keys are matched against the declaring component type and its base types. - /// - private static readonly Dictionary> SkippedPropertiesByComponentType = new Dictionary> - { - [typeof(Collider)] = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "GeometryHolder" - } - }; - - /// - /// Check if a component type is from a native plugin that may crash when accessed via reflection - /// - private static bool IsUnsafeNativeComponent(Type componentType) - { - if (componentType == null) return true; - - string fullName = componentType.FullName ?? ""; - string namespaceName = componentType.Namespace ?? ""; - - foreach (string prefix in UnsafeNamespacePrefixes) - { - if (namespaceName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || - fullName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - /// - /// Check if a component type is unsafe to inspect in detail. - /// - private static bool ShouldSkipDetailedInspection(Type componentType) - { - if (IsUnsafeNativeComponent(componentType)) - { - return true; - } - - foreach (Type unsafeBaseType in UnsafeDetailedInspectionBaseTypes) - { - if (unsafeBaseType.IsAssignableFrom(componentType)) - { - return true; - } - } - - return false; - } - - /// - /// Get information about the components attached to a GameObject - /// - /// The GameObject to get components from - /// Whether to include detailed component information - /// When false, the per-component 'properties' object is omitted entirely. - /// Useful for callers that only need component type names, saving substantial response size. - /// A JArray containing component information - private static JArray GetComponentsInfo( - GameObject gameObject, - bool includeDetailedInfo = false, - bool includeProperties = true) - { - Component[] components = gameObject.GetComponents(); - JArray componentsArray = new JArray(); - - foreach (Component component in components) - { - if (component == null) continue; - - Type componentType = component.GetType(); - bool skipDetailedInspection = ShouldSkipDetailedInspection(componentType); - - JObject componentJson = new JObject - { - ["type"] = componentType.Name, - ["enabled"] = IsComponentEnabled(component) - }; - - // Add detailed information if requested and component is safe to inspect - if (includeDetailedInfo && includeProperties) - { - if (skipDetailedInspection) - { - componentJson["properties"] = new JObject - { - ["_skipped"] = "Detailed property serialization skipped for safety" - }; - } - else - { - componentJson["properties"] = GetComponentProperties(component); - } - } - - componentsArray.Add(componentJson); - } - - return componentsArray; - } - - /// - /// Check if a component is enabled (if it has an enabled property) - /// - /// The component to check - /// True if the component is enabled, false otherwise - private static bool IsComponentEnabled(Component component) - { - // Check if the component is a Behaviour (has enabled property) - if (component is Behaviour behaviour) - { - return behaviour.enabled; - } - - // Check if the component is a Renderer - if (component is Renderer renderer) - { - return renderer.enabled; - } - - // Check if the component is a Collider - if (component is Collider collider) - { - return collider.enabled; - } - - // Default to true for components without an enabled property - return true; - } - - /// - /// Maximum depth for serializing nested objects to prevent stack overflow from circular references - /// - private const int MaxSerializationDepth = 5; - - /// - /// Maximum items to serialize in a collection to prevent excessive output - /// - private const int MaxCollectionItems = 50; - - /// - /// Get all serialized fields, public fields and public properties of a component - /// - /// The component to get properties from - /// A JObject containing all the component properties - private static JObject GetComponentProperties(Component component) - { - if (component == null) return null; - - JObject propertiesJson = new JObject(); - Type componentType = component.GetType(); - - // Track visited objects to prevent circular reference loops - HashSet visited = new HashSet(new ReferenceEqualityComparer()); - - // Get serialized fields (both public and private with SerializeField attribute) - FieldInfo[] fields = componentType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - foreach (FieldInfo field in fields) - { - // Include public fields and serialized private fields - bool isSerializedField = field.IsPublic || field.GetCustomAttributes(typeof(SerializeField), true).Length > 0; - - if (!isSerializedField) continue; - try - { - object value = field.GetValue(component); - propertiesJson[field.Name] = SerializeValue(value, 0, visited); - } - catch (Exception) - { - // Skip fields that cannot be serialized - propertiesJson[field.Name] = "Unable to serialize"; - } - } - - // Get public properties - PropertyInfo[] properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance); - foreach (PropertyInfo property in properties) - { - // Only include properties with a getter and skip properties that might cause issues or are not useful - if (!property.CanRead || ShouldSkipProperty(componentType, property)) continue; - - try - { - object value = property.GetValue(component); - propertiesJson[property.Name] = SerializeValue(value, 0, visited); - } - catch (Exception) - { - // Skip properties that cannot be serialized - propertiesJson[property.Name] = "Unable to serialize"; - } - } - - return propertiesJson; - } - - /// - /// Reference equality comparer for tracking visited objects (prevents circular reference infinite loops) - /// - private class ReferenceEqualityComparer : IEqualityComparer - { - public new bool Equals(object x, object y) => ReferenceEquals(x, y); - public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); - } - - /// - /// Determine if a property should be skipped during serialization - /// - /// The property to check - /// True if the property should be skipped, false otherwise - private static bool ShouldSkipProperty(Type componentType, PropertyInfo property) - { - if (property == null) - { - return true; - } - - // Skip non-public getters and indexers exposed as properties. - if (property.GetMethod == null || !property.GetMethod.IsPublic || property.GetIndexParameters().Length > 0) - { - return true; - } - - if (GloballySkippedPropertyNames.Contains(property.Name)) - { - return true; - } - - foreach (KeyValuePair> skippedEntry in SkippedPropertiesByComponentType) - { - if (skippedEntry.Key.IsAssignableFrom(componentType) && - skippedEntry.Value.Contains(property.Name)) - { - return true; - } - } - - return false; - } - - /// - /// Serialize a value to a JToken with depth limiting and circular reference protection - /// - /// The value to serialize - /// Current recursion depth - /// Set of already visited reference objects to detect circular references - /// A JToken representing the value - private static JToken SerializeValue(object value, int depth = 0, HashSet visited = null) - { - if (value == null) - return JValue.CreateNull(); - - // Depth limit check to prevent stack overflow - if (depth > MaxSerializationDepth) - return "[max depth exceeded]"; - - Type valueType = value.GetType(); - - // For reference types (excluding strings), check for circular references - if (!valueType.IsValueType && !(value is string)) - { - if (visited == null) - visited = new HashSet(new ReferenceEqualityComparer()); - - if (visited.Contains(value)) - return "[circular reference]"; - - visited.Add(value); - } - - // Handle common Unity types - if (value is Vector2 vector2) - return new JObject { ["x"] = vector2.x, ["y"] = vector2.y }; - - if (value is Vector3 vector3) - return new JObject { ["x"] = vector3.x, ["y"] = vector3.y, ["z"] = vector3.z }; - - if (value is Vector4 vector4) - return new JObject { ["x"] = vector4.x, ["y"] = vector4.y, ["z"] = vector4.z, ["w"] = vector4.w }; - - if (value is Quaternion quaternion) - return new JObject { ["x"] = quaternion.x, ["y"] = quaternion.y, ["z"] = quaternion.z, ["w"] = quaternion.w }; - - if (value is Color color) - return new JObject { ["r"] = color.r, ["g"] = color.g, ["b"] = color.b, ["a"] = color.a }; - - if (value is Bounds bounds) - return new JObject { - ["center"] = SerializeValue(bounds.center, depth + 1, visited), - ["size"] = SerializeValue(bounds.size, depth + 1, visited) - }; - - if (value is Rect rect) - return new JObject { ["x"] = rect.x, ["y"] = rect.y, ["width"] = rect.width, ["height"] = rect.height }; - - if (value is UnityEngine.Object unityObject) - return unityObject != null ? unityObject.name : null; - - // Handle arrays and lists with item limit - if (value is System.Collections.IList list) - { - JArray array = new JArray(); - int count = 0; - foreach (var item in list) - { - if (count >= MaxCollectionItems) - { - array.Add($"[... and {list.Count - count} more items]"); - break; - } - array.Add(SerializeValue(item, depth + 1, visited)); - count++; - } - return array; - } - - // Handle dictionaries with item limit - if (value is System.Collections.IDictionary dict) - { - JObject obj = new JObject(); - int count = 0; - foreach (System.Collections.DictionaryEntry entry in dict) - { - if (count >= MaxCollectionItems) - { - obj["_truncated"] = $"{dict.Count - count} more entries"; - break; - } - obj[entry.Key.ToString()] = SerializeValue(entry.Value, depth + 1, visited); - count++; - } - return obj; - } - - // Handle enum by using the name - if (value is Enum enumValue) - return enumValue.ToString(); - - // Handle primitive types directly - if (valueType.IsPrimitive || value is string || value is decimal) - { - return JToken.FromObject(value); - } - - // For complex types we don't recognize, return type name to avoid unsafe deep serialization - return $"[{valueType.Name}]"; - } - } -} diff --git a/Editor/Resources/GetMenuItemsResource.cs b/Editor/Resources/GetMenuItemsResource.cs deleted file mode 100644 index 84ce488f..00000000 --- a/Editor/Resources/GetMenuItemsResource.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Resources -{ - /// - /// Resource for retrieving all available Unity menu items - /// - public class GetMenuItemsResource : McpResourceBase - { - private string _description; - - public GetMenuItemsResource() - { - Name = "get_menu_items"; - Description = "List of available menu items in Unity to execute"; - Uri = "unity://menu-items"; - } - - /// - /// Fetch all available menu items in the Unity Editor - /// - /// Resource parameters as a JObject (not used) - /// A JObject containing the list of menu items - public override JObject Fetch(JObject parameters) - { - // Get all menu items - JArray menuItems = GetAllMenuItems(); - - // Create the response - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved {menuItems.Count} menu items", - ["menuItems"] = menuItems - }; - } - - /// - /// Get all available menu items in the Unity Editor - /// - /// A list of menu item paths - private JArray GetAllMenuItems() - { - var menuItemsArray = new JArray(); - - // Find all methods with MenuItem attribute in loaded assemblies - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - foreach (var assembly in assemblies) - { - // Skip system assemblies to improve performance - if (assembly.FullName.StartsWith("System.") || - assembly.FullName.StartsWith("Microsoft.") || - assembly.FullName.StartsWith("mscorlib")) - { - continue; - } - - foreach (var type in assembly.GetTypes()) - { - foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) - { - var menuItemAttributes = method.GetCustomAttributes(typeof(MenuItem), false); - foreach (MenuItem menuItemAttribute in menuItemAttributes) - { - // Ignore object type context menu items - if(menuItemAttribute.menuItem.StartsWith("CONTEXT")) continue; - - menuItemsArray.Add(menuItemAttribute.menuItem); - } - } - } - } - - return menuItemsArray; - } - } -} diff --git a/Editor/Resources/GetMenuItemsResource.cs.meta b/Editor/Resources/GetMenuItemsResource.cs.meta deleted file mode 100644 index d4e88d42..00000000 --- a/Editor/Resources/GetMenuItemsResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 22bd2edf991e27943ab8aeb2827dfcf6 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetPackagesResource.cs b/Editor/Resources/GetPackagesResource.cs deleted file mode 100644 index 4bcfc9c6..00000000 --- a/Editor/Resources/GetPackagesResource.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; -using UnityEditor; -using UnityEditor.PackageManager; -using UnityEditor.PackageManager.Requests; -using McpUnity.Unity; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Resources -{ - /// - /// Resource for getting package information from the Unity Package Manager - /// - public class GetPackagesResource : McpResourceBase - { - private ListRequest _listRequest; - - public GetPackagesResource() - { - Name = "get_packages"; - Description = "Retrieve all packages from the Unity Package Manager"; - Uri = "unity://packages"; - } - - /// - /// Execute the resource to get packages information - /// - /// Optional parameters for filtering - /// JObject containing packages information - public override JObject Fetch(JObject parameters) - { - // Get project packages (installed) - var projectPackages = GetProjectPackages(); - - // Get registry packages - var registryPackages = GetRegistryPackages(); - - // Return combined result - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved {projectPackages.Count} project packages and {registryPackages.Count} registry packages", - ["projectPackages"] = projectPackages, - ["registryPackages"] = registryPackages - }; - } - - /// - /// Get packages installed in the current project - /// - private JArray GetProjectPackages() - { - JArray result = new JArray(); - - // List installed packages - _listRequest = Client.List(true); - - // Wait for the request to complete - while (!_listRequest.IsCompleted) - { - System.Threading.Thread.Sleep(100); - } - - if (_listRequest.Status == StatusCode.Success) - { - foreach (var package in _listRequest.Result) - { - result.Add(PackageToJObject(package, "installed")); - } - } - else if (_listRequest.Status == StatusCode.Failure) - { - Debug.LogError($"[MCP Unity] Failed to list project packages: {_listRequest.Error.message}"); - } - - return result; - } - - /// - /// Get packages available from the Unity Registry - /// - private JArray GetRegistryPackages() - { - JArray result = new JArray(); - - // Search Unity registry packages - SearchRequest searchRequest = Client.SearchAll(); - - // Wait for the request to complete - while (!searchRequest.IsCompleted) - { - System.Threading.Thread.Sleep(100); - } - - if (searchRequest.Status == StatusCode.Success) - { - foreach (var package in searchRequest.Result) - { - // Check if package is already installed - string state = "not_installed"; - if (_listRequest.Status == StatusCode.Success) - { - foreach (var installedPackage in _listRequest.Result) - { - if (installedPackage.name == package.name) - { - state = "installed"; - break; - } - } - } - - result.Add(PackageToJObject(package, state)); - } - } - else if (searchRequest.Status == StatusCode.Failure) - { - Debug.LogError($"[MCP Unity] Failed to search registry packages: {searchRequest.Error.message}"); - } - - return result; - } - - /// - /// Convert a package info object to JObject - /// - /// Package info - /// Installation state - /// JObject with package info - private JObject PackageToJObject(UnityEditor.PackageManager.PackageInfo package, string state) - { - return new JObject - { - ["name"] = package.name, - ["displayName"] = package.displayName, - ["version"] = package.version, - ["description"] = package.description, - ["category"] = package.category, - ["source"] = package.source.ToString(), - ["state"] = state, - ["author"] = new JObject - { - ["name"] = package.author?.name, - ["email"] = package.author?.email, - ["url"] = package.author?.url - } - }; - } - } -} diff --git a/Editor/Resources/GetPackagesResource.cs.meta b/Editor/Resources/GetPackagesResource.cs.meta deleted file mode 100644 index 6afccef1..00000000 --- a/Editor/Resources/GetPackagesResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 86b7a5e264997a9429dc226752dc16de -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetScenesHierarchyResource.cs b/Editor/Resources/GetScenesHierarchyResource.cs deleted file mode 100644 index 95a377f9..00000000 --- a/Editor/Resources/GetScenesHierarchyResource.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; -using UnityEngine.SceneManagement; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Resources -{ - /// - /// Resource for retrieving all game objects in the Unity scenes hierarchy - /// - /// -/// Resource for retrieving all game objects in the Unity scenes hierarchy -/// -public class GetScenesHierarchyResource : McpResourceBase - { - public GetScenesHierarchyResource() - { - Name = "get_scenes_hierarchy"; - Description = "Retrieves all game objects in the Unity loaded scenes with their active state"; - Uri = "unity://scenes_hierarchy"; - } - - /// - /// Fetch all game objects in the Unity loaded scenes - /// - /// Resource parameters as a JObject (not used) - /// A JObject containing the hierarchy of game objects - public override JObject Fetch(JObject parameters) - { - // Get all game objects in the hierarchy - JArray hierarchyArray = GetSceneHierarchy(); - - // Create the response - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved hierarchy with {hierarchyArray.Count} root objects", - ["hierarchy"] = hierarchyArray - }; - } - - /// - /// Get all game objects in the Unity loaded scenes - /// - /// A JArray containing the hierarchy of game objects - private JArray GetSceneHierarchy() - { - JArray rootObjectsArray = new JArray(); - - // Get all loaded scenes - int sceneCount = SceneManager.sceneCount; - for (int i = 0; i < sceneCount; i++) - { - Scene scene = SceneManager.GetSceneAt(i); - - if (scene.isLoaded == false) - { - continue; - } - - // Create a scene object - JObject sceneObject = new JObject - { - ["name"] = scene.name, - ["path"] = scene.path, - ["buildIndex"] = scene.buildIndex, - ["isDirty"] = scene.isDirty, - ["rootObjects"] = new JArray() - }; - - // Get root game objects in the scene - GameObject[] rootObjects = scene.GetRootGameObjects(); - JArray rootObjectsInScene = (JArray)sceneObject["rootObjects"]; - - foreach (GameObject rootObject in rootObjects) - { - // Add the root object and its children to the array - rootObjectsInScene.Add(GetGameObjectResource.GameObjectToJObject(rootObject, false)); - } - - // Add the scene to the root objects array - rootObjectsArray.Add(sceneObject); - } - - return rootObjectsArray; - } - } -} diff --git a/Editor/Resources/GetScenesHierarchyResource.cs.meta b/Editor/Resources/GetScenesHierarchyResource.cs.meta deleted file mode 100644 index 452d65e6..00000000 --- a/Editor/Resources/GetScenesHierarchyResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2f976786d0da9fc4b8791c32d26fee43 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/GetTestsResource.cs b/Editor/Resources/GetTestsResource.cs deleted file mode 100644 index ac099d11..00000000 --- a/Editor/Resources/GetTestsResource.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using McpUnity.Services; -using Newtonsoft.Json.Linq; -using UnityEditor.TestTools.TestRunner.Api; - -namespace McpUnity.Resources -{ - /// - /// Resource for getting available tests from Unity Test Runner - /// - public class GetTestsResource : McpResourceBase - { - private readonly ITestRunnerService _testRunnerService; - - /// - /// Constructor - /// - public GetTestsResource(ITestRunnerService testRunnerService) - { - Name = "get_tests"; - Description = "Gets available tests from Unity Test Runner"; - Uri = "unity://tests/{testMode}"; - IsAsync = true; - _testRunnerService = testRunnerService; - } - - /// - /// Asynchronously fetch tests based on provided parameters - /// - /// Resource parameters as a JObject - /// TaskCompletionSource to set the result or exception - public override async void FetchAsync(JObject parameters, TaskCompletionSource tcs) - { - // Get filter parameters - string testModeFilter = parameters["testMode"]?.ToObject(); - List allTests = await _testRunnerService.GetAllTestsAsync(testModeFilter); - var results = new JArray(); - - foreach (ITestAdaptor test in allTests) - { - results.Add(new JObject - { - ["name"] = test.Name, - ["fullName"] = test.FullName, - ["testMode"] = test.TestMode.ToString(), - ["runState"] = test.RunState.ToString() - }); - } - - tcs.SetResult(new JObject - { - ["success"] = true, - ["message"] = $"Retrieved {allTests.Count} tests", - ["tests"] = results - }); - } - } -} diff --git a/Editor/Resources/GetTestsResource.cs.meta b/Editor/Resources/GetTestsResource.cs.meta deleted file mode 100644 index 32d4a814..00000000 --- a/Editor/Resources/GetTestsResource.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 81803790e9b5f0a48a1f41138abd3fb5 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Resources/McpResourceBase.cs b/Editor/Resources/McpResourceBase.cs deleted file mode 100644 index 08843e36..00000000 --- a/Editor/Resources/McpResourceBase.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using UnityEngine; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Resources -{ - /// - /// Base class for MCP Unity resources that provide data from the Unity Editor - /// - public abstract class McpResourceBase - { - /// - /// The name of the resource as used in API calls - /// - public string Name { get; protected set; } - - /// - /// Description of the resource's functionality - /// - public string Description { get; protected set; } - - /// - /// The URI pattern of the resource - /// - public string Uri { get; protected set; } - - /// - /// Whether this resource is enabled and available for use - /// - public bool IsEnabled { get; protected set; } = true; - - /// - /// Indicates if the fetch operation is asynchronous. - /// - public bool IsAsync { get; protected set; } = false; - - /// - /// Synchronously fetch the resource data. - /// Implement this for synchronous resources (IsAsync = false). - /// - /// Parameters extracted from the URI or query. - /// Result as JObject. - public virtual JObject Fetch(JObject parameters) - { - // Default implementation throws, forcing sync resources to override. - throw new NotImplementedException($"Synchronous Fetch not implemented for resource '{Name}'. Mark IsAsync=true and implement FetchAsync, or override Fetch."); - } - - /// - /// Asynchronously fetch the resource data. - /// Implement this for asynchronous resources (IsAsync = true). - /// The implementation MUST eventually call tcs.SetResult() or tcs.SetException(). - /// - /// Parameters extracted from the URI or query. - /// TaskCompletionSource to set the result on. - public virtual void FetchAsync(JObject parameters, TaskCompletionSource tcs) - { - // Default implementation throws, forcing async resources to override. - tcs.SetException(new NotImplementedException($"Asynchronous FetchAsync not implemented for resource '{Name}'. Mark IsAsync=false and implement Fetch, or override FetchAsync.")); - } - } -} diff --git a/Editor/Resources/McpResourceBase.cs.meta b/Editor/Resources/McpResourceBase.cs.meta deleted file mode 100644 index 52ee8662..00000000 --- a/Editor/Resources/McpResourceBase.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 693aa406b841aec4eb4e6507a6e4ce8e -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Services.meta b/Editor/Services.meta deleted file mode 100644 index 763a1e48..00000000 --- a/Editor/Services.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b8235205ed1cea24e8a3b09364705a64 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Services/ConsoleLogsService.cs b/Editor/Services/ConsoleLogsService.cs deleted file mode 100644 index 1dbbf01d..00000000 --- a/Editor/Services/ConsoleLogsService.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; - -namespace McpUnity.Services -{ - /// - /// Service for managing Unity console logs - /// - public class ConsoleLogsService : IConsoleLogsService - { - // Static mapping for MCP log types to Unity log types - // Some MCP types map to multiple Unity types (e.g., "error" includes Error, Exception and Assert) - private static readonly Dictionary> LogTypeMapping = new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - { "info", new HashSet(StringComparer.OrdinalIgnoreCase) { "Log" } }, - { "error", new HashSet(StringComparer.OrdinalIgnoreCase) { "Error", "Exception", "Assert" } } - }; - - // Structure to store log information - private class LogEntry - { - public string Message { get; set; } - public string StackTrace { get; set; } - public LogType Type { get; set; } - public DateTime Timestamp { get; set; } - } - - // Constants for log management - private const int MaxLogEntries = 1000; - private const int CleanupThreshold = 200; // Remove oldest entries when exceeding max - - // Collection to store all log messages - private readonly List _logEntries = new List(); - - /// - /// Constructor - /// - public ConsoleLogsService() - { - StartListening(); - } - - /// - /// Start listening for logs - /// - public void StartListening() - { - // Register for log messages - Application.logMessageReceivedThreaded += OnLogMessageReceived; - -#if UNITY_6000_0_OR_NEWER - // Unity 6 specific implementation - ConsoleWindowUtility.consoleLogsChanged += OnConsoleCountChanged; -#else - // Unity 2022.3 implementation using reflection - EditorApplication.update += CheckConsoleClearViaReflection; -#endif - } - - /// - /// Stop listening for logs - /// - public void StopListening() - { - // Unregister from log messages - Application.logMessageReceivedThreaded -= OnLogMessageReceived; - -#if UNITY_6000_0_OR_NEWER - // Unity 6 specific implementation - ConsoleWindowUtility.consoleLogsChanged -= OnConsoleCountChanged; -#else - // Unity 2022.3 implementation using reflection - EditorApplication.update -= CheckConsoleClearViaReflection; -#endif - } - /// - /// Get logs as a JSON array with pagination support - /// - /// Filter by log type (empty for all) - /// Starting index (0-based) - /// Maximum number of logs to return (default: 100) - /// Whether to include stack trace in logs (default: true) - /// JObject containing logs array and pagination info - public JObject GetLogsAsJson(string logType = "", int offset = 0, int limit = 100, bool includeStackTrace = true) - { - // Convert log entries to a JSON array, filtering by logType if provided - JArray logsArray = new JArray(); - bool filter = !string.IsNullOrEmpty(logType); - int totalCount = 0; - int filteredCount = 0; - int currentIndex = 0; - - // Map MCP log types to Unity log types outside the loop for better performance - HashSet unityLogTypes = null; - if (filter) - { - if (LogTypeMapping.TryGetValue(logType, out var mapped)) - { - unityLogTypes = mapped; - } - else - { - // If no mapping exists, create a set with the original type for case-insensitive comparison - unityLogTypes = new HashSet(StringComparer.OrdinalIgnoreCase) { logType }; - } - } - - lock (_logEntries) - { - totalCount = _logEntries.Count; - - // Single pass: count filtered entries and collect the requested page (newest first) - for (int i = _logEntries.Count - 1; i >= 0; i--) - { - var entry = _logEntries[i]; - - // Skip if filtering and entry doesn't match the filter - if (filter && !unityLogTypes.Contains(entry.Type.ToString())) - continue; - - // Count filtered entries - filteredCount++; - - // Check if we're in the offset range and haven't reached the limit yet - if (currentIndex >= offset && logsArray.Count < limit) - { - var logObject = new JObject - { - ["message"] = entry.Message, - ["type"] = entry.Type.ToString(), - ["timestamp"] = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff") - }; - - // Only include stack trace if requested - if (includeStackTrace) - { - logObject["stackTrace"] = entry.StackTrace; - } - - logsArray.Add(logObject); - } - - currentIndex++; - - // Early exit if we've collected enough logs - if (currentIndex >= offset + limit) break; - } - } - - return new JObject - { - ["logs"] = logsArray, - ["_totalCount"] = totalCount, - ["_filteredCount"] = filteredCount, - ["_returnedCount"] = logsArray.Count - }; - } - - /// - /// Clear all stored logs - /// - private void ClearLogs() - { - lock (_logEntries) - { - _logEntries.Clear(); - } - } - - /// - /// Manually clean up old log entries, keeping only the most recent ones - /// - /// Number of recent entries to keep (default: 500) - public void CleanupOldLogs(int keepCount = 500) - { - lock (_logEntries) - { - if (_logEntries.Count > keepCount) - { - int removeCount = _logEntries.Count - keepCount; - _logEntries.RemoveRange(0, removeCount); - } - } - } - - /// - /// Get current log count - /// - /// Number of stored log entries - public int GetLogCount() - { - lock (_logEntries) - { - return _logEntries.Count; - } - } - - /// - /// Check if console was cleared using reflection (for Unity 2022.3) - /// - private void CheckConsoleClearViaReflection() - { - 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); - - // If we had logs before, but now we don't, console was likely cleared - if (currentTotalCount == 0 && _logEntries.Count > 0) - { - ClearLogs(); - } - } - catch (Exception ex) - { - // Just log the error but don't break functionality - Debug.LogError($"[MCP Unity] Error checking console clear: {ex.Message}"); - } - } - - /// - /// Callback for when a log message is received - /// - /// The log message - /// The stack trace - /// The log type - private void OnLogMessageReceived(string logString, string stackTrace, LogType type) - { - // Add the log entry to our collection - lock (_logEntries) - { - _logEntries.Add(new LogEntry - { - Message = logString, - StackTrace = stackTrace, - Type = type, - Timestamp = DateTime.Now - }); - - // Clean up old entries if we exceed the maximum - if (_logEntries.Count > MaxLogEntries) - { - _logEntries.RemoveRange(0, CleanupThreshold); - } - } - } - -#if UNITY_6000_0_OR_NEWER - /// - /// Called when the console logs count changes - /// - private void OnConsoleCountChanged() - { - ConsoleWindowUtility.GetConsoleLogCounts(out int error, out int warning, out int log); - if (error == 0 && warning == 0 && log == 0 && _logEntries.Count > 0) - { - ClearLogs(); - } - } -#endif - } -} diff --git a/Editor/Services/ConsoleLogsService.cs.meta b/Editor/Services/ConsoleLogsService.cs.meta deleted file mode 100644 index 244b6696..00000000 --- a/Editor/Services/ConsoleLogsService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2ffc26907cd8fcf4f830d24697fbf712 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Services/IConsoleLogsService.cs b/Editor/Services/IConsoleLogsService.cs deleted file mode 100644 index 23ce313b..00000000 --- a/Editor/Services/IConsoleLogsService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using UnityEngine; - -namespace McpUnity.Services -{ - /// - /// Interface for the console logs service - /// - public interface IConsoleLogsService - { - /// - /// Get logs as a JSON object with pagination support - /// - /// Filter by log type (empty for all) - /// Starting index (0-based) - /// Maximum number of logs to return (default: 100) - /// Whether to include stack trace in logs (default: true) - /// JObject containing logs array and pagination info - JObject GetLogsAsJson(string logType = "", int offset = 0, int limit = 100, bool includeStackTrace = true); - - /// - /// Start listening for logs - /// - void StartListening(); - - /// - /// Stop listening for logs - /// - void StopListening(); - - /// - /// Manually clean up old log entries, keeping only the most recent ones - /// - /// Number of recent entries to keep (default: 500) - void CleanupOldLogs(int keepCount = 500); - - /// - /// Get current log count - /// - /// Number of stored log entries - int GetLogCount(); - } -} diff --git a/Editor/Services/IConsoleLogsService.cs.meta b/Editor/Services/IConsoleLogsService.cs.meta deleted file mode 100644 index f962d63f..00000000 --- a/Editor/Services/IConsoleLogsService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 817c810fa155fd349bd98b255d892759 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Services/ITestRunnerService.cs b/Editor/Services/ITestRunnerService.cs deleted file mode 100644 index bc26da68..00000000 --- a/Editor/Services/ITestRunnerService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using UnityEditor.TestTools.TestRunner.Api; - -namespace McpUnity.Services -{ - /// - /// Interface for the test runner service - /// - public interface ITestRunnerService - { - /// - /// Asynchronously retrieves all available tests using the TestRunnerApi. - /// - /// Optional test mode filter (EditMode, PlayMode, or empty for all) - /// List of test items matching the specified test mode, or all tests if no mode specified - Task> GetAllTestsAsync(string testModeFilter = ""); - - /// - /// Executes tests using the TestRunnerApi and returns the results as a JSON object. - /// - /// The test mode to run (EditMode or PlayMode). - /// If true, only failed test results are included in the output. - /// If true, all logs are included in the output. - /// A filter string to select specific tests to run. - /// Task that resolves with test results when tests are complete - Task ExecuteTestsAsync(TestMode testMode, bool returnOnlyFailures, bool returnWithLogs, string testFilter); - } -} \ No newline at end of file diff --git a/Editor/Services/ITestRunnerService.cs.meta b/Editor/Services/ITestRunnerService.cs.meta deleted file mode 100644 index fc131414..00000000 --- a/Editor/Services/ITestRunnerService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6588e2534852ce4469c45cfc3da5ab73 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Services/TestRunnerService.cs b/Editor/Services/TestRunnerService.cs deleted file mode 100644 index 9af4557a..00000000 --- a/Editor/Services/TestRunnerService.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using UnityEditor.TestTools.TestRunner.Api; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Services -{ - /// - /// Service for accessing Unity Test Runner functionality - /// Implements ICallbacks for TestRunnerApi. - /// - public class TestRunnerService : ITestRunnerService, ICallbacks - { - private readonly TestRunnerApi _testRunnerApi; - private TaskCompletionSource _tcs; - private bool _returnOnlyFailures; - private bool _returnWithLogs; - private List _results; - - /// - /// Constructor - /// - public TestRunnerService() - { - _testRunnerApi = ScriptableObject.CreateInstance(); - _results = new List(); - _testRunnerApi.RegisterCallbacks(this); - } - - /// - /// Async retrieval of all tests using TestRunnerApi callbacks - /// - /// Optional test mode filter (EditMode, PlayMode, or empty for all) - /// List of test items matching the specified test mode, or all tests if no mode specified - public async Task> GetAllTestsAsync(string testModeFilter = "") - { - var tests = new List(); - var tasks = new List>>(); - - if (string.IsNullOrEmpty(testModeFilter) || testModeFilter.Equals("EditMode", StringComparison.OrdinalIgnoreCase)) - { - tasks.Add(RetrieveTestsAsync(TestMode.EditMode)); - } - if (string.IsNullOrEmpty(testModeFilter) || testModeFilter.Equals("PlayMode", StringComparison.OrdinalIgnoreCase)) - { - tasks.Add(RetrieveTestsAsync(TestMode.PlayMode)); - } - - var results = await Task.WhenAll(tasks); - - foreach (var result in results) - { - tests.AddRange(result); - } - - return tests; - } - - /// - /// Executes tests and returns a JSON summary. - /// - /// The test mode to run (EditMode or PlayMode). - /// If true, only failed test results are included in the output. - /// If true, all logs are included in the output. - /// A filter string to select specific tests to run. - /// Task that resolves with test results when tests are complete - public async Task ExecuteTestsAsync(TestMode testMode, bool returnOnlyFailures, bool returnWithLogs, string testFilter = "") - { - var filter = new Filter { testMode = testMode }; - - _tcs = new TaskCompletionSource(); - _returnOnlyFailures = returnOnlyFailures; - _returnWithLogs = returnWithLogs; - - if (!string.IsNullOrEmpty(testFilter)) - { - filter.testNames = new[] { testFilter }; - } - - _testRunnerApi.Execute(new ExecutionSettings(filter)); - - return await WaitForCompletionAsync( - McpUnitySettings.Instance.RequestTimeoutSeconds); - } - - /// - /// Asynchronously retrieves all test adaptors for the specified test mode. - /// - /// The test mode to retrieve tests for (EditMode or PlayMode). - /// A task that resolves to a list of ITestAdaptor representing all tests in the given mode. - private Task> RetrieveTestsAsync(TestMode mode) - { - var tcs = new TaskCompletionSource>(); - var tests = new List(); - - _testRunnerApi.RetrieveTestList(mode, adaptor => - { - CollectTestItems(adaptor, tests); - tcs.SetResult(tests); - }); - - return tcs.Task; - } - - /// - /// Recursively collect test items from test adaptors - /// - private void CollectTestItems(ITestAdaptor testAdaptor, List tests) - { - if (testAdaptor.IsSuite) - { - // For suites (namespaces, classes), collect all children - foreach (var child in testAdaptor.Children) - { - CollectTestItems(child, tests); - } - } - else - { - tests.Add(testAdaptor); - } - } - - #region ICallbacks Implementation - - /// - /// Called when the test run starts. - /// - public void RunStarted(ITestAdaptor testsToRun) - { - if (_tcs == null) - return; - - _results.Clear(); - McpLogger.LogInfo($"Test run started: {testsToRun?.Name}"); - } - - /// - /// Called when an individual test starts. - /// - public void TestStarted(ITestAdaptor test) - { - // Optionally implement per-test start logic or logging. - } - - /// - /// Called when an individual test finishes. - /// - public void TestFinished(ITestResultAdaptor result) - { - if (_tcs == null) - return; - - _results.Add(result); - } - - /// - /// Called when the test run finishes. - /// - public void RunFinished(ITestResultAdaptor result) - { - if (_tcs == null) - return; - - var summary = BuildResultJson(_results, result); - _tcs.TrySetResult(summary); - _tcs = null; - } - - #endregion - - #region Helpers - - private async Task WaitForCompletionAsync(int timeoutSeconds) - { - var delayTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds)); - var winner = await Task.WhenAny(_tcs.Task, delayTask); - - if (winner != _tcs.Task) - { - _tcs.TrySetResult( - McpUnitySocketHandler.CreateErrorResponse( - $"Test run timed out after {timeoutSeconds} seconds", - "test_runner_timeout")); - } - return await _tcs.Task; - } - - private JObject BuildResultJson(List results, ITestResultAdaptor result) - { - var arr = new JArray(results - .Where(r => !r.HasChildren) - .Where(r => !_returnOnlyFailures || r.ResultState.StartsWith("Failed")) - .Select(r => new JObject { - ["name"] = r.Name, - ["fullName"] = r.FullName, - ["state"] = r.ResultState, - ["message"] = r.Message, - ["duration"] = r.Duration, - ["logs"] = _returnWithLogs ? r.Output : null, - ["stackTrace"] = r.StackTrace - })); - - int testCount = result.PassCount + result.SkipCount + result.FailCount; - return new JObject { - ["success"] = true, - ["type"] = "text", - ["message"] = $"{result.Test.Name} test run completed: {result.PassCount}/{testCount} passed - {result.FailCount}/{testCount} failed - {result.SkipCount}/{testCount} skipped", - ["resultState"] = result.ResultState, - ["durationSeconds"] = result.Duration, - ["testCount"] = results.Count, - ["passCount"] = result.PassCount, - ["failCount"] = result.FailCount, - ["skipCount"] = result.SkipCount, - ["results"] = arr - }; - } - - #endregion - } -} diff --git a/Editor/Services/TestRunnerService.cs.meta b/Editor/Services/TestRunnerService.cs.meta deleted file mode 100644 index 5ddc7e83..00000000 --- a/Editor/Services/TestRunnerService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b904b066c31e2bc498baf64673388731 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tests/AssignMaterialCommandTests.cs b/Editor/Tests/AssignMaterialCommandTests.cs new file mode 100644 index 00000000..8655f0a8 --- /dev/null +++ b/Editor/Tests/AssignMaterialCommandTests.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using McpUnity.Extensions.Commands; +using NUnit.Framework; +using Unity.Pipeline; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace McpUnity.Extensions.Tests +{ + public class AssignMaterialCommandTests + { + private const string Root = "Assets/__McpUnityAssignMaterialTests"; + + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + EnsureFolder(Root); + } + + [TearDown] + public void TearDown() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + + [Test] + public void Assign_RejectsNonGameObjectInput() + { + var material = CreateMaterialAsset("Input"); + + Assert.Throws(() => + AssignMaterialCommand.Assign(Ref(material), Ref(material))); + } + + [Test] + public void Assign_RejectsNonMaterialInput() + { + var gameObject = CreateRendererObject("Renderer"); + + Assert.Throws(() => + AssignMaterialCommand.Assign(Ref(gameObject), Ref(gameObject))); + } + + [Test] + public void Assign_RequiresRenderer() + { + var gameObject = new GameObject("NoRenderer"); + var material = CreateMaterialAsset("Material"); + + Assert.Throws(() => + AssignMaterialCommand.Assign(Ref(gameObject), Ref(material))); + } + + [TestCase(-1)] + [TestCase(1)] + public void Assign_ValidatesSlot(int slot) + { + var gameObject = CreateRendererObject("Renderer"); + var renderer = gameObject.GetComponent(); + renderer.sharedMaterials = new[] { CreateMaterialAsset("Original") }; + var replacement = CreateMaterialAsset("Replacement"); + + Assert.Throws(() => + AssignMaterialCommand.Assign(Ref(gameObject), Ref(replacement), slot)); + } + + [Test] + public void Assign_UpdatesSharedMaterialMarksDirtyAndReturnsIdentities() + { + var gameObject = CreateRendererObject("Renderer"); + var renderer = gameObject.GetComponent(); + renderer.sharedMaterials = new[] { CreateMaterialAsset("Original") }; + var replacement = CreateMaterialAsset("Replacement"); + + object result = null; + Assert.DoesNotThrow(() => + result = AssignMaterialCommand.Assign(Ref(gameObject), Ref(replacement))); + + Assert.That(renderer.sharedMaterials[0], Is.SameAs(replacement)); + Assert.That(EditorUtility.IsDirty(renderer), Is.True); + Assert.That(Property(result, "Slot"), Is.EqualTo(0)); + Assert.That(Property(result, "GameObject").InstanceId, + Is.EqualTo(PipelineUtils.GetObjectId(gameObject))); + Assert.That(Property(result, "Material").Guid, Is.Not.Empty); + } + + [Test] + public void Assign_RecordsPrefabInstancePropertyModification() + { + var source = CreateRendererObject("PrefabSource"); + source.GetComponent().sharedMaterials = new[] { CreateMaterialAsset("Original") }; + var prefabPath = Root + "/Renderer.prefab"; + var prefab = PrefabUtility.SaveAsPrefabAsset(source, prefabPath); + UnityEngine.Object.DestroyImmediate(source); + var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); + var replacement = CreateMaterialAsset("Replacement"); + + Assert.DoesNotThrow(() => + AssignMaterialCommand.Assign(Ref(instance), Ref(replacement))); + + var modifications = PrefabUtility.GetPropertyModifications(instance.GetComponent()); + Assert.That(modifications, Is.Not.Null); + Assert.That( + modifications.Any(modification => + modification.propertyPath == "m_Materials.Array.data[0]"), + Is.True); + } + + private static GameObject CreateRendererObject(string name) + { + var gameObject = new GameObject(name); + gameObject.AddComponent(); + return gameObject; + } + + private static Material CreateMaterialAsset(string name) + { + var shader = Shader.Find("Standard") ?? + Shader.Find("Sprites/Default") ?? + Shader.Find("Hidden/InternalErrorShader"); + Assert.That(shader, Is.Not.Null); + var material = new Material(shader); + AssetDatabase.CreateAsset(material, Root + "/" + name + ".mat"); + return material; + } + + private static ObjectRef Ref(UnityEngine.Object obj) => + new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) }; + + private static T Property(object instance, string name) + { + Assert.That(instance, Is.Not.Null); + var property = instance.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + Assert.That(property, Is.Not.Null, $"Expected public property '{name}'."); + return (T)property.GetValue(instance); + } + + private static void EnsureFolder(string path) + { + if (AssetDatabase.IsValidFolder(path)) + return; + + var parent = Path.GetDirectoryName(path)?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent)) + EnsureFolder(parent); + AssetDatabase.CreateFolder(parent, Path.GetFileName(path)); + } + } +} diff --git a/Editor/Tests/AssignMaterialCommandTests.cs.meta b/Editor/Tests/AssignMaterialCommandTests.cs.meta new file mode 100644 index 00000000..c194864e --- /dev/null +++ b/Editor/Tests/AssignMaterialCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a7a1c2c47f64ead9f44d1898230fba7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/BatchExecuteToolTests.cs b/Editor/Tests/BatchExecuteToolTests.cs deleted file mode 100644 index 585e444f..00000000 --- a/Editor/Tests/BatchExecuteToolTests.cs +++ /dev/null @@ -1,501 +0,0 @@ -using System.Collections; -using System.Threading.Tasks; -using NUnit.Framework; -using McpUnity.Tools; -using McpUnity.Unity; -using UnityEngine; -using UnityEngine.TestTools; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tests -{ - /// - /// Tests for BatchExecuteTool functionality - /// - public class BatchExecuteToolTests - { - private BatchExecuteTool _batchTool; - private GameObject _testObject; - - [SetUp] - public void SetUp() - { - _batchTool = new BatchExecuteTool(name => - name == "get_scene_info" ? new GetSceneInfoTool() : null); - } - - [TearDown] - public void TearDown() - { - // Clean up any test objects - if (_testObject != null) - { - Object.DestroyImmediate(_testObject); - _testObject = null; - } - } - - #region Basic Properties Tests - - [Test] - public void BatchExecuteTool_HasCorrectName() - { - Assert.AreEqual("batch_execute", _batchTool.Name); - } - - [Test] - public void BatchExecuteTool_IsAsync() - { - Assert.IsTrue(_batchTool.IsAsync, "BatchExecuteTool should be async"); - } - - [Test] - public void BatchExecuteTool_HasDescription() - { - Assert.IsNotNull(_batchTool.Description); - Assert.IsTrue(_batchTool.Description.Contains("batch"), "Description should mention batch"); - } - - #endregion - - #region Validation Tests - - [UnityTest] - public IEnumerator BatchExecuteTool_WithEmptyOperations_ReturnsError() - { - // Arrange - JObject parameters = new JObject - { - ["operations"] = new JArray() - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsNotNull(result["error"], "Should return error for empty operations"); - Assert.IsTrue(result["error"]["message"].ToString().Contains("operations"), - "Error should mention operations"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_WithNullOperations_ReturnsError() - { - // Arrange - JObject parameters = new JObject(); - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsNotNull(result["error"], "Should return error for null operations"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_WithTooManyOperations_ReturnsError() - { - // Arrange - JArray operations = new JArray(); - for (int i = 0; i < 101; i++) - { - operations.Add(new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject() - }); - } - - JObject parameters = new JObject - { - ["operations"] = operations - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsNotNull(result["error"], "Should return error for too many operations"); - Assert.IsTrue(result["error"]["message"].ToString().Contains("100"), - "Error should mention limit"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_WithNestedBatchExecute_ReturnsError() - { - // Arrange - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "batch_execute", - ["params"] = new JObject - { - ["operations"] = new JArray() - } - } - } - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Should fail because of nested batch_execute - Assert.IsFalse(result["success"]?.ToObject() ?? true, "Should fail for nested batch"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_WithUnknownTool_ReturnsError() - { - // Arrange - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "nonexistent_tool_12345", - ["params"] = new JObject() - } - }, - ["stopOnError"] = true - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsFalse(result["success"]?.ToObject() ?? true, "Should fail for unknown tool"); - Assert.IsNotNull(result["results"], "Should have results array"); - JArray results = result["results"] as JArray; - Assert.AreEqual(1, results.Count); - Assert.IsFalse(results[0]["success"]?.ToObject() ?? true); - Assert.IsTrue(results[0]["error"]?.ToString().Contains("Unknown tool")); - } - - #endregion - - #region Successful Execution Tests - - [UnityTest] - public IEnumerator BatchExecuteTool_WithSingleOperation_Succeeds() - { - // Arrange - get_scene_info requires no parameters and always succeeds - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "op1" - } - } - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsTrue(result["success"]?.ToObject() ?? false, "Should succeed"); - Assert.IsNotNull(result["results"], "Should have results array"); - Assert.IsNotNull(result["summary"], "Should have summary"); - - JObject summary = result["summary"] as JObject; - Assert.AreEqual(1, summary["total"]?.ToObject()); - Assert.AreEqual(1, summary["succeeded"]?.ToObject()); - Assert.AreEqual(0, summary["failed"]?.ToObject()); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_WithMultipleOperations_Succeeds() - { - // Arrange - Use operations that don't require external dependencies - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "op1" - }, - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "op2" - } - } - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsTrue(result["success"]?.ToObject() ?? false, "Should succeed"); - - JObject summary = result["summary"] as JObject; - Assert.AreEqual(2, summary["total"]?.ToObject()); - Assert.AreEqual(2, summary["succeeded"]?.ToObject()); - Assert.AreEqual(0, summary["failed"]?.ToObject()); - - JArray results = result["results"] as JArray; - Assert.AreEqual(2, results.Count); - Assert.AreEqual("op1", results[0]["id"]?.ToString()); - Assert.AreEqual("op2", results[1]["id"]?.ToString()); - } - - #endregion - - #region StopOnError Tests - - [UnityTest] - public IEnumerator BatchExecuteTool_StopOnErrorTrue_StopsAtFirstError() - { - // Arrange - First operation fails, second should not execute - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "nonexistent_tool", - ["params"] = new JObject(), - ["id"] = "fail1" - }, - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "should_not_run" - } - }, - ["stopOnError"] = true - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Assert.IsFalse(result["success"]?.ToObject() ?? true, "Should fail"); - - JObject summary = result["summary"] as JObject; - Assert.AreEqual(2, summary["total"]?.ToObject()); - Assert.AreEqual(0, summary["succeeded"]?.ToObject()); - Assert.AreEqual(1, summary["failed"]?.ToObject()); - Assert.AreEqual(1, summary["executed"]?.ToObject(), "Should only execute 1 operation"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_StopOnErrorFalse_ContinuesAfterError() - { - // Arrange - First operation fails, but should continue - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "nonexistent_tool", - ["params"] = new JObject(), - ["id"] = "fail1" - }, - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "should_run" - } - }, - ["stopOnError"] = false - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Should have partial success - Assert.IsFalse(result["success"]?.ToObject() ?? true, "Overall should fail"); - - JObject summary = result["summary"] as JObject; - Assert.AreEqual(2, summary["total"]?.ToObject()); - Assert.AreEqual(1, summary["succeeded"]?.ToObject(), "Second operation should succeed"); - Assert.AreEqual(1, summary["failed"]?.ToObject()); - Assert.AreEqual(2, summary["executed"]?.ToObject(), "Should execute both operations"); - } - - #endregion - - #region Response Format Tests - - [UnityTest] - public IEnumerator BatchExecuteTool_ResponseContainsRequiredFields() - { - // Arrange - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject() - } - } - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - Check all required fields are present - Assert.IsNotNull(result["success"], "Response should have 'success' field"); - Assert.IsNotNull(result["type"], "Response should have 'type' field"); - Assert.IsNotNull(result["message"], "Response should have 'message' field"); - Assert.IsNotNull(result["results"], "Response should have 'results' field"); - Assert.IsNotNull(result["summary"], "Response should have 'summary' field"); - - JObject summary = result["summary"] as JObject; - Assert.IsNotNull(summary["total"], "Summary should have 'total' field"); - Assert.IsNotNull(summary["succeeded"], "Summary should have 'succeeded' field"); - Assert.IsNotNull(summary["failed"], "Summary should have 'failed' field"); - Assert.IsNotNull(summary["executed"], "Summary should have 'executed' field"); - } - - [UnityTest] - public IEnumerator BatchExecuteTool_OperationResultsHaveCorrectFormat() - { - // Arrange - JObject parameters = new JObject - { - ["operations"] = new JArray - { - new JObject - { - ["tool"] = "get_scene_info", - ["params"] = new JObject(), - ["id"] = "custom_id" - } - } - }; - - var tcs = new TaskCompletionSource(); - - // Act - _batchTool.ExecuteAsync(parameters, tcs); - - while (!tcs.Task.IsCompleted) - { - yield return null; - } - - JObject result = tcs.Task.Result; - - // Assert - JArray results = result["results"] as JArray; - Assert.AreEqual(1, results.Count); - - JObject opResult = results[0] as JObject; - Assert.IsNotNull(opResult["index"], "Operation result should have 'index' field"); - Assert.IsNotNull(opResult["id"], "Operation result should have 'id' field"); - Assert.IsNotNull(opResult["success"], "Operation result should have 'success' field"); - Assert.AreEqual("custom_id", opResult["id"]?.ToString()); - Assert.AreEqual(0, opResult["index"]?.ToObject()); - } - - #endregion - } -} diff --git a/Editor/Tests/BatchExecuteToolTests.cs.meta b/Editor/Tests/BatchExecuteToolTests.cs.meta deleted file mode 100644 index ad371822..00000000 --- a/Editor/Tests/BatchExecuteToolTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d9fbcf3701ae045a38915e6104c5b58b \ No newline at end of file diff --git a/Editor/Tests/CommandDiscoveryTests.cs b/Editor/Tests/CommandDiscoveryTests.cs new file mode 100644 index 00000000..b259b4aa --- /dev/null +++ b/Editor/Tests/CommandDiscoveryTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using Unity.Pipeline.Commands; +using Unity.Pipeline.Editor; + +namespace McpUnity.Extensions.Tests +{ + public class CommandDiscoveryTests + { + private static readonly string[] ExpectedNames = + { + "assign_material", + "duplicate_gameobject", + "editor_step", + "inspect_gameobject", + "unload_scene" + }; + + [SetUp] + public void SetUp() + { + CommandRegistry.SetDiscovery(new TypeCacheCommandDiscovery()); + } + + [Test] + public void ExtensionAssembly_DeclaresExactlyFivePublicStaticCommandsWithExactNames() + { + var methods = FindExtensionCommandMethods(); + + Assert.That(methods, Has.Count.EqualTo(ExpectedNames.Length)); + Assert.That( + methods.Select(method => method.GetCustomAttribute().Name), + Is.EquivalentTo(ExpectedNames)); + Assert.That(methods, Has.All.Matches(method => method.IsPublic && method.IsStatic)); + } + + [Test] + public void PipelineDiscovery_ContainsEachExtensionCommandExactlyOnce() + { + var discoveredNames = CommandRegistry.DiscoverCommands() + .Select(command => command.Name) + .ToList(); + + foreach (var expectedName in ExpectedNames) + { + Assert.That( + discoveredNames.Count(name => string.Equals(name, expectedName, StringComparison.Ordinal)), + Is.EqualTo(1), + $"Pipeline discovery should contain exactly one '{expectedName}' command."); + } + } + + private static List FindExtensionCommandMethods() + { + var methods = new List(); + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var type in GetLoadableTypes(assembly)) + { + if (type.Namespace == null || + !type.Namespace.StartsWith("McpUnity.Extensions", StringComparison.Ordinal)) + { + continue; + } + + methods.AddRange(type + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + .Where(method => method.GetCustomAttribute() != null)); + } + } + + return methods; + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException exception) + { + return exception.Types.Where(type => type != null); + } + } + } +} diff --git a/Editor/Tests/CommandDiscoveryTests.cs.meta b/Editor/Tests/CommandDiscoveryTests.cs.meta new file mode 100644 index 00000000..2c809b4b --- /dev/null +++ b/Editor/Tests/CommandDiscoveryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29f9b791e34b4d19ab7081587377a5ef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/DuplicateGameObjectCommandTests.cs b/Editor/Tests/DuplicateGameObjectCommandTests.cs new file mode 100644 index 00000000..b8915919 --- /dev/null +++ b/Editor/Tests/DuplicateGameObjectCommandTests.cs @@ -0,0 +1,121 @@ +using System; +using McpUnity.Extensions.Commands; +using NUnit.Framework; +using Unity.Pipeline; +using Unity.Pipeline.Editor.Authoring; +using Unity.Pipeline.Models; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace McpUnity.Extensions.Tests +{ + public class DuplicateGameObjectCommandTests + { + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + Undo.ClearAll(); + } + + [TearDown] + public void TearDown() + { + Undo.ClearAll(); + } + + [Test] + public void Duplicate_RejectsNonGameObjectSource() + { + var material = new Material(FindShader()); + try + { + Assert.Throws(() => + DuplicateGameObjectCommand.Duplicate(Ref(material))); + } + finally + { + UnityEngine.Object.DestroyImmediate(material); + } + } + + [Test] + public void Duplicate_PreservesSourceSceneWhenParentIsOmitted() + { + var source = new GameObject("Source"); + source.transform.position = new Vector3(3f, 4f, 5f); + + AuthoringResult result = null; + Assert.DoesNotThrow(() => + result = DuplicateGameObjectCommand.Duplicate(Ref(source), name: "Copy")); + + Assert.That(ObjectResolver.TryResolve(ToRef(result), out var resolved, out var error), Is.True, error); + var duplicate = (GameObject)resolved; + Assert.That(duplicate, Is.Not.SameAs(source)); + Assert.That(duplicate.name, Is.EqualTo("Copy")); + Assert.That(duplicate.scene, Is.EqualTo(source.scene)); + Assert.That(duplicate.transform.position, Is.EqualTo(source.transform.position)); + } + + [Test] + public void Duplicate_LeavesNameUnchangedWhenRenameIsOmitted() + { + var source = new GameObject("Source"); + + var result = DuplicateGameObjectCommand.Duplicate(Ref(source)); + + Assert.That(ObjectResolver.TryResolve(ToRef(result), out var resolved, out var error), Is.True, error); + Assert.That(resolved.name, Is.EqualTo(source.name)); + } + + [Test] + public void Duplicate_AppliesOptionalParentWithWorldPositionStaysFalseByDefault() + { + var source = new GameObject("Source"); + source.transform.localPosition = new Vector3(2f, 0f, 0f); + var parent = new GameObject("Parent"); + parent.transform.position = new Vector3(10f, 0f, 0f); + + AuthoringResult result = null; + Assert.DoesNotThrow(() => + result = DuplicateGameObjectCommand.Duplicate( + Ref(source), + parent: Ref(parent), + name: "ChildCopy")); + + Assert.That(ObjectResolver.TryResolve(ToRef(result), out var resolved, out var error), Is.True, error); + var duplicate = (GameObject)resolved; + Assert.That(duplicate.transform.parent, Is.SameAs(parent.transform)); + Assert.That(duplicate.transform.localPosition, Is.EqualTo(source.transform.localPosition)); + Assert.That(duplicate.name, Is.EqualTo("ChildCopy")); + } + + [Test] + public void Duplicate_RegistersCreatedObjectWithUndo() + { + var source = new GameObject("Source"); + + AuthoringResult result = null; + Assert.DoesNotThrow(() => result = DuplicateGameObjectCommand.Duplicate(Ref(source))); + Assert.That(result.InstanceId, Is.Not.Null); + Assert.That(PipelineUtils.IdToObject(result.InstanceId.Value), Is.Not.Null); + + Undo.PerformUndo(); + + Assert.That(PipelineUtils.IdToObject(result.InstanceId.Value), Is.Null); + Assert.That(source, Is.Not.Null, "Undo should remove only the duplicate."); + } + + private static ObjectRef Ref(UnityEngine.Object obj) => + new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) }; + + private static ObjectRef ToRef(AuthoringResult result) => + new ObjectRef { InstanceId = result.InstanceId }; + + private static Shader FindShader() => + Shader.Find("Standard") ?? + Shader.Find("Sprites/Default") ?? + Shader.Find("Hidden/InternalErrorShader"); + } +} diff --git a/Editor/Tests/DuplicateGameObjectCommandTests.cs.meta b/Editor/Tests/DuplicateGameObjectCommandTests.cs.meta new file mode 100644 index 00000000..e464d0bf --- /dev/null +++ b/Editor/Tests/DuplicateGameObjectCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d2b6ee93ac4042b4891bb222765ca406 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/EditorStepCommandTests.cs b/Editor/Tests/EditorStepCommandTests.cs new file mode 100644 index 00000000..b725677e --- /dev/null +++ b/Editor/Tests/EditorStepCommandTests.cs @@ -0,0 +1,17 @@ +using System; +using McpUnity.Extensions.Commands; +using NUnit.Framework; +using UnityEditor; + +namespace McpUnity.Extensions.Tests +{ + public class EditorStepCommandTests + { + [Test] + public void Step_RejectsOutsidePlayMode() + { + Assert.That(EditorApplication.isPlaying, Is.False); + Assert.Throws(() => EditorStepCommand.Step()); + } + } +} diff --git a/Editor/Tests/EditorStepCommandTests.cs.meta b/Editor/Tests/EditorStepCommandTests.cs.meta new file mode 100644 index 00000000..fc3ef546 --- /dev/null +++ b/Editor/Tests/EditorStepCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4293364d13db4bd18ed39c29ed865d4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/GetGameObjectResourceTests.cs b/Editor/Tests/GetGameObjectResourceTests.cs deleted file mode 100644 index 99378710..00000000 --- a/Editor/Tests/GetGameObjectResourceTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using NUnit.Framework; -using McpUnity.Resources; -using Newtonsoft.Json.Linq; -using UnityEngine; - -namespace McpUnity.Tests -{ - /// - /// Regression tests for GameObject serialization safety. - /// - public class GetGameObjectResourceTests - { - private GameObject _testObject; - - [SetUp] - public void SetUp() - { - _testObject = new GameObject("GetGameObjectResourceTests_Object"); - } - - [TearDown] - public void TearDown() - { - if (_testObject != null) - { - Object.DestroyImmediate(_testObject); - _testObject = null; - } - } - - [Test] - public void GameObjectToJObject_WithCollider_SkipsDetailedPropertiesForSafety() - { - // Arrange - _testObject.AddComponent(); - - // Act - JObject result = GetGameObjectResource.GameObjectToJObject(_testObject, true); - - // Assert - Assert.IsNotNull(result); - - JArray components = (JArray)result["components"]; - Assert.IsNotNull(components); - - JObject colliderJson = null; - foreach (JToken component in components) - { - if (component?["type"]?.ToString() == nameof(BoxCollider)) - { - colliderJson = (JObject)component; - break; - } - } - - Assert.IsNotNull(colliderJson, "Expected serialized component list to include BoxCollider."); - Assert.AreEqual(true, colliderJson["enabled"]?.ToObject()); - Assert.AreEqual( - "Detailed property serialization skipped for safety", - colliderJson["properties"]?["_skipped"]?.ToString()); - } - - [Test] - public void GameObjectToJObject_WithMaxDepthZero_HasNoChildrenAndIsTruncated() - { - // Arrange: parent with one child. - var child = new GameObject("Child"); - child.transform.SetParent(_testObject.transform); - - try - { - // Act - JObject result = GetGameObjectResource.GameObjectToJObject( - _testObject, includeDetailedComponents: true, maxDepth: 0); - - // Assert - Assert.IsNotNull(result); - JArray children = (JArray)result["children"]; - Assert.IsNotNull(children); - Assert.AreEqual(0, children.Count, "maxDepth=0 should not serialize any children."); - Assert.AreEqual(true, result["_truncated"]?.ToObject()); - Assert.AreEqual("depth_limit", result["_truncatedReason"]?.ToString()); - Assert.AreEqual(1, result["_childCount"]?.ToObject()); - } - finally - { - Object.DestroyImmediate(child); - } - } - - [Test] - public void GameObjectToJObject_WithMaxDepthOne_StopsAtGrandchildren() - { - // Arrange: parent → child → grandchild. - var child = new GameObject("Child"); - child.transform.SetParent(_testObject.transform); - var grandchild = new GameObject("Grandchild"); - grandchild.transform.SetParent(child.transform); - - try - { - // Act - JObject result = GetGameObjectResource.GameObjectToJObject( - _testObject, includeDetailedComponents: true, maxDepth: 1); - - // Assert: parent has child, but child must be flagged truncated and have no grandchildren. - Assert.IsNotNull(result); - Assert.IsNull(result["_truncated"], "Root should not be truncated when its own children fit at maxDepth=1."); - - JArray children = (JArray)result["children"]; - Assert.AreEqual(1, children.Count); - - JObject childJson = (JObject)children[0]; - Assert.AreEqual("Child", childJson["name"]?.ToString()); - - JArray grandchildren = (JArray)childJson["children"]; - Assert.AreEqual(0, grandchildren.Count, "Grandchildren should be omitted at depth=1."); - Assert.AreEqual(true, childJson["_truncated"]?.ToObject()); - Assert.AreEqual("depth_limit", childJson["_truncatedReason"]?.ToString()); - Assert.AreEqual(1, childJson["_childCount"]?.ToObject()); - } - finally - { - Object.DestroyImmediate(grandchild); - Object.DestroyImmediate(child); - } - } - - [Test] - public void GameObjectToJObject_WithIncludeComponentsFalse_OmitsComponents() - { - // Arrange - _testObject.AddComponent(); - - // Act - JObject result = GetGameObjectResource.GameObjectToJObject( - _testObject, includeDetailedComponents: true, includeComponents: false); - - // Assert - Assert.IsNotNull(result); - Assert.IsFalse(result.ContainsKey("components"), - "Expected 'components' key to be omitted when includeComponents=false."); - } - - [Test] - public void GameObjectToJObject_WithIncludeComponentPropertiesFalse_OmitsProperties() - { - // Arrange - _testObject.AddComponent(); - - // Act - JObject result = GetGameObjectResource.GameObjectToJObject( - _testObject, includeDetailedComponents: true, includeComponentProperties: false); - - // Assert - JArray components = (JArray)result["components"]; - Assert.IsNotNull(components); - - JObject rigidbodyJson = null; - foreach (JToken component in components) - { - if (component?["type"]?.ToString() == nameof(Rigidbody)) - { - rigidbodyJson = (JObject)component; - break; - } - } - - Assert.IsNotNull(rigidbodyJson, "Expected Rigidbody to be listed."); - Assert.IsNotNull(rigidbodyJson["type"]); - Assert.IsNotNull(rigidbodyJson["enabled"]); - Assert.IsFalse(rigidbodyJson.ContainsKey("properties"), - "Expected 'properties' to be omitted on each component when includeComponentProperties=false."); - } - } -} diff --git a/Editor/Tests/GetGameObjectResourceTests.cs.meta b/Editor/Tests/GetGameObjectResourceTests.cs.meta deleted file mode 100644 index 059123b8..00000000 --- a/Editor/Tests/GetGameObjectResourceTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 97b7ccdbc03964341937c2f49e6a2186 diff --git a/Editor/Tests/InspectionCommandTests.cs b/Editor/Tests/InspectionCommandTests.cs new file mode 100644 index 00000000..001700f6 --- /dev/null +++ b/Editor/Tests/InspectionCommandTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using McpUnity.Extensions.Commands; +using NUnit.Framework; +using Unity.Pipeline; +using Unity.Pipeline.Models; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace McpUnity.Extensions.Tests +{ + public class InspectionCommandTests + { + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [Test] + public void Inspect_ReturnsIdentityStateTransformAndComponentSummaries() + { + var target = new GameObject("Inspectable") + { + layer = 5, + tag = "Untagged", + isStatic = true + }; + target.transform.localPosition = new Vector3(1f, 2f, 3f); + target.AddComponent(); + target.SetActive(false); + + object result = null; + Assert.DoesNotThrow(() => result = InspectGameObjectCommand.Inspect(Ref(target))); + + var root = Property(result, "Root"); + Assert.That(Property(root, "Name"), Is.EqualTo("Inspectable")); + Assert.That(Property(root, "Path"), Is.EqualTo("/Inspectable")); + Assert.That(Property(root, "ActiveSelf"), Is.False); + Assert.That(Property(root, "ActiveInHierarchy"), Is.False); + Assert.That(Property(root, "Layer"), Is.EqualTo(5)); + Assert.That(Property(root, "Tag"), Is.EqualTo("Untagged")); + Assert.That(Property(root, "IsStatic"), Is.True); + + var identity = Property(root, "Identity"); + Assert.That(identity.InstanceId, Is.EqualTo(PipelineUtils.GetObjectId(target))); + + var transform = Property(root, "Transform"); + var localPosition = Property(transform, "LocalPosition"); + Assert.That(Property(localPosition, "X"), Is.EqualTo(1f)); + Assert.That(Property(localPosition, "Y"), Is.EqualTo(2f)); + Assert.That(Property(localPosition, "Z"), Is.EqualTo(3f)); + + var components = Property(root, "Components"); + Assert.That( + components.Cast().Select(component => Property(component, "Type")), + Does.Contain(nameof(BoxCollider))); + } + + [Test] + public void Inspect_ClampsDepthToZeroAndMarksOmittedChildren() + { + var root = new GameObject("Root"); + var child = new GameObject("Child"); + child.transform.SetParent(root.transform); + + object result = null; + Assert.DoesNotThrow(() => + result = InspectGameObjectCommand.Inspect(Ref(root), maxDepth: -20)); + + Assert.That(Property(result, "MaxDepth"), Is.EqualTo(0)); + var rootNode = Property(result, "Root"); + Assert.That(Property(rootNode, "Children"), Is.Empty); + Assert.That(Property(rootNode, "ChildrenTruncated"), Is.True); + } + + [Test] + public void Inspect_ClampsNodeLimitAndMarksNodeTruncation() + { + var root = new GameObject("Root"); + new GameObject("First").transform.SetParent(root.transform); + new GameObject("Second").transform.SetParent(root.transform); + + object result = null; + Assert.DoesNotThrow(() => + result = InspectGameObjectCommand.Inspect(Ref(root), maxNodes: 0)); + + Assert.That(Property(result, "MaxNodes"), Is.EqualTo(1)); + Assert.That(Property(result, "NodesReturned"), Is.EqualTo(1)); + Assert.That(Property(result, "NodeLimitReached"), Is.True); + Assert.That(Property(Property(result, "Root"), "ChildrenTruncated"), Is.True); + } + + [Test] + public void Inspect_ClampsUpperBoundsAndUsesDocumentedDefaults() + { + var root = new GameObject("Root"); + + object defaults = null; + Assert.DoesNotThrow(() => defaults = InspectGameObjectCommand.Inspect(Ref(root))); + Assert.That(Property(defaults, "MaxDepth"), Is.EqualTo(2)); + Assert.That(Property(defaults, "MaxNodes"), Is.EqualTo(200)); + Assert.That(Property(defaults, "MaxPropertiesPerComponent"), Is.EqualTo(100)); + + object clamped = null; + Assert.DoesNotThrow(() => + clamped = InspectGameObjectCommand.Inspect( + Ref(root), + maxDepth: 99, + maxNodes: 5000, + maxPropertiesPerComponent: 999)); + Assert.That(Property(clamped, "MaxDepth"), Is.EqualTo(8)); + Assert.That(Property(clamped, "MaxNodes"), Is.EqualTo(1000)); + Assert.That(Property(clamped, "MaxPropertiesPerComponent"), Is.EqualTo(200)); + } + + [Test] + public void Inspect_BoundsSerializedPropertiesAndSkipsScriptReference() + { + var root = new GameObject("Root"); + root.AddComponent(); + + object result = null; + Assert.DoesNotThrow(() => + result = InspectGameObjectCommand.Inspect( + Ref(root), + includeProperties: true, + maxPropertiesPerComponent: 1)); + + var components = Property(Property(result, "Root"), "Components"); + var fixture = components.Cast() + .Single(component => Property(component, "Type") == nameof(InspectionFixtureComponent)); + var properties = Property(fixture, "Properties"); + + Assert.That(properties, Has.Count.EqualTo(1)); + Assert.That(Property(fixture, "PropertiesTruncated"), Is.True); + Assert.That( + properties.Cast().Select(property => Property(property, "Path")), + Does.Not.Contain("m_Script")); + } + + private static ObjectRef Ref(UnityEngine.Object obj) => + new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) }; + + private static object Property(object instance, string name) + { + Assert.That(instance, Is.Not.Null, $"Cannot read '{name}' from a null result."); + var property = instance.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + Assert.That(property, Is.Not.Null, $"Expected public property '{name}' on {instance.GetType().Name}."); + return property.GetValue(instance); + } + + private static T Property(object instance, string name) => (T)Property(instance, name); + } + + public sealed class InspectionFixtureComponent : MonoBehaviour + { + public int First = 1; + public string Second = "two"; + public Vector3 Third = Vector3.one; + public AnimationCurve Unsupported = AnimationCurve.Linear(0f, 0f, 1f, 1f); + } +} diff --git a/Editor/Tests/InspectionCommandTests.cs.meta b/Editor/Tests/InspectionCommandTests.cs.meta new file mode 100644 index 00000000..f5adec0f --- /dev/null +++ b/Editor/Tests/InspectionCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d1e1731d24f54c68af99f6570fe329d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/MaterialToolsTests.cs b/Editor/Tests/MaterialToolsTests.cs deleted file mode 100644 index 71eef06a..00000000 --- a/Editor/Tests/MaterialToolsTests.cs +++ /dev/null @@ -1,670 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using NUnit.Framework; -using McpUnity.Tools; -using McpUnity.Utils; -using UnityEngine; -using UnityEngine.TestTools; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tests -{ - /// - /// Tests for Material Tools functionality - /// - public class MaterialToolsTests - { - private string _testMaterialPath; - private string _testMaterialDir; - - [SetUp] - public void SetUp() - { - // Create test directory - _testMaterialDir = "Assets/TestMaterials"; - _testMaterialPath = Path.Combine(_testMaterialDir, "TestMaterial.mat"); - - // Ensure test directory exists - if (!AssetDatabase.IsValidFolder(_testMaterialDir)) - { - AssetDatabase.CreateFolder("Assets", "TestMaterials"); - } - } - - [TearDown] - public void TearDown() - { - // Clean up test materials - if (File.Exists(_testMaterialPath)) - { - AssetDatabase.DeleteAsset(_testMaterialPath); - } - - // Clean up test directory - if (AssetDatabase.IsValidFolder(_testMaterialDir)) - { - AssetDatabase.DeleteAsset(_testMaterialDir); - } - - AssetDatabase.Refresh(); - } - - #region MaterialToolUtils Tests - - [Test] - public void FindShader_WithStandardShader_ReturnsShader() - { - // Act - Shader shader = MaterialToolUtils.FindShader("Standard"); - - // Assert - Assert.IsNotNull(shader, "Standard shader should be found"); - Assert.AreEqual("Standard", shader.name); - } - - [Test] - public void FindShader_WithUnlitColor_ReturnsShader() - { - // Act - Shader shader = MaterialToolUtils.FindShader("Unlit/Color"); - - // Assert - Assert.IsNotNull(shader, "Unlit/Color shader should be found"); - } - - [Test] - public void FindShader_WithNonExistentShader_ReturnsNull() - { - // Act - Shader shader = MaterialToolUtils.FindShader("NonExistent/Shader/12345"); - - // Assert - Assert.IsNull(shader, "Non-existent shader should return null"); - } - - [Test] - public void LoadMaterial_WithNonExistentPath_ReturnsNull() - { - // Act - Material material = MaterialToolUtils.LoadMaterial("Assets/NonExistent/Material.mat"); - - // Assert - Assert.IsNull(material, "Non-existent material should return null"); - } - - [Test] - public void LoadMaterial_WithNullPath_ReturnsNull() - { - // Act - Material material = MaterialToolUtils.LoadMaterial(null); - - // Assert - Assert.IsNull(material, "Null path should return null"); - } - - [Test] - public void LoadMaterial_WithEmptyPath_ReturnsNull() - { - // Act - Material material = MaterialToolUtils.LoadMaterial(""); - - // Assert - Assert.IsNull(material, "Empty path should return null"); - } - - #endregion - - #region CreateMaterialTool Tests - - [Test] - public void CreateMaterialTool_WithValidParameters_CreatesMaterial() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["name"] = "TestMaterial", - ["shader"] = "Standard", - ["savePath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - Assert.IsTrue(File.Exists(_testMaterialPath), "Material file should be created"); - - Material material = AssetDatabase.LoadAssetAtPath(_testMaterialPath); - Assert.IsNotNull(material, "Material asset should be loadable"); - Assert.AreEqual("TestMaterial", material.name); - Assert.AreEqual("Standard", material.shader.name); - } - - [Test] - public void CreateMaterialTool_WithProperties_AppliesProperties() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["name"] = "TestMaterialWithProps", - ["shader"] = "Standard", - ["savePath"] = _testMaterialPath, - ["properties"] = new JObject - { - ["_Color"] = new JObject { ["r"] = 1f, ["g"] = 0f, ["b"] = 0f, ["a"] = 1f }, - ["_Metallic"] = 0.5f - } - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - - Material material = AssetDatabase.LoadAssetAtPath(_testMaterialPath); - Assert.IsNotNull(material); - - Color color = material.GetColor("_Color"); - Assert.AreEqual(1f, color.r, 0.01f, "Red should be 1"); - Assert.AreEqual(0f, color.g, 0.01f, "Green should be 0"); - - float metallic = material.GetFloat("_Metallic"); - Assert.AreEqual(0.5f, metallic, 0.01f, "Metallic should be 0.5"); - } - - [Test] - public void CreateMaterialTool_WithMissingName_ReturnsError() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["shader"] = "Standard", - ["savePath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void CreateMaterialTool_WithMissingSavePath_ReturnsError() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["name"] = "TestMaterial", - ["shader"] = "Standard" - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void CreateMaterialTool_WithInvalidShader_ReturnsError() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["name"] = "TestMaterial", - ["shader"] = "NonExistent/Shader/12345", - ["savePath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("not_found_error", result["error"]["type"].ToString()); - } - - [Test] - public void CreateMaterialTool_WithDefaultShader_UsesAutoDetectedShader() - { - // Arrange - CreateMaterialTool tool = new CreateMaterialTool(); - JObject parameters = new JObject - { - ["name"] = "TestMaterial", - ["savePath"] = _testMaterialPath - }; - string expectedShader = MaterialToolUtils.GetDefaultShaderName(); - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - - Material material = AssetDatabase.LoadAssetAtPath(_testMaterialPath); - Assert.IsNotNull(material); - Assert.AreEqual(expectedShader, material.shader.name, "Default shader should match auto-detected render pipeline shader"); - } - - #endregion - - #region GetMaterialInfoTool Tests - - [Test] - public void GetMaterialInfoTool_WithValidMaterial_ReturnsInfo() - { - // Arrange - Create a material first - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - GetMaterialInfoTool tool = new GetMaterialInfoTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - Assert.AreEqual("TestMaterial", result["materialName"].ToString()); - Assert.AreEqual("Standard", result["shaderName"].ToString()); - Assert.IsNotNull(result["properties"], "Should have properties array"); - Assert.IsTrue(((JArray)result["properties"]).Count > 0, "Should have at least one property"); - } - - [Test] - public void GetMaterialInfoTool_WithMissingMaterialPath_ReturnsError() - { - // Arrange - GetMaterialInfoTool tool = new GetMaterialInfoTool(); - JObject parameters = new JObject(); - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void GetMaterialInfoTool_WithNonExistentMaterial_ReturnsError() - { - // Arrange - GetMaterialInfoTool tool = new GetMaterialInfoTool(); - JObject parameters = new JObject - { - ["materialPath"] = "Assets/NonExistent/Material.mat" - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("not_found_error", result["error"]["type"].ToString()); - } - - [Test] - public void GetMaterialInfoTool_ReturnsCorrectPropertyTypes() - { - // Arrange - Create a material first - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - GetMaterialInfoTool tool = new GetMaterialInfoTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject()); - JArray properties = (JArray)result["properties"]; - - // Find _Color property - JObject colorProp = null; - foreach (JObject prop in properties) - { - if (prop["name"].ToString() == "_Color") - { - colorProp = prop; - break; - } - } - - Assert.IsNotNull(colorProp, "_Color property should exist"); - Assert.AreEqual("Color", colorProp["type"].ToString()); - Assert.IsNotNull(colorProp["value"], "Color value should exist"); - } - - #endregion - - #region ModifyMaterialTool Tests - - [Test] - public void ModifyMaterialTool_WithValidProperties_ModifiesMaterial() - { - // Arrange - Create a material first - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - testMat.SetColor("_Color", Color.white); - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - ModifyMaterialTool tool = new ModifyMaterialTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath, - ["properties"] = new JObject - { - ["_Color"] = new JObject { ["r"] = 0f, ["g"] = 1f, ["b"] = 0f, ["a"] = 1f } - } - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - - // Reload material to verify changes were saved - Material modifiedMat = AssetDatabase.LoadAssetAtPath(_testMaterialPath); - Color color = modifiedMat.GetColor("_Color"); - Assert.AreEqual(0f, color.r, 0.01f, "Red should be 0"); - Assert.AreEqual(1f, color.g, 0.01f, "Green should be 1"); - } - - [Test] - public void ModifyMaterialTool_WithMissingMaterialPath_ReturnsError() - { - // Arrange - ModifyMaterialTool tool = new ModifyMaterialTool(); - JObject parameters = new JObject - { - ["properties"] = new JObject { ["_Color"] = new JObject { ["r"] = 1f } } - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void ModifyMaterialTool_WithEmptyProperties_ReturnsError() - { - // Arrange - ModifyMaterialTool tool = new ModifyMaterialTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath, - ["properties"] = new JObject() - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void ModifyMaterialTool_WithUnknownProperty_ReportsUnknown() - { - // Arrange - Create a material first - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - ModifyMaterialTool tool = new ModifyMaterialTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath, - ["properties"] = new JObject - { - ["_Color"] = new JObject { ["r"] = 1f, ["g"] = 0f, ["b"] = 0f, ["a"] = 1f }, - ["_NonExistentProperty"] = 0.5f - } - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should still succeed"); - Assert.IsNotNull(result["unknownProperties"], "Should report unknown properties"); - JArray unknownProps = (JArray)result["unknownProperties"]; - Assert.IsTrue(unknownProps.Count > 0, "Should have at least one unknown property"); - } - - #endregion - - #region AssignMaterialTool Tests - - [Test] - public void AssignMaterialTool_WithMissingGameObjectIdentifier_ReturnsError() - { - // Arrange - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["materialPath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void AssignMaterialTool_WithMissingMaterialPath_ReturnsError() - { - // Arrange - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["objectPath"] = "/TestCube" - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - - [Test] - public void AssignMaterialTool_WithNonExistentGameObject_ReturnsError() - { - // Arrange - Create a material first - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["objectPath"] = "/NonExistentGameObject12345", - ["materialPath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("not_found_error", result["error"]["type"].ToString()); - } - - [Test] - public void AssignMaterialTool_WithNonExistentMaterial_ReturnsError() - { - // Arrange - Create a test GameObject - GameObject testObj = GameObject.CreatePrimitive(PrimitiveType.Cube); - testObj.name = "TestCube"; - - try - { - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(testObj), - ["materialPath"] = "Assets/NonExistent/Material.mat" - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("not_found_error", result["error"]["type"].ToString()); - } - finally - { - // Cleanup - Object.DestroyImmediate(testObj); - } - } - - [Test] - public void AssignMaterialTool_WithValidParameters_AssignsMaterial() - { - // Arrange - Create a material and a test GameObject - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - testMat.SetColor("_Color", Color.red); - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - GameObject testObj = GameObject.CreatePrimitive(PrimitiveType.Cube); - testObj.name = "TestCube"; - - try - { - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(testObj), - ["materialPath"] = _testMaterialPath, - ["slot"] = 0 - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsTrue(result["success"].ToObject(), "Tool should succeed"); - - Renderer renderer = testObj.GetComponent(); - Assert.AreEqual(testMat, renderer.sharedMaterial, "Material should be assigned"); - } - finally - { - // Cleanup - Object.DestroyImmediate(testObj); - } - } - - [Test] - public void AssignMaterialTool_WithInvalidSlot_ReturnsError() - { - // Arrange - Create a material and a test GameObject - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - GameObject testObj = GameObject.CreatePrimitive(PrimitiveType.Cube); - testObj.name = "TestCube"; - - try - { - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(testObj), - ["materialPath"] = _testMaterialPath, - ["slot"] = 99 // Invalid slot for a cube with 1 material - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("validation_error", result["error"]["type"].ToString()); - } - finally - { - // Cleanup - Object.DestroyImmediate(testObj); - } - } - - [Test] - public void AssignMaterialTool_WithGameObjectWithoutRenderer_ReturnsError() - { - // Arrange - Create a material and an empty test GameObject - Material testMat = new Material(Shader.Find("Standard")); - testMat.name = "TestMaterial"; - AssetDatabase.CreateAsset(testMat, _testMaterialPath); - AssetDatabase.SaveAssets(); - - GameObject testObj = new GameObject("TestEmpty"); // No renderer - - try - { - AssignMaterialTool tool = new AssignMaterialTool(); - JObject parameters = new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(testObj), - ["materialPath"] = _testMaterialPath - }; - - // Act - JObject result = tool.Execute(parameters); - - // Assert - Assert.IsNotNull(result["error"], "Should return error"); - Assert.AreEqual("component_error", result["error"]["type"].ToString()); - } - finally - { - // Cleanup - Object.DestroyImmediate(testObj); - } - } - - #endregion - } -} diff --git a/Editor/Tests/McpBackgroundTickLifecycleTests.cs b/Editor/Tests/McpBackgroundTickLifecycleTests.cs deleted file mode 100644 index 7f51a32d..00000000 --- a/Editor/Tests/McpBackgroundTickLifecycleTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.InteropServices; -using McpUnity.Utils; -using NUnit.Framework; -using UnityEditor; - -namespace McpUnity.Tests -{ - public class McpBackgroundTickLifecycleTests - { -#if UNITY_EDITOR_WIN - private bool _wasTickRunning; - - [SetUp] - public void PreserveBackgroundTickState() - { - _wasTickRunning = GetTimerId() != UIntPtr.Zero; - } - - [TearDown] - public void RestoreBackgroundTickState() - { - MethodInfo lifecycleMethod = GetTickType().GetMethod( - _wasTickRunning ? "Start" : "Stop", - BindingFlags.Public | BindingFlags.Static); - lifecycleMethod.Invoke(null, null); - } -#endif - - [Test] - public void BackgroundTickRequiresExplicitLifecycleMethods() - { - Type tickType = typeof(McpLogger).Assembly.GetType("McpUnity.Utils.McpBackgroundTick"); - - Assert.NotNull(tickType); - Assert.IsNull( - tickType.GetCustomAttribute(), - "The background tick must not start automatically when the editor assembly loads."); - Assert.NotNull(tickType.GetMethod("Start", BindingFlags.Public | BindingFlags.Static)); - Assert.NotNull(tickType.GetMethod("Stop", BindingFlags.Public | BindingFlags.Static)); - } - - [Test] - public void StopCanBeCalledMoreThanOnce() - { - Type tickType = typeof(McpLogger).Assembly.GetType("McpUnity.Utils.McpBackgroundTick"); - MethodInfo stop = tickType.GetMethod("Stop", BindingFlags.Public | BindingFlags.Static); - - Assert.DoesNotThrow(() => stop.Invoke(null, null)); - Assert.DoesNotThrow(() => stop.Invoke(null, null)); - } - -#if UNITY_EDITOR_WIN - [Test] - public void StartDoesNotCreateAnotherTimerWhenAlreadyStarted() - { - Type tickType = GetTickType(); - MethodInfo start = tickType.GetMethod("Start", BindingFlags.Public | BindingFlags.Static); - MethodInfo stop = tickType.GetMethod("Stop", BindingFlags.Public | BindingFlags.Static); - - stop.Invoke(null, null); - try - { - start.Invoke(null, null); - UIntPtr firstTimerId = GetTimerId(); - start.Invoke(null, null); - - Assert.AreNotEqual(UIntPtr.Zero, firstTimerId); - Assert.AreEqual(firstTimerId, GetTimerId()); - } - finally - { - stop.Invoke(null, null); - } - } - - [Test] - public void TimerInteropCapturesLastWin32Error() - { - Type tickType = GetTickType(); - MethodInfo setTimer = tickType.GetMethod( - "SetTimer", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.NotNull(setTimer); - DllImportAttribute import = setTimer.GetCustomAttribute(); - Assert.NotNull(import); - Assert.IsTrue(import.SetLastError); - } - - private static Type GetTickType() - { - return typeof(McpLogger).Assembly.GetType("McpUnity.Utils.McpBackgroundTick"); - } - - private static UIntPtr GetTimerId() - { - FieldInfo timerId = GetTickType().GetField("_timerId", BindingFlags.NonPublic | BindingFlags.Static); - return (UIntPtr)timerId.GetValue(null); - } -#endif - } -} diff --git a/Editor/Tests/McpBackgroundTickLifecycleTests.cs.meta b/Editor/Tests/McpBackgroundTickLifecycleTests.cs.meta deleted file mode 100644 index daddfb82..00000000 --- a/Editor/Tests/McpBackgroundTickLifecycleTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3e0edbb3edba4147927c6d79a6a515f6 diff --git a/Editor/Tests/McpUnity.Editor.Tests.asmdef b/Editor/Tests/McpUnity.Editor.Tests.asmdef index a4debc44..78d39558 100644 --- a/Editor/Tests/McpUnity.Editor.Tests.asmdef +++ b/Editor/Tests/McpUnity.Editor.Tests.asmdef @@ -1,8 +1,11 @@ { "name": "McpUnity.Editor.Tests", - "rootNamespace": "McpUnity.Tests", + "rootNamespace": "McpUnity.Extensions.Tests", "references": [ - "McpUnity.Editor", + "McpUnity.Extensions", + "Unity.Pipeline", + "Unity.Pipeline.Editor", + "Unity.Nuget.Newtonsoft-Json", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], @@ -11,10 +14,9 @@ ], "excludePlatforms": [], "allowUnsafeCode": false, - "overrideReferences": true, + "overrideReferences": false, "precompiledReferences": [ - "nunit.framework.dll", - "Newtonsoft.Json.dll" + "nunit.framework.dll" ], "autoReferenced": false, "defineConstraints": [ diff --git a/Editor/Tests/McpUnityServerRetryTests.cs b/Editor/Tests/McpUnityServerRetryTests.cs deleted file mode 100644 index 7423fb99..00000000 --- a/Editor/Tests/McpUnityServerRetryTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; -using McpUnity.Unity; -using McpUnity.Utils; -using NUnit.Framework; - -namespace McpUnity.Tests -{ - public class McpUnityServerRetryTests - { - [Test] - public void DelayedStartRetryDelayUsesBoundedBackoff() - { - MethodInfo method = typeof(McpUnityServer).GetMethod( - "GetDelayedStartDelaySeconds", - BindingFlags.NonPublic | BindingFlags.Static); - - Assert.NotNull(method, "McpUnityServer should expose a private retry delay helper for bounded restart backoff."); - - double DelayForAttempt(int attempt) - { - return (double)method.Invoke(null, new object[] { attempt }); - } - - Assert.AreEqual(0.25d, DelayForAttempt(0), 0.001d); - Assert.AreEqual(0.25d, DelayForAttempt(1), 0.001d); - Assert.AreEqual(0.5d, DelayForAttempt(2), 0.001d); - Assert.AreEqual(1d, DelayForAttempt(3), 0.001d); - Assert.AreEqual(2d, DelayForAttempt(4), 0.001d); - Assert.AreEqual(3d, DelayForAttempt(5), 0.001d); - Assert.AreEqual(5d, DelayForAttempt(6), 0.001d); - Assert.AreEqual(5d, DelayForAttempt(10), 0.001d); - } - - [Test] - public void FailedStartCleanupStopsTheBackgroundTickBeforeTheNullServerBranch() - { - MethodInfo cleanup = typeof(McpUnityServer).GetMethod( - "CleanupFailedStart", - BindingFlags.NonPublic | BindingFlags.Instance); - Type tickType = typeof(McpLogger).Assembly.GetType("McpUnity.Utils.McpBackgroundTick"); - MethodInfo stop = tickType.GetMethod("Stop", BindingFlags.Public | BindingFlags.Static); - - Assert.NotNull(cleanup); - Assert.NotNull(stop); - List instructions = ReadInstructions(cleanup); - IlInstruction stopCall = instructions.Find(instruction => - instruction.OpCode == OpCodes.Call && instruction.MetadataToken == stop.MetadataToken); - IlInstruction nullServerBranch = instructions.Find(instruction => instruction.OpCode.FlowControl == FlowControl.Cond_Branch); - IlInstruction nullServerReturn = instructions.Find(instruction => - instruction.Offset > nullServerBranch.Offset && instruction.OpCode == OpCodes.Ret); - - Assert.AreNotEqual(default(IlInstruction), stopCall, "Failed-start cleanup must call the background tick stop method."); - Assert.AreNotEqual(default(IlInstruction), nullServerBranch, "Failed-start cleanup must branch for a null WebSocket server."); - Assert.AreNotEqual(default(IlInstruction), nullServerReturn, "The null-server branch must return after clearing clients."); - Assert.Less(stopCall.Offset, nullServerBranch.Offset, "The background tick must stop before the null-server early-return branch is evaluated."); - Assert.Less(stopCall.Offset, nullServerReturn.Offset, "The background tick must stop before the null-server early return."); - } - - private static List ReadInstructions(MethodInfo method) - { - byte[] bytes = method.GetMethodBody().GetILAsByteArray(); - var instructions = new List(); - int offset = 0; - - while (offset < bytes.Length) - { - int instructionOffset = offset; - OpCode opCode = ReadOpCode(bytes, ref offset); - int metadataToken = opCode.OperandType == OperandType.InlineMethod - ? BitConverter.ToInt32(bytes, offset) - : 0; - - instructions.Add(new IlInstruction(instructionOffset, opCode, metadataToken)); - offset += GetOperandSize(bytes, offset, opCode.OperandType); - } - - return instructions; - } - - private static OpCode ReadOpCode(byte[] bytes, ref int offset) - { - short value = bytes[offset++] == 0xfe - ? (short)(0xfe00 | bytes[offset++]) - : (short)bytes[offset - 1]; - - foreach (FieldInfo field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) - { - if (field.FieldType == typeof(OpCode)) - { - OpCode opCode = (OpCode)field.GetValue(null); - if (opCode.Value == value) - { - return opCode; - } - } - } - - throw new InvalidOperationException($"Unknown IL opcode: 0x{value:X4}."); - } - - private static int GetOperandSize(byte[] bytes, int offset, OperandType operandType) - { - switch (operandType) - { - case OperandType.InlineNone: - return 0; - case OperandType.ShortInlineBrTarget: - case OperandType.ShortInlineI: - case OperandType.ShortInlineVar: - return 1; - case OperandType.InlineVar: - return 2; - case OperandType.InlineBrTarget: - case OperandType.InlineField: - case OperandType.InlineI: - case OperandType.InlineMethod: - case OperandType.InlineSig: - case OperandType.InlineString: - case OperandType.InlineTok: - case OperandType.InlineType: - case OperandType.ShortInlineR: - return 4; - case OperandType.InlineI8: - case OperandType.InlineR: - return 8; - case OperandType.InlineSwitch: - return sizeof(int) + BitConverter.ToInt32(bytes, offset) * sizeof(int); - default: - throw new InvalidOperationException($"Unsupported IL operand type: {operandType}."); - } - } - - private readonly struct IlInstruction - { - public IlInstruction(int offset, OpCode opCode, int metadataToken) - { - Offset = offset; - OpCode = opCode; - MetadataToken = metadataToken; - } - - public int Offset { get; } - public OpCode OpCode { get; } - public int MetadataToken { get; } - } - } -} diff --git a/Editor/Tests/McpUnityServerRetryTests.cs.meta b/Editor/Tests/McpUnityServerRetryTests.cs.meta deleted file mode 100644 index 344c0d2a..00000000 --- a/Editor/Tests/McpUnityServerRetryTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: df14dde02266467a91f8c70ae91e4e6c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tests/PackageContractTests.cs b/Editor/Tests/PackageContractTests.cs new file mode 100644 index 00000000..7190273c --- /dev/null +++ b/Editor/Tests/PackageContractTests.cs @@ -0,0 +1,53 @@ +using System.IO; +using System.Linq; +using McpUnity.Extensions.Commands; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor.PackageManager; + +namespace McpUnity.Extensions.Tests +{ + public class PackageContractTests + { + [Test] + public void PackageManifest_DeclaresUnityCliExtensionIdentityAndExactDependencies() + { + var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly); + Assert.That(package, Is.Not.Null); + var manifest = JObject.Parse(File.ReadAllText(Path.Combine(package.assetPath, "package.json"))); + + Assert.That((string)manifest["version"], Is.EqualTo("2.0.0")); + Assert.That((string)manifest["unity"], Is.EqualTo("6000.0")); + Assert.That((string)manifest["displayName"], + Does.Contain("MCP Unity Extensions for Unity CLI")); + Assert.That((string)manifest["description"], + Does.Contain("MCP Unity Extensions for Unity CLI")); + + var dependencies = (JObject)manifest["dependencies"]; + Assert.That(dependencies.Properties().Select(property => property.Name), Is.EquivalentTo(new[] + { + "com.unity.pipeline", + "com.unity.test-framework" + })); + Assert.That((string)dependencies["com.unity.pipeline"], Is.EqualTo("0.3.1-exp.1")); + Assert.That((string)dependencies["com.unity.test-framework"], Is.EqualTo("1.3.3")); + } + + [Test] + public void EditorAssembly_IsEditorOnlyAndReferencesOnlyPipelineAssemblies() + { + var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly); + var asmdef = JObject.Parse(File.ReadAllText( + Path.Combine(package.assetPath, "Editor/McpUnity.Editor.asmdef"))); + + Assert.That((string)asmdef["name"], Is.EqualTo("McpUnity.Extensions")); + Assert.That((string)asmdef["rootNamespace"], Is.EqualTo("McpUnity.Extensions")); + Assert.That( + asmdef["includePlatforms"].Values(), + Is.EquivalentTo(new[] { "Editor" })); + Assert.That( + asmdef["references"].Values(), + Is.EquivalentTo(new[] { "Unity.Pipeline", "Unity.Pipeline.Editor" })); + } + } +} diff --git a/Editor/Tests/PackageContractTests.cs.meta b/Editor/Tests/PackageContractTests.cs.meta new file mode 100644 index 00000000..7c6805ff --- /dev/null +++ b/Editor/Tests/PackageContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d44b93600de044c0a91258fbab5a7feb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/PathHandlingTests.cs b/Editor/Tests/PathHandlingTests.cs deleted file mode 100644 index 7b00d664..00000000 --- a/Editor/Tests/PathHandlingTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using NUnit.Framework; -using McpUnity.Utils; -using UnityEngine; -using UnityEngine.TestTools; - -namespace McpUnity.Tests -{ - /// - /// Tests for path handling with spaces and special characters - /// - public class PathHandlingTests - { - private string _tempDir; - private string _tempDirWithSpaces; - - [SetUp] - public void SetUp() - { - // Create temp directories for testing - _tempDir = Path.Combine(Path.GetTempPath(), "McpUnityTest"); - _tempDirWithSpaces = Path.Combine(Path.GetTempPath(), "MCP Unity Test With Spaces"); - - // Clean up if exists from previous test - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - if (Directory.Exists(_tempDirWithSpaces)) - Directory.Delete(_tempDirWithSpaces, true); - } - - [TearDown] - public void TearDown() - { - // Clean up temp directories - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, true); - if (Directory.Exists(_tempDirWithSpaces)) - Directory.Delete(_tempDirWithSpaces, true); - } - - [Test] - public void ValidateServerPath_WithValidPath_ReturnsTrue() - { - // Arrange - Directory.CreateDirectory(_tempDir); - File.WriteAllText(Path.Combine(_tempDir, "package.json"), "{}"); - - // Act - bool result = McpUtils.ValidateServerPath(_tempDir); - - // Assert - Assert.IsTrue(result, "ValidateServerPath should return true for valid path with package.json"); - } - - [Test] - public void ValidateServerPath_WithSpacesInPath_ReturnsTrue() - { - // Arrange - Directory.CreateDirectory(_tempDirWithSpaces); - File.WriteAllText(Path.Combine(_tempDirWithSpaces, "package.json"), "{}"); - - // Act - bool result = McpUtils.ValidateServerPath(_tempDirWithSpaces); - - // Assert - Assert.IsTrue(result, "ValidateServerPath should return true for path with spaces"); - } - - [Test] - public void ValidateServerPath_WithNonExistentPath_ReturnsFalse() - { - // Arrange - string nonExistentPath = Path.Combine(Path.GetTempPath(), "NonExistentMcpUnityPath12345"); - LogAssert.Expect(LogType.Error, new Regex(@"\[MCP Unity\] Server path does not exist:")); - - // Act - bool result = McpUtils.ValidateServerPath(nonExistentPath); - - // Assert - Assert.IsFalse(result, "ValidateServerPath should return false for non-existent path"); - } - - [Test] - public void ValidateServerPath_WithMissingPackageJson_ReturnsFalse() - { - // Arrange - Directory.CreateDirectory(_tempDir); - // Don't create package.json - LogAssert.Expect(LogType.Error, new Regex(@"\[MCP Unity\] package\.json not found in server path:")); - - // Act - bool result = McpUtils.ValidateServerPath(_tempDir); - - // Assert - Assert.IsFalse(result, "ValidateServerPath should return false when package.json is missing"); - } - - [Test] - public void ValidateServerPath_WithNullPath_ReturnsFalse() - { - // Arrange - LogAssert.Expect(LogType.Error, "[MCP Unity] Server path is null or empty. Cannot validate."); - - // Act - bool result = McpUtils.ValidateServerPath(null); - - // Assert - Assert.IsFalse(result, "ValidateServerPath should return false for null path"); - } - - [Test] - public void ValidateServerPath_WithEmptyPath_ReturnsFalse() - { - // Arrange - LogAssert.Expect(LogType.Error, "[MCP Unity] Server path is null or empty. Cannot validate."); - - // Act - bool result = McpUtils.ValidateServerPath(""); - - // Assert - Assert.IsFalse(result, "ValidateServerPath should return false for empty path"); - } - - [Test] - public void EncodePathForFileUrl_WithSpaces_EncodesCorrectly() - { - // Arrange - string pathWithSpaces = "/Users/John Doe/My Project/package.json"; - string expected = "/Users/John%20Doe/My%20Project/package.json"; - - // Act - string result = McpUtils.EncodePathForFileUrl(pathWithSpaces); - - // Assert - Assert.AreEqual(expected, result, "Spaces should be encoded as %20"); - } - - [Test] - public void EncodePathForFileUrl_WithoutSpaces_ReturnsUnchanged() - { - // Arrange - string pathWithoutSpaces = "/Users/JohnDoe/MyProject/package.json"; - - // Act - string result = McpUtils.EncodePathForFileUrl(pathWithoutSpaces); - - // Assert - Assert.AreEqual(pathWithoutSpaces, result, "Path without spaces should remain unchanged"); - } - - [Test] - public void EncodePathForFileUrl_WithMultipleSpaces_EncodesAll() - { - // Arrange - string pathWithMultipleSpaces = "C:/Users/John Doe/Game Projects/My Unity Game/Assets"; - string expected = "C:/Users/John%20Doe/Game%20Projects/My%20Unity%20Game/Assets"; - - // Act - string result = McpUtils.EncodePathForFileUrl(pathWithMultipleSpaces); - - // Assert - Assert.AreEqual(expected, result, "All spaces should be encoded as %20"); - } - - } -} diff --git a/Editor/Tests/PathHandlingTests.cs.meta b/Editor/Tests/PathHandlingTests.cs.meta deleted file mode 100644 index 5c792bfe..00000000 --- a/Editor/Tests/PathHandlingTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4a6fe7ffcdfeb410aaf62a5312c37fd2 \ No newline at end of file diff --git a/Editor/Tests/UnloadSceneCommandTests.cs b/Editor/Tests/UnloadSceneCommandTests.cs new file mode 100644 index 00000000..5fdcdf42 --- /dev/null +++ b/Editor/Tests/UnloadSceneCommandTests.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Reflection; +using McpUnity.Extensions.Commands; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace McpUnity.Extensions.Tests +{ + public class UnloadSceneCommandTests + { + private const string Root = "Assets/__McpUnityUnloadSceneTests"; + + [SetUp] + public void SetUp() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + EnsureFolder(Root); + } + + [TearDown] + public void TearDown() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + AssetDatabase.DeleteAsset(Root); + AssetDatabase.Refresh(); + } + + [Test] + public void Unload_RejectsSceneThatIsNotLoaded() + { + var path = Root + "/SavedButClosed.unity"; + CreateScene(path, NewSceneMode.Single); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + Assert.Throws(() => UnloadSceneCommand.Unload(path)); + } + + [Test] + public void Unload_RejectsDirtySceneUnlessForced() + { + CreateScene(Root + "/Active.unity", NewSceneMode.Single); + var targetPath = Root + "/Dirty.unity"; + var target = CreateScene(targetPath, NewSceneMode.Additive); + SceneManager.MoveGameObjectToScene(new GameObject("Unsaved"), target); + EditorSceneManager.MarkSceneDirty(target); + + Assert.Throws(() => UnloadSceneCommand.Unload(targetPath)); + Assert.That(SceneManager.GetSceneByPath(targetPath).isLoaded, Is.True); + } + + [Test] + public void Unload_RejectsSoleLoadedActiveScene() + { + var path = Root + "/Only.unity"; + CreateScene(path, NewSceneMode.Single); + + Assert.Throws(() => UnloadSceneCommand.Unload(path, force: true)); + Assert.That(SceneManager.GetActiveScene().path, Is.EqualTo(path)); + } + + [Test] + public void Unload_ForcedDirtyActiveSceneChoosesDeterministicAlternative() + { + var zPath = Root + "/Zeta.unity"; + var aPath = Root + "/Alpha.unity"; + var targetPath = Root + "/Target.unity"; + CreateScene(zPath, NewSceneMode.Single); + CreateScene(aPath, NewSceneMode.Additive); + var target = CreateScene(targetPath, NewSceneMode.Additive); + SceneManager.SetActiveScene(target); + SceneManager.MoveGameObjectToScene(new GameObject("Unsaved"), target); + EditorSceneManager.MarkSceneDirty(target); + + object result = null; + Assert.DoesNotThrow(() => result = UnloadSceneCommand.Unload(targetPath, force: true)); + + Assert.That(SceneManager.GetSceneByPath(targetPath).isLoaded, Is.False); + Assert.That(SceneManager.GetActiveScene().path, Is.EqualTo(aPath)); + Assert.That(Property(result, "UnloadedPath"), Is.EqualTo(targetPath)); + Assert.That(Property(result, "ActiveScenePath"), Is.EqualTo(aPath)); + } + + private static Scene CreateScene(string path, NewSceneMode mode) + { + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, mode); + Assert.That(EditorSceneManager.SaveScene(scene, path), Is.True); + return scene; + } + + private static void EnsureFolder(string path) + { + if (AssetDatabase.IsValidFolder(path)) + return; + + var parent = Path.GetDirectoryName(path)?.Replace('\\', '/'); + if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent)) + EnsureFolder(parent); + AssetDatabase.CreateFolder(parent, Path.GetFileName(path)); + } + + private static T Property(object instance, string name) + { + Assert.That(instance, Is.Not.Null); + var property = instance.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + Assert.That(property, Is.Not.Null, $"Expected public property '{name}'."); + return (T)property.GetValue(instance); + } + } +} diff --git a/Editor/Tests/UnloadSceneCommandTests.cs.meta b/Editor/Tests/UnloadSceneCommandTests.cs.meta new file mode 100644 index 00000000..923786e6 --- /dev/null +++ b/Editor/Tests/UnloadSceneCommandTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54480e3aad7d4814be7cf36699aa55b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Tests/UpdateComponentToolTests.cs b/Editor/Tests/UpdateComponentToolTests.cs deleted file mode 100644 index 88fac936..00000000 --- a/Editor/Tests/UpdateComponentToolTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using McpUnity.Tools; -using McpUnity.Utils; -using Newtonsoft.Json.Linq; -using NUnit.Framework; -using UnityEditor; -using UnityEngine; - -namespace McpUnity.Tests -{ - public class UpdateComponentToolTests - { - private const string TestAssetDir = "Assets/UpdateComponentToolTests"; - private const string TestAssetPath = TestAssetDir + "/ReferencedAsset.asset"; - - private GameObject _gameObject; - private ScriptableObject _referencedAsset; - - [SetUp] - public void SetUp() - { - _gameObject = new GameObject("UpdateComponentToolTestObject"); - - if (!AssetDatabase.IsValidFolder(TestAssetDir)) - { - AssetDatabase.CreateFolder("Assets", "UpdateComponentToolTests"); - } - - _referencedAsset = ScriptableObject.CreateInstance(); - AssetDatabase.CreateAsset(_referencedAsset, TestAssetPath); - AssetDatabase.SaveAssets(); - } - - [TearDown] - public void TearDown() - { - if (_gameObject != null) - { - UnityEngine.Object.DestroyImmediate(_gameObject); - } - - if (AssetDatabase.IsValidFolder(TestAssetDir)) - { - AssetDatabase.DeleteAsset(TestAssetDir); - } - - AssetDatabase.Refresh(); - } - - [Test] - public void Execute_WithPrivateSerializedFieldInBaseClass_UpdatesValue() - { - var component = _gameObject.AddComponent(); - var tool = new UpdateComponentTool(); - - JObject result = tool.Execute(new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(_gameObject), - ["componentName"] = nameof(DerivedUpdateComponentToolTestComponent), - ["componentData"] = new JObject - { - ["_baseValue"] = 42 - } - }); - - Assert.IsTrue(result["success"]?.Value() ?? false, result.ToString()); - Assert.AreEqual(42, component.BaseValue); - } - - [Test] - public void Execute_WithNestedObjectReferencePath_UpdatesReference() - { - var component = _gameObject.AddComponent(); - var tool = new UpdateComponentTool(); - - JObject result = tool.Execute(new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(_gameObject), - ["componentName"] = nameof(UpdateComponentToolTestComponent), - ["componentData"] = new JObject - { - ["_eventReference._event"] = TestAssetPath - } - }); - - Assert.IsTrue(result["success"]?.Value() ?? false, result.ToString()); - Assert.AreSame(_referencedAsset, component.EventReference); - } - - [Test] - public void Execute_WithMissingObjectReferencePath_ReturnsUpdateError() - { - var component = _gameObject.AddComponent(); - component.SetEventReference(_referencedAsset); - var tool = new UpdateComponentTool(); - - JObject result = tool.Execute(new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(_gameObject), - ["componentName"] = nameof(UpdateComponentToolTestComponent), - ["componentData"] = new JObject - { - ["_eventReference._event"] = "Assets/UpdateComponentToolTests/Missing.asset" - } - }); - - Assert.IsNotNull(result["error"], result.ToString()); - StringAssert.Contains("Could not find asset", result["error"]["message"]?.ToString()); - Assert.AreSame(_referencedAsset, component.EventReference); - } - - } - - [Serializable] - public class NestedEventReference - { - [SerializeField] private ScriptableObject _event; - - public ScriptableObject Event => _event; - - public void SetEvent(ScriptableObject value) - { - _event = value; - } - } - - public class UpdateComponentToolTestComponent : MonoBehaviour - { - [SerializeField] private NestedEventReference _eventReference = new NestedEventReference(); - - public ScriptableObject EventReference => _eventReference.Event; - - public void SetEventReference(ScriptableObject value) - { - _eventReference.SetEvent(value); - } - } - - public class BaseUpdateComponentToolTestComponent : MonoBehaviour - { - [SerializeField] private int _baseValue; - - public int BaseValue => _baseValue; - } - - public class DerivedUpdateComponentToolTestComponent : BaseUpdateComponentToolTestComponent - { - } -} diff --git a/Editor/Tests/UpdateComponentToolTests.cs.meta b/Editor/Tests/UpdateComponentToolTests.cs.meta deleted file mode 100644 index 69e0a483..00000000 --- a/Editor/Tests/UpdateComponentToolTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1f7d6d963fd14294a8e91f27a14851b2 diff --git a/Editor/Tools.meta b/Editor/Tools.meta deleted file mode 100644 index 199b3f76..00000000 --- a/Editor/Tools.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 8e2c205bdbb4464498b907b56521fa48 -timeCreated: 1741796067 \ No newline at end of file diff --git a/Editor/Tools/AddAssetToSceneTool.cs b/Editor/Tools/AddAssetToSceneTool.cs deleted file mode 100644 index e0c12903..00000000 --- a/Editor/Tools/AddAssetToSceneTool.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for adding assets from the AssetDatabase to the Unity scene - /// - public class AddAssetToSceneTool : McpToolBase - { - public AddAssetToSceneTool() - { - Name = "add_asset_to_scene"; - Description = "Adds an asset from the AssetDatabase to the Unity scene"; - } - - /// - /// Execute the AddAssetToScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters - string assetPath = parameters["assetPath"]?.ToObject(); - string guid = parameters["guid"]?.ToObject(); - Vector3 position = parameters["position"]?.ToObject() != null - ? new Vector3( - parameters["position"]["x"]?.ToObject() ?? 0f, - parameters["position"]["y"]?.ToObject() ?? 0f, - parameters["position"]["z"]?.ToObject() ?? 0f - ) - : Vector3.zero; - - // Optional parent game object - string parentPath = parameters["parentPath"]?.ToObject(); - int? parentId = parameters["parentId"]?.ToObject(); - - // Validate parameters - require either assetPath or guid - if (string.IsNullOrEmpty(assetPath) && string.IsNullOrEmpty(guid)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'assetPath' or 'guid' not provided", - "validation_error" - ); - } - - // If we have a GUID but no path, convert GUID to path - if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(guid)) - { - assetPath = AssetDatabase.GUIDToAssetPath(guid); - if (string.IsNullOrEmpty(assetPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Asset with GUID '{guid}' not found", - "not_found_error" - ); - } - } - - // Load the asset - UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(assetPath); - if (asset == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to load asset at path '{assetPath}'", - "not_found_error" - ); - } - - // Check if the asset is a prefab or another type that can be instantiated - bool isPrefab = PrefabUtility.GetPrefabAssetType(asset) != PrefabAssetType.NotAPrefab; - bool canInstantiate = asset is GameObject || isPrefab; - - if (!canInstantiate) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Asset of type '{asset.GetType().Name}' cannot be instantiated in the scene", - "invalid_asset_type" - ); - } - - // Instantiate the asset - GameObject instance = null; - try - { - instance = (GameObject)PrefabUtility.InstantiatePrefab(asset); - - // Set position - instance.transform.position = position; - - // Set parent if specified - if (!string.IsNullOrEmpty(parentPath) || parentId.HasValue) - { - GameObject parent = null; - - // Try to find parent by ID first - if (parentId.HasValue) - { - parent = UnityObjectId.ObjectFromId(parentId.Value) as GameObject; - } - // Otherwise try to find by path - else if (!string.IsNullOrEmpty(parentPath)) - { - parent = GameObject.Find(parentPath); - } - - if (parent != null) - { - instance.transform.SetParent(parent.transform, false); - } - else - { - McpLogger.LogWarning($"Parent object not found, asset will be created at the root of the scene"); - } - } - - // Select the newly created object - Selection.activeGameObject = instance; - EditorGUIUtility.PingObject(instance); - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error instantiating asset: {ex.Message}", - "instantiation_error" - ); - } - - // Log the action - McpLogger.LogInfo($"Added asset '{asset.name}' to scene from path '{assetPath}'"); - - // Create the response - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully added asset '{asset.name}' with instance ID {UnityObjectId.GetObjectId(instance)} to the scene", - ["instanceId"] = UnityObjectId.GetObjectId(instance) - }; - } - } -} diff --git a/Editor/Tools/AddAssetToSceneTool.cs.meta b/Editor/Tools/AddAssetToSceneTool.cs.meta deleted file mode 100644 index 9eecf061..00000000 --- a/Editor/Tools/AddAssetToSceneTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ef39157e609269944a62ac7391f9008e -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/AddPackageTool.cs b/Editor/Tools/AddPackageTool.cs deleted file mode 100644 index a0f3a388..00000000 --- a/Editor/Tools/AddPackageTool.cs +++ /dev/null @@ -1,342 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEditor; -using UnityEditor.PackageManager; -using UnityEditor.PackageManager.Requests; -using Newtonsoft.Json.Linq; -using System.Threading.Tasks; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for adding new packages into the Unity Package Manager - /// - public class AddPackageTool : McpToolBase - { - // Class to track each package operation - private class PackageOperation - { - public AddRequest Request { get; set; } - public TaskCompletionSource CompletionSource { get; set; } - } - - // Queue of active package operations - private readonly List _activeOperations = new List(); - - // Flag to track if the update callback is registered - private bool _updateCallbackRegistered = false; - - public AddPackageTool() - { - Name = "add_package"; - Description = "Adds a new packages into the Unity Package Manager"; - IsAsync = true; // Package Manager operations are asynchronous - } - - /// - /// Execute the AddPackage tool asynchronously - /// - /// Tool parameters as a JObject - /// TaskCompletionSource to set the result or exception - public override void ExecuteAsync(JObject parameters, TaskCompletionSource tcs) - { - // Extract source parameter - string source = parameters["source"]?.ToObject(); - if (string.IsNullOrEmpty(source)) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'source' not provided", - "validation_error" - )); - return; - } - - // Create and register the operation - var operation = new PackageOperation - { - CompletionSource = tcs - }; - - switch (source.ToLowerInvariant()) - { - case "registry": - operation.Request = AddFromRegistry(parameters, tcs); - break; - case "github": - operation.Request = AddFromGitHub(parameters, tcs); - break; - case "disk": - operation.Request = AddFromDisk(parameters, tcs); - break; - default: - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Unknown method '{source}'. Valid methods are: registry, github, disk", - "validation_error" - )); - return; - } - - // If request creation failed, the error has already been set on the tcs - if (operation.Request == null) - { - return; - } - - lock (_activeOperations) - { - _activeOperations.Add(operation); - - // Register update callback if not already registered - if (!_updateCallbackRegistered) - { - EditorApplication.update += CheckOperationsCompletion; - _updateCallbackRegistered = true; - } - } - } - - /// - /// Add a package from the Unity registry - /// - private AddRequest AddFromRegistry(JObject parameters, TaskCompletionSource tcs) - { - // Extract parameters - string packageName = parameters["packageName"]?.ToObject(); - if (string.IsNullOrEmpty(packageName)) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'packageName' not provided for registry method", - "validation_error" - )); - return null; - } - - string version = parameters["version"]?.ToObject(); - string packageIdentifier = packageName; - - // Add version if specified - if (!string.IsNullOrEmpty(version)) - { - packageIdentifier = $"{packageName}@{version}"; - } - - McpLogger.LogInfo($"Adding package from registry: {packageIdentifier}"); - - try - { - // Add the package - return Client.Add(packageIdentifier); - } - catch (Exception ex) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Exception adding package: {ex.Message}", - "package_manager_error" - )); - return null; - } - } - - /// - /// Add a package from GitHub - /// - private AddRequest AddFromGitHub(JObject parameters, TaskCompletionSource tcs) - { - // Extract parameters - string packageUrl = parameters["repositoryUrl"]?.ToObject(); - - if (string.IsNullOrEmpty(packageUrl)) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'repositoryUrl' not provided for github method", - "validation_error" - )); - return null; - } - - string branch = parameters["branch"]?.ToObject(); - string path = parameters["path"]?.ToObject(); - - // Remove any .git suffix if present - if (packageUrl.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) - { - packageUrl = packageUrl.Substring(0, packageUrl.Length - 4); - } - - // Add branch if specified - if (!string.IsNullOrEmpty(branch)) - { - packageUrl += "#" + branch; - } - - // Add path if specified - if (!string.IsNullOrEmpty(path)) - { - if (!string.IsNullOrEmpty(branch)) - { - // Branch is already added, append path with slash - packageUrl += "/" + path; - } - else - { - // No branch, use hash followed by path - packageUrl += "#" + path; - } - } - - McpLogger.LogInfo($"Adding package from GitHub: {packageUrl}"); - - try - { - // Add the package - return Client.Add(packageUrl); - } - catch (Exception ex) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Exception adding package: {ex.Message}", - "package_manager_error" - )); - return null; - } - } - - /// - /// Add a package from disk - /// - private AddRequest AddFromDisk(JObject parameters, TaskCompletionSource tcs) - { - // Extract parameters - string path = parameters["path"]?.ToObject(); - - if (string.IsNullOrEmpty(path)) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'path' not provided for disk method", - "validation_error" - )); - return null; - } - - // Format as file URL with proper encoding for paths containing spaces - string encodedPath = McpUtils.EncodePathForFileUrl(path); - string packageUrl = $"file:{encodedPath}"; - - McpLogger.LogInfo($"Adding package from disk: {packageUrl}"); - - try - { - // Add the package - return Client.Add(packageUrl); - } - catch (Exception ex) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Exception adding package: {ex.Message}", - "package_manager_error" - )); - return null; - } - } - - /// - /// Check all active operations for completion - /// - private void CheckOperationsCompletion() - { - // Store initial count - int initialCount = _activeOperations.Count; - - lock (_activeOperations) - { - // Process operations in reverse order to safely remove completed ones - for (int i = _activeOperations.Count - 1; i >= 0; i--) - { - var operation = _activeOperations[i]; - - if (operation.Request != null && operation.Request.IsCompleted) - { - // Process the completed operation - ProcessCompletedOperation(operation); - - // Remove it from the active operations list - _activeOperations.RemoveAt(i); - } - } - - // If all operations are completed, unregister the update callback - if (_activeOperations.Count == 0 && _updateCallbackRegistered) - { - EditorApplication.update -= CheckOperationsCompletion; - _updateCallbackRegistered = false; - } - } - - // If any operations completed, force a GC collection to clean up UPM request objects - if (initialCount != _activeOperations.Count) - { - GC.Collect(); - } - } - - /// - /// Process a completed package operation - /// - private void ProcessCompletedOperation(PackageOperation operation) - { - if (operation.CompletionSource == null) - { - McpLogger.LogError("TaskCompletionSource is null when processing completed operation"); - return; - } - - // Check request status - if (operation.Request.Status == StatusCode.Success) - { - var result = operation.Request.Result; - if (result != null) - { - operation.CompletionSource.SetResult(new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully added package: {result.displayName} ({result.name}) version {result.version}", - ["packageInfo"] = JObject.FromObject(new - { - name = result.name, - displayName = result.displayName, - version = result.version - }) - }); - } - else - { - operation.CompletionSource.SetResult(new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Package operation completed successfully, but no package information was returned." - }); - } - - McpLogger.LogInfo($"Added package {result.displayName} ({result.name}) version {result.version}"); - } - else if (operation.Request.Status == StatusCode.Failure) - { - operation.CompletionSource.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Failed to add package: {operation.Request.Error.message}", - "package_manager_error" - )); - } - else - { - operation.CompletionSource.SetResult(McpUnitySocketHandler.CreateErrorResponse( - $"Unknown package manager status: {operation.Request.Status}", - "package_manager_error" - )); - } - } - } -} diff --git a/Editor/Tools/AddPackageTool.cs.meta b/Editor/Tools/AddPackageTool.cs.meta deleted file mode 100644 index a6191efe..00000000 --- a/Editor/Tools/AddPackageTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 3d0262aa8c7e0e64881ea66fde6bbc79 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/BatchExecuteTool.cs b/Editor/Tools/BatchExecuteTool.cs deleted file mode 100644 index 3abef13a..00000000 --- a/Editor/Tools/BatchExecuteTool.cs +++ /dev/null @@ -1,312 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Threading.Tasks; -using UnityEngine; -using UnityEditor; -using McpUnity.Unity; -using Newtonsoft.Json.Linq; -using Unity.EditorCoroutines.Editor; - -namespace McpUnity.Tools -{ - /// - /// Tool for executing multiple operations in a single batch request. - /// Supports sequential execution, stop-on-error, and atomic rollback. - /// - public class BatchExecuteTool : McpToolBase - { - private readonly Func _getTool; - - public BatchExecuteTool(McpUnityServer server) - : this(name => server != null && server.TryGetTool(name, out McpToolBase tool) ? tool : null) - { - } - - public BatchExecuteTool(Func getTool) - { - _getTool = getTool ?? (_ => null); - Name = "batch_execute"; - Description = "Executes multiple tool operations in a single batch request. Reduces round-trips and enables atomic operations."; - IsAsync = true; - } - - public override void ExecuteAsync(JObject parameters, TaskCompletionSource tcs) - { - EditorCoroutineUtility.StartCoroutineOwnerless(ExecuteBatchCoroutine(parameters, tcs)); - } - - private IEnumerator ExecuteBatchCoroutine(JObject parameters, TaskCompletionSource tcs) - { - JArray operations = parameters["operations"] as JArray; - bool stopOnError = parameters["stopOnError"]?.ToObject() ?? true; - bool atomic = parameters["atomic"]?.ToObject() ?? false; - - // Validate operations array - if (operations == null || operations.Count == 0) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "The 'operations' array is required and must contain at least one operation.", - "validation_error" - )); - yield break; - } - - // Validate max operations (prevent abuse) - if (operations.Count > 100) - { - tcs.SetResult(McpUnitySocketHandler.CreateErrorResponse( - "Maximum of 100 operations allowed per batch.", - "validation_error" - )); - yield break; - } - - JArray results = new JArray(); - int succeeded = 0; - int failed = 0; - int undoGroup = -1; - - // Start undo group for atomic operations - if (atomic) - { - Undo.IncrementCurrentGroup(); - undoGroup = Undo.GetCurrentGroup(); - Undo.SetCurrentGroupName("Batch Execute"); - } - - for (int i = 0; i < operations.Count; i++) - { - JObject operation = operations[i] as JObject; - if (operation == null) - { - results.Add(CreateOperationResult(i, null, false, null, "Invalid operation format")); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - continue; - } - - string toolName = operation["tool"]?.ToString(); - JObject toolParams = operation["params"] as JObject ?? new JObject(); - string operationId = operation["id"]?.ToString() ?? i.ToString(); - - // Validate tool name - if (string.IsNullOrEmpty(toolName)) - { - results.Add(CreateOperationResult(i, operationId, false, null, "Missing 'tool' name in operation")); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - continue; - } - - // Prevent recursive batch execution - if (toolName == Name) - { - results.Add(CreateOperationResult(i, operationId, false, null, "Cannot nest batch_execute operations")); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - continue; - } - - // Get the tool - McpToolBase tool = _getTool(toolName); - if (tool == null) - { - results.Add(CreateOperationResult(i, operationId, false, null, $"Unknown tool: {toolName}")); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - continue; - } - - // Execute the tool - JObject toolResult = null; - Exception toolException = null; - - if (tool.IsAsync) - { - var toolTcs = new TaskCompletionSource(); - - try - { - tool.ExecuteAsync(toolParams, toolTcs); - } - catch (Exception ex) - { - toolException = ex; - } - - // Wait for async tool completion (yield must be outside try-catch) - if (toolException == null) - { - while (!toolTcs.Task.IsCompleted) - { - yield return null; - } - - if (toolTcs.Task.IsFaulted) - { - toolException = toolTcs.Task.Exception?.InnerException ?? toolTcs.Task.Exception; - } - else - { - toolResult = toolTcs.Task.Result; - } - } - } - else - { - try - { - toolResult = tool.Execute(toolParams); - } - catch (Exception ex) - { - toolException = ex; - } - } - - // Process result - if (toolException != null) - { - results.Add(CreateOperationResult(i, operationId, false, null, toolException.Message)); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - } - else if (toolResult != null) - { - // Check if the result indicates an error - bool isError = toolResult["error"] != null; - bool isSuccess = toolResult["success"]?.ToObject() ?? !isError; - - if (isSuccess && !isError) - { - results.Add(CreateOperationResult(i, operationId, true, toolResult, null)); - succeeded++; - } - else - { - string errorMessage = toolResult["error"]?["message"]?.ToString() - ?? toolResult["message"]?.ToString() - ?? "Tool execution failed"; - results.Add(CreateOperationResult(i, operationId, false, toolResult, errorMessage)); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - } - } - else - { - results.Add(CreateOperationResult(i, operationId, false, null, "Tool returned null result")); - failed++; - - if (stopOnError) - { - RevertIfAtomic(atomic, undoGroup); - break; - } - } - - // Yield to allow Unity to process other events - yield return null; - } - - // Collapse undo group - if (atomic && undoGroup >= 0 && failed == 0) - { - Undo.CollapseUndoOperations(undoGroup); - } - - // Build response - string message; - if (failed == 0) - { - message = $"Successfully executed {succeeded}/{operations.Count} operations."; - } - else if (atomic && stopOnError) - { - message = $"Batch execution failed and rolled back. {succeeded} operations succeeded before failure."; - } - else if (stopOnError) - { - message = $"Batch execution stopped on error. {succeeded}/{operations.Count} operations succeeded."; - } - else - { - message = $"Batch execution completed with errors. {succeeded}/{operations.Count} operations succeeded, {failed} failed."; - } - - tcs.SetResult(new JObject - { - ["success"] = failed == 0, - ["type"] = "text", - ["message"] = message, - ["results"] = results, - ["summary"] = new JObject - { - ["total"] = operations.Count, - ["succeeded"] = succeeded, - ["failed"] = failed, - ["executed"] = succeeded + failed - } - }); - } - - private void RevertIfAtomic(bool atomic, int undoGroup) - { - if (atomic && undoGroup >= 0) - { - Undo.RevertAllDownToGroup(undoGroup); - } - } - - private JObject CreateOperationResult(int index, string id, bool success, JObject result, string error) - { - var operationResult = new JObject - { - ["index"] = index, - ["id"] = id ?? index.ToString(), - ["success"] = success - }; - - if (success && result != null) - { - operationResult["result"] = result; - } - else if (!success) - { - operationResult["error"] = error ?? "Unknown error"; - } - - return operationResult; - } - } -} diff --git a/Editor/Tools/BatchExecuteTool.cs.meta b/Editor/Tools/BatchExecuteTool.cs.meta deleted file mode 100644 index a52d6609..00000000 --- a/Editor/Tools/BatchExecuteTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ff9afbf78278c4235b9d82d2cb8b824e \ No newline at end of file diff --git a/Editor/Tools/CreatePrefabTool.cs b/Editor/Tools/CreatePrefabTool.cs deleted file mode 100644 index 5204f273..00000000 --- a/Editor/Tools/CreatePrefabTool.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for creating prefabs with optional MonoBehaviour scripts - /// - public class CreatePrefabTool : McpToolBase - { - public CreatePrefabTool() - { - Name = "create_prefab"; - Description = "Creates a prefab with optional MonoBehaviour script and serialized field values"; - } - - /// - /// Execute the CreatePrefab tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters - string componentName = parameters["componentName"]?.ToObject(); - string prefabName = parameters["prefabName"]?.ToObject(); - JObject fieldValues = parameters["fieldValues"]?.ToObject(); - - // Validate required parameters - if (string.IsNullOrEmpty(prefabName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'prefabName' not provided", - "validation_error" - ); - } - - // Create a temporary GameObject - GameObject tempObject = new GameObject(prefabName); - - // Add component if provided - if (!string.IsNullOrEmpty(componentName)) - { - try - { - // Add component - Component component = AddComponent(tempObject, componentName); - - // Apply field values if provided and component exists - ApplyFieldValues(fieldValues, component); - } - catch (Exception) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to add component '{componentName}' to GameObject", - "component_error" - ); - } - } - - // For safety, we'll create a unique name if prefab already exists - int counter = 1; - string prefabPath = $"{prefabName}.prefab"; - while (AssetDatabase.AssetPathToGUID(prefabPath) != "") - { - prefabPath = $"{prefabName}_{counter}.prefab"; - counter++; - } - - // Create the prefab - bool success = false; - PrefabUtility.SaveAsPrefabAsset(tempObject, prefabPath, out success); - - // Clean up temporary object - UnityEngine.Object.DestroyImmediate(tempObject); - - // Refresh the asset database - AssetDatabase.Refresh(); - - // Log the action - McpLogger.LogInfo($"Created prefab '{prefabName}' at path '{prefabPath}' from script '{componentName}'"); - - string message = success ? $"Successfully created prefab '{prefabName}' at path '{prefabPath}'" : $"Failed to create prefab '{prefabName}' at path '{prefabPath}'"; - - // Create the response - return new JObject - { - ["success"] = success, - ["type"] = "text", - ["message"] = message, - ["prefabPath"] = prefabPath - }; - } - - private Component AddComponent(GameObject gameObject, string componentName) - { - // Find the script type - Type scriptType = Type.GetType($"{componentName}, Assembly-CSharp"); - if (scriptType == null) - { - // Try with just the class name - scriptType = Type.GetType(componentName); - } - - if (scriptType == null) - { - // Try to find the type using AppDomain - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - scriptType = assembly.GetType(componentName); - if (scriptType != null) - break; - } - } - - // Throw an error if the type was not found - if (scriptType == null) - { - return null; - } - - // Check if the type is a MonoBehaviour - if (!typeof(MonoBehaviour).IsAssignableFrom(scriptType)) - { - return null; - } - - return gameObject.AddComponent(scriptType); - } - - private void ApplyFieldValues(JObject fieldValues, Component component) - { - // Apply field values if provided and component exists - if (fieldValues == null || fieldValues.Count == 0) - { - return; - } - - Undo.RecordObject(component, "Set field values"); - - foreach (var property in fieldValues.Properties()) - { - // Get the field/property info - var fieldInfo = component.GetType().GetField(property.Name, - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance); - - if (fieldInfo != null) - { - // Set field value - object value = property.Value.ToObject(fieldInfo.FieldType); - fieldInfo.SetValue(component, value); - } - else - { - // Try property - var propInfo = component.GetType().GetProperty(property.Name, - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic | - System.Reflection.BindingFlags.Instance); - - if (propInfo != null && propInfo.CanWrite) - { - object value = property.Value.ToObject(propInfo.PropertyType); - propInfo.SetValue(component, value); - } - } - } - } - } -} diff --git a/Editor/Tools/CreatePrefabTool.cs.meta b/Editor/Tools/CreatePrefabTool.cs.meta deleted file mode 100644 index 9b3afce5..00000000 --- a/Editor/Tools/CreatePrefabTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1fe1226cc07d8a149aa31786552a43ca \ No newline at end of file diff --git a/Editor/Tools/CreateSceneTool.cs b/Editor/Tools/CreateSceneTool.cs deleted file mode 100644 index 267c0118..00000000 --- a/Editor/Tools/CreateSceneTool.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for creating and saving a new Unity scene - /// - public class CreateSceneTool : McpToolBase - { - public CreateSceneTool() - { - Name = "create_scene"; - Description = "Creates a new scene and saves it to the specified path"; - } - - /// - /// Execute the CreateScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(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 McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'sceneName' not provided", - "validation_error" - ); - } - - // Default folder path - if (string.IsNullOrEmpty(folderPath)) - { - folderPath = "Assets"; - } - - // Ensure folder exists - if (!AssetDatabase.IsValidFolder(folderPath)) - { - // Attempt to create nested folders as needed - 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 = UnityEditor.SceneManagement.EditorSceneManager.NewScene(UnityEditor.SceneManagement.NewSceneSetup.DefaultGameObjects, UnityEditor.SceneManagement.NewSceneMode.Single); - - bool saved = UnityEditor.SceneManagement.EditorSceneManager.SaveScene(newScene, scenePath); - if (!saved) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to save scene at '{scenePath}'", - "save_error" - ); - } - - AssetDatabase.Refresh(); - - // Make the scene active if requested - if (makeActive) - { - UnityEditor.SceneManagement.EditorSceneManager.OpenScene(scenePath, UnityEditor.SceneManagement.OpenSceneMode.Single); - } - - // Optionally add to build settings - if (addToBuildSettings) - { - AddSceneToBuildSettings(scenePath); - } - - McpLogger.LogInfo($"Created scene '{sceneName}' at path '{scenePath}'"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully created scene '{sceneName}' at path '{scenePath}'", - ["scenePath"] = scenePath - }; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error creating scene: {ex.Message}", - "scene_creation_error" - ); - } - } - - private void AddSceneToBuildSettings(string scenePath) - { - var scenes = UnityEditor.EditorBuildSettings.scenes; - - // Check if already present - foreach (var s in scenes) - { - if (s.path == scenePath) - { - return; - } - } - - var newList = new UnityEditor.EditorBuildSettingsScene[scenes.Length + 1]; - for (int i = 0; i < scenes.Length; i++) - { - newList[i] = scenes[i]; - } - newList[newList.Length - 1] = new UnityEditor.EditorBuildSettingsScene(scenePath, true); - UnityEditor.EditorBuildSettings.scenes = newList; - } - } -} - - diff --git a/Editor/Tools/CreateSceneTool.cs.meta b/Editor/Tools/CreateSceneTool.cs.meta deleted file mode 100644 index 629016d6..00000000 --- a/Editor/Tools/CreateSceneTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9d6810880891ad34eacebdf1130b3de2 \ No newline at end of file diff --git a/Editor/Tools/DeleteSceneTool.cs b/Editor/Tools/DeleteSceneTool.cs deleted file mode 100644 index 3241cb2a..00000000 --- a/Editor/Tools/DeleteSceneTool.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Linq; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for deleting a Unity scene and removing it from Build Settings - /// - public class DeleteSceneTool : McpToolBase - { - public DeleteSceneTool() - { - Name = "delete_scene"; - Description = "Deletes a scene by path or name and removes it from Build Settings"; - } - - /// - /// Execute the DeleteScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - string scenePath = parameters["scenePath"]?.ToObject(); - string sceneName = parameters["sceneName"]?.ToObject(); - string folderPath = parameters["folderPath"]?.ToObject(); - - if (string.IsNullOrEmpty(scenePath)) - { - if (string.IsNullOrEmpty(sceneName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Provide either 'scenePath' or 'sceneName'", - "validation_error" - ); - } - - // Resolve scene path by name (optionally within folderPath) - string filter = $"{sceneName} t:Scene"; - string[] searchInFolders = null; - if (!string.IsNullOrEmpty(folderPath)) - { - // Ensure folder exists - if (!AssetDatabase.IsValidFolder(folderPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Folder '{folderPath}' does not exist", - "not_found_error" - ); - } - searchInFolders = new[] { folderPath }; - } - - var guids = AssetDatabase.FindAssets(filter, searchInFolders); - foreach (var guid in guids) - { - var path = AssetDatabase.GUIDToAssetPath(guid); - if (System.IO.Path.GetFileNameWithoutExtension(path) == sceneName) - { - scenePath = path; - break; - } - } - - if (string.IsNullOrEmpty(scenePath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Scene named '{sceneName}' not found", - "not_found_error" - ); - } - } - - try - { - // If the scene is open, close it without saving changes - var scene = UnityEditor.SceneManagement.EditorSceneManager.GetSceneByPath(scenePath); - if (scene.IsValid() && scene.isLoaded) - { - UnityEditor.SceneManagement.EditorSceneManager.CloseScene(scene, true); - } - - // Remove from Build Settings - RemoveSceneFromBuildSettings(scenePath); - - // Delete asset - bool deleted = AssetDatabase.DeleteAsset(scenePath); - AssetDatabase.Refresh(); - - if (!deleted) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to delete scene at '{scenePath}'", - "delete_error" - ); - } - - McpLogger.LogInfo($"Deleted scene at path '{scenePath}' and removed from Build Settings"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully deleted scene at path '{scenePath}' and removed from Build Settings", - ["scenePath"] = scenePath - }; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error deleting scene: {ex.Message}", - "scene_delete_error" - ); - } - } - - private void RemoveSceneFromBuildSettings(string scenePath) - { - var scenes = UnityEditor.EditorBuildSettings.scenes; - var filtered = scenes.Where(s => s.path != scenePath).ToArray(); - if (filtered.Length != scenes.Length) - { - UnityEditor.EditorBuildSettings.scenes = filtered; - } - } - } -} - - diff --git a/Editor/Tools/DeleteSceneTool.cs.meta b/Editor/Tools/DeleteSceneTool.cs.meta deleted file mode 100644 index 4bdf1b31..00000000 --- a/Editor/Tools/DeleteSceneTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f531906872d66084086a3f0d8f7f3d3e \ No newline at end of file diff --git a/Editor/Tools/GameObjectTools.cs b/Editor/Tools/GameObjectTools.cs deleted file mode 100644 index 9f9fa5fa..00000000 --- a/Editor/Tools/GameObjectTools.cs +++ /dev/null @@ -1,430 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; -using McpUnity.Unity; -using McpUnity.Utils; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Utility class for common GameObject operations - /// - public static class GameObjectToolUtils - { - /// - /// Find a GameObject by instance ID or hierarchy path - /// - /// Optional instance ID - /// Optional hierarchy path - /// Output GameObject if found - /// Description of how the object was identified - /// Error JObject if not found, null if successful - public static JObject FindGameObject(int? instanceId, string objectPath, out GameObject gameObject, out string identifierInfo) - { - gameObject = null; - identifierInfo = ""; - - if (instanceId.HasValue) - { - gameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - identifierInfo = $"instance ID {instanceId.Value}"; - } - else if (!string.IsNullOrEmpty(objectPath)) - { - gameObject = GameObject.Find(objectPath); - if (gameObject == null) - { - // Try finding by traversing hierarchy - gameObject = FindGameObjectByPath(objectPath); - } - identifierInfo = $"path '{objectPath}'"; - } - else - { - return McpUnitySocketHandler.CreateErrorResponse( - "Either 'instanceId' or 'objectPath' must be provided.", - "validation_error" - ); - } - - if (gameObject == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"GameObject not found using {identifierInfo}.", - "not_found_error" - ); - } - - return null; // Success - } - - /// - /// Find a GameObject by its hierarchy path - /// - private static GameObject FindGameObjectByPath(string path) - { - if (string.IsNullOrEmpty(path)) return null; - - path = path.TrimStart('/'); - string[] parts = path.Split('/'); - - if (parts.Length == 0) return null; - - // Find root object - GameObject current = null; - GameObject[] rootObjects = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects(); - - foreach (var root in rootObjects) - { - if (root.name == parts[0]) - { - current = root; - break; - } - } - - if (current == null) return null; - - // Traverse children - for (int i = 1; i < parts.Length; i++) - { - Transform child = current.transform.Find(parts[i]); - if (child == null) return null; - current = child.gameObject; - } - - return current; - } - - /// - /// Get the full hierarchy path of a GameObject - /// - public static string GetGameObjectPath(GameObject obj) - { - if (obj == null) return null; - string path = "/" + obj.name; - while (obj.transform.parent != null) - { - obj = obj.transform.parent.gameObject; - path = "/" + obj.name + path; - } - return path; - } - } - - /// - /// Tool for duplicating GameObjects in the Unity Editor - /// - public class DuplicateGameObjectTool : McpToolBase - { - public DuplicateGameObjectTool() - { - Name = "duplicate_gameobject"; - Description = "Duplicates a GameObject in the Unity scene. Can create multiple copies and optionally rename or reparent them."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - string newName = parameters["newName"]?.ToObject(); - string newParentPath = parameters["newParent"]?.ToObject(); - int? newParentId = parameters["newParentId"]?.ToObject(); - int count = parameters["count"]?.ToObject() ?? 1; - - // Validate count - if (count < 1 || count > 100) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Count must be between 1 and 100.", - "validation_error" - ); - } - - // Find source GameObject - JObject error = GameObjectToolUtils.FindGameObject(instanceId, objectPath, out GameObject sourceObject, out string identifierInfo); - if (error != null) return error; - - // Find new parent if specified - GameObject newParent = null; - if (newParentId.HasValue) - { - newParent = UnityObjectId.ObjectFromId(newParentId.Value) as GameObject; - if (newParent == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"New parent GameObject not found with instance ID {newParentId.Value}.", - "not_found_error" - ); - } - } - else if (!string.IsNullOrEmpty(newParentPath)) - { - newParent = GameObject.Find(newParentPath); - if (newParent == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"New parent GameObject not found at path '{newParentPath}'.", - "not_found_error" - ); - } - } - - // Create duplicates - JArray duplicatedObjects = new JArray(); - - for (int i = 0; i < count; i++) - { - GameObject duplicate = UnityEngine.Object.Instantiate(sourceObject); - Undo.RegisterCreatedObjectUndo(duplicate, $"Duplicate {sourceObject.name}"); - - // Set name - if (!string.IsNullOrEmpty(newName)) - { - duplicate.name = count > 1 ? $"{newName} ({i + 1})" : newName; - } - else - { - // Remove "(Clone)" suffix and optionally add number - string baseName = sourceObject.name; - duplicate.name = count > 1 ? $"{baseName} ({i + 1})" : baseName; - } - - // Set parent - Transform targetParent = newParent != null ? newParent.transform : sourceObject.transform.parent; - if (targetParent != null) - { - duplicate.transform.SetParent(targetParent, true); - } - - duplicatedObjects.Add(new JObject - { - ["instanceId"] = UnityObjectId.GetObjectId(duplicate), - ["name"] = duplicate.name, - ["path"] = GameObjectToolUtils.GetGameObjectPath(duplicate) - }); - } - - EditorUtility.SetDirty(sourceObject.scene.GetRootGameObjects()[0]); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = count == 1 - ? $"Successfully duplicated GameObject '{sourceObject.name}'." - : $"Successfully created {count} duplicates of GameObject '{sourceObject.name}'.", - ["duplicatedObjects"] = duplicatedObjects - }; - } - } - - /// - /// Tool for deleting GameObjects in the Unity Editor - /// - public class DeleteGameObjectTool : McpToolBase - { - public DeleteGameObjectTool() - { - Name = "delete_gameobject"; - Description = "Deletes a GameObject from the Unity scene. By default, also deletes all children."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - bool includeChildren = parameters["includeChildren"]?.ToObject() ?? true; - - // Find target GameObject - JObject error = GameObjectToolUtils.FindGameObject(instanceId, objectPath, out GameObject targetObject, out string identifierInfo); - if (error != null) return error; - - string deletedName = targetObject.name; - string deletedPath = GameObjectToolUtils.GetGameObjectPath(targetObject); - int childCount = targetObject.transform.childCount; - - if (!includeChildren && childCount > 0) - { - // Move children to parent before deleting - Transform parent = targetObject.transform.parent; - Transform[] children = new Transform[childCount]; - - for (int i = 0; i < childCount; i++) - { - children[i] = targetObject.transform.GetChild(i); - } - - foreach (Transform child in children) - { - Undo.SetTransformParent(child, parent, "Reparent before delete"); - } - } - - // Delete the GameObject - Undo.DestroyObjectImmediate(targetObject); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = includeChildren && childCount > 0 - ? $"Successfully deleted GameObject '{deletedName}' and {childCount} children." - : $"Successfully deleted GameObject '{deletedName}'.", - ["deletedPath"] = deletedPath, - ["childrenPreserved"] = !includeChildren && childCount > 0 ? childCount : 0 - }; - } - } - - /// - /// Tool for changing the parent of GameObjects in the Unity Editor - /// - public class ReparentGameObjectTool : McpToolBase - { - public ReparentGameObjectTool() - { - Name = "reparent_gameobject"; - Description = "Changes the parent of a GameObject. Can move to a new parent or to the root level (null parent)."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - string newParentPath = parameters["newParent"]?.ToObject(); - int? newParentId = parameters["newParentId"]?.ToObject(); - bool worldPositionStays = parameters["worldPositionStays"]?.ToObject() ?? true; - - // Find target GameObject - JObject error = GameObjectToolUtils.FindGameObject(instanceId, objectPath, out GameObject targetObject, out string identifierInfo); - if (error != null) return error; - - string oldPath = GameObjectToolUtils.GetGameObjectPath(targetObject); - Transform oldParent = targetObject.transform.parent; - - // Find new parent (null means root level) - Transform newParentTransform = null; - bool moveToRoot = false; - - // Check if explicitly moving to root (newParent is null or empty string) - if (parameters["newParent"] != null && parameters["newParent"].Type == JTokenType.Null) - { - moveToRoot = true; - } - else if (newParentId.HasValue) - { - GameObject newParent = UnityObjectId.ObjectFromId(newParentId.Value) as GameObject; - if (newParent == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"New parent GameObject not found with instance ID {newParentId.Value}.", - "not_found_error" - ); - } - newParentTransform = newParent.transform; - } - else if (!string.IsNullOrEmpty(newParentPath)) - { - GameObject newParent = GameObject.Find(newParentPath); - if (newParent == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"New parent GameObject not found at path '{newParentPath}'.", - "not_found_error" - ); - } - newParentTransform = newParent.transform; - } - else if (parameters["newParent"] == null && parameters["newParentId"] == null) - { - // Neither specified - move to root - moveToRoot = true; - } - - // Prevent parenting to self or descendants - if (newParentTransform != null) - { - if (newParentTransform == targetObject.transform) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Cannot parent a GameObject to itself.", - "validation_error" - ); - } - - if (newParentTransform.IsChildOf(targetObject.transform)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Cannot parent a GameObject to one of its descendants.", - "validation_error" - ); - } - } - - // Check if already at target parent - if (moveToRoot && oldParent == null) - { - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{targetObject.name}' is already at the root level.", - ["instanceId"] = UnityObjectId.GetObjectId(targetObject), - ["name"] = targetObject.name, - ["path"] = oldPath, - ["changed"] = false - }; - } - - if (!moveToRoot && newParentTransform == oldParent) - { - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{targetObject.name}' is already a child of the specified parent.", - ["instanceId"] = UnityObjectId.GetObjectId(targetObject), - ["name"] = targetObject.name, - ["path"] = oldPath, - ["changed"] = false - }; - } - - // Perform reparenting - Undo.SetTransformParent(targetObject.transform, newParentTransform, "Reparent GameObject"); - - if (!worldPositionStays) - { - // Reset local position when worldPositionStays is false - Undo.RecordObject(targetObject.transform, "Reset Local Position"); - targetObject.transform.localPosition = Vector3.zero; - targetObject.transform.localRotation = Quaternion.identity; - targetObject.transform.localScale = Vector3.one; - } - - string newPath = GameObjectToolUtils.GetGameObjectPath(targetObject); - string parentDescription = newParentTransform != null - ? $"'{newParentTransform.gameObject.name}'" - : "root level"; - - EditorUtility.SetDirty(targetObject); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully reparented GameObject '{targetObject.name}' to {parentDescription}.", - ["instanceId"] = UnityObjectId.GetObjectId(targetObject), - ["name"] = targetObject.name, - ["oldPath"] = oldPath, - ["newPath"] = newPath, - ["changed"] = true - }; - } - } -} diff --git a/Editor/Tools/GameObjectTools.cs.meta b/Editor/Tools/GameObjectTools.cs.meta deleted file mode 100644 index b42223c5..00000000 --- a/Editor/Tools/GameObjectTools.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e32bc6751ab884d03a051a1eb83ac276 \ No newline at end of file diff --git a/Editor/Tools/GetConsoleLogsTool.cs b/Editor/Tools/GetConsoleLogsTool.cs deleted file mode 100644 index 7a6cd917..00000000 --- a/Editor/Tools/GetConsoleLogsTool.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using Newtonsoft.Json.Linq; -using McpUnity.Services; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for retrieving logs from the Unity console with pagination support - /// - public class GetConsoleLogsTool : McpToolBase - { - private readonly IConsoleLogsService _consoleLogsService; - - public GetConsoleLogsTool(IConsoleLogsService consoleLogsService) - { - Name = "get_console_logs"; - Description = "Retrieves logs from the Unity console with pagination support to avoid token limits"; - _consoleLogsService = consoleLogsService; - } - - public override JObject Execute(JObject parameters) - { - try - { - string logType = parameters?["logType"]?.ToString(); - if (string.IsNullOrWhiteSpace(logType)) logType = null; - - int offset = Math.Max(0, GetIntParameter(parameters, "offset", 0)); - int limit = Math.Max(1, Math.Min(500, GetIntParameter(parameters, "limit", 50))); - bool includeStackTrace = GetBoolParameter(parameters, "includeStackTrace", true); - - // Use the console logs service to get logs - JObject result = _consoleLogsService.GetLogsAsJson(logType, offset, limit, includeStackTrace); - - // Add formatted message with pagination info - string typeFilter = logType != null ? $" of type '{logType}'" : ""; - int returnedCount = result["_returnedCount"]?.Value() ?? 0; - int filteredCount = result["_filteredCount"]?.Value() ?? 0; - int totalCount = result["_totalCount"]?.Value() ?? 0; - - result["message"] = $"Retrieved {returnedCount} of {filteredCount} log entries{typeFilter} (offset: {offset}, limit: {limit}, total: {totalCount})"; - result["success"] = true; - result["type"] = "text"; - - // Remove internal count fields - result.Remove("_totalCount"); - result.Remove("_filteredCount"); - result.Remove("_returnedCount"); - - McpLogger.LogInfo($"Console logs retrieved: {returnedCount} entries (logType={logType}, offset={offset}, limit={limit}, includeStackTrace={includeStackTrace})"); - - return result; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error retrieving console logs: {ex.Message}", - "console_logs_error" - ); - } - } - - private static int GetIntParameter(JObject parameters, string key, int defaultValue) - { - if (parameters?[key] != null && int.TryParse(parameters[key].ToString(), out int value)) - return value; - return defaultValue; - } - - private static bool GetBoolParameter(JObject parameters, string key, bool defaultValue) - { - if (parameters?[key] != null && bool.TryParse(parameters[key].ToString(), out bool value)) - return value; - return defaultValue; - } - } -} diff --git a/Editor/Tools/GetConsoleLogsTool.cs.meta b/Editor/Tools/GetConsoleLogsTool.cs.meta deleted file mode 100644 index 60a74155..00000000 --- a/Editor/Tools/GetConsoleLogsTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3f8a9c2d5e6f7a8b9c0d1e2f3a4b5c6d diff --git a/Editor/Tools/GetGameObjectTool.cs b/Editor/Tools/GetGameObjectTool.cs deleted file mode 100644 index 6402895b..00000000 --- a/Editor/Tools/GetGameObjectTool.cs +++ /dev/null @@ -1,94 +0,0 @@ -using McpUnity.Resources; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for retrieving detailed information about a specific GameObject. - /// This tool provides the same functionality as the get_gameobject resource, - /// but as a tool that can be invoked directly without URI template parameters. - /// - public class GetGameObjectTool : McpToolBase - { - public GetGameObjectTool() - { - Name = "get_gameobject"; - Description = "Retrieves detailed information about a specific GameObject by instance ID, name, or hierarchical path (e.g., \"Parent/Child/MyObject\"). Returns component properties (Transform position/rotation/scale, etc.) plus a scoped hierarchy of children. Use optional 'maxDepth' (default 2), 'includeComponents', and 'includeComponentProperties' to control response size; when limits are hit, nodes carry a '_truncated' marker so you can re-query a narrower subtree."; - } - - /// - /// Execute the GetGameObject tool with the provided parameters - /// - /// Tool parameters as a JObject. Required 'idOrName' (instance ID, name, or path). - /// Optional 'maxDepth' (int, default 2), 'includeComponents' (bool, default true), - /// 'includeComponentProperties' (bool, default true). - /// A JObject containing the GameObject data - public override JObject Execute(JObject parameters) - { - // Validate parameters - if (parameters == null || !parameters.ContainsKey("idOrName")) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Missing required parameter: idOrName", - "validation_error" - ); - } - - string idOrName = parameters["idOrName"]?.ToObject(); - - if (string.IsNullOrEmpty(idOrName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Parameter 'idOrName' cannot be null or empty", - "validation_error" - ); - } - - GameObject gameObject = null; - - // Try to parse as an instance ID first - if (int.TryParse(idOrName, out int instanceId)) - { - // Unity Instance IDs are typically negative, but we'll accept any integer - UnityEngine.Object unityObject = UnityObjectId.ObjectFromId(instanceId); - gameObject = unityObject as GameObject; - } - else - { - // Otherwise, treat it as a name or hierarchical path - gameObject = GameObject.Find(idOrName); - } - - // Check if the GameObject was found - if (gameObject == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"GameObject with '{idOrName}' reference not found. Make sure the GameObject exists and is loaded in the current scene(s).", - "not_found_error" - ); - } - - int maxDepth = parameters["maxDepth"]?.ToObject() ?? GetGameObjectResource.DefaultMaxChildDepth; - bool includeComponents = parameters["includeComponents"]?.ToObject() ?? true; - bool includeComponentProperties = parameters["includeComponentProperties"]?.ToObject() ?? true; - - // Convert the GameObject to a JObject using the resource's static method - JObject gameObjectData = GetGameObjectResource.GameObjectToJObject( - gameObject, true, maxDepth, includeComponents, includeComponentProperties); - - // Create the response - return new JObject - { - ["success"] = true, - ["message"] = $"Retrieved GameObject data for '{gameObject.name}'", - ["gameObject"] = gameObjectData, - ["instanceId"] = UnityObjectId.GetObjectId(gameObject) - }; - } - } -} - diff --git a/Editor/Tools/GetPlayModeStatusTool.cs b/Editor/Tools/GetPlayModeStatusTool.cs deleted file mode 100644 index 7010389e..00000000 --- a/Editor/Tools/GetPlayModeStatusTool.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Newtonsoft.Json.Linq; -using UnityEditor; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for getting Unity play mode status - /// - public class GetPlayModeStatusTool : McpToolBase - { - public GetPlayModeStatusTool() - { - Name = "get_play_mode_status"; - Description = "Gets Unity play mode status (isPlaying, isPaused)."; - } - - public override JObject Execute(JObject parameters) - { - try - { - bool isPlaying = EditorApplication.isPlaying; - bool isPaused = EditorApplication.isPaused; - - var result = new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = isPlaying ? (isPaused ? "Play mode (paused)" : "Play mode") : "Edit mode", - ["isPlaying"] = isPlaying, - ["isPaused"] = isPaused - }; - - McpLogger.LogInfo($"Play mode status requested: isPlaying={isPlaying}, isPaused={isPaused}"); - - return result; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error getting play mode status: {ex.Message}", - "play_mode_error" - ); - } - } - } -} diff --git a/Editor/Tools/GetPlayModeStatusTool.cs.meta b/Editor/Tools/GetPlayModeStatusTool.cs.meta deleted file mode 100644 index 631b4b58..00000000 --- a/Editor/Tools/GetPlayModeStatusTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: dffebe6143c5cbb4bae56ce6088a4b91 \ No newline at end of file diff --git a/Editor/Tools/GetSceneInfoTool.cs b/Editor/Tools/GetSceneInfoTool.cs deleted file mode 100644 index 09698162..00000000 --- a/Editor/Tools/GetSceneInfoTool.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using UnityEditor.SceneManagement; -using UnityEngine.SceneManagement; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for getting information about the active Unity scene - /// - public class GetSceneInfoTool : McpToolBase - { - public GetSceneInfoTool() - { - Name = "get_scene_info"; - Description = "Gets information about the active scene including name, path, dirty state, root object count, and loaded state"; - } - - /// - /// Execute the GetSceneInfo tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - try - { - Scene activeScene = SceneManager.GetActiveScene(); - - if (!activeScene.IsValid()) - { - return McpUnitySocketHandler.CreateErrorResponse( - "No valid active scene", - "validation_error" - ); - } - - // Get all loaded scenes info - int loadedSceneCount = SceneManager.sceneCount; - var loadedScenes = new JArray(); - - for (int i = 0; i < loadedSceneCount; 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, - ["rootCount"] = scene.isLoaded ? scene.rootCount : 0, - ["isActive"] = scene == activeScene - }); - } - - var result = new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Active scene: '{activeScene.name}'", - ["activeScene"] = new JObject - { - ["name"] = activeScene.name, - ["path"] = activeScene.path, - ["buildIndex"] = activeScene.buildIndex, - ["isDirty"] = activeScene.isDirty, - ["isLoaded"] = activeScene.isLoaded, - ["rootCount"] = activeScene.isLoaded ? activeScene.rootCount : 0 - }, - ["loadedSceneCount"] = loadedSceneCount, - ["loadedScenes"] = loadedScenes - }; - - McpLogger.LogInfo($"Retrieved scene info for active scene '{activeScene.name}'"); - - return result; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error getting scene info: {ex.Message}", - "scene_info_error" - ); - } - } - } -} diff --git a/Editor/Tools/GetSceneInfoTool.cs.meta b/Editor/Tools/GetSceneInfoTool.cs.meta deleted file mode 100644 index a0cc6471..00000000 --- a/Editor/Tools/GetSceneInfoTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 43eb7dc38d4aa46118d8607c3246f423 \ No newline at end of file diff --git a/Editor/Tools/LoadSceneTool.cs b/Editor/Tools/LoadSceneTool.cs deleted file mode 100644 index df9676d3..00000000 --- a/Editor/Tools/LoadSceneTool.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using UnityEditor; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for loading a Unity scene, optionally additively - /// - public class LoadSceneTool : McpToolBase - { - public LoadSceneTool() - { - Name = "load_scene"; - Description = "Loads a scene by path or name. Supports additive loading (default: false)"; - } - - /// - /// Execute the LoadScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - string scenePath = parameters["scenePath"]?.ToObject(); - string sceneName = parameters["sceneName"]?.ToObject(); - string folderPath = parameters["folderPath"]?.ToObject(); - bool additive = parameters["additive"]?.ToObject() ?? false; - - if (string.IsNullOrEmpty(scenePath)) - { - if (string.IsNullOrEmpty(sceneName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Provide either 'scenePath' or 'sceneName'", - "validation_error" - ); - } - - // Resolve scene path by name (optionally within folderPath) - string filter = $"{sceneName} t:Scene"; - string[] searchInFolders = null; - if (!string.IsNullOrEmpty(folderPath)) - { - if (!AssetDatabase.IsValidFolder(folderPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Folder '{folderPath}' does not exist", - "not_found_error" - ); - } - searchInFolders = new[] { folderPath }; - } - - var guids = AssetDatabase.FindAssets(filter, searchInFolders); - foreach (var guid in guids) - { - var path = AssetDatabase.GUIDToAssetPath(guid); - if (System.IO.Path.GetFileNameWithoutExtension(path) == sceneName) - { - scenePath = path; - break; - } - } - - if (string.IsNullOrEmpty(scenePath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Scene named '{sceneName}' not found", - "not_found_error" - ); - } - } - - try - { - // Avoid any save prompts: save open scenes before replacing them (non-additive) - if (!additive) - { - UnityEditor.SceneManagement.EditorSceneManager.SaveOpenScenes(); - } - - var mode = additive - ? UnityEditor.SceneManagement.OpenSceneMode.Additive - : UnityEditor.SceneManagement.OpenSceneMode.Single; - - var openedScene = UnityEditor.SceneManagement.EditorSceneManager.OpenScene(scenePath, mode); - - // For non-additive, scene becomes active automatically. For additive, we do not change active scene. - - McpLogger.LogInfo($"Loaded scene at path '{scenePath}' (additive={additive})"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully loaded scene at path '{scenePath}' (additive={additive.ToString().ToLower()})", - ["scenePath"] = scenePath, - ["additive"] = additive - }; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error loading scene: {ex.Message}", - "scene_load_error" - ); - } - } - } -} - - diff --git a/Editor/Tools/LoadSceneTool.cs.meta b/Editor/Tools/LoadSceneTool.cs.meta deleted file mode 100644 index 11676395..00000000 --- a/Editor/Tools/LoadSceneTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 77c0c977efd3f724590ee3d7891f042b \ No newline at end of file diff --git a/Editor/Tools/MaterialTools.cs b/Editor/Tools/MaterialTools.cs deleted file mode 100644 index 7beb7ffc..00000000 --- a/Editor/Tools/MaterialTools.cs +++ /dev/null @@ -1,814 +0,0 @@ -using System; -using System.Collections.Generic; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using UnityEngine.Rendering; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Utility class for Material tool operations - /// - public static class MaterialToolUtils - { - /// - /// Get the default lit shader based on the current render pipeline - /// - public static string GetDefaultShaderName() - { - // Check for URP - if (UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline != null) - { - string pipelineName = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline.GetType().Name; - - if (pipelineName.Contains("Universal") || pipelineName.Contains("URP")) - { - return "Universal Render Pipeline/Lit"; - } - else if (pipelineName.Contains("HD") || pipelineName.Contains("HDRP")) - { - return "HDRP/Lit"; - } - else - { - McpLogger.LogWarning("Unknown render pipeline, defaulting to Standard shader"); - } - } - - // Default to Standard (Built-in Render Pipeline) - return "Standard"; - } - - /// - /// Find a shader by name, searching common Unity shader paths - /// - public static Shader FindShader(string shaderName) - { - // Try direct lookup first - Shader shader = Shader.Find(shaderName); - if (shader != null) - { - return shader; - } - - // Common shader path prefixes to try - string[] prefixes = new string[] - { - "", - "Standard", - "Universal Render Pipeline/", - "URP/", - "HDRP/", - "Hidden/", - "Legacy Shaders/", - "Mobile/", - "Particles/", - "Skybox/", - "Sprites/", - "UI/", - "Unlit/" - }; - - foreach (string prefix in prefixes) - { - shader = Shader.Find(prefix + shaderName); - if (shader != null) - { - return shader; - } - } - - return null; - } - - /// - /// Load a material from an asset path - /// - public static Material LoadMaterial(string materialPath) - { - if (string.IsNullOrEmpty(materialPath)) - { - return null; - } - - // Ensure path starts with Assets/ - if (!materialPath.StartsWith("Assets/")) - { - materialPath = "Assets/" + materialPath; - } - - // Ensure .mat extension - if (!materialPath.EndsWith(".mat")) - { - materialPath += ".mat"; - } - - return AssetDatabase.LoadAssetAtPath(materialPath); - } - - /// - /// Convert a JToken to a shader property value - /// - public static object ConvertPropertyValue(JToken token, ShaderPropertyType propertyType) - { - if (token == null) - { - return null; - } - - switch (propertyType) - { - case ShaderPropertyType.Color: - if (token.Type == JTokenType.Object) - { - JObject color = (JObject)token; - return new Color( - color["r"]?.ToObject() ?? 0f, - color["g"]?.ToObject() ?? 0f, - color["b"]?.ToObject() ?? 0f, - color["a"]?.ToObject() ?? 1f - ); - } - break; - - case ShaderPropertyType.Vector: - if (token.Type == JTokenType.Object) - { - JObject vec = (JObject)token; - return new Vector4( - vec["x"]?.ToObject() ?? 0f, - vec["y"]?.ToObject() ?? 0f, - vec["z"]?.ToObject() ?? 0f, - vec["w"]?.ToObject() ?? 0f - ); - } - break; - - case ShaderPropertyType.Float: - case ShaderPropertyType.Range: - return token.ToObject(); - - case ShaderPropertyType.Texture: - // Texture path - string texPath = token.ToObject(); - if (!string.IsNullOrEmpty(texPath)) - { - if (!texPath.StartsWith("Assets/")) - { - texPath = "Assets/" + texPath; - } - return AssetDatabase.LoadAssetAtPath(texPath); - } - break; - - case ShaderPropertyType.Int: - return token.ToObject(); - } - - return null; - } - - /// - /// Find a GameObject by instance ID or path - /// - public static GameObject FindGameObject(int? instanceId, string objectPath) - { - GameObject gameObject = null; - - if (instanceId.HasValue) - { - gameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - } - else if (!string.IsNullOrEmpty(objectPath)) - { - gameObject = GameObject.Find(objectPath); - - if (gameObject == null) - { - // Try to find using hierarchy path - gameObject = FindGameObjectByPath(objectPath); - } - } - - return gameObject; - } - - /// - /// Find a GameObject by its hierarchy path - /// - private static GameObject FindGameObjectByPath(string path) - { - string[] pathParts = path.Split('/'); - GameObject[] rootGameObjects = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects(); - - if (pathParts.Length == 0) - { - return null; - } - - foreach (GameObject rootObj in rootGameObjects) - { - if (rootObj.name == pathParts[0]) - { - GameObject current = rootObj; - - for (int i = 1; i < pathParts.Length; i++) - { - Transform child = current.transform.Find(pathParts[i]); - if (child == null) - { - return null; - } - current = child.gameObject; - } - - return current; - } - } - - return null; - } - } - - /// - /// Tool for creating new materials - /// - public class CreateMaterialTool : McpToolBase - { - public CreateMaterialTool() - { - Name = "create_material"; - Description = "Creates a new material with the specified shader and saves it to the project"; - } - - public override JObject Execute(JObject parameters) - { - // Extract parameters - string name = parameters["name"]?.ToObject(); - string shaderName = parameters["shader"]?.ToObject(); - string savePath = parameters["savePath"]?.ToObject(); - JObject properties = parameters["properties"] as JObject; - JObject colorParam = parameters["color"] as JObject; - - // Validate required parameters - if (string.IsNullOrEmpty(name)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'name' not provided", - "validation_error" - ); - } - - if (string.IsNullOrEmpty(savePath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'savePath' not provided", - "validation_error" - ); - } - - // Default shader based on current render pipeline - if (string.IsNullOrEmpty(shaderName)) - { - shaderName = MaterialToolUtils.GetDefaultShaderName(); - } - - // Find the shader - Shader shader = MaterialToolUtils.FindShader(shaderName); - if (shader == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Shader '{shaderName}' not found in Unity", - "not_found_error" - ); - } - - // Ensure save path has proper format - if (!savePath.StartsWith("Assets/")) - { - savePath = "Assets/" + savePath; - } - if (!savePath.EndsWith(".mat")) - { - savePath += ".mat"; - } - - // Ensure directory exists - string directory = System.IO.Path.GetDirectoryName(savePath); - if (!System.IO.Directory.Exists(directory)) - { - System.IO.Directory.CreateDirectory(directory); - } - - // Create the material - Material material = new Material(shader); - material.name = name; - - // Apply color if provided (auto-detect correct property name) - if (colorParam != null) - { - Color color = new Color( - colorParam["r"]?.ToObject() ?? 1f, - colorParam["g"]?.ToObject() ?? 1f, - colorParam["b"]?.ToObject() ?? 1f, - colorParam["a"]?.ToObject() ?? 1f - ); - ApplyBaseColor(material, color); - } - - // Apply initial properties if provided - if (properties != null && properties.Count > 0) - { - ApplyMaterialProperties(material, properties); - } - - // Save the material as an asset - AssetDatabase.CreateAsset(material, savePath); - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - - McpLogger.LogInfo($"[MCP Unity] Created material '{name}' with shader '{shaderName}' at '{savePath}'"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully created material '{name}' with shader '{shaderName}'", - ["materialPath"] = savePath, - ["materialName"] = name, - ["shaderName"] = shader.name - }; - } - - private void ApplyMaterialProperties(Material material, JObject properties) - { - Shader shader = material.shader; - int propertyCount = shader.GetPropertyCount(); - - foreach (var prop in properties.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - - // Find the property in the shader - for (int i = 0; i < propertyCount; i++) - { - string shaderPropName = shader.GetPropertyName(i); - if (shaderPropName == propName) - { - ShaderPropertyType propType = shader.GetPropertyType(i); - object value = MaterialToolUtils.ConvertPropertyValue(propValue, propType); - - if (value != null) - { - SetMaterialProperty(material, propName, propType, value); - } - break; - } - } - } - } - - private void SetMaterialProperty(Material material, string propName, ShaderPropertyType propType, object value) - { - switch (propType) - { - case ShaderPropertyType.Color: - material.SetColor(propName, (Color)value); - break; - case ShaderPropertyType.Vector: - material.SetVector(propName, (Vector4)value); - break; - case ShaderPropertyType.Float: - case ShaderPropertyType.Range: - material.SetFloat(propName, (float)value); - break; - case ShaderPropertyType.Texture: - material.SetTexture(propName, (Texture)value); - break; - case ShaderPropertyType.Int: - material.SetInt(propName, (int)value); - break; - } - } - - /// - /// Apply base color to material, auto-detecting the correct property name - /// - private void ApplyBaseColor(Material material, Color color) - { - // Common color property names in order of preference - string[] colorPropertyNames = new string[] - { - "_BaseColor", // URP Lit, HDRP Lit - "_Color", // Standard, Legacy shaders - "_TintColor", // Particle shaders - "_MainColor" // Some custom shaders - }; - - foreach (string propName in colorPropertyNames) - { - if (material.HasProperty(propName)) - { - material.SetColor(propName, color); - return; - } - } - - // Fallback: try to find any color property - Shader shader = material.shader; - int propertyCount = shader.GetPropertyCount(); - for (int i = 0; i < propertyCount; i++) - { - if (shader.GetPropertyType(i) == ShaderPropertyType.Color) - { - string propName = shader.GetPropertyName(i); - material.SetColor(propName, color); - return; - } - } - } - } - - /// - /// Tool for assigning materials to GameObjects - /// - public class AssignMaterialTool : McpToolBase - { - public AssignMaterialTool() - { - Name = "assign_material"; - Description = "Assigns a material to a GameObject's Renderer component"; - } - - public override JObject Execute(JObject parameters) - { - // Extract parameters - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - string materialPath = parameters["materialPath"]?.ToObject(); - int slot = parameters["slot"]?.ToObject() ?? 0; - - // Validate parameters - if (!instanceId.HasValue && string.IsNullOrEmpty(objectPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Either 'instanceId' or 'objectPath' must be provided", - "validation_error" - ); - } - - if (string.IsNullOrEmpty(materialPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'materialPath' not provided", - "validation_error" - ); - } - - // Find the GameObject - GameObject gameObject = MaterialToolUtils.FindGameObject(instanceId, objectPath); - if (gameObject == null) - { - string identifier = instanceId.HasValue ? $"ID {instanceId.Value}" : $"path '{objectPath}'"; - return McpUnitySocketHandler.CreateErrorResponse( - $"GameObject with {identifier} not found", - "not_found_error" - ); - } - - // Get the Renderer component - Renderer renderer = gameObject.GetComponent(); - if (renderer == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"GameObject '{gameObject.name}' does not have a Renderer component", - "component_error" - ); - } - - // Load the material - Material material = MaterialToolUtils.LoadMaterial(materialPath); - if (material == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Material at path '{materialPath}' not found", - "not_found_error" - ); - } - - // Validate slot index - Material[] materials = renderer.sharedMaterials; - if (slot < 0 || slot >= materials.Length) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Material slot {slot} is out of range. GameObject has {materials.Length} material slot(s) (0-{materials.Length - 1})", - "validation_error" - ); - } - - // Record for undo - Undo.RecordObject(renderer, $"Assign Material to {gameObject.name}"); - - // Assign the material - materials[slot] = material; - renderer.sharedMaterials = materials; - - // Mark as dirty - EditorUtility.SetDirty(renderer); - if (PrefabUtility.IsPartOfAnyPrefab(gameObject)) - { - PrefabUtility.RecordPrefabInstancePropertyModifications(renderer); - } - - McpLogger.LogInfo($"[MCP Unity] Assigned material '{material.name}' to '{gameObject.name}' at slot {slot}"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully assigned material '{material.name}' to '{gameObject.name}' at slot {slot}", - ["gameObjectName"] = gameObject.name, - ["materialName"] = material.name, - ["slot"] = slot - }; - } - } - - /// - /// Tool for modifying material properties - /// - public class ModifyMaterialTool : McpToolBase - { - public ModifyMaterialTool() - { - Name = "modify_material"; - Description = "Modifies properties of an existing material"; - } - - public override JObject Execute(JObject parameters) - { - // Extract parameters - string materialPath = parameters["materialPath"]?.ToObject(); - JObject properties = parameters["properties"] as JObject; - - // Validate parameters - if (string.IsNullOrEmpty(materialPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'materialPath' not provided", - "validation_error" - ); - } - - if (properties == null || properties.Count == 0) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'properties' not provided or empty", - "validation_error" - ); - } - - // Load the material - Material material = MaterialToolUtils.LoadMaterial(materialPath); - if (material == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Material at path '{materialPath}' not found", - "not_found_error" - ); - } - - // Record for undo - Undo.RecordObject(material, $"Modify Material {material.name}"); - - // Apply properties - Shader shader = material.shader; - int propertyCount = shader.GetPropertyCount(); - List modifiedProperties = new List(); - List unknownProperties = new List(); - - foreach (var prop in properties.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - bool found = false; - - // Find the property in the shader - for (int i = 0; i < propertyCount; i++) - { - string shaderPropName = shader.GetPropertyName(i); - if (shaderPropName == propName) - { - found = true; - ShaderPropertyType propType = shader.GetPropertyType(i); - object value = MaterialToolUtils.ConvertPropertyValue(propValue, propType); - - if (value != null) - { - SetMaterialProperty(material, propName, propType, value); - modifiedProperties.Add(propName); - } - break; - } - } - - if (!found) - { - unknownProperties.Add(propName); - } - } - - // Mark as dirty and save - EditorUtility.SetDirty(material); - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - - McpLogger.LogInfo($"[MCP Unity] Modified material '{material.name}': {string.Join(", ", modifiedProperties)}"); - - JObject result = new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully modified material '{material.name}'", - ["materialName"] = material.name, - ["modifiedProperties"] = new JArray(modifiedProperties) - }; - - if (unknownProperties.Count > 0) - { - result["unknownProperties"] = new JArray(unknownProperties); - result["message"] = $"Modified material '{material.name}'. Some properties were not found: {string.Join(", ", unknownProperties)}"; - } - - return result; - } - - private void SetMaterialProperty(Material material, string propName, ShaderPropertyType propType, object value) - { - switch (propType) - { - case ShaderPropertyType.Color: - material.SetColor(propName, (Color)value); - break; - case ShaderPropertyType.Vector: - material.SetVector(propName, (Vector4)value); - break; - case ShaderPropertyType.Float: - case ShaderPropertyType.Range: - material.SetFloat(propName, (float)value); - break; - case ShaderPropertyType.Texture: - material.SetTexture(propName, (Texture)value); - break; - case ShaderPropertyType.Int: - material.SetInt(propName, (int)value); - break; - } - } - } - - /// - /// Tool for getting material information - /// - public class GetMaterialInfoTool : McpToolBase - { - public GetMaterialInfoTool() - { - Name = "get_material_info"; - Description = "Gets detailed information about a material including its shader and all properties"; - } - - public override JObject Execute(JObject parameters) - { - // Extract parameters - string materialPath = parameters["materialPath"]?.ToObject(); - - // Validate parameters - if (string.IsNullOrEmpty(materialPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'materialPath' not provided", - "validation_error" - ); - } - - // Load the material - Material material = MaterialToolUtils.LoadMaterial(materialPath); - if (material == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Material at path '{materialPath}' not found", - "not_found_error" - ); - } - - // Get shader info - Shader shader = material.shader; - int propertyCount = shader.GetPropertyCount(); - - // Build properties array - JArray propertiesArray = new JArray(); - for (int i = 0; i < propertyCount; i++) - { - string propName = shader.GetPropertyName(i); - string propDescription = shader.GetPropertyDescription(i); - ShaderPropertyType propType = shader.GetPropertyType(i); - - JObject propInfo = new JObject - { - ["name"] = propName, - ["description"] = propDescription, - ["type"] = propType.ToString() - }; - - // Get current value - propInfo["value"] = GetPropertyValue(material, propName, propType); - - // Add range info if applicable - if (propType == ShaderPropertyType.Range) - { - Vector2 rangeLimits = shader.GetPropertyRangeLimits(i); - propInfo["rangeMin"] = rangeLimits.x; - propInfo["rangeMax"] = rangeLimits.y; - } - - propertiesArray.Add(propInfo); - } - - // Build render queue info - string renderQueueName = "Custom"; - int renderQueue = material.renderQueue; - if (renderQueue <= 2000) renderQueueName = "Background"; - else if (renderQueue <= 2450) renderQueueName = "Geometry"; - else if (renderQueue <= 2500) renderQueueName = "AlphaTest"; - else if (renderQueue <= 3000) renderQueueName = "Transparent"; - else renderQueueName = "Overlay"; - - McpLogger.LogInfo($"[MCP Unity] Retrieved info for material '{material.name}'"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Material info for '{material.name}'", - ["materialName"] = material.name, - ["materialPath"] = materialPath, - ["shaderName"] = shader.name, - ["renderQueue"] = renderQueue, - ["renderQueueCategory"] = renderQueueName, - ["enableInstancing"] = material.enableInstancing, - ["doubleSidedGI"] = material.doubleSidedGI, - ["passCount"] = material.passCount, - ["properties"] = propertiesArray - }; - } - - private JToken GetPropertyValue(Material material, string propName, ShaderPropertyType propType) - { - switch (propType) - { - case ShaderPropertyType.Color: - Color color = material.GetColor(propName); - return new JObject - { - ["r"] = color.r, - ["g"] = color.g, - ["b"] = color.b, - ["a"] = color.a - }; - - case ShaderPropertyType.Vector: - Vector4 vec = material.GetVector(propName); - return new JObject - { - ["x"] = vec.x, - ["y"] = vec.y, - ["z"] = vec.z, - ["w"] = vec.w - }; - - case ShaderPropertyType.Float: - case ShaderPropertyType.Range: - return material.GetFloat(propName); - - case ShaderPropertyType.Texture: - Texture tex = material.GetTexture(propName); - if (tex != null) - { - return AssetDatabase.GetAssetPath(tex); - } - return null; - - case ShaderPropertyType.Int: - return material.GetInt(propName); - - default: - return null; - } - } - } -} diff --git a/Editor/Tools/McpToolBase.cs b/Editor/Tools/McpToolBase.cs deleted file mode 100644 index 700033e3..00000000 --- a/Editor/Tools/McpToolBase.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using UnityEngine; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Base class for MCP Unity tools that interact with the Unity Editor - /// - public abstract class McpToolBase - { - /// - /// The name of the tool as used in API calls - /// - public string Name { get; protected set; } - - /// - /// Description of the tool's functionality - /// - public string Description { get; protected set; } - - /// - /// Flag indicating if the tool executes asynchronously on the main thread. - /// If true, ExecuteAsync should be overridden. - /// If false, Execute should be overridden. - /// - public bool IsAsync { get; protected set; } = false; - - /// - /// Execute the tool asynchronously with the provided parameters. - /// This should be overridden by tools that need to run on the Unity main thread - /// or perform long-running operations without blocking the WebSocket handler. - /// - /// Tool parameters as a JObject - /// TaskCompletionSource to set the result or exception of the execution - public virtual void ExecuteAsync(JObject parameters, TaskCompletionSource tcs) - { - // Default implementation for tools that don't override this. - // Indicate that this method should have been overridden if IsAsync is true. - tcs.TrySetException(new NotImplementedException("ExecuteAsync must be overridden if IsAsync is true.")); - } - - /// - /// Execute the tool synchronously with the provided parameters. - /// This should be overridden by tools that can execute quickly and directly - /// within the WebSocket message handler thread. - /// - /// Tool parameters as a JObject - /// The result of the tool execution as a JObject, or an error JObject - public virtual JObject Execute(JObject parameters) - { - // Default implementation for tools that don't override this. - // Indicate that this method should have been overridden if IsAsync is false. - return McpUnity.Unity.McpUnitySocketHandler.CreateErrorResponse( - "Execute must be overridden if IsAsync is false.", - "implementation_error" - ); - } - } -} diff --git a/Editor/Tools/McpToolBase.cs.meta b/Editor/Tools/McpToolBase.cs.meta deleted file mode 100644 index a3d089b8..00000000 --- a/Editor/Tools/McpToolBase.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a66a1809c0e92834781bc2b80f061958 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/MenuItemTool.cs b/Editor/Tools/MenuItemTool.cs deleted file mode 100644 index d818d745..00000000 --- a/Editor/Tools/MenuItemTool.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Threading.Tasks; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for executing Unity Editor menu items - /// - public class MenuItemTool : McpToolBase - { - public MenuItemTool() - { - Name = "execute_menu_item"; - Description = "Executes functions tagged with the MenuItem attribute"; - } - - /// - /// Execute the MenuItem tool with the provided parameters synchronously - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters with defaults - string menuPath = parameters["menuPath"]?.ToObject(); - if (string.IsNullOrEmpty(menuPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'menuPath' not provided", - "validation_error" - ); - } - - // Log the execution - McpLogger.LogInfo($"[MCP Unity] Executing menu item: {menuPath}"); - - // Execute the menu item - bool success = EditorApplication.ExecuteMenuItem(menuPath); - - // Create the response - return new JObject - { - ["success"] = success, - ["type"] = "text", - ["message"] = success - ? $"Successfully executed menu item: {menuPath}" - : $"Failed to execute menu item: {menuPath}" - }; - } - } -} diff --git a/Editor/Tools/MenuItemTool.cs.meta b/Editor/Tools/MenuItemTool.cs.meta deleted file mode 100644 index 68112ec4..00000000 --- a/Editor/Tools/MenuItemTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 873e30cee2cfdaf4e99fc088be3d00d6 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/RecompileScriptsTool.cs b/Editor/Tools/RecompileScriptsTool.cs deleted file mode 100644 index 5ea87ab0..00000000 --- a/Editor/Tools/RecompileScriptsTool.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using McpUnity.Utils; -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.Compilation; -using UnityEngine; - -namespace McpUnity.Tools { - /// - /// Tool to recompile all scripts in the Unity project - /// - public class RecompileScriptsTool : McpToolBase - { - private class CompilationRequest - { - public readonly bool ReturnWithLogs; - public readonly int LogsLimit; - public readonly TaskCompletionSource CompletionSource; - - public CompilationRequest(bool returnWithLogs, int logsLimit, TaskCompletionSource completionSource) - { - ReturnWithLogs = returnWithLogs; - LogsLimit = logsLimit; - CompletionSource = completionSource; - } - } - - private class CompilationResult - { - public readonly List SortedLogs; - public readonly int WarningsCount; - public readonly int ErrorsCount; - - public bool HasErrors => ErrorsCount > 0; - - public CompilationResult(List sortedLogs, int warningsCount, int errorsCount) - { - SortedLogs = sortedLogs; - WarningsCount = warningsCount; - ErrorsCount = errorsCount; - } - } - - private readonly List _pendingRequests = new List(); - private readonly List _compilationLogs = new List(); - private int _processedAssemblies = 0; - - public RecompileScriptsTool() - { - Name = "recompile_scripts"; - Description = "Recompiles all scripts in the Unity project"; - IsAsync = true; // Compilation is asynchronous - } - - /// - /// Execute the Recompile tool asynchronously - /// - /// Tool parameters as a JObject - /// TaskCompletionSource to set the result or exception - public override void ExecuteAsync(JObject parameters, TaskCompletionSource tcs) - { - // Extract and store parameters - var returnWithLogs = GetBoolParameter(parameters, "returnWithLogs", true); - var logsLimit = Mathf.Clamp(GetIntParameter(parameters, "logsLimit", 100), 0, 1000); - var request = new CompilationRequest(returnWithLogs, logsLimit, tcs); - - bool hasActiveRequest = false; - lock (_pendingRequests) - { - hasActiveRequest = _pendingRequests.Count > 0; - _pendingRequests.Add(request); - } - - if (hasActiveRequest) - { - McpLogger.LogInfo("Recompilation already in progress. Waiting for completion..."); - return; - } - - // On first request, initialize compilation listeners and start compilation - StartCompilationTracking(); - - if (EditorApplication.isCompiling == false) - { - McpLogger.LogInfo("Recompiling all scripts in the Unity project"); - CompilationPipeline.RequestScriptCompilation(); - } - } - - /// - /// Subscribe to compilation events, reset tracked state - /// - private void StartCompilationTracking() - { - _compilationLogs.Clear(); - _processedAssemblies = 0; - CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished; - CompilationPipeline.compilationFinished += OnCompilationFinished; - } - - /// - /// Unsubscribe from compilation events - /// - private void StopCompilationTracking() - { - CompilationPipeline.assemblyCompilationFinished -= OnAssemblyCompilationFinished; - CompilationPipeline.compilationFinished -= OnCompilationFinished; - } - - /// - /// Record compilation logs for every single assembly - /// - private void OnAssemblyCompilationFinished(string assemblyPath, CompilerMessage[] messages) - { - _processedAssemblies++; - _compilationLogs.AddRange(messages); - } - - /// - /// Stop tracking and complete all pending requests - /// - private void OnCompilationFinished(object _) - { - McpLogger.LogInfo($"Recompilation completed. Processed {_processedAssemblies} assemblies with {_compilationLogs.Count} compiler messages"); - - // Sort logs by type: first errors, then warnings and info - List sortedLogs = _compilationLogs.OrderBy(x => x.type).ToList(); - int errorsCount = _compilationLogs.Count(l => l.type == CompilerMessageType.Error); - int warningsCount = _compilationLogs.Count(l => l.type == CompilerMessageType.Warning); - CompilationResult result = new CompilationResult(sortedLogs, warningsCount, errorsCount); - - // Stop tracking before completing requests - StopCompilationTracking(); - - // Complete all requests received before compilation end, the next received request will start a new compilation - List requestsToComplete = new List(); - - lock (_pendingRequests) - { - requestsToComplete.AddRange(_pendingRequests); - _pendingRequests.Clear(); - } - - foreach (var request in requestsToComplete) - { - CompleteRequest(request, result); - } - } - - /// - /// Process a completed compilation request - /// - private static void CompleteRequest(CompilationRequest request, CompilationResult result) - { - JArray logsArray = new JArray(); - IEnumerable logsToReturn = request.ReturnWithLogs ? result.SortedLogs.Take(request.LogsLimit) : Enumerable.Empty(); - - foreach (var message in logsToReturn) - { - var logObject = new JObject - { - ["message"] = message.message, - ["type"] = message.type.ToString() - }; - - // Add file information if available - if (!string.IsNullOrEmpty(message.file)) - { - logObject["file"] = message.file; - logObject["line"] = message.line; - logObject["column"] = message.column; - } - - logsArray.Add(logObject); - } - - string summaryMessage = result.HasErrors - ? $"Recompilation completed with {result.ErrorsCount} error(s) and {result.WarningsCount} warning(s)" - : $"Successfully recompiled all scripts with {result.WarningsCount} warning(s)"; - - summaryMessage += $" (returnWithLogs: {request.ReturnWithLogs}, logsLimit: {request.LogsLimit})"; - - var response = new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = summaryMessage, - ["logs"] = logsArray - }; - - request.CompletionSource.SetResult(response); - } - - /// - /// Helper method to safely extract integer parameters with default values - /// - /// JObject containing parameters - /// Parameter key to extract - /// Default value if parameter is missing or invalid - /// Extracted integer value or default - private static int GetIntParameter(JObject parameters, string key, int defaultValue) - { - if (parameters?[key] != null && int.TryParse(parameters[key].ToString(), out int value)) - return value; - return defaultValue; - } - - /// - /// Helper method to safely extract boolean parameters with default values - /// - /// JObject containing parameters - /// Parameter key to extract - /// Default value if parameter is missing or invalid - /// Extracted boolean value or default - private static bool GetBoolParameter(JObject parameters, string key, bool defaultValue) - { - if (parameters?[key] != null && bool.TryParse(parameters[key].ToString(), out bool value)) - return value; - return defaultValue; - } - } -} \ No newline at end of file diff --git a/Editor/Tools/RecompileScriptsTool.cs.meta b/Editor/Tools/RecompileScriptsTool.cs.meta deleted file mode 100644 index 98c53a52..00000000 --- a/Editor/Tools/RecompileScriptsTool.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 9db4c0c982944b9da2e14745cf799f99 -timeCreated: 1758591273 \ No newline at end of file diff --git a/Editor/Tools/RunTestsTool.cs b/Editor/Tools/RunTestsTool.cs deleted file mode 100644 index d8997194..00000000 --- a/Editor/Tools/RunTestsTool.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using McpUnity.Unity; -using UnityEngine; -using Newtonsoft.Json.Linq; -using System.Collections.Generic; -using UnityEditor.TestTools.TestRunner.Api; -using McpUnity.Services; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for running Unity Test Runner tests - /// - public class RunTestsTool : McpToolBase - { - private readonly ITestRunnerService _testRunnerService; - - public RunTestsTool(ITestRunnerService testRunnerService) - { - Name = "run_tests"; - Description = "Runs tests using Unity's Test Runner"; - IsAsync = true; - _testRunnerService = testRunnerService; - } - - /// - /// Executes the RunTests tool asynchronously on the main thread. - /// - /// Tool parameters, including optional 'testMode' and 'testFilter'. - /// TaskCompletionSource to set the result or exception. - public override async void ExecuteAsync(JObject parameters, TaskCompletionSource tcs) - { - // Parse parameters - string testModeStr = parameters?["testMode"]?.ToObject() ?? "EditMode"; - string testFilter = parameters?["testFilter"]?.ToObject(); // Optional - bool returnOnlyFailures = parameters?["returnOnlyFailures"]?.ToObject() ?? false; // Optional - bool returnWithLogs = parameters?["returnWithLogs"]?.ToObject() ?? false; // Optional - - TestMode testMode = TestMode.EditMode; - - if (Enum.TryParse(testModeStr, true, out TestMode parsedMode)) - { - testMode = parsedMode; - } - - McpLogger.LogInfo($"Executing RunTestsTool: Mode={testMode}, Filter={testFilter ?? "(none)"}"); - - // Call the service to run tests - JObject result = await _testRunnerService.ExecuteTestsAsync(testMode, returnOnlyFailures, returnWithLogs, testFilter); - tcs.SetResult(result); - } - } -} diff --git a/Editor/Tools/RunTestsTool.cs.meta b/Editor/Tools/RunTestsTool.cs.meta deleted file mode 100644 index 1c172328..00000000 --- a/Editor/Tools/RunTestsTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 2927ec83161e2f34cb555e0521b7bb1e -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/SaveSceneTool.cs b/Editor/Tools/SaveSceneTool.cs deleted file mode 100644 index c5e43ada..00000000 --- a/Editor/Tools/SaveSceneTool.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using UnityEditor; -using UnityEditor.SceneManagement; -using UnityEngine.SceneManagement; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for saving a Unity scene - /// - public class SaveSceneTool : McpToolBase - { - public SaveSceneTool() - { - Name = "save_scene"; - Description = "Saves the current active scene. Optionally saves to a new path (Save As)"; - } - - /// - /// Execute the SaveScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - string scenePath = parameters["scenePath"]?.ToObject(); - bool saveAs = parameters["saveAs"]?.ToObject() ?? false; - - try - { - Scene activeScene = SceneManager.GetActiveScene(); - - if (!activeScene.IsValid()) - { - return McpUnitySocketHandler.CreateErrorResponse( - "No valid active scene to save", - "validation_error" - ); - } - - string targetPath; - - if (saveAs || !string.IsNullOrEmpty(scenePath)) - { - // Save As mode - need a path - if (string.IsNullOrEmpty(scenePath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Parameter 'scenePath' is required when 'saveAs' is true", - "validation_error" - ); - } - - // Ensure the path has .unity extension - if (!scenePath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase)) - { - scenePath += ".unity"; - } - - // Ensure the path starts with Assets/ - if (!scenePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - scenePath = "Assets/" + scenePath; - } - - // Ensure the directory exists - string directory = System.IO.Path.GetDirectoryName(scenePath); - if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory)) - { - CreateFolderHierarchy(directory); - } - - targetPath = scenePath; - } - else - { - // Save to current path - targetPath = activeScene.path; - - if (string.IsNullOrEmpty(targetPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Scene has no path. Use 'scenePath' parameter to specify where to save the scene", - "validation_error" - ); - } - } - - bool saved = EditorSceneManager.SaveScene(activeScene, targetPath); - - if (!saved) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to save scene to '{targetPath}'", - "save_error" - ); - } - - AssetDatabase.Refresh(); - - McpLogger.LogInfo($"Saved scene '{activeScene.name}' to path '{targetPath}'"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully saved scene '{activeScene.name}' to '{targetPath}'", - ["scenePath"] = targetPath, - ["sceneName"] = activeScene.name - }; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error saving scene: {ex.Message}", - "scene_save_error" - ); - } - } - - /// - /// Creates folder hierarchy for the given path - /// - private void CreateFolderHierarchy(string 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; - } - } - } -} diff --git a/Editor/Tools/SaveSceneTool.cs.meta b/Editor/Tools/SaveSceneTool.cs.meta deleted file mode 100644 index 0e1b2dd3..00000000 --- a/Editor/Tools/SaveSceneTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 40d0c05b18b8d411f829c4b845ee98e0 \ No newline at end of file diff --git a/Editor/Tools/SelectGameObjectTool.cs b/Editor/Tools/SelectGameObjectTool.cs deleted file mode 100644 index 2c14055c..00000000 --- a/Editor/Tools/SelectGameObjectTool.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Threading.Tasks; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for selecting GameObjects in the Unity Editor - /// - public class SelectGameObjectTool : McpToolBase - { - public SelectGameObjectTool() - { - Name = "select_gameobject"; - Description = "Sets the selected GameObject in the Unity editor by path, name or instance ID"; - } - - /// - /// Execute the SelectGameObject tool with the provided parameters synchronously - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters - string objectPath = parameters["objectPath"]?.ToObject(); - string objectName = parameters["objectName"]?.ToObject(); - int? instanceId = parameters["instanceId"]?.ToObject(); - GameObject selectedGameObject = null; - - // Validate parameters - require either objectPath or instanceId - if (string.IsNullOrEmpty(objectPath) && string.IsNullOrEmpty(objectName) && !instanceId.HasValue) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'objectPath', 'objectName' or 'instanceId' not provided", - "validation_error" - ); - } - - // First try to find by instance ID if provided - if (instanceId.HasValue) - { - selectedGameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - } - else if (!string.IsNullOrEmpty(objectPath)) - { - // Try to find the object by path in the hierarchy - selectedGameObject = GameObject.Find(objectPath); - } - else - { - // Try to find the object by name in the hierarchy - selectedGameObject = GameObject.Find(objectName); - } - - Selection.activeGameObject = selectedGameObject; - - // Ping the selected object - EditorGUIUtility.PingObject(selectedGameObject); - - McpLogger.LogInfo($"[MCP Unity] Selected GameObject: {selectedGameObject?.name}"); - - // Create the response - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully selected GameObject {selectedGameObject?.name}" - }; - } - } -} diff --git a/Editor/Tools/SelectGameObjectTool.cs.meta b/Editor/Tools/SelectGameObjectTool.cs.meta deleted file mode 100644 index 7dfa99ff..00000000 --- a/Editor/Tools/SelectGameObjectTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9165cb1f07ae1a34cbcdb180f757a767 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/SendConsoleLogTool.cs b/Editor/Tools/SendConsoleLogTool.cs deleted file mode 100644 index 25757fc0..00000000 --- a/Editor/Tools/SendConsoleLogTool.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Threading.Tasks; -using McpUnity.Unity; -using UnityEngine; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for sending notification messages to the Unity console - /// - public class SendConsoleLogTool : McpToolBase - { - public SendConsoleLogTool() - { - Name = "send_console_log"; - Description = "Sends a message to the Unity console"; - } - - /// - /// Execute the NotifyMessage tool with the provided parameters synchronously - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters - string message = parameters["message"]?.ToObject(); - string type = parameters["type"]?.ToObject()?.ToLower() ?? "info"; - - if (string.IsNullOrEmpty(message)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'message' not provided", - "validation_error" - ); - } - - // Log the message based on type - switch (type) - { - case "error": - Debug.LogError($"[MCP]: {message}"); - break; - case "warning": - Debug.LogWarning($"[MCP]: {message}"); - break; - default: - Debug.Log($"[MCP]: {message}"); - break; - } - - // Create the response - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Message displayed: {message}" - }; - } - } -} diff --git a/Editor/Tools/SendConsoleLogTool.cs.meta b/Editor/Tools/SendConsoleLogTool.cs.meta deleted file mode 100644 index 59b80e28..00000000 --- a/Editor/Tools/SendConsoleLogTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 35bb1bb57a358064c9ca8f2c8ba3caf2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/SetPlayModeStatusTool.cs b/Editor/Tools/SetPlayModeStatusTool.cs deleted file mode 100644 index f0193c76..00000000 --- a/Editor/Tools/SetPlayModeStatusTool.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using Newtonsoft.Json.Linq; -using UnityEditor; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for controlling Unity play mode (play, pause, step) - /// - public class SetPlayModeStatusTool : McpToolBase - { - public SetPlayModeStatusTool() - { - Name = "set_play_mode_status"; - Description = "Controls Unity play mode. Actions: 'play', 'pause', 'stop', 'step'."; - } - - public override JObject Execute(JObject parameters) - { - try - { - string action = parameters?["action"]?.ToString()?.ToLowerInvariant(); - - if (string.IsNullOrEmpty(action)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Missing required parameter 'action'. Valid actions: 'play', 'pause', 'stop', 'step'", - "missing_parameter" - ); - } - - bool wasPlaying = EditorApplication.isPlaying; - bool wasPaused = EditorApplication.isPaused; - - switch (action) - { - case "play": - if (!EditorApplication.isPlaying) - { - // Start play mode - EditorApplication.isPlaying = true; - } - else if (EditorApplication.isPaused) - { - // Unpause if already playing - EditorApplication.isPaused = false; - } - break; - - case "pause": - if (EditorApplication.isPlaying) - { - EditorApplication.isPaused = !EditorApplication.isPaused; - } - else - { - return McpUnitySocketHandler.CreateErrorResponse( - "Cannot pause: Editor is not in play mode", - "invalid_state" - ); - } - break; - - case "stop": - if (EditorApplication.isPlaying) - { - EditorApplication.isPlaying = false; - } - break; - - case "step": - if (EditorApplication.isPlaying) - { - EditorApplication.Step(); - } - else - { - return McpUnitySocketHandler.CreateErrorResponse( - "Cannot step: Editor is not in play mode", - "invalid_state" - ); - } - break; - - default: - return McpUnitySocketHandler.CreateErrorResponse( - $"Invalid action '{action}'. Valid actions: 'play', 'pause', 'stop', 'step'", - "invalid_parameter" - ); - } - - // Give Unity a moment to update state - bool isPlaying = EditorApplication.isPlaying; - bool isPaused = EditorApplication.isPaused; - - var result = new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Action '{action}' executed. State: {(isPlaying ? (isPaused ? "Playing (paused)" : "Playing") : "Edit mode")}", - ["action"] = action, - ["wasPlaying"] = wasPlaying, - ["wasPaused"] = wasPaused, - ["isPlaying"] = isPlaying, - ["isPaused"] = isPaused - }; - - McpLogger.LogInfo($"Play mode action '{action}' executed. isPlaying={isPlaying}, isPaused={isPaused}"); - - return result; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error controlling play mode: {ex.Message}", - "play_mode_error" - ); - } - } - } -} diff --git a/Editor/Tools/SetPlayModeStatusTool.cs.meta b/Editor/Tools/SetPlayModeStatusTool.cs.meta deleted file mode 100644 index e3561552..00000000 --- a/Editor/Tools/SetPlayModeStatusTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8c3f7e9a2d4b1c5e8f9a0b1c2d3e4f5a diff --git a/Editor/Tools/TransformTools.cs b/Editor/Tools/TransformTools.cs deleted file mode 100644 index a9112edd..00000000 --- a/Editor/Tools/TransformTools.cs +++ /dev/null @@ -1,484 +0,0 @@ -using UnityEngine; -using UnityEditor; -using McpUnity.Unity; -using McpUnity.Utils; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for moving a GameObject's position in the Unity Editor. - /// Supports world/local space and absolute/relative positioning. - /// - public class MoveGameObjectTool : McpToolBase - { - public MoveGameObjectTool() - { - Name = "move_gameobject"; - Description = "Moves a GameObject to a new position. Supports world/local space and absolute/relative positioning."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - // Find the GameObject - var findResult = TransformToolUtils.FindGameObject(parameters); - if (findResult.Error != null) - return findResult.Error; - - GameObject gameObject = findResult.GameObject; - Transform transform = gameObject.transform; - - // Extract position - JObject positionObj = parameters["position"] as JObject; - if (positionObj == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'position' not provided", - "validation_error" - ); - } - - Vector3 position = new Vector3( - positionObj["x"]?.ToObject() ?? 0f, - positionObj["y"]?.ToObject() ?? 0f, - positionObj["z"]?.ToObject() ?? 0f - ); - - // Get space and relative flags - string space = parameters["space"]?.ToObject() ?? "world"; - bool relative = parameters["relative"]?.ToObject() ?? false; - - // Record undo - Undo.RecordObject(transform, "Move GameObject"); - - // Apply the position change - if (space.ToLower() == "local") - { - if (relative) - transform.localPosition += position; - else - transform.localPosition = position; - } - else // world space - { - if (relative) - transform.position += position; - else - transform.position = position; - } - - EditorUtility.SetDirty(gameObject); - - // Return result with new position - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{gameObject.name}' moved successfully.", - ["instanceId"] = UnityObjectId.GetObjectId(gameObject), - ["name"] = gameObject.name, - ["path"] = TransformToolUtils.GetGameObjectPath(gameObject), - ["position"] = new JObject - { - ["world"] = new JObject - { - ["x"] = transform.position.x, - ["y"] = transform.position.y, - ["z"] = transform.position.z - }, - ["local"] = new JObject - { - ["x"] = transform.localPosition.x, - ["y"] = transform.localPosition.y, - ["z"] = transform.localPosition.z - } - } - }; - } - } - - /// - /// Tool for rotating a GameObject in the Unity Editor. - /// Supports world/local space and absolute/relative rotation using Euler angles. - /// - public class RotateGameObjectTool : McpToolBase - { - public RotateGameObjectTool() - { - Name = "rotate_gameobject"; - Description = "Rotates a GameObject using Euler angles. Supports world/local space and absolute/relative rotation."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - // Find the GameObject - var findResult = TransformToolUtils.FindGameObject(parameters); - if (findResult.Error != null) - return findResult.Error; - - GameObject gameObject = findResult.GameObject; - Transform transform = gameObject.transform; - - // Extract rotation (Euler angles) - JObject rotationObj = parameters["rotation"] as JObject; - if (rotationObj == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'rotation' not provided", - "validation_error" - ); - } - - Vector3 eulerAngles = new Vector3( - rotationObj["x"]?.ToObject() ?? 0f, - rotationObj["y"]?.ToObject() ?? 0f, - rotationObj["z"]?.ToObject() ?? 0f - ); - - // Get space and relative flags - string space = parameters["space"]?.ToObject() ?? "world"; - bool relative = parameters["relative"]?.ToObject() ?? false; - - // Record undo - Undo.RecordObject(transform, "Rotate GameObject"); - - // Apply the rotation - if (relative) - { - // Relative rotation - add to current rotation - Space unitySpace = space.ToLower() == "local" ? Space.Self : Space.World; - transform.Rotate(eulerAngles, unitySpace); - } - else - { - // Absolute rotation - set directly - if (space.ToLower() == "local") - transform.localEulerAngles = eulerAngles; - else - transform.eulerAngles = eulerAngles; - } - - EditorUtility.SetDirty(gameObject); - - // Return result with new rotation - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{gameObject.name}' rotated successfully.", - ["instanceId"] = UnityObjectId.GetObjectId(gameObject), - ["name"] = gameObject.name, - ["path"] = TransformToolUtils.GetGameObjectPath(gameObject), - ["rotation"] = new JObject - { - ["world"] = new JObject - { - ["x"] = transform.eulerAngles.x, - ["y"] = transform.eulerAngles.y, - ["z"] = transform.eulerAngles.z - }, - ["local"] = new JObject - { - ["x"] = transform.localEulerAngles.x, - ["y"] = transform.localEulerAngles.y, - ["z"] = transform.localEulerAngles.z - } - } - }; - } - } - - /// - /// Tool for scaling a GameObject in the Unity Editor. - /// Supports absolute and relative (multiplicative) scaling. - /// - public class ScaleGameObjectTool : McpToolBase - { - public ScaleGameObjectTool() - { - Name = "scale_gameobject"; - Description = "Scales a GameObject. Supports absolute and relative (multiplicative) scaling."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - // Find the GameObject - var findResult = TransformToolUtils.FindGameObject(parameters); - if (findResult.Error != null) - return findResult.Error; - - GameObject gameObject = findResult.GameObject; - Transform transform = gameObject.transform; - - // Extract scale - JObject scaleObj = parameters["scale"] as JObject; - if (scaleObj == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'scale' not provided", - "validation_error" - ); - } - - Vector3 scale = new Vector3( - scaleObj["x"]?.ToObject() ?? 1f, - scaleObj["y"]?.ToObject() ?? 1f, - scaleObj["z"]?.ToObject() ?? 1f - ); - - // Get relative flag - bool relative = parameters["relative"]?.ToObject() ?? false; - - // Record undo - Undo.RecordObject(transform, "Scale GameObject"); - - // Apply the scale - if (relative) - { - // Relative scale - multiply current scale - transform.localScale = Vector3.Scale(transform.localScale, scale); - } - else - { - // Absolute scale - set directly - transform.localScale = scale; - } - - EditorUtility.SetDirty(gameObject); - - // Return result with new scale - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{gameObject.name}' scaled successfully.", - ["instanceId"] = UnityObjectId.GetObjectId(gameObject), - ["name"] = gameObject.name, - ["path"] = TransformToolUtils.GetGameObjectPath(gameObject), - ["scale"] = new JObject - { - ["x"] = transform.localScale.x, - ["y"] = transform.localScale.y, - ["z"] = transform.localScale.z - } - }; - } - } - - /// - /// Tool for setting a GameObject's full transform (position, rotation, scale) in one operation. - /// All parameters are optional - only provided values will be changed. - /// - public class SetTransformTool : McpToolBase - { - public SetTransformTool() - { - Name = "set_transform"; - Description = "Sets a GameObject's transform (position, rotation, scale) in one operation. All transform properties are optional."; - IsAsync = false; - } - - public override JObject Execute(JObject parameters) - { - // Find the GameObject - var findResult = TransformToolUtils.FindGameObject(parameters); - if (findResult.Error != null) - return findResult.Error; - - GameObject gameObject = findResult.GameObject; - Transform transform = gameObject.transform; - - // Check that at least one transform property is provided - JObject positionObj = parameters["position"] as JObject; - JObject rotationObj = parameters["rotation"] as JObject; - JObject scaleObj = parameters["scale"] as JObject; - - if (positionObj == null && rotationObj == null && scaleObj == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - "At least one of 'position', 'rotation', or 'scale' must be provided", - "validation_error" - ); - } - - // Get space flag (applies to position and rotation) - string space = parameters["space"]?.ToObject() ?? "world"; - bool isLocal = space.ToLower() == "local"; - - // Record undo - Undo.RecordObject(transform, "Set Transform"); - - // Apply position if provided - if (positionObj != null) - { - Vector3 position = new Vector3( - positionObj["x"]?.ToObject() ?? (isLocal ? transform.localPosition.x : transform.position.x), - positionObj["y"]?.ToObject() ?? (isLocal ? transform.localPosition.y : transform.position.y), - positionObj["z"]?.ToObject() ?? (isLocal ? transform.localPosition.z : transform.position.z) - ); - - if (isLocal) - transform.localPosition = position; - else - transform.position = position; - } - - // Apply rotation if provided - if (rotationObj != null) - { - Vector3 eulerAngles = new Vector3( - rotationObj["x"]?.ToObject() ?? (isLocal ? transform.localEulerAngles.x : transform.eulerAngles.x), - rotationObj["y"]?.ToObject() ?? (isLocal ? transform.localEulerAngles.y : transform.eulerAngles.y), - rotationObj["z"]?.ToObject() ?? (isLocal ? transform.localEulerAngles.z : transform.eulerAngles.z) - ); - - if (isLocal) - transform.localEulerAngles = eulerAngles; - else - transform.eulerAngles = eulerAngles; - } - - // Apply scale if provided - if (scaleObj != null) - { - Vector3 scale = new Vector3( - scaleObj["x"]?.ToObject() ?? transform.localScale.x, - scaleObj["y"]?.ToObject() ?? transform.localScale.y, - scaleObj["z"]?.ToObject() ?? transform.localScale.z - ); - transform.localScale = scale; - } - - EditorUtility.SetDirty(gameObject); - - // Return result with full transform data - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"GameObject '{gameObject.name}' transform updated successfully.", - ["instanceId"] = UnityObjectId.GetObjectId(gameObject), - ["name"] = gameObject.name, - ["path"] = TransformToolUtils.GetGameObjectPath(gameObject), - ["transform"] = new JObject - { - ["position"] = new JObject - { - ["world"] = new JObject - { - ["x"] = transform.position.x, - ["y"] = transform.position.y, - ["z"] = transform.position.z - }, - ["local"] = new JObject - { - ["x"] = transform.localPosition.x, - ["y"] = transform.localPosition.y, - ["z"] = transform.localPosition.z - } - }, - ["rotation"] = new JObject - { - ["world"] = new JObject - { - ["x"] = transform.eulerAngles.x, - ["y"] = transform.eulerAngles.y, - ["z"] = transform.eulerAngles.z - }, - ["local"] = new JObject - { - ["x"] = transform.localEulerAngles.x, - ["y"] = transform.localEulerAngles.y, - ["z"] = transform.localEulerAngles.z - } - }, - ["scale"] = new JObject - { - ["x"] = transform.localScale.x, - ["y"] = transform.localScale.y, - ["z"] = transform.localScale.z - } - } - }; - } - } - - /// - /// Utility class for common transform tool operations - /// - internal static class TransformToolUtils - { - /// - /// Result of finding a GameObject - /// - public struct FindResult - { - public GameObject GameObject; - public JObject Error; - } - - /// - /// Find a GameObject by instanceId or objectPath from parameters - /// - public static FindResult FindGameObject(JObject parameters) - { - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - - GameObject gameObject = null; - string identifierInfo = ""; - - if (instanceId.HasValue) - { - gameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - identifierInfo = $"instance ID {instanceId.Value}"; - } - else if (!string.IsNullOrEmpty(objectPath)) - { - gameObject = GameObject.Find(objectPath); - identifierInfo = $"path '{objectPath}'"; - } - else - { - return new FindResult - { - Error = McpUnitySocketHandler.CreateErrorResponse( - "Either 'instanceId' or 'objectPath' must be provided", - "validation_error" - ) - }; - } - - if (gameObject == null) - { - return new FindResult - { - Error = McpUnitySocketHandler.CreateErrorResponse( - $"GameObject not found with {identifierInfo}", - "not_found_error" - ) - }; - } - - return new FindResult { GameObject = gameObject }; - } - - /// - /// Get the hierarchy path of a GameObject - /// - public static string GetGameObjectPath(GameObject obj) - { - if (obj == null) return null; - string path = "/" + obj.name; - while (obj.transform.parent != null) - { - obj = obj.transform.parent.gameObject; - path = "/" + obj.name + path; - } - return path; - } - } -} diff --git a/Editor/Tools/TransformTools.cs.meta b/Editor/Tools/TransformTools.cs.meta deleted file mode 100644 index 521c9a41..00000000 --- a/Editor/Tools/TransformTools.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 45ce397497b614ef2ba5df0d2031e3c9 \ No newline at end of file diff --git a/Editor/Tools/UnloadSceneTool.cs b/Editor/Tools/UnloadSceneTool.cs deleted file mode 100644 index fd3342f3..00000000 --- a/Editor/Tools/UnloadSceneTool.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using UnityEditor; -using UnityEditor.SceneManagement; -using UnityEngine.SceneManagement; -using Newtonsoft.Json.Linq; -using McpUnity.Unity; -using McpUnity.Utils; - -namespace McpUnity.Tools -{ - /// - /// Tool for unloading a Unity scene (without deleting the asset) - /// - public class UnloadSceneTool : McpToolBase - { - public UnloadSceneTool() - { - Name = "unload_scene"; - Description = "Unloads a scene by path or name (does not delete the scene asset, just closes it from the hierarchy)"; - } - - /// - /// Execute the UnloadScene tool with the provided parameters - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - string scenePath = parameters["scenePath"]?.ToObject(); - string sceneName = parameters["sceneName"]?.ToObject(); - bool removeScene = parameters["removeScene"]?.ToObject() ?? true; - - if (string.IsNullOrEmpty(scenePath) && string.IsNullOrEmpty(sceneName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Provide either 'scenePath' or 'sceneName'", - "validation_error" - ); - } - - try - { - Scene sceneToUnload; - - if (!string.IsNullOrEmpty(scenePath)) - { - sceneToUnload = SceneManager.GetSceneByPath(scenePath); - } - else - { - sceneToUnload = SceneManager.GetSceneByName(sceneName); - } - - if (!sceneToUnload.IsValid()) - { - string identifier = !string.IsNullOrEmpty(scenePath) ? $"path '{scenePath}'" : $"name '{sceneName}'"; - return McpUnitySocketHandler.CreateErrorResponse( - $"Scene with {identifier} is not currently loaded", - "not_found_error" - ); - } - - // Check if this is the only loaded scene - if (SceneManager.sceneCount <= 1) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Cannot unload the only loaded scene. Load another scene first or create a new scene", - "validation_error" - ); - } - - string unloadedSceneName = sceneToUnload.name; - string unloadedScenePath = sceneToUnload.path; - bool wasDirty = sceneToUnload.isDirty; - - // If scene has unsaved changes, save it first - if (wasDirty) - { - bool savePrompt = parameters["saveIfDirty"]?.ToObject() ?? true; - if (savePrompt && !string.IsNullOrEmpty(unloadedScenePath)) - { - EditorSceneManager.SaveScene(sceneToUnload); - } - } - - // Close/unload the scene - bool success = EditorSceneManager.CloseScene(sceneToUnload, removeScene); - - if (!success) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Failed to unload scene '{unloadedSceneName}'", - "unload_error" - ); - } - - McpLogger.LogInfo($"Unloaded scene '{unloadedSceneName}' (path: '{unloadedScenePath}')"); - - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully unloaded scene '{unloadedSceneName}'", - ["sceneName"] = unloadedSceneName, - ["scenePath"] = unloadedScenePath, - ["wasDirty"] = wasDirty, - ["removed"] = removeScene - }; - } - catch (Exception ex) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Error unloading scene: {ex.Message}", - "scene_unload_error" - ); - } - } - } -} diff --git a/Editor/Tools/UnloadSceneTool.cs.meta b/Editor/Tools/UnloadSceneTool.cs.meta deleted file mode 100644 index 3f8eecd9..00000000 --- a/Editor/Tools/UnloadSceneTool.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 74d85884c08c2427ea9a470bf0ab6bae \ No newline at end of file diff --git a/Editor/Tools/UpdateComponentTool.cs b/Editor/Tools/UpdateComponentTool.cs deleted file mode 100644 index 8e019881..00000000 --- a/Editor/Tools/UpdateComponentTool.cs +++ /dev/null @@ -1,858 +0,0 @@ -using System; -using System.Reflection; -using McpUnity.Unity; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Tools -{ - /// - /// Tool for updating component data in the Unity Editor - /// - public class UpdateComponentTool : McpToolBase - { - public UpdateComponentTool() - { - Name = "update_component"; - Description = "Updates component fields on a GameObject or adds it to the GameObject if it does not contain the component"; - } - - /// - /// Execute the UpdateComponent tool with the provided parameters synchronously - /// - /// Tool parameters as a JObject - public override JObject Execute(JObject parameters) - { - // Extract parameters - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - string componentName = parameters["componentName"]?.ToObject(); - JObject componentData = parameters["componentData"] as JObject; - - // Validate parameters - require either instanceId or objectPath - if (!instanceId.HasValue && string.IsNullOrEmpty(objectPath)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Either 'instanceId' or 'objectPath' must be provided", - "validation_error" - ); - } - - if (string.IsNullOrEmpty(componentName)) - { - return McpUnitySocketHandler.CreateErrorResponse( - "Required parameter 'componentName' not provided", - "validation_error" - ); - } - - // Find the GameObject by instance ID or path - GameObject gameObject = null; - string identifier = "unknown"; - - if (instanceId.HasValue) - { - gameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - identifier = $"ID {instanceId.Value}"; - } - else - { - // Find by path - gameObject = GameObject.Find(objectPath); - identifier = $"path '{objectPath}'"; - - if (gameObject == null) - { - // Try to find using the Unity Scene hierarchy path - gameObject = FindGameObjectByPath(objectPath); - } - } - - if (gameObject == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"GameObject with path '{objectPath}' or instance ID {instanceId} not found", - "not_found_error" - ); - } - - McpLogger.LogInfo($"[MCP Unity] Updating component '{componentName}' on GameObject '{gameObject.name}' (found by {identifier})"); - - // Try to find the component by name - Component component = gameObject.GetComponent(componentName); - - // If component not found, try to add it - if (component == null) - { - Type componentType = FindComponentType(componentName); - if (componentType == null) - { - return McpUnitySocketHandler.CreateErrorResponse( - $"Component type '{componentName}' not found in Unity", - "component_error" - ); - } - - component = Undo.AddComponent(gameObject, componentType); - - // Ensure changes are saved - EditorUtility.SetDirty(gameObject); - if (PrefabUtility.IsPartOfAnyPrefab(gameObject)) - { - PrefabUtility.RecordPrefabInstancePropertyModifications(component); - } - - McpLogger.LogInfo($"[MCP Unity] Added component '{componentName}' to GameObject '{gameObject.name}'"); - } - // Update component fields - if (componentData != null && componentData.Count > 0) - { - bool success = UpdateComponentData(component, componentData, out string errorMessage); - // If update failed, return error - if (!success) - { - return McpUnitySocketHandler.CreateErrorResponse(errorMessage, "update_error"); - } - - // Ensure field changes are saved - EditorUtility.SetDirty(gameObject); - if (PrefabUtility.IsPartOfAnyPrefab(gameObject)) - { - PrefabUtility.RecordPrefabInstancePropertyModifications(component); - } - - } - - // Create the response - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = $"Successfully updated component '{componentName}' on GameObject '{gameObject.name}'" - }; - } - - /// - /// Find a GameObject by its hierarchy path - /// - /// The path to the GameObject (e.g. "Canvas/Panel/Button") - /// The GameObject if found, null otherwise - private GameObject FindGameObjectByPath(string path) - { - // Split the path by '/' - string[] pathParts = path.Split('/'); - GameObject[] rootGameObjects = UnityEngine.SceneManagement.SceneManager.GetActiveScene().GetRootGameObjects(); - - // If the path is empty, return null - if (pathParts.Length == 0) - { - return null; - } - - // Search through all root GameObjects in all scenes - foreach (GameObject rootObj in rootGameObjects) - { - if (rootObj.name == pathParts[0]) - { - // Found the root object, now traverse down the path - GameObject current = rootObj; - - // Start from index 1 since we've already matched the root - for (int i = 1; i < pathParts.Length; i++) - { - Transform child = current.transform.Find(pathParts[i]); - if (child == null) - { - // Path segment not found - return null; - } - - // Move to the next level - current = child.gameObject; - } - - // If we got here, we found the full path - return current; - } - } - - // Not found - return null; - } - - /// - /// Find a component type by name - /// - /// The name of the component type - /// The component type, or null if not found - private Type FindComponentType(string componentName) - { - // First try direct match - Type type = Type.GetType(componentName); - if (type != null && typeof(Component).IsAssignableFrom(type)) - { - return type; - } - - // Try common Unity namespaces - string[] commonNamespaces = new string[] - { - "UnityEngine", - "UnityEngine.UI", - "UnityEngine.EventSystems", - "UnityEngine.Animations", - "UnityEngine.Rendering", - "TMPro" - }; - - foreach (string ns in commonNamespaces) - { - type = Type.GetType($"{ns}.{componentName}, UnityEngine"); - if (type != null && typeof(Component).IsAssignableFrom(type)) - { - return type; - } - } - - // Try assemblies search - foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - foreach (Type t in assembly.GetTypes()) - { - if (t.Name == componentName && typeof(Component).IsAssignableFrom(t)) - { - return t; - } - } - } - catch (Exception) - { - // Some assemblies might throw exceptions when getting types - continue; - } - } - - return null; - } - - private FieldInfo GetFieldRecursive(Type type, string fieldName) - { - while (type != null && type != typeof(object)) - { - FieldInfo field = type.GetField(fieldName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); - if (field != null) - { - return field; - } - - type = type.BaseType; - } - - return null; - } - - private PropertyInfo GetPropertyRecursive(Type type, string propertyName) - { - while (type != null && type != typeof(object)) - { - PropertyInfo prop = type.GetProperty(propertyName, - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); - if (prop != null) - { - return prop; - } - - type = type.BaseType; - } - - return null; - } - - /// - /// Update component data based on the provided JObject - /// Uses SerializedObject API as primary method (handles base class fields and nested paths), - /// with reflection as fallback for non-serialized properties. - /// - /// The component to update - /// The data to apply to the component - /// Error message if any fields failed to update - /// True if all fields were updated successfully - private bool UpdateComponentData(Component component, JObject componentData, out string errorMessage) - { - errorMessage = ""; - - if (component == null || componentData == null) - { - errorMessage = "Component or component data is null"; - return false; - } - - Type componentType = component.GetType(); - bool fullSuccess = true; - - // Record object for undo - Undo.RecordObject(component, $"Update {componentType.Name} fields"); - - SerializedObject serializedObject = new SerializedObject(component); - - foreach (var property in componentData.Properties()) - { - string fieldName = property.Name; - JToken fieldValue = property.Value; - - if (string.IsNullOrEmpty(fieldName)) - { - continue; - } - - SerializedProperty serializedProperty = serializedObject.FindProperty(fieldName); - if (serializedProperty != null) - { - if (!TrySetSerializedPropertyValue(serializedProperty, fieldValue, out string setError)) - { - fullSuccess = false; - errorMessage = setError; - McpLogger.LogWarning($"[MCP Unity] {errorMessage}"); - break; - } - - continue; - } - - FieldInfo fieldInfo = GetFieldRecursive(componentType, fieldName); - if (fieldInfo != null) - { - if (!TryConvertJTokenToValue(fieldValue, fieldInfo.FieldType, out object value, out string convertError)) - { - fullSuccess = false; - errorMessage = $"Could not convert field '{fieldName}' on component '{componentType.Name}': {convertError}"; - McpLogger.LogWarning($"[MCP Unity] {errorMessage}"); - break; - } - - fieldInfo.SetValue(component, value); - continue; - } - - PropertyInfo propertyInfo = GetPropertyRecursive(componentType, fieldName); - if (propertyInfo != null) - { - if (!propertyInfo.CanWrite) - { - fullSuccess = false; - errorMessage = $"Property '{fieldName}' on component '{componentType.Name}' is read-only"; - McpLogger.LogWarning($"[MCP Unity] {errorMessage}"); - break; - } - - if (!TryConvertJTokenToValue(fieldValue, propertyInfo.PropertyType, out object value, out string convertError)) - { - fullSuccess = false; - errorMessage = $"Could not convert property '{fieldName}' on component '{componentType.Name}': {convertError}"; - McpLogger.LogWarning($"[MCP Unity] {errorMessage}"); - break; - } - - propertyInfo.SetValue(component, value); - continue; - } - - fullSuccess = false; - errorMessage = $"Field or Property with name '{fieldName}' not found on component '{componentType.Name}'"; - McpLogger.LogWarning($"[MCP Unity] {errorMessage}"); - break; - } - - if (fullSuccess) - { - serializedObject.ApplyModifiedProperties(); - } - - return fullSuccess; - } - - private bool TrySetSerializedPropertyValue(SerializedProperty prop, JToken value, out string errorMessage) - { - errorMessage = ""; - - try - { - switch (prop.propertyType) - { - case SerializedPropertyType.Integer: - prop.intValue = value.ToObject(); - return true; - case SerializedPropertyType.Boolean: - prop.boolValue = value.ToObject(); - return true; - case SerializedPropertyType.Float: - prop.floatValue = value.ToObject(); - return true; - case SerializedPropertyType.String: - prop.stringValue = value.Type == JTokenType.Null ? null : value.ToObject(); - return true; - case SerializedPropertyType.Color: - if (!TryReadColor(value, out Color color, out errorMessage)) return false; - prop.colorValue = color; - return true; - case SerializedPropertyType.Vector2: - if (!TryReadVector2(value, out Vector2 vector2, out errorMessage)) return false; - prop.vector2Value = vector2; - return true; - case SerializedPropertyType.Vector3: - if (!TryReadVector3(value, out Vector3 vector3, out errorMessage)) return false; - prop.vector3Value = vector3; - return true; - case SerializedPropertyType.Vector4: - if (!TryReadVector4(value, out Vector4 vector4, out errorMessage)) return false; - prop.vector4Value = vector4; - return true; - case SerializedPropertyType.Quaternion: - if (!TryReadQuaternion(value, out Quaternion quaternion, out errorMessage)) return false; - prop.quaternionValue = quaternion; - return true; - case SerializedPropertyType.Rect: - if (!TryReadRect(value, out Rect rect, out errorMessage)) return false; - prop.rectValue = rect; - return true; - case SerializedPropertyType.Bounds: - if (!TryReadBounds(value, out Bounds bounds, out errorMessage)) return false; - prop.boundsValue = bounds; - return true; - case SerializedPropertyType.Enum: - return TrySetEnumProperty(prop, value, out errorMessage); - case SerializedPropertyType.ObjectReference: - if (!TryLoadObjectReference(value, typeof(UnityEngine.Object), out UnityEngine.Object asset, out errorMessage)) - { - return false; - } - - prop.objectReferenceValue = asset; - return true; - case SerializedPropertyType.LayerMask: - prop.intValue = value.ToObject(); - return true; - case SerializedPropertyType.Vector2Int: - if (!TryReadVector2Int(value, out Vector2Int vector2Int, out errorMessage)) return false; - prop.vector2IntValue = vector2Int; - return true; - case SerializedPropertyType.Vector3Int: - if (!TryReadVector3Int(value, out Vector3Int vector3Int, out errorMessage)) return false; - prop.vector3IntValue = vector3Int; - return true; - case SerializedPropertyType.RectInt: - if (!TryReadRectInt(value, out RectInt rectInt, out errorMessage)) return false; - prop.rectIntValue = rectInt; - return true; - case SerializedPropertyType.BoundsInt: - if (!TryReadBoundsInt(value, out BoundsInt boundsInt, out errorMessage)) return false; - prop.boundsIntValue = boundsInt; - return true; - case SerializedPropertyType.Generic: - return TrySetGenericProperty(prop, value, out errorMessage); - case SerializedPropertyType.ArraySize: - prop.intValue = value.ToObject(); - return true; - default: - errorMessage = $"Unsupported property type '{prop.propertyType}' for '{prop.propertyPath}'"; - return false; - } - } - catch (Exception ex) - { - errorMessage = $"Could not set '{prop.propertyPath}' ({prop.propertyType}): {ex.Message}"; - return false; - } - } - - /// - /// Convert a JToken to a value of the specified type - /// - /// The JToken to convert - /// The target type to convert to - /// The converted value - private bool TryConvertJTokenToValue(JToken token, Type targetType, out object value, out string errorMessage) - { - value = null; - errorMessage = ""; - - if (token == null || token.Type == JTokenType.Null) - { - if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) - { - errorMessage = $"Cannot assign null to value type '{targetType.Name}'"; - return false; - } - - return true; - } - - // Handle Unity Vector types - if (targetType == typeof(Vector2) && token.Type == JTokenType.Object) - { - bool success = TryReadVector2(token, out Vector2 vector, out errorMessage); - value = vector; - return success; - } - - if (targetType == typeof(Vector3) && token.Type == JTokenType.Object) - { - bool success = TryReadVector3(token, out Vector3 vector, out errorMessage); - value = vector; - return success; - } - - if (targetType == typeof(Vector4) && token.Type == JTokenType.Object) - { - bool success = TryReadVector4(token, out Vector4 vector, out errorMessage); - value = vector; - return success; - } - - if (targetType == typeof(Quaternion) && token.Type == JTokenType.Object) - { - bool success = TryReadQuaternion(token, out Quaternion quaternion, out errorMessage); - value = quaternion; - return success; - } - - if (targetType == typeof(Color) && token.Type == JTokenType.Object) - { - bool success = TryReadColor(token, out Color color, out errorMessage); - value = color; - return success; - } - - if (targetType == typeof(Bounds) && token.Type == JTokenType.Object) - { - bool success = TryReadBounds(token, out Bounds bounds, out errorMessage); - value = bounds; - return success; - } - - if (targetType == typeof(Rect) && token.Type == JTokenType.Object) - { - bool success = TryReadRect(token, out Rect rect, out errorMessage); - value = rect; - return success; - } - - // Handle UnityEngine.Object types (assets) by path or GUID - if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) - { - bool success = TryLoadObjectReference(token, targetType, out UnityEngine.Object asset, out errorMessage); - value = asset; - return success; - } - - // Handle enum types - if (targetType.IsEnum) - { - if (token.Type == JTokenType.String) - { - string enumName = token.ToObject(); - if (Enum.TryParse(targetType, enumName, true, out object result)) - { - value = result; - return true; - } - - if (int.TryParse(enumName, out int enumValue)) - { - value = Enum.ToObject(targetType, enumValue); - return true; - } - - errorMessage = $"'{enumName}' is not a valid value for enum '{targetType.Name}'"; - return false; - } - - if (token.Type == JTokenType.Integer) - { - value = Enum.ToObject(targetType, token.ToObject()); - return true; - } - - errorMessage = $"Expected string or integer for enum '{targetType.Name}'"; - return false; - } - - try - { - value = token.ToObject(targetType); - return true; - } - catch (Exception ex) - { - errorMessage = $"Error converting value to type {targetType.Name}: {ex.Message}"; - return false; - } - } - - private bool TrySetGenericProperty(SerializedProperty prop, JToken value, out string errorMessage) - { - errorMessage = ""; - - if (value.Type != JTokenType.Object) - { - errorMessage = $"Expected object value for '{prop.propertyPath}'"; - return false; - } - - foreach (var child in ((JObject)value).Properties()) - { - SerializedProperty childProp = prop.FindPropertyRelative(child.Name); - if (childProp == null) - { - errorMessage = $"Nested property '{child.Name}' not found under '{prop.propertyPath}'"; - return false; - } - - if (!TrySetSerializedPropertyValue(childProp, child.Value, out errorMessage)) - { - return false; - } - } - - return true; - } - - private bool TrySetEnumProperty(SerializedProperty prop, JToken value, out string errorMessage) - { - errorMessage = ""; - - if (value.Type == JTokenType.String) - { - string enumValue = value.ToObject(); - string[] enumNames = prop.enumNames; - - for (int i = 0; i < enumNames.Length; i++) - { - if (string.Equals(enumNames[i], enumValue, StringComparison.OrdinalIgnoreCase)) - { - prop.enumValueIndex = i; - return true; - } - } - - errorMessage = $"'{enumValue}' is not a valid value for '{prop.propertyPath}'"; - return false; - } - - if (value.Type == JTokenType.Integer) - { - int index = value.ToObject(); - if (index < 0 || index >= prop.enumNames.Length) - { - errorMessage = $"Enum index {index} is out of range for '{prop.propertyPath}'"; - return false; - } - - prop.enumValueIndex = index; - return true; - } - - errorMessage = $"Expected string or integer enum value for '{prop.propertyPath}'"; - return false; - } - - private bool TryLoadObjectReference(JToken token, Type targetType, out UnityEngine.Object asset, out string errorMessage) - { - asset = null; - errorMessage = ""; - - if (token == null || token.Type == JTokenType.Null) - { - return true; - } - - string assetPath = null; - - if (token.Type == JTokenType.String) - { - string input = token.ToObject(); - if (string.IsNullOrEmpty(input)) - { - return true; - } - - if (input.StartsWith("Assets/") || input.StartsWith("Packages/")) - { - assetPath = input; - } - else - { - string[] guids = AssetDatabase.FindAssets($"{input} t:{targetType.Name}"); - if (guids.Length == 0) - { - guids = AssetDatabase.FindAssets(input); - } - - if (guids.Length > 0) - { - assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); - } - } - } - else if (token.Type == JTokenType.Object) - { - JObject obj = (JObject)token; - string guid = obj["guid"]?.ToObject(); - string path = obj["path"]?.ToObject(); - - if (!string.IsNullOrEmpty(guid)) - { - assetPath = AssetDatabase.GUIDToAssetPath(guid); - if (string.IsNullOrEmpty(assetPath)) - { - errorMessage = $"Could not find asset with GUID '{guid}'"; - return false; - } - } - else - { - assetPath = path; - } - } - else - { - errorMessage = "Object references must be null, an asset path, an asset name, or an object with guid/path"; - return false; - } - - if (string.IsNullOrEmpty(assetPath)) - { - errorMessage = $"Could not find asset '{token}'"; - return false; - } - - asset = AssetDatabase.LoadAssetAtPath(assetPath, targetType); - if (asset == null) - { - errorMessage = $"Could not find asset at path '{assetPath}'"; - return false; - } - - return true; - } - - private static bool TryReadObject(JToken value, string expectedType, out JObject obj, out string errorMessage) - { - obj = value as JObject; - errorMessage = ""; - - if (obj != null) - { - return true; - } - - errorMessage = $"Expected object value for {expectedType}"; - return false; - } - - private static bool TryReadVector2(JToken value, out Vector2 vector, out string errorMessage) - { - vector = Vector2.zero; - if (!TryReadObject(value, "Vector2", out JObject obj, out errorMessage)) return false; - vector = new Vector2(obj["x"]?.ToObject() ?? 0f, obj["y"]?.ToObject() ?? 0f); - return true; - } - - private static bool TryReadVector3(JToken value, out Vector3 vector, out string errorMessage) - { - vector = Vector3.zero; - if (!TryReadObject(value, "Vector3", out JObject obj, out errorMessage)) return false; - vector = new Vector3(obj["x"]?.ToObject() ?? 0f, obj["y"]?.ToObject() ?? 0f, obj["z"]?.ToObject() ?? 0f); - return true; - } - - private static bool TryReadVector4(JToken value, out Vector4 vector, out string errorMessage) - { - vector = Vector4.zero; - if (!TryReadObject(value, "Vector4", out JObject obj, out errorMessage)) return false; - vector = new Vector4(obj["x"]?.ToObject() ?? 0f, obj["y"]?.ToObject() ?? 0f, obj["z"]?.ToObject() ?? 0f, obj["w"]?.ToObject() ?? 0f); - return true; - } - - private static bool TryReadQuaternion(JToken value, out Quaternion quaternion, out string errorMessage) - { - quaternion = Quaternion.identity; - if (!TryReadObject(value, "Quaternion", out JObject obj, out errorMessage)) return false; - quaternion = new Quaternion(obj["x"]?.ToObject() ?? 0f, obj["y"]?.ToObject() ?? 0f, obj["z"]?.ToObject() ?? 0f, obj["w"]?.ToObject() ?? 1f); - return true; - } - - private static bool TryReadColor(JToken value, out Color color, out string errorMessage) - { - color = Color.clear; - if (!TryReadObject(value, "Color", out JObject obj, out errorMessage)) return false; - color = new Color(obj["r"]?.ToObject() ?? 0f, obj["g"]?.ToObject() ?? 0f, obj["b"]?.ToObject() ?? 0f, obj["a"]?.ToObject() ?? 1f); - return true; - } - - private static bool TryReadRect(JToken value, out Rect rect, out string errorMessage) - { - rect = new Rect(); - if (!TryReadObject(value, "Rect", out JObject obj, out errorMessage)) return false; - rect = new Rect(obj["x"]?.ToObject() ?? 0f, obj["y"]?.ToObject() ?? 0f, obj["width"]?.ToObject() ?? 0f, obj["height"]?.ToObject() ?? 0f); - return true; - } - - private static bool TryReadBounds(JToken value, out Bounds bounds, out string errorMessage) - { - bounds = new Bounds(Vector3.zero, Vector3.one); - if (!TryReadObject(value, "Bounds", out JObject obj, out errorMessage)) return false; - - Vector3 center = Vector3.zero; - Vector3 size = Vector3.one; - if (obj["center"] != null && !TryReadVector3(obj["center"], out center, out errorMessage)) return false; - if (obj["size"] != null && !TryReadVector3(obj["size"], out size, out errorMessage)) return false; - - bounds = new Bounds(center, size); - return true; - } - - private static bool TryReadVector2Int(JToken value, out Vector2Int vector, out string errorMessage) - { - vector = Vector2Int.zero; - if (!TryReadObject(value, "Vector2Int", out JObject obj, out errorMessage)) return false; - vector = new Vector2Int(obj["x"]?.ToObject() ?? 0, obj["y"]?.ToObject() ?? 0); - return true; - } - - private static bool TryReadVector3Int(JToken value, out Vector3Int vector, out string errorMessage) - { - vector = Vector3Int.zero; - if (!TryReadObject(value, "Vector3Int", out JObject obj, out errorMessage)) return false; - vector = new Vector3Int(obj["x"]?.ToObject() ?? 0, obj["y"]?.ToObject() ?? 0, obj["z"]?.ToObject() ?? 0); - return true; - } - - private static bool TryReadRectInt(JToken value, out RectInt rect, out string errorMessage) - { - rect = new RectInt(); - if (!TryReadObject(value, "RectInt", out JObject obj, out errorMessage)) return false; - rect = new RectInt(obj["x"]?.ToObject() ?? 0, obj["y"]?.ToObject() ?? 0, obj["width"]?.ToObject() ?? 0, obj["height"]?.ToObject() ?? 0); - return true; - } - - private static bool TryReadBoundsInt(JToken value, out BoundsInt bounds, out string errorMessage) - { - bounds = new BoundsInt(Vector3Int.zero, Vector3Int.one); - if (!TryReadObject(value, "BoundsInt", out JObject obj, out errorMessage)) return false; - - Vector3Int position = Vector3Int.zero; - Vector3Int size = Vector3Int.one; - if (obj["position"] != null && !TryReadVector3Int(obj["position"], out position, out errorMessage)) return false; - if (obj["size"] != null && !TryReadVector3Int(obj["size"], out size, out errorMessage)) return false; - - bounds = new BoundsInt(position, size); - return true; - } - } -} diff --git a/Editor/Tools/UpdateComponentTool.cs.meta b/Editor/Tools/UpdateComponentTool.cs.meta deleted file mode 100644 index 998bb368..00000000 --- a/Editor/Tools/UpdateComponentTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: da399442b5700bb448f52ad4c015d266 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Tools/UpdateGameObjectTool.cs b/Editor/Tools/UpdateGameObjectTool.cs deleted file mode 100644 index c68dc5e4..00000000 --- a/Editor/Tools/UpdateGameObjectTool.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; -using McpUnity.Utils; // For GameObjectHierarchyCreator and McpLogger -using McpUnity.Unity; // For McpUnitySocketHandler -using Newtonsoft.Json.Linq; // For JObject - -namespace McpUnity.Tools -{ - /// - /// Tool for updating or creating a GameObject in the Unity Editor. - /// Supports setting name, tag, layer, active state, and static state by instance ID or hierarchy path. - /// Returns a JObject result similar to UpdateComponentTool for consistency. - /// - public class UpdateGameObjectTool : McpToolBase - { - public UpdateGameObjectTool() - { - Name = "update_gameobject"; - Description = "Updates or creates a GameObject and its properties (name, tag, layer, active state, static state) based on instance ID or object path."; - IsAsync = false; // Operations are expected to be quick - } - - /// - /// Executes the update or creation of a GameObject based on the provided parameters. - /// - /// A JObject containing: instanceId (int?), objectPath (string), name (string), tag (string), layer (int?), isActiveSelf (bool?), isStatic (bool?) - /// JObject with success, message, instanceId, name, and path fields (see UpdateComponentTool for format) - public override JObject Execute(JObject parameters) - { - // Extract parameters from JObject - int? instanceId = parameters["instanceId"]?.ToObject(); - string objectPath = parameters["objectPath"]?.ToObject(); - JObject gameObjectData = parameters["gameObjectData"] as JObject; - - string newName = gameObjectData? ["name"]?.ToObject(); - string newTag = gameObjectData? ["tag"]?.ToObject(); - int? newLayer = gameObjectData? ["layer"]?.ToObject(); - bool? newIsActiveSelf = (gameObjectData?["activeSelf"] ?? gameObjectData?["isActiveSelf"])?.ToObject(); - bool? newIsStatic = (gameObjectData?["isStatic"] ?? gameObjectData?["static"])?.ToObject(); - - GameObject targetGameObject = null; - string identifierInfo = ""; - - // Identify or create the GameObject by instanceId or objectPath - if (instanceId.HasValue) - { - targetGameObject = UnityObjectId.ObjectFromId(instanceId.Value) as GameObject; - identifierInfo = $"instance ID {instanceId.Value}"; - } - else if (!string.IsNullOrEmpty(objectPath)) - { - // Will create the GameObject if it doesn't exist - targetGameObject = GameObjectHierarchyCreator.FindOrCreateHierarchicalGameObject(objectPath); - identifierInfo = $"path '{objectPath}'"; - } - else - { - // Neither instanceId nor objectPath was provided - return McpUnitySocketHandler.CreateErrorResponse("Either 'instanceId' or 'objectPath' must be provided.", "validation_error"); - } - - // Check if we could not identify or create the GameObject - if (targetGameObject == null) - { - return McpUnitySocketHandler.CreateErrorResponse($"Target GameObject could not be identified or created using {identifierInfo}.", "unknown_error"); - } - - // Record for undo in Unity Editor - Undo.RecordObject(targetGameObject, "Update GameObject Properties"); - bool propertiesUpdated = false; - string originalNameForLog = targetGameObject.name; - - // Update name if provided and different - if (!string.IsNullOrEmpty(newName) && targetGameObject.name != newName) - { - targetGameObject.name = newName; - propertiesUpdated = true; - } - - // Update tag if provided and different, warn if tag doesn't exist - if (!string.IsNullOrEmpty(newTag)) - { - bool tagExists = Array.Exists(UnityEditorInternal.InternalEditorUtility.tags, t => t.Equals(newTag)); - if (!tagExists) - { - McpLogger.LogWarning($"UpdateGameObjectTool: Tag '{newTag}' does not exist for GameObject '{originalNameForLog}'. Tag not changed. Please create the tag in Unity's Tag Manager."); - } - else if (!targetGameObject.CompareTag(newTag)) - { - targetGameObject.tag = newTag; - propertiesUpdated = true; - } - } - - // Update layer if provided and valid - if (newLayer.HasValue) - { - if (newLayer.Value < 0 || newLayer.Value > 31) - { - McpLogger.LogWarning($"UpdateGameObjectTool: Invalid layer value {newLayer.Value} for GameObject '{originalNameForLog}'. Layer must be between 0 and 31. Layer not changed."); - } - else if (targetGameObject.layer != newLayer.Value) - { - targetGameObject.layer = newLayer.Value; - propertiesUpdated = true; - } - } - - // Update active state if provided and different - if (newIsActiveSelf.HasValue && targetGameObject.activeSelf != newIsActiveSelf.Value) - { - targetGameObject.SetActive(newIsActiveSelf.Value); - propertiesUpdated = true; - } - - // Update static state if provided and different - if (newIsStatic.HasValue && targetGameObject.isStatic != newIsStatic.Value) - { - targetGameObject.isStatic = newIsStatic.Value; - propertiesUpdated = true; - } - - // Mark as dirty if any property was changed - if (propertiesUpdated) - { - EditorUtility.SetDirty(targetGameObject); - } - - // Compose result message and return as JObject (like UpdateComponentTool) - return new JObject - { - ["success"] = true, - ["type"] = "text", - ["message"] = propertiesUpdated - ? $"GameObject '{targetGameObject.name}' (identified by {identifierInfo}) updated successfully." - : $"No properties were changed for GameObject '{targetGameObject.name}' (identified by {identifierInfo}).", - ["instanceId"] = UnityObjectId.GetObjectId(targetGameObject), - ["name"] = targetGameObject.name, - ["path"] = GetGameObjectPath(targetGameObject) - }; - } - - /// - /// Utility to get the hierarchy path of a GameObject (root/child/.../target) - /// - /// The GameObject to get the path for - /// Hierarchy path as string - private static string GetGameObjectPath(GameObject obj) - { - if (obj == null) return null; - string path = "/" + obj.name; - while (obj.transform.parent != null) - { - obj = obj.transform.parent.gameObject; - path = "/" + obj.name + path; - } - return path; - } - } -} diff --git a/Editor/Tools/UpdateGameObjectTool.cs.meta b/Editor/Tools/UpdateGameObjectTool.cs.meta deleted file mode 100644 index 8a212615..00000000 --- a/Editor/Tools/UpdateGameObjectTool.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 63e86e1bcdcf1a440a9c171c2d385629 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/UnityBridge.meta b/Editor/UnityBridge.meta deleted file mode 100644 index 8e6c6da5..00000000 --- a/Editor/UnityBridge.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 141d6bf30581c964d8b9b5ebbc841e16 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/UnityBridge/McpUnityEditorWindow.cs b/Editor/UnityBridge/McpUnityEditorWindow.cs deleted file mode 100644 index b3a30963..00000000 --- a/Editor/UnityBridge/McpUnityEditorWindow.cs +++ /dev/null @@ -1,690 +0,0 @@ -using System; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; - -namespace McpUnity.Unity -{ - /// - /// Editor window for controlling the MCP Unity Server. - /// Provides UI for starting/stopping the server and configuring settings. - /// - public class McpUnityEditorWindow : EditorWindow - { - private GUIStyle _headerStyle; - private GUIStyle _subHeaderStyle; - private GUIStyle _boxStyle; - private GUIStyle _wrappedLabelStyle; - private GUIStyle _connectedClientBoxStyle; // Style for individual connected clients - private GUIStyle _connectedClientLabelStyle; // Style for labels in connected client boxes - private int _selectedTab = 0; - private readonly string[] _tabNames = { "Server", "Help" }; - private bool _isInitialized = false; - private string _mcpConfigJson = ""; - private bool _tabsIndentationJson = false; - private bool _useRelativePathJson = false; - private Vector2 _helpTabScrollPosition = Vector2.zero; - private Vector2 _serverTabScrollPosition = Vector2.zero; - - [MenuItem("Tools/MCP Unity/Server Window", false, 1)] - public static void ShowWindow() - { - var window = GetWindow("MCP Unity"); - window.minSize = new Vector2(600, 400); - } - - private void OnGUI() - { - InitializeStyles(); - - EditorGUILayout.BeginVertical(); - - // Header - EditorGUILayout.Space(); - WrappedLabel("MCP Unity", _headerStyle); - EditorGUILayout.Space(); - - // Tabs - _selectedTab = GUILayout.Toolbar(_selectedTab, _tabNames); - EditorGUILayout.Space(); - - switch (_selectedTab) - { - case 0: // Server tab - DrawServerTab(); - break; - case 1: // Help tab - DrawHelpTab(); - break; - } - - // Version info at the bottom - GUILayout.FlexibleSpace(); - WrappedLabel($"MCP Unity v{McpUnitySettings.ServerVersion}", EditorStyles.miniLabel, GUILayout.Width(150)); - - EditorGUILayout.EndVertical(); - } - - #region Tab Drawing Methods - - private void DrawServerTab() - { - _serverTabScrollPosition = EditorGUILayout.BeginScrollView(_serverTabScrollPosition); - EditorGUILayout.BeginVertical("box"); - - // Server status - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.LabelField("Status:", GUILayout.Width(120)); - - McpUnitySettings settings = McpUnitySettings.Instance; - McpUnityServer mcpUnityServer = McpUnityServer.Instance; - bool hasScheduledStart = mcpUnityServer.HasScheduledStart; - string statusText = hasScheduledStart ? mcpUnityServer.ScheduledStartStatus : (mcpUnityServer.IsListening ? "Server Online" : "Server Offline"); - Color statusColor = hasScheduledStart ? Color.yellow : (mcpUnityServer.IsListening ? Color.green : Color.red); - - GUIStyle statusStyle = new GUIStyle(EditorStyles.boldLabel); - statusStyle.normal.textColor = statusColor; - - EditorGUILayout.LabelField(statusText, statusStyle); - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Port configuration - EditorGUILayout.BeginHorizontal(); - int newPort = EditorGUILayout.IntField("Connection Port", settings.Port); - if (newPort < 1 || newPort > 65536) - { - newPort = settings.Port; - Debug.LogError($"{newPort} is an invalid port number. Please enter a number between 1 and 65535."); - } - - if (newPort != settings.Port) - { - settings.Port = newPort; - settings.SaveSettings(); - mcpUnityServer.RestartServer(); - } - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Test timeout setting - EditorGUILayout.BeginHorizontal(); - int newTimeout = EditorGUILayout.IntField(new GUIContent("Request Timeout (seconds)", "Timeout in seconds for tool request"), settings.RequestTimeoutSeconds); - if (newTimeout < McpUnitySettings.RequestTimeoutMinimum) - { - newTimeout = McpUnitySettings.RequestTimeoutMinimum; - Debug.LogError($"Request timeout must be at least {McpUnitySettings.RequestTimeoutMinimum} seconds."); - } - - if (newTimeout != settings.RequestTimeoutSeconds) - { - settings.RequestTimeoutSeconds = newTimeout; - settings.SaveSettings(); - } - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - // Auto start server toggle - bool autoStartServer = EditorGUILayout.Toggle(new GUIContent("Auto Start Server", "Automatically starts the MCP server when Unity opens"), settings.AutoStartServer); - if (autoStartServer != settings.AutoStartServer) - { - settings.AutoStartServer = autoStartServer; - settings.SaveSettings(); - } - - EditorGUILayout.Space(); - - // Allow remote connections toggle - bool allowRemoteConnections = EditorGUILayout.Toggle(new GUIContent("Allow Remote Connections", "Allow connections from remote MCP bridges. When disabled, only localhost connections are allowed (default)."), settings.AllowRemoteConnections); - if (allowRemoteConnections != settings.AllowRemoteConnections) - { - settings.AllowRemoteConnections = allowRemoteConnections; - settings.SaveSettings(); - // Restart server to apply binding change - mcpUnityServer.RestartServer(); - } - - EditorGUILayout.Space(); - - // Enable info logs toggle - bool enableInfoLogs = EditorGUILayout.Toggle(new GUIContent("Enable Info Logs", "Show informational logs in the Unity console"), settings.EnableInfoLogs); - if (enableInfoLogs != settings.EnableInfoLogs) - { - settings.EnableInfoLogs = enableInfoLogs; - settings.SaveSettings(); - } - - EditorGUILayout.Space(); - - // Server control buttons - EditorGUILayout.BeginHorizontal(); - - // Connect button - enabled only when disconnected - GUI.enabled = !mcpUnityServer.IsListening && !hasScheduledStart; - if (GUILayout.Button("Start Server", GUILayout.Height(30))) - { - mcpUnityServer.StartServer(); - } - - // Disconnect button - enabled only when connected - GUI.enabled = mcpUnityServer.IsListening || hasScheduledStart; - if (GUILayout.Button("Stop Server", GUILayout.Height(30))) - { - mcpUnityServer.StopServer(); - } - - GUI.enabled = true; - if (GUILayout.Button("Restart Server", GUILayout.Height(30))) - { - mcpUnityServer.RestartServer(); - } - - //Repaint(); - - GUI.enabled = true; - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(); - - EditorGUILayout.LabelField("Connected Clients", EditorStyles.boldLabel); - EditorGUILayout.BeginVertical("box"); // Keep the default gray box for the container - - var clients = mcpUnityServer.Clients; - - if (clients.Count > 0) - { - foreach (var client in clients) - { - EditorGUILayout.BeginVertical(_connectedClientBoxStyle); // Use green background for each client - - // Check if we have a meaningful client name (not empty and not the fallback) - string clientName = client.Value; - bool hasMeaningfulName = !string.IsNullOrEmpty(clientName) - && !clientName.Equals("Unknown MCP Client", StringComparison.OrdinalIgnoreCase); - - if (hasMeaningfulName) - { - // Show name prominently when available - EditorGUILayout.LabelField(clientName, EditorStyles.boldLabel); - EditorGUILayout.LabelField($"ID: {client.Key}", _connectedClientLabelStyle); - } - else - { - // Show just the ID when no meaningful name is available - EditorGUILayout.LabelField($"Client: {client.Key}", EditorStyles.boldLabel); - } - - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - } - } - else - { - GUIStyle wrapStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel); - wrapStyle.wordWrap = true; - GUILayout.Label("No clients connected\nInvoke a tool from the MCP Client to connect", wrapStyle, GUILayout.ExpandWidth(true)); - } - - EditorGUILayout.EndVertical(); - - // NPM Executable Path - string newNpmPath = EditorGUILayout.TextField(new GUIContent("NPM Executable Path", "Optional: Full path to the npm executable (e.g., /Users/user/.asdf/shims/npm or C:\\path\\to\\npm.cmd). If not set, 'npm' from the system PATH will be used."), settings.NpmExecutablePath); - if (newNpmPath != settings.NpmExecutablePath) - { - settings.NpmExecutablePath = newNpmPath; - settings.SaveSettings(); - } - - EditorGUILayout.Space(); - - // MCP Config generation section - EditorGUILayout.Space(); - EditorGUILayout.LabelField("MCP Configuration", EditorStyles.boldLabel); - - var beforeTabs = _tabsIndentationJson; - var beforeRelative = _useRelativePathJson; - _tabsIndentationJson = EditorGUILayout.Toggle("Use Tabs indentation", _tabsIndentationJson); - _useRelativePathJson = EditorGUILayout.Toggle( - new GUIContent( - "Use relative path", - "Emit a path relative to the Unity project root. Use this when pasting into workspace-scoped configs (e.g. .vscode/mcp.json) that are shared via git."), - _useRelativePathJson); - - if (string.IsNullOrEmpty(_mcpConfigJson) || beforeTabs != _tabsIndentationJson || beforeRelative != _useRelativePathJson) - { - var pathMode = _useRelativePathJson ? PathMode.ProjectRelative : PathMode.Absolute; - _mcpConfigJson = McpUtils.GenerateMcpConfigJson(_tabsIndentationJson, pathMode); - } - - if (GUILayout.Button("Copy to Clipboard", GUILayout.Height(30))) - { - EditorGUIUtility.systemCopyBuffer = _mcpConfigJson; - } - - EditorGUILayout.TextArea(_mcpConfigJson, GUILayout.Height(200)); - - EditorGUILayout.Space(); - - ShowConfigButton("Windsurf", McpUtils.AddToWindsurfIdeConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Claude Desktop", McpUtils.AddToClaudeDesktopConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Cursor", McpUtils.AddToCursorConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Cursor (Project)", McpUtils.AddToCursorProjectConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Claude Code", McpUtils.AddToClaudeCodeConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Claude Code (Project)", McpUtils.AddToClaudeCodeProjectConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("GitHub Copilot", McpUtils.AddToGitHubCopilotConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("Codex CLI", McpUtils.AddToCodexCliConfig); - - EditorGUILayout.Space(); - - ShowConfigButton( - "Codex CLI (Project)", - McpUtils.AddToCodexCliProjectConfig, - "Codex only loads this project config after you mark the project as trusted. The first time you run `codex` from this project's root, approve the trust prompt."); - - EditorGUILayout.Space(); - - ShowConfigButton("Google Antigravity", McpUtils.AddToAntigravityConfig); - - EditorGUILayout.Space(); - - ShowConfigButton("OpenCode", McpUtils.AddToOpenCodeConfig); - - EditorGUILayout.Separator(); - EditorGUILayout.Separator(); - - EditorGUILayout.Space(); - - // Force Install Server button - if (GUILayout.Button("Force Install Server", GUILayout.Height(30))) - { - McpUnityServer.Instance.InstallServer(); - McpLogger.LogInfo("MCP Unity Server installed successfully."); - } - - EditorGUILayout.EndVertical(); - EditorGUILayout.EndScrollView(); - } - - private void DrawHelpTab() - { - // Begin scrollable area - _helpTabScrollPosition = EditorGUILayout.BeginScrollView(_helpTabScrollPosition); - - WrappedLabel("About MCP Unity", _subHeaderStyle); - EditorGUILayout.BeginVertical(_boxStyle); - WrappedLabel("MCP Unity is a Unity Editor integration of the Model Context Protocol (MCP), which enables standardized communication between AI models and applications."); - EditorGUILayout.Space(); - - if (GUILayout.Button("Open MCP Protocol Documentation")) - { - Application.OpenURL("https://modelcontextprotocol.io"); - } - - EditorGUILayout.EndVertical(); - - // IDE Integration settings - EditorGUILayout.Space(); - WrappedLabel("IDE Integration Settings", _subHeaderStyle); - - EditorGUILayout.BeginVertical(_boxStyle); - string ideIntegrationTooltip = "Add the Library/PackedCache folder to VSCode-like IDE workspaces so code can be indexed for the AI to access it. This improves code intelligence for Unity packages in VSCode, Cursor, and similar IDEs."; - - WrappedLabel("These settings help improve code intelligence in VSCode-like IDEs by adding the Unity Package Cache to your workspace. This is automatically configured when the MCP Unity tool is opened in Unity."); - EditorGUILayout.Space(); - - // Add button to manually update workspace - if (GUILayout.Button(new GUIContent("Update Workspace Cache Now", ideIntegrationTooltip), GUILayout.Height(24))) - { - bool updated = VsCodeWorkspaceUtils.AddPackageCacheToWorkspace(); - if (updated) - { - EditorUtility.DisplayDialog("Workspace Updated", "Successfully added Library/PackedCache to workspace files. Please restart your IDE and open the workspace.", "OK"); - } - else - { - EditorUtility.DisplayDialog("Workspace Update Failed", "No workspace files were found or needed updating.", "OK"); - } - } - - EditorGUILayout.EndVertical(); - - EditorGUILayout.Space(); - WrappedLabel("Available Tools", _subHeaderStyle); - - EditorGUILayout.BeginVertical(_boxStyle); - - // execute_menu_item - WrappedLabel("execute_menu_item", EditorStyles.boldLabel); - WrappedLabel("Executes a function that is currently tagged with MenuItem attribute in the project or in the Unity Editor's menu path"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Execute the menu item 'GameObject/Create Empty' to create a new empty GameObject", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // select_gameobject - WrappedLabel("select_gameobject", EditorStyles.boldLabel); - WrappedLabel("Selects game objects in the Unity hierarchy by path or instance ID"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Select the Main Camera object in my scene", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // update_gameobject - WrappedLabel("update_gameobject", EditorStyles.boldLabel); - WrappedLabel("Updates a GameObject's core properties (name, tag, layer, active/static state), or creates the GameObject if it does not exist"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Set the Player object's tag to 'Enemy' and make it inactive", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // update_component - WrappedLabel("update_component", EditorStyles.boldLabel); - WrappedLabel("Updates component fields on a GameObject or adds it to the GameObject if it does not contain the component"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Add a Rigidbody component to the Player object and set its mass to 5", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // add_package - WrappedLabel("add_package", EditorStyles.boldLabel); - WrappedLabel("Installs new packages in the Unity Package Manager"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Add the TextMeshPro package to my project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // run_tests - WrappedLabel("run_tests", EditorStyles.boldLabel); - WrappedLabel("Runs tests using the Unity Test Runner"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Run all the EditMode tests in my project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // send_console_log - WrappedLabel("send_console_log", EditorStyles.boldLabel); - WrappedLabel("Sends console logs to the Unity Editor console"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Send a console log to Unity that the task has been completed", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // add_asset_to_scene - WrappedLabel("add_asset_to_scene", EditorStyles.boldLabel); - WrappedLabel("Adds an asset from the AssetDatabase to the Unity scene"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Add the Player prefab from my project to the current scene", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - - // recompile_scripts - WrappedLabel("recompile_scripts", EditorStyles.boldLabel); - WrappedLabel("Recompiles all scripts in the Unity project"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Recompile scripts in my project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - - EditorGUILayout.EndVertical(); - - // Available Resources section - EditorGUILayout.Space(); - WrappedLabel("Available Resources", _subHeaderStyle); - - EditorGUILayout.BeginVertical(_boxStyle); - - // unity://menu-items - WrappedLabel("unity://menu-items", EditorStyles.boldLabel); - WrappedLabel("Retrieves a list of all available menu items in the Unity Editor"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Show me all available menu items related to GameObject creation", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://hierarchy - WrappedLabel("unity://hierarchy", EditorStyles.boldLabel); - WrappedLabel("Retrieves a list of all game objects in the Unity hierarchy"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Show me the current scene hierarchy structure", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://gameobject/{id} - WrappedLabel("unity://gameobject/{id}", EditorStyles.boldLabel); - WrappedLabel("Retrieves detailed information about a specific GameObject, including all components with serialized properties and fields"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Get me detailed information about the Player GameObject", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://logs - WrappedLabel("unity://logs", EditorStyles.boldLabel); - WrappedLabel("Retrieves a list of all logs from the Unity console"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Show me the recent error messages from the Unity console", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://packages - WrappedLabel("unity://packages", EditorStyles.boldLabel); - WrappedLabel("Retrieves information about installed and available packages from the Unity Package Manager"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("List all the packages currently installed in my Unity project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://assets - WrappedLabel("unity://assets", EditorStyles.boldLabel); - WrappedLabel("Retrieves information about assets in the Unity Asset Database"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("Find all texture assets in my project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - // unity://tests/{testMode} - WrappedLabel("unity://tests/{testMode}", EditorStyles.boldLabel); - WrappedLabel("Retrieves information about tests in the Unity Test Runner"); - EditorGUILayout.BeginVertical(EditorStyles.helpBox); - EditorGUILayout.LabelField("Example prompt:", EditorStyles.miniLabel); - WrappedLabel("List all available tests in my Unity project", new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }); - EditorGUILayout.EndVertical(); - EditorGUILayout.Space(); - - EditorGUILayout.EndVertical(); - - // Author information - EditorGUILayout.Space(); - WrappedLabel("Author", _subHeaderStyle); - - EditorGUILayout.BeginVertical(_boxStyle); - - WrappedLabel("Created by CoderGamester", EditorStyles.boldLabel); - EditorGUILayout.Space(); - - WrappedLabel("For issues, feedback, or contributions, please visit:"); - - // Begin horizontal layout for buttons - EditorGUILayout.BeginHorizontal(); - - if (GUILayout.Button("GitHub: https://github.com/CoderGamester", GUILayout.Height(30))) - { - Application.OpenURL("https://github.com/CoderGamester"); - } - - if (GUILayout.Button("LinkedIn: Miguel Tomás", GUILayout.Height(30))) - { - Application.OpenURL("https://www.linkedin.com/in/miguel-tomas/"); - } - - // End horizontal layout - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.EndVertical(); - - // End scrollable area - EditorGUILayout.EndScrollView(); - } - - #endregion - - #region Helper Methods - - private void InitializeStyles() - { - if (_isInitialized) return; - - // Window title - titleContent = new GUIContent("MCP Unity"); - - // Header style - _headerStyle = new GUIStyle(EditorStyles.largeLabel) - { - fontSize = 20, - fontStyle = FontStyle.Bold, - alignment = TextAnchor.MiddleCenter, - margin = new RectOffset(0, 0, 10, 10) - }; - - // Sub-header style - _subHeaderStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 14, - margin = new RectOffset(0, 0, 10, 5) - }; - - // Box style - _boxStyle = new GUIStyle(EditorStyles.helpBox) - { - padding = new RectOffset(10, 10, 10, 10), - margin = new RectOffset(0, 0, 10, 10) - }; - - // Connected client box style with green background - _connectedClientBoxStyle = new GUIStyle(EditorStyles.helpBox) - { - padding = new RectOffset(10, 10, 10, 10), - margin = new RectOffset(0, 0, 5, 5) - }; - - // Create a light green texture for the background - Texture2D greenTexture = new Texture2D(1, 1); - greenTexture.SetPixel(0, 0, new Color(0.8f, 1.0f, 0.8f, 1.0f)); // Light green color - greenTexture.Apply(); - - _connectedClientBoxStyle.normal.background = greenTexture; - - // Label style for text in connected client boxes (black text for contrast) - _connectedClientLabelStyle = new GUIStyle(EditorStyles.label) - { - normal = { textColor = Color.black } - }; - - // Wrapped label style for text that needs to wrap - _wrappedLabelStyle = new GUIStyle(EditorStyles.label) - { - wordWrap = true, - richText = true - }; - - _isInitialized = true; - } - - /// - /// Creates a label with text that properly wraps based on available width - /// - /// The text to display - /// Optional style override (wordWrap will be forced true) - /// Layout options - private void WrappedLabel(string text, GUIStyle style = null, params GUILayoutOption[] options) - { - if (style == null) - { - // Use our predefined wrapped label style - EditorGUILayout.LabelField(text, _wrappedLabelStyle, options); - return; - } - - // Create a temporary style with wordWrap enabled based on the provided style - GUIStyle wrappedStyle = new GUIStyle(style) - { - wordWrap = true - }; - - EditorGUILayout.LabelField(text, wrappedStyle, options); - } - - - - // Helper to show a config button with unified logic - private void ShowConfigButton(string configLabel, Func configAction, string successFollowUp = null) - { - bool isSupported = McpUtils.IsAutoConfigSupported(configLabel); - - using (new EditorGUI.DisabledScope(!isSupported)) - { - if (GUILayout.Button($"Configure {configLabel}", GUILayout.Height(30))) - { - bool added = configAction(_tabsIndentationJson); - if (added) - { - string message = $"The MCP configuration was successfully added to the {configLabel} config file."; - if (!string.IsNullOrEmpty(successFollowUp)) - { - message += "\n\n" + successFollowUp; - } - EditorUtility.DisplayDialog("Success", message, "OK"); - } - else - { - EditorUtility.DisplayDialog("Error", $"The MCP configuration could not be added to the {configLabel} config file.", "OK"); - } - } - } - - if (!isSupported) - { - WrappedLabel(McpUtils.GetAutoConfigUnsupportedReason(configLabel), EditorStyles.miniLabel); - } - } - - - #endregion - } -} diff --git a/Editor/UnityBridge/McpUnityEditorWindow.cs.meta b/Editor/UnityBridge/McpUnityEditorWindow.cs.meta deleted file mode 100644 index 1490c5bf..00000000 --- a/Editor/UnityBridge/McpUnityEditorWindow.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b37b7af2843115b4592e82f0a4445d47 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/UnityBridge/McpUnityServer.cs b/Editor/UnityBridge/McpUnityServer.cs deleted file mode 100644 index 499ba475..00000000 --- a/Editor/UnityBridge/McpUnityServer.cs +++ /dev/null @@ -1,795 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using UnityEditor; -using UnityEngine; -using McpUnity.Tools; -using McpUnity.Resources; -using McpUnity.Services; -using McpUnity.Utils; -using WebSocketSharp.Server; -using System.IO; -using System.Net.Sockets; -using UnityEditor.Callbacks; - -namespace McpUnity.Unity -{ - /// - /// Custom WebSocket close codes for Unity-specific events. - /// Range 4000-4999 is reserved for application use. - /// - public static class UnityCloseCode - { - /// - /// Unity is entering Play mode - clients should use fast polling instead of backoff - /// - public const ushort PlayMode = 4001; - } - - /// - /// MCP Unity Server to communicate Node.js MCP server. - /// Uses WebSockets to communicate with Node.js. - /// - [InitializeOnLoad] - public class McpUnityServer : IDisposable - { - private static McpUnityServer _instance; - - private readonly Dictionary _tools = new Dictionary(); - private readonly Dictionary _resources = new Dictionary(); - - private const int DelayedStartMaxAttempts = 10; - private static readonly double[] DelayedStartRetryDelaySeconds = { 0.25d, 0.5d, 1d, 2d, 3d, 5d }; - - private WebSocketServer _webSocketServer; - private CancellationTokenSource _cts; - private TestRunnerService _testRunnerService; - private ConsoleLogsService _consoleLogsService; - private bool _delayedStartScheduled; - private bool _delayedStartRequiresAutoStart; - private int _delayedStartAttempt; - private double _delayedStartEarliestTime; - private string _delayedStartReason; - private int _connectionGeneration; - private int _activeConnectionGeneration; - - private enum StartServerResult - { - Started, - AlreadyListening, - Skipped, - AddressAlreadyInUse, - Failed - } - - /// - /// Singleton instance accessor. Returns null in batch mode. - /// - public static McpUnityServer Instance - { - get - { - // Don't create instance in batch mode to avoid hanging builds - if (Application.isBatchMode) - { - return null; - } - - if (_instance == null) - { - _instance = new McpUnityServer(); - } - return _instance; - } - } - - /// - /// Current Listening state - /// - public bool IsListening => _webSocketServer?.IsListening ?? false; - - /// - /// True when a delayed start or retry is waiting for Unity/editor socket cleanup. - /// - public bool HasScheduledStart => _delayedStartScheduled; - - /// - /// Human-readable status for scheduled restart attempts. - /// - public string ScheduledStartStatus - { - get - { - if (!_delayedStartScheduled) - { - return string.Empty; - } - - return $"Retrying port {McpUnitySettings.Instance.Port} (attempt {_delayedStartAttempt}/{DelayedStartMaxAttempts})"; - } - } - - /// - /// Thread-safe dictionary of connected clients with this server. - /// WebSocketSharp dispatches OnOpen/OnClose on thread pool threads, - /// so concurrent access must be safe. - /// - public ConcurrentDictionary Clients { get; } = new ConcurrentDictionary(); - - /// - /// Disposes the McpUnityServer instance, stopping the WebSocket server and unsubscribing from Unity Editor events. - /// This method ensures proper cleanup of resources and prevents memory leaks or unexpected behavior during domain reloads or editor shutdown. - /// - public void Dispose() - { - StopServer(); - - EditorApplication.quitting -= OnEditorQuitting; - AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; - AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; - EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; - - GC.SuppressFinalize(this); - } - - /// - /// Start the WebSocket Server to communicate with Node.js - /// - public void StartServer() - { - CancelScheduledStart(); - ScheduleStartServer(requireAutoStart: false, reason: "manual start"); - } - - /// - /// Stop the current server and start it again after Unity has had a chance to release the socket. - /// - public void RestartServer() - { - StopServer(); - ScheduleStartServer(requireAutoStart: false, reason: "manual restart"); - } - - /// - /// Stop the WebSocket server - /// - /// Optional custom close code to send to clients before stopping - /// Optional reason message for the close - public void StopServer(ushort? closeCode = null, string closeReason = null) - { - CancelScheduledStart(); - _activeConnectionGeneration = 0; - McpBackgroundTick.Stop(); - - if (_webSocketServer == null) - { - Clients.Clear(); - return; - } - - try - { - CloseAllClients(closeCode ?? 1000, closeReason ?? "Server stopping"); - - _webSocketServer?.Stop(); - - McpLogger.LogInfo("WebSocket server stopped"); - } - catch (Exception ex) - { - McpLogger.LogError($"Error during WebSocketServer.Stop(): {ex.Message}\n{ex.StackTrace}"); - } - finally - { - _webSocketServer = null; - Clients.Clear(); - McpLogger.LogInfo("WebSocket server stopped and resources cleaned up."); - } - } - - /// - /// Try to get a tool by name - /// - public bool TryGetTool(string name, out McpToolBase tool) - { - return _tools.TryGetValue(name, out tool); - } - - /// - /// Try to get a resource by name - /// - public bool TryGetResource(string name, out McpResourceBase resource) - { - return _resources.TryGetValue(name, out resource); - } - - /// - /// Installs the MCP Node.js server by running 'npm install' and 'npm run build' - /// in the server directory if 'node_modules' or 'build' folders are missing. - /// - public void InstallServer() - { - string serverPath = McpUtils.GetServerPath(); - - if (string.IsNullOrEmpty(serverPath) || !Directory.Exists(serverPath)) - { - McpLogger.LogError($"Server path not found or invalid: {serverPath}. Make sure that MCP Node.js server is installed."); - return; - } - - // Validate server path and warn about potential issues (spaces, special characters) - if (!McpUtils.ValidateServerPath(serverPath)) - { - McpLogger.LogError("Server path validation failed. See previous errors for details."); - return; - } - - string nodeModulesPath = Path.Combine(serverPath, "node_modules"); - if (!Directory.Exists(nodeModulesPath)) - { - McpUtils.RunNpmCommand("install", serverPath); - } - - string buildPath = Path.Combine(serverPath, "build"); - if (!Directory.Exists(buildPath)) - { - McpUtils.RunNpmCommand("run build", serverPath); - } - } - - internal bool ShouldTrackClient(int connectionGeneration) - { - return connectionGeneration == _activeConnectionGeneration && IsListening; - } - - /// - /// Private constructor to enforce singleton pattern - /// - private McpUnityServer() - { - // Skip all initialization in batch mode (Unity Cloud Build, CI, headless builds) - // The npm install/build commands can hang indefinitely without node.js available - if (Application.isBatchMode) - { - McpLogger.LogInfo("MCP Unity server disabled: Running in batch mode (Unity Cloud Build or CI)"); - return; - } - - EditorApplication.quitting -= OnEditorQuitting; // Prevent multiple subscriptions on domain reload - EditorApplication.quitting += OnEditorQuitting; - - AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; - AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; - - AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; - AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; - - EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; - EditorApplication.playModeStateChanged += OnPlayModeStateChanged; - - InstallServer(); - InitializeServices(); - RegisterResources(); - RegisterTools(); - - // Initial start if auto-start is enabled and not recovering from a reload where it was off - if (McpUnitySettings.Instance.AutoStartServer) - { - ScheduleStartServer(requireAutoStart: true, reason: "auto-start"); - } - } - - private StartServerResult StartServerInternal(bool logAddressInUseAsError, int attempt = 1, double nextRetryDelaySeconds = 0) - { - // Skip starting server if this is a Multiplayer Play Mode clone instance - // Only the main editor should run the WebSocket server to avoid port conflicts - if (McpUtils.IsMultiplayerPlayModeClone()) - { - McpLogger.LogInfo("Server startup skipped: Running as Multiplayer Play Mode clone instance. Only the main editor runs the MCP server."); - return StartServerResult.Skipped; - } - - if (IsListening) - { - McpLogger.LogInfo($"Server start requested, but already listening on port {McpUnitySettings.Instance.Port}."); - return StartServerResult.AlreadyListening; - } - - if (_webSocketServer != null) - { - StopServer(); - } - - WebSocketServer webSocketServer = null; - try - { - int connectionGeneration = Interlocked.Increment(ref _connectionGeneration); - var host = McpUnitySettings.Instance.AllowRemoteConnections ? "0.0.0.0" : "localhost"; - webSocketServer = new WebSocketServer($"ws://{host}:{McpUnitySettings.Instance.Port}"); - webSocketServer.Log.Output = (data, path) => { }; - webSocketServer.AddWebSocketService("/McpUnity", () => new McpUnitySocketHandler(this, connectionGeneration)); - webSocketServer.Start(); - _webSocketServer = webSocketServer; - _activeConnectionGeneration = connectionGeneration; - McpBackgroundTick.Start(); - McpLogger.LogInfo($"WebSocket server started successfully on {host}:{McpUnitySettings.Instance.Port}."); - return StartServerResult.Started; - } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) - { - CleanupFailedStart(webSocketServer); - string message = $"Failed to start WebSocket server: Port {McpUnitySettings.Instance.Port} is already in use. {ex.Message}"; - if (logAddressInUseAsError) - { - McpLogger.LogError(message); - } - else - { - McpLogger.LogWarning($"{message} Attempt {attempt}/{DelayedStartMaxAttempts}; retrying in {nextRetryDelaySeconds:0.##}s."); - } - - return StartServerResult.AddressAlreadyInUse; - } - catch (Exception ex) - { - CleanupFailedStart(webSocketServer); - McpLogger.LogError($"Failed to start WebSocket server: {ex.Message}\n{ex.StackTrace}"); - return StartServerResult.Failed; - } - } - - private static double GetDelayedStartDelaySeconds(int attempt) - { - int normalizedAttempt = Math.Max(attempt, 1); - int delayIndex = Math.Min(normalizedAttempt - 1, DelayedStartRetryDelaySeconds.Length - 1); - return DelayedStartRetryDelaySeconds[delayIndex]; - } - - private void ScheduleStartServer(bool requireAutoStart, string reason, int attempt = 1) - { - int normalizedAttempt = Math.Min(Math.Max(attempt, 1), DelayedStartMaxAttempts); - if (_delayedStartScheduled) - { - _delayedStartRequiresAutoStart = _delayedStartRequiresAutoStart && requireAutoStart; - _delayedStartAttempt = Math.Max(_delayedStartAttempt, normalizedAttempt); - _delayedStartReason = reason; - return; - } - - _delayedStartScheduled = true; - _delayedStartRequiresAutoStart = requireAutoStart; - _delayedStartAttempt = normalizedAttempt; - _delayedStartReason = reason; - double delaySeconds = GetDelayedStartDelaySeconds(normalizedAttempt); - _delayedStartEarliestTime = EditorApplication.timeSinceStartup + delaySeconds; - McpLogger.LogInfo($"WebSocket server start scheduled in {delaySeconds:0.##}s ({reason}, attempt {normalizedAttempt}/{DelayedStartMaxAttempts})."); - EditorApplication.delayCall += StartServerAfterDelay; - EditorApplication.update += StartServerAfterDelayOnUpdate; - } - - private void CancelScheduledStart() - { - if (!_delayedStartScheduled) - { - return; - } - - EditorApplication.delayCall -= StartServerAfterDelay; - EditorApplication.update -= StartServerAfterDelayOnUpdate; - _delayedStartScheduled = false; - _delayedStartRequiresAutoStart = false; - _delayedStartAttempt = 0; - _delayedStartEarliestTime = 0; - _delayedStartReason = null; - } - - private void StartServerAfterDelay() - { - if (!_delayedStartScheduled) - { - return; - } - - if (EditorApplication.timeSinceStartup < _delayedStartEarliestTime) - { - EditorApplication.delayCall += StartServerAfterDelay; - return; - } - - RunScheduledStart(); - } - - private void StartServerAfterDelayOnUpdate() - { - if (!_delayedStartScheduled) - { - EditorApplication.update -= StartServerAfterDelayOnUpdate; - return; - } - - if (EditorApplication.timeSinceStartup < _delayedStartEarliestTime) - { - return; - } - - RunScheduledStart(); - } - - private void RunScheduledStart() - { - _delayedStartScheduled = false; - EditorApplication.delayCall -= StartServerAfterDelay; - EditorApplication.update -= StartServerAfterDelayOnUpdate; - - bool requireAutoStart = _delayedStartRequiresAutoStart; - int attempt = Math.Min(Math.Max(_delayedStartAttempt, 1), DelayedStartMaxAttempts); - string reason = _delayedStartReason; - _delayedStartRequiresAutoStart = false; - _delayedStartAttempt = 0; - _delayedStartEarliestTime = 0; - _delayedStartReason = null; - - if (Application.isBatchMode || _instance != this) - { - return; - } - - if (requireAutoStart && !McpUnitySettings.Instance.AutoStartServer) - { - McpLogger.LogInfo("Scheduled WebSocket server start skipped because auto-start is disabled."); - return; - } - - if (IsListening) - { - return; - } - - bool isFinalAttempt = attempt >= DelayedStartMaxAttempts; - double nextRetryDelaySeconds = isFinalAttempt ? 0 : GetDelayedStartDelaySeconds(attempt + 1); - StartServerResult result = StartServerInternal(isFinalAttempt, attempt, nextRetryDelaySeconds); - if (result == StartServerResult.AddressAlreadyInUse && !isFinalAttempt) - { - ScheduleStartServer(requireAutoStart, reason ?? "port still in use", attempt + 1); - } - } - - private void CleanupFailedStart(WebSocketServer webSocketServer) - { - McpBackgroundTick.Stop(); - - if (webSocketServer == null) - { - Clients.Clear(); - return; - } - - try - { - if (webSocketServer.IsListening) - { - webSocketServer.Stop(); - } - } - catch (Exception ex) - { - McpLogger.LogWarning($"Error cleaning up failed WebSocket server start: {ex.Message}"); - } - finally - { - if (ReferenceEquals(_webSocketServer, webSocketServer)) - { - _webSocketServer = null; - } - - Clients.Clear(); - } - } - - /// - /// Close all connected clients with a specific close code - /// - /// WebSocket close code - /// Reason message for the close - private void CloseAllClients(ushort closeCode, string reason) - { - if (_webSocketServer == null) - { - return; - } - - try - { - var service = _webSocketServer.WebSocketServices["/McpUnity"]; - if (service?.Sessions != null) - { - // Get all active session IDs and close each with the custom code - var sessionIds = new List(service.Sessions.IDs); - foreach (var sessionId in sessionIds) - { - service.Sessions.CloseSession(sessionId, closeCode, reason); - } - McpLogger.LogInfo($"Closed {sessionIds.Count} client connection(s) with code {closeCode}: {reason}"); - } - } - catch (Exception ex) - { - McpLogger.LogError($"Error closing client connections: {ex.Message}"); - } - } - - /// - /// Register all available tools - /// - private void RegisterTools() - { - // Register MenuItemTool - MenuItemTool menuItemTool = new MenuItemTool(); - _tools.Add(menuItemTool.Name, menuItemTool); - - // Register SelectGameObjectTool - SelectGameObjectTool selectGameObjectTool = new SelectGameObjectTool(); - _tools.Add(selectGameObjectTool.Name, selectGameObjectTool); - - // Register UpdateGameObjectTool - UpdateGameObjectTool updateGameObjectTool = new UpdateGameObjectTool(); - _tools.Add(updateGameObjectTool.Name, updateGameObjectTool); - - // Register PackageManagerTool - AddPackageTool addPackageTool = new AddPackageTool(); - _tools.Add(addPackageTool.Name, addPackageTool); - - // Register RunTestsTool - RunTestsTool runTestsTool = new RunTestsTool(_testRunnerService); - _tools.Add(runTestsTool.Name, runTestsTool); - - // Register SendConsoleLogTool - SendConsoleLogTool sendConsoleLogTool = new SendConsoleLogTool(); - _tools.Add(sendConsoleLogTool.Name, sendConsoleLogTool); - - // Register GetConsoleLogsTool - GetConsoleLogsTool getConsoleLogsTool = new GetConsoleLogsTool(_consoleLogsService); - _tools.Add(getConsoleLogsTool.Name, getConsoleLogsTool); - - // Register UpdateComponentTool - UpdateComponentTool updateComponentTool = new UpdateComponentTool(); - _tools.Add(updateComponentTool.Name, updateComponentTool); - - // Register AddAssetToSceneTool - AddAssetToSceneTool addAssetToSceneTool = new AddAssetToSceneTool(); - _tools.Add(addAssetToSceneTool.Name, addAssetToSceneTool); - - // Register CreatePrefabTool - CreatePrefabTool createPrefabTool = new CreatePrefabTool(); - _tools.Add(createPrefabTool.Name, createPrefabTool); - - // Register CreateSceneTool - CreateSceneTool createSceneTool = new CreateSceneTool(); - _tools.Add(createSceneTool.Name, createSceneTool); - - // Register DeleteSceneTool - DeleteSceneTool deleteSceneTool = new DeleteSceneTool(); - _tools.Add(deleteSceneTool.Name, deleteSceneTool); - - // Register LoadSceneTool - LoadSceneTool loadSceneTool = new LoadSceneTool(); - _tools.Add(loadSceneTool.Name, loadSceneTool); - - // Register SaveSceneTool - SaveSceneTool saveSceneTool = new SaveSceneTool(); - _tools.Add(saveSceneTool.Name, saveSceneTool); - - // Register GetSceneInfoTool - GetSceneInfoTool getSceneInfoTool = new GetSceneInfoTool(); - _tools.Add(getSceneInfoTool.Name, getSceneInfoTool); - - // Register GetPlayModeStatusTool - GetPlayModeStatusTool getPlayModeStatusTool = new GetPlayModeStatusTool(); - _tools.Add(getPlayModeStatusTool.Name, getPlayModeStatusTool); - - // Register SetPlayModeStatusTool - SetPlayModeStatusTool setPlayModeStatusTool = new SetPlayModeStatusTool(); - _tools.Add(setPlayModeStatusTool.Name, setPlayModeStatusTool); - - // Register UnloadSceneTool - UnloadSceneTool unloadSceneTool = new UnloadSceneTool(); - _tools.Add(unloadSceneTool.Name, unloadSceneTool); - - // Register RecompileScriptsTool - RecompileScriptsTool recompileScriptsTool = new RecompileScriptsTool(); - _tools.Add(recompileScriptsTool.Name, recompileScriptsTool); - - // Register GetGameObjectTool - GetGameObjectTool getGameObjectTool = new GetGameObjectTool(); - _tools.Add(getGameObjectTool.Name, getGameObjectTool); - - // Register DuplicateGameObjectTool - DuplicateGameObjectTool duplicateGameObjectTool = new DuplicateGameObjectTool(); - _tools.Add(duplicateGameObjectTool.Name, duplicateGameObjectTool); - - // Register DeleteGameObjectTool - DeleteGameObjectTool deleteGameObjectTool = new DeleteGameObjectTool(); - _tools.Add(deleteGameObjectTool.Name, deleteGameObjectTool); - - // Register ReparentGameObjectTool - ReparentGameObjectTool reparentGameObjectTool = new ReparentGameObjectTool(); - _tools.Add(reparentGameObjectTool.Name, reparentGameObjectTool); - - // Register Transform Tools - MoveGameObjectTool moveGameObjectTool = new MoveGameObjectTool(); - _tools.Add(moveGameObjectTool.Name, moveGameObjectTool); - - RotateGameObjectTool rotateGameObjectTool = new RotateGameObjectTool(); - _tools.Add(rotateGameObjectTool.Name, rotateGameObjectTool); - - ScaleGameObjectTool scaleGameObjectTool = new ScaleGameObjectTool(); - _tools.Add(scaleGameObjectTool.Name, scaleGameObjectTool); - - SetTransformTool setTransformTool = new SetTransformTool(); - _tools.Add(setTransformTool.Name, setTransformTool); - - // Register Material Tools - CreateMaterialTool createMaterialTool = new CreateMaterialTool(); - _tools.Add(createMaterialTool.Name, createMaterialTool); - - AssignMaterialTool assignMaterialTool = new AssignMaterialTool(); - _tools.Add(assignMaterialTool.Name, assignMaterialTool); - - ModifyMaterialTool modifyMaterialTool = new ModifyMaterialTool(); - _tools.Add(modifyMaterialTool.Name, modifyMaterialTool); - - GetMaterialInfoTool getMaterialInfoTool = new GetMaterialInfoTool(); - _tools.Add(getMaterialInfoTool.Name, getMaterialInfoTool); - - // Register BatchExecuteTool (must be registered last as it needs access to other tools) - BatchExecuteTool batchExecuteTool = new BatchExecuteTool(this); - _tools.Add(batchExecuteTool.Name, batchExecuteTool); - } - - /// - /// Register all available resources - /// - private void RegisterResources() - { - // Register GetMenuItemsResource - GetMenuItemsResource getMenuItemsResource = new GetMenuItemsResource(); - _resources.Add(getMenuItemsResource.Name, getMenuItemsResource); - - // Register GetConsoleLogsResource - GetConsoleLogsResource getConsoleLogsResource = new GetConsoleLogsResource(_consoleLogsService); - _resources.Add(getConsoleLogsResource.Name, getConsoleLogsResource); - - // Register GetScenesHierarchyResource - GetScenesHierarchyResource getScenesHierarchyResource = new GetScenesHierarchyResource(); - _resources.Add(getScenesHierarchyResource.Name, getScenesHierarchyResource); - - // Register GetPackagesResource - GetPackagesResource getPackagesResource = new GetPackagesResource(); - _resources.Add(getPackagesResource.Name, getPackagesResource); - - // Register GetAssetsResource - GetAssetsResource getAssetsResource = new GetAssetsResource(); - _resources.Add(getAssetsResource.Name, getAssetsResource); - - // Register GetTestsResource - GetTestsResource getTestsResource = new GetTestsResource(_testRunnerService); - _resources.Add(getTestsResource.Name, getTestsResource); - - // Register GetGameObjectResource - GetGameObjectResource getGameObjectResource = new GetGameObjectResource(); - _resources.Add(getGameObjectResource.Name, getGameObjectResource); - } - - /// - /// Initialize services used by the server - /// - private void InitializeServices() - { - // Initialize the test runner service - _testRunnerService = new TestRunnerService(); - - // Initialize the console logs service - _consoleLogsService = new ConsoleLogsService(); - } - - /// - /// Called after every domain reload - /// - [DidReloadScripts] - private static void AfterReload() - { - // Skip initialization in batch mode (Unity Cloud Build, CI, headless builds) - // This prevents npm commands from hanging the build process - if (Application.isBatchMode) - { - return; - } - - // Ensure Instance is created and hooks are set up after initial domain load - var currentInstance = Instance; - } - - /// - /// Handles the Unity Editor quitting event. Ensures the server is properly stopped and disposed. - /// - private static void OnEditorQuitting() - { - if (Application.isBatchMode || _instance == null) return; - - McpLogger.LogInfo("Editor is quitting. Ensuring server is stopped."); - _instance.Dispose(); - } - - /// - /// Handles the Unity Editor's 'before assembly reload' event. - /// Stops the WebSocket server to prevent port conflicts and ensure a clean state before scripts are recompiled. - /// - private static void OnBeforeAssemblyReload() - { - if (Application.isBatchMode || _instance == null) return; - - _instance.StopServer(); - } - - /// - /// Handles the Unity Editor's 'after assembly reload' event. - /// If auto-start is enabled, attempts to restart the WebSocket server if it's not already listening. - /// This ensures the server is operational after script recompilation. - /// - private static void OnAfterAssemblyReload() - { - if (Application.isBatchMode || _instance == null) return; - - if (McpUnitySettings.Instance.AutoStartServer && !_instance.IsListening) - { - _instance.ScheduleStartServer(requireAutoStart: true, reason: "assembly reload"); - } - } - - /// - /// Handles changes in Unity Editor's play mode state. - /// Stops the server when exiting Edit Mode if configured, and restarts it when entering Play Mode or returning to Edit Mode if auto-start is enabled. - /// - /// The current play mode state change. - private static void OnPlayModeStateChanged(PlayModeStateChange state) - { - if (Application.isBatchMode || _instance == null) return; - - switch (state) - { - case PlayModeStateChange.ExitingEditMode: - // About to enter Play Mode - use custom close code so clients use fast polling - _instance.StopServer(UnityCloseCode.PlayMode, "Unity entering Play mode"); - break; - case PlayModeStateChange.EnteredPlayMode: - // The assumption above (server stays down through Play, a domain reload on exit - // will restart it) only holds when Enter Play Mode Options are OFF or don't disable - // domain reload. With "Reload Scene without Reload Domain" -- Project Settings > - // Editor > Enter Play Mode Options, a real, supported Unity profile some projects - // pick specifically for iteration speed -- NO domain reload happens on either side - // of Play Mode, so nothing was ever going to bring the server back. That leaves it - // down for the entire Play session, with no way for a client to even ask Unity to - // exit Play, since that request needs this same server. Confirmed live: a client - // got locked in Play with no way out until the Editor was closed by hand. - // Restarting here covers both profiles: if a domain reload already restarted it via - // OnAfterAssemblyReload, IsListening is already true and this is a no-op; if it - // didn't, this is the only thing that brings it back. - if (!_instance.IsListening && McpUnitySettings.Instance.AutoStartServer) - { - _instance.ScheduleStartServer(requireAutoStart: true, reason: "entered play mode"); - } - break; - case PlayModeStateChange.ExitingPlayMode: - break; - case PlayModeStateChange.EnteredEditMode: - // Returned to Edit Mode - if (!_instance.IsListening && McpUnitySettings.Instance.AutoStartServer) - { - _instance.ScheduleStartServer(requireAutoStart: true, reason: "entered edit mode"); - } - break; - } - } - } -} diff --git a/Editor/UnityBridge/McpUnityServer.cs.meta b/Editor/UnityBridge/McpUnityServer.cs.meta deleted file mode 100644 index 48ce2f1a..00000000 --- a/Editor/UnityBridge/McpUnityServer.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ca1c774925b38a149b67fa9318aba113 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/UnityBridge/McpUnitySettings.cs b/Editor/UnityBridge/McpUnitySettings.cs deleted file mode 100644 index 2223431e..00000000 --- a/Editor/UnityBridge/McpUnitySettings.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.IO; -using McpUnity.Utils; -using UnityEngine; -using UnityEditor; - -namespace McpUnity.Unity -{ - /// - /// Handles persistence of MCP Unity settings - /// - [Serializable] - public class McpUnitySettings - { - // Constants - public const string ServerVersion = "1.2.0"; - public const string PackageName = "com.gamelovers.mcp-unity"; - public const int RequestTimeoutMinimum = 10; - - // Paths - private const string SettingsPath = "ProjectSettings/McpUnitySettings.json"; - - private static McpUnitySettings _instance; - - [Tooltip("Port number for MCP server")] - public int Port = 8090; - - [Tooltip("Timeout in seconds for tool request")] - public int RequestTimeoutSeconds = RequestTimeoutMinimum; - - [Tooltip("Whether to automatically start the MCP server when Unity opens")] - public bool AutoStartServer = true; - - [Tooltip("Whether to show info logs in the Unity console")] - public bool EnableInfoLogs = false; - - [Tooltip("Optional: Full path to the npm executable (e.g., /Users/user/.asdf/shims/npm or C:\\path\\to\\npm.cmd). If not set, 'npm' from the system PATH will be used.")] - public string NpmExecutablePath = string.Empty; - - [Tooltip("Allow connections from remote MCP bridges. When disabled, only localhost connections are allowed (default).")] - public bool AllowRemoteConnections = false; - - /// - /// Singleton instance of settings - /// - public static McpUnitySettings Instance - { - get - { - if (_instance == null) - { - _instance = new McpUnitySettings(); - } - return _instance; - } - } - - /// - /// Private constructor for singleton - /// - private McpUnitySettings() - { - LoadSettings(); - } - - /// - /// Load settings from disk - /// - public void LoadSettings() - { - try - { - // Load settings from McpUnitySettings.json - if (File.Exists(SettingsPath)) - { - string json = File.ReadAllText(SettingsPath); - JsonUtility.FromJsonOverwrite(json, this); - } - else - { - // Create default settings file on the first time initialization - SaveSettings(); - } - } - catch (Exception ex) - { - // Can't use LoggerService here as it depends on settings - Debug.LogError($"[MCP Unity] Failed to load settings: {ex.Message}"); - } - } - - /// - /// Save settings to disk - /// - /// - /// WARNING: This file is also read by the MCP server. Changes here will require updates to it. See mcpUnity.ts - /// - public void SaveSettings() - { - try - { - // Save settings to McpUnitySettings.json - string json = JsonUtility.ToJson(this, true); - File.WriteAllText(SettingsPath, json); - } - catch (Exception ex) - { - // Can't use LoggerService here as it might create circular dependency - Debug.LogError($"[MCP Unity] Failed to save settings: {ex.Message}"); - } - } - } -} diff --git a/Editor/UnityBridge/McpUnitySettings.cs.meta b/Editor/UnityBridge/McpUnitySettings.cs.meta deleted file mode 100644 index f4a1774f..00000000 --- a/Editor/UnityBridge/McpUnitySettings.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 36f9e0452348eb44cad23688666e32c3 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/UnityBridge/McpUnitySocketHandler.cs b/Editor/UnityBridge/McpUnitySocketHandler.cs deleted file mode 100644 index e0c90612..00000000 --- a/Editor/UnityBridge/McpUnitySocketHandler.cs +++ /dev/null @@ -1,376 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using WebSocketSharp; -using WebSocketSharp.Server; -using McpUnity.Tools; -using McpUnity.Resources; -using Unity.EditorCoroutines.Editor; -using System.Collections; -using System.Collections.Specialized; -using System.Collections.Concurrent; -using McpUnity.Utils; - -namespace McpUnity.Unity -{ - /// - /// Drains work queued from background WebSocket threads on the Unity main thread via - /// EditorApplication.update, which keeps firing even when the Editor is unfocused. - /// - /// This replaces dispatching through EditorApplication.delayCall: delayCall is a plain - /// static delegate, and a "+=" performed from the WebSocketSharp background thread is not - /// reliably observed/drained by the main thread while the Editor is idle in the - /// background. The result was that requests received while Unity was not the foreground - /// app were never processed, and the MCP client timed out. Draining a thread-safe queue - /// from EditorApplication.update fixes this without relying on cross-thread delegate - /// mutation. - /// - [InitializeOnLoad] - internal static class McpMainThreadDispatcher - { - private static readonly ConcurrentQueue _queue = new ConcurrentQueue(); - - static McpMainThreadDispatcher() - { - EditorApplication.update -= Drain; - EditorApplication.update += Drain; - } - - public static void Enqueue(Action action) - { - if (action != null) _queue.Enqueue(action); - } - - private static void Drain() - { - while (_queue.TryDequeue(out var action)) - { - try { action(); } - catch (Exception ex) { McpLogger.LogError($"MainThreadDispatcher action failed: {ex}"); } - } - } - } - - /// - /// WebSocket handler for MCP Unity communications - /// - public class McpUnitySocketHandler : WebSocketBehavior - { - private readonly McpUnityServer _server; - private readonly int _connectionGeneration; - - /// - /// Creates a WebSocket handler for the active server generation. - /// - public McpUnitySocketHandler(McpUnityServer server, int connectionGeneration) - { - _server = server; - _connectionGeneration = connectionGeneration; - } - - /// - /// Create a standardized error response - /// - /// Error message - /// Type of error - /// A JObject containing the error information - public static JObject CreateErrorResponse(string message, string errorType) - { - return new JObject - { - ["error"] = new JObject - { - ["type"] = errorType, - ["message"] = message - } - }; - } - - /// - /// Handle incoming messages from WebSocket clients. - /// WebSocketSharp invokes this on a background thread; we marshal the entire - /// message-handling body onto Unity's main thread via EditorApplication.delayCall - /// before touching any Editor APIs. - /// - /// Why this matters: accessing EditorStyles or scheduling EditorCoroutines from - /// a background thread can NRE inside PropertyEditor+Styles..cctor, which under - /// CLR rules permanently bricks that type for the rest of the AppDomain and - /// turns the Inspector black until Unity is restarted. - /// - protected override void OnMessage(MessageEventArgs e) - { - if (!_server.ShouldTrackClient(_connectionGeneration)) - { - CloseUntrackedConnection(); - return; - } - - string data = e.Data; - // Dispatch via a thread-safe queue drained in EditorApplication.update rather than - // EditorApplication.delayCall. A delayCall "+=" from this background thread is not - // reliably drained by the main thread while the Editor is unfocused/idle, so the - // request would never run and the client would time out. See McpMainThreadDispatcher. - McpMainThreadDispatcher.Enqueue(() => HandleMessageAsync(data)); - } - - /// - /// Handle WebSocket connection open. - /// Supports multiple concurrent MCP clients (e.g. multiple Claude Code instances). - /// Cleans up only inactive (dead) sessions to prevent file descriptor accumulation - /// while keeping other active clients connected. - /// websocket-sharp uses Mono's IOSelector/select(), which can crash when FD - /// values exceed ~1024, so stale session cleanup is important. - /// See: https://github.com/CoderGamester/mcp-unity/issues/110 - /// - protected override void OnOpen() - { - if (!_server.ShouldTrackClient(_connectionGeneration)) - { - CloseUntrackedConnection(); - return; - } - - // Clean up inactive (dead) sessions to prevent file descriptor accumulation. - // Only removes sessions that are no longer connected — active clients are preserved. - // Note: Do NOT use ActiveIDs here — it pings every client and blocks. - var inactiveIds = Sessions.InactiveIDs.ToList(); - if (inactiveIds.Count > 0) - { - foreach (var oldId in inactiveIds) - { - // Also remove from our tracking dictionary - _server.Clients.TryRemove(oldId, out _); - try - { - Sessions.CloseSession(oldId, CloseStatusCode.Normal, "Stale session cleanup"); - } - catch (Exception ex) - { - McpLogger.LogWarning($"Error closing stale session {oldId}: {ex.Message}"); - } - } - McpLogger.LogInfo($"Cleaned up {inactiveIds.Count} inactive session(s)"); - } - - // Extract client name from the X-Client-Name header (if available) - string clientName = ""; - NameValueCollection headers = Context.Headers; - if (headers != null && headers.Contains("X-Client-Name")) - { - clientName = headers["X-Client-Name"]; - } - - if (!_server.ShouldTrackClient(_connectionGeneration)) - { - CloseUntrackedConnection(); - return; - } - - // Add the client to the server's tracking dictionary - _server.Clients[ID] = clientName; - - McpLogger.LogInfo($"WebSocket client connected (ID: {ID}, Name: {(string.IsNullOrEmpty(clientName) ? "Unknown" : clientName)}, Total clients: {_server.Clients.Count})"); - } - - /// - /// Handle WebSocket connection close - /// - protected override void OnClose(CloseEventArgs e) - { - _server.Clients.TryGetValue(ID, out string clientName); - - // Remove the client from the server - _server.Clients.TryRemove(ID, out _); - - string reason = e.Reason; - if (reason == "An exception has occurred while receiving.") - { - reason = "connection closed by client"; - } - - McpLogger.LogInfo($"WebSocket client '{clientName}' disconnected: {reason} (Remaining clients: {_server.Clients.Count})"); - } - - /// - /// Handle WebSocket errors - /// - protected override void OnError(ErrorEventArgs e) - { - McpLogger.LogError($"WebSocket error: {e.Message}"); - } - - /// - /// Process a WebSocket message on the Unity main thread. - /// Safe to call EditorCoroutineUtility, Selection, and other Editor APIs from here. - /// - private async void HandleMessageAsync(string data) - { - try - { - if (!_server.ShouldTrackClient(_connectionGeneration)) - { - CloseUntrackedConnection(); - return; - } - - McpLogger.LogInfo($"WebSocket message received: {data}"); - JObject requestJson; - try - { - requestJson = JObject.Parse(data); - } - catch (JsonReaderException jre) - { - McpLogger.LogError($"Invalid JSON received: {jre.Message}. Data: {data}"); - // Attempt to send a parse error response. No requestId is available yet. - Send(CreateResponse(null, CreateErrorResponse($"Invalid JSON format: {jre.Message}", "invalid_json")).ToString(Formatting.None)); - return; - } - - var method = requestJson["method"]?.ToString(); - var parameters = requestJson["params"] as JObject ?? new JObject(); - var requestId = requestJson["id"]?.ToString(); - // We need to dispatch to Unity's main thread and wait for completion - var tcs = new TaskCompletionSource(); - - if (string.IsNullOrEmpty(method)) - { - tcs.SetResult(CreateErrorResponse("Missing method in request", "invalid_request")); - } - else if (_server.TryGetTool(method, out var tool)) - { - EditorCoroutineUtility.StartCoroutineOwnerless(ExecuteTool(tool, parameters, tcs)); - } - else if (_server.TryGetResource(method, out var resource)) - { - EditorCoroutineUtility.StartCoroutineOwnerless(FetchResourceCoroutine(resource, parameters, tcs)); - } - else - { - tcs.SetResult(CreateErrorResponse($"Unknown method: {method}", "unknown_method")); - } - - JObject responseJson = await tcs.Task; - JObject jsonRpcResponse = CreateResponse(requestId, responseJson); - string responseStr = jsonRpcResponse.ToString(Formatting.None); - - McpLogger.LogInfo($"WebSocket message response for request ID '{requestId}': {responseStr}"); - - // Send the response back to the client - Send(responseStr); - } - catch (Exception ex) - { - McpLogger.LogError($"Error processing message: {ex.Message}"); - - Send(CreateErrorResponse($"Internal server error: {ex.Message}", "internal_error").ToString(Formatting.None)); - } - } - - private void CloseUntrackedConnection() - { - try - { - WebSocket webSocket = Context?.WebSocket; - if (webSocket?.ReadyState == WebSocketState.Open) - { - webSocket.Close(CloseStatusCode.Away, "Server is restarting"); - } - } - catch (Exception ex) - { - McpLogger.LogWarning($"Error closing untracked WebSocket connection: {ex.Message}"); - } - } - - /// - /// Execute a tool with the provided parameters - /// - private IEnumerator ExecuteTool(McpToolBase tool, JObject parameters, TaskCompletionSource tcs) - { - try - { - if (tool.IsAsync) - { - tool.ExecuteAsync(parameters, tcs); - } - else - { - var result = tool.Execute(parameters); - tcs.SetResult(result); - } - } - catch (Exception ex) - { - McpLogger.LogError($"Error executing tool {tool.Name}: {ex.Message}\n{ex.StackTrace}"); - tcs.SetResult(CreateErrorResponse( - $"Failed to execute tool {tool.Name}: {ex.Message}", - "tool_execution_error" - )); - } - - yield return null; - } - - /// - /// Fetch a resource with the provided parameters - /// - private IEnumerator FetchResourceCoroutine(McpResourceBase resource, JObject parameters, TaskCompletionSource tcs) - { - try - { - if (resource.IsAsync) - { - resource.FetchAsync(parameters, tcs); - } - else - { - var result = resource.Fetch(parameters); - tcs.SetResult(result); - } - } - catch (Exception ex) - { - McpLogger.LogError($"Error fetching resource {resource.Name}: {ex.Message}\n{ex.StackTrace}"); - tcs.SetResult(CreateErrorResponse( - $"Failed to fetch resource {resource.Name}: {ex.Message}", - "resource_fetch_error" - )); - } - yield return null; - } - - /// - /// Create a JSON-RPC 2.0 response - /// - /// Request ID - /// Result object - /// JSON-RPC 2.0 response - private JObject CreateResponse(string requestId, JObject result) - { - // Format as JSON-RPC 2.0 response - JObject jsonRpcResponse = new JObject - { - ["id"] = requestId - }; - - // Add result or error - if (result.TryGetValue("error", out var errorObj)) - { - jsonRpcResponse["error"] = errorObj; - } - else - { - jsonRpcResponse["result"] = result; - } - - return jsonRpcResponse; - } - } -} diff --git a/Editor/UnityBridge/McpUnitySocketHandler.cs.meta b/Editor/UnityBridge/McpUnitySocketHandler.cs.meta deleted file mode 100644 index 3c132433..00000000 --- a/Editor/UnityBridge/McpUnitySocketHandler.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6cdc25bb9ba16374c86e42480ff8e3b9 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Utils.meta b/Editor/Utils.meta deleted file mode 100644 index 508c7dcc..00000000 --- a/Editor/Utils.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 653d639cd0bb4a88a40d33ae3f57571b -timeCreated: 1743635743 \ No newline at end of file diff --git a/Editor/Utils/GameObjectHierarchyCreator.cs b/Editor/Utils/GameObjectHierarchyCreator.cs deleted file mode 100644 index 39c3b5a9..00000000 --- a/Editor/Utils/GameObjectHierarchyCreator.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using UnityEngine; -using UnityEditor; // Required for Undo operations - -namespace McpUnity.Utils -{ - public static class GameObjectHierarchyCreator - { - public static GameObject FindOrCreateHierarchicalGameObject(string path) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentException("GameObject path cannot be null or empty.", nameof(path)); - } - - path = path.Trim('/'); - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentException("GameObject path cannot consist only of slashes.", nameof(path)); - } - - string[] parts = path.Split('/'); - GameObject currentParent = null; - GameObject foundOrCreatedObject = null; - - for (int i = 0; i < parts.Length; i++) - { - string name = parts[i]; - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentException($"Invalid path: empty segment at part {i + 1} in path '{path}'. Ensure segments are not empty."); - } - - Transform childTransform; - if (currentParent == null) - { - GameObject rootObj = GameObject.Find(name); - childTransform = rootObj?.transform; - } - else - { - childTransform = currentParent.transform.Find(name); - } - - if (childTransform == null) - { - GameObject newObj = new GameObject(name); - Undo.RegisterCreatedObjectUndo(newObj, $"Create {name}"); - if (currentParent != null) - { - newObj.transform.SetParent(currentParent.transform, false); - } - foundOrCreatedObject = newObj; - currentParent = newObj; - } - else - { - foundOrCreatedObject = childTransform.gameObject; - currentParent = foundOrCreatedObject; - } - } - - if (foundOrCreatedObject == null) - { - throw new InvalidOperationException($"Failed to find or create GameObject for path '{path}'. This indicates an unexpected state."); - } - - return foundOrCreatedObject; - } - } -} diff --git a/Editor/Utils/GameObjectHierarchyCreator.cs.meta b/Editor/Utils/GameObjectHierarchyCreator.cs.meta deleted file mode 100644 index 04e489b7..00000000 --- a/Editor/Utils/GameObjectHierarchyCreator.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 3697ac47725de644d8b35a23fea536c2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Utils/Logger.cs b/Editor/Utils/Logger.cs deleted file mode 100644 index 97ce39c4..00000000 --- a/Editor/Utils/Logger.cs +++ /dev/null @@ -1,43 +0,0 @@ -using UnityEngine; -using McpUnity.Unity; - -namespace McpUnity.Utils -{ - /// - /// Special logger to use inside the MCP Unity Editor project - /// - public static class McpLogger - { - private const string LogPrefix = "[MCP Unity] "; - - /// - /// Log an info message if info logs are enabled - /// - /// Message to log - public static void LogInfo(string message) - { - if (McpUnitySettings.Instance.EnableInfoLogs) - { - Debug.LogFormat(LogType.Log, LogOption.NoStacktrace, null, "{0}{1}", LogPrefix, message); - } - } - - /// - /// Log a warning message - /// - /// Message to log - public static void LogWarning(string message) - { - Debug.LogWarning($"{LogPrefix}{message}"); - } - - /// - /// Log an error message - /// - /// Message to log - public static void LogError(string message) - { - Debug.LogError($"{LogPrefix}{message}"); - } - } -} diff --git a/Editor/Utils/Logger.cs.meta b/Editor/Utils/Logger.cs.meta deleted file mode 100644 index 1dcc4b91..00000000 --- a/Editor/Utils/Logger.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: d9f2327ac25b8994498b269be0b5a68e -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Utils/McpBackgroundTick.cs b/Editor/Utils/McpBackgroundTick.cs deleted file mode 100644 index 13de6728..00000000 --- a/Editor/Utils/McpBackgroundTick.cs +++ /dev/null @@ -1,110 +0,0 @@ -#if UNITY_EDITOR -using System; -using System.Runtime.InteropServices; -using System.Threading; - -namespace McpUnity.Utils -{ - /// - /// Keeps the Unity Editor loop ticking while the editor window is unfocused on Windows. - /// - internal static class McpBackgroundTick - { -#if UNITY_EDITOR_WIN - private delegate void TimerProc(IntPtr hWnd, uint uMsg, UIntPtr nIDEvent, uint dwTime); - - [DllImport("user32.dll", SetLastError = true)] - private static extern UIntPtr SetTimer(IntPtr hWnd, UIntPtr nIDEvent, uint uElapse, TimerProc lpTimerFunc); - - [DllImport("user32.dll", SetLastError = true)] - private static extern bool KillTimer(IntPtr hWnd, UIntPtr uIDEvent); - - private const uint TickIntervalMs = 100; - - // Rooted for the complete native timer lifetime so the callback cannot be collected. - private static readonly TimerProc Callback = OnTimer; - private static UIntPtr _timerId; - private static int _callbackRunning; -#endif - - /// - /// Starts the Windows background timer after the WebSocket server is listening. - /// - public static void Start() - { -#if UNITY_EDITOR_WIN - if (_timerId != UIntPtr.Zero) - { - return; - } - - _timerId = SetTimer(IntPtr.Zero, UIntPtr.Zero, TickIntervalMs, Callback); - if (_timerId == UIntPtr.Zero) - { - McpLogger.LogError($"Failed to start background editor tick timer. Win32 error: {Marshal.GetLastWin32Error()}."); - } -#endif - } - - /// - /// Stops the Windows background timer. Safe to call repeatedly. - /// - public static void Stop() - { -#if UNITY_EDITOR_WIN - UIntPtr timerId = _timerId; - if (timerId == UIntPtr.Zero) - { - return; - } - - try - { - if (!KillTimer(IntPtr.Zero, timerId)) - { - McpLogger.LogWarning($"Failed to stop background editor tick timer. Win32 error: {Marshal.GetLastWin32Error()}."); - } - } - catch (Exception ex) - { - McpLogger.LogError($"Error stopping background editor tick timer: {ex.Message}"); - } - finally - { - _timerId = UIntPtr.Zero; - } -#endif - } - -#if UNITY_EDITOR_WIN - private static void OnTimer(IntPtr hWnd, uint uMsg, UIntPtr nIDEvent, uint dwTime) - { - if (Interlocked.Exchange(ref _callbackRunning, 1) != 0) - { - return; - } - - try - { - UnityEditor.EditorApplication.QueuePlayerLoopUpdate(); - } - catch (Exception ex) - { - try - { - McpLogger.LogError($"Error during background editor tick: {ex.Message}"); - } - catch - { - // Nothing may escape the native callback boundary. - } - } - finally - { - Volatile.Write(ref _callbackRunning, 0); - } - } -#endif - } -} -#endif diff --git a/Editor/Utils/McpBackgroundTick.cs.meta b/Editor/Utils/McpBackgroundTick.cs.meta deleted file mode 100644 index 5994d4aa..00000000 --- a/Editor/Utils/McpBackgroundTick.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c6a75d0993683af4f8d6b111db870d9c \ No newline at end of file diff --git a/Editor/Utils/McpUtils.cs b/Editor/Utils/McpUtils.cs deleted file mode 100644 index bd51e421..00000000 --- a/Editor/Utils/McpUtils.cs +++ /dev/null @@ -1,1165 +0,0 @@ -using System; -using System.IO; -using System.Collections.Generic; -using System.Reflection; -using McpUnity.Unity; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace McpUnity.Utils -{ - /// - /// Controls how the path to Server~/build/index.js is rendered in generated MCP configs. - /// - public enum PathMode - { - /// Absolute filesystem path. Required for per-user/global configs (Cursor, Windsurf, etc.). - Absolute, - /// Path relative to the Unity project root. Used for OpenCode's opencode.json. - ProjectRelative, - /// Project-relative path prefixed with ${workspaceFolder}/. Used for VS Code / GitHub Copilot's .vscode/mcp.json. - VSCodeWorkspaceFolder - } - - /// - /// Utility class for MCP configuration and system operations - /// - public static class McpUtils - { - - // Cached result for Multiplayer Play Mode clone detection - private static bool? _isMultiplayerPlayModeClone; - - /// - /// Generates the MCP configuration JSON to setup the Unity MCP server in different AI Clients - /// - public static string GenerateMcpConfigJson(bool useTabsIndentation, PathMode pathMode = PathMode.Absolute) - { - var config = new Dictionary - { - { "mcpServers", new Dictionary - { - { "mcp-unity", new Dictionary - { - { "command", "node" }, - { "args", new[] { GetIndexJsPath(pathMode) } } - } - } - } - } - }; - - // Initialize string writer with proper indentation - var stringWriter = new StringWriter(); - using (var jsonWriter = new JsonTextWriter(stringWriter)) - { - jsonWriter.Formatting = Formatting.Indented; - - // Set indentation character and count - if (useTabsIndentation) - { - jsonWriter.IndentChar = '\t'; - jsonWriter.Indentation = 1; - } - else - { - jsonWriter.IndentChar = ' '; - jsonWriter.Indentation = 2; - } - - // Serialize directly to the JsonTextWriter - var serializer = new JsonSerializer(); - serializer.Serialize(jsonWriter, config); - } - - return stringWriter.ToString().Replace("\\", "/").Replace("//", "/"); - } - - /// - /// Generates the MCP configuration JSON for OpenCode (https://opencode.ai/). - /// OpenCode uses a different schema than the standard `mcpServers` shape: - /// { "$schema": ..., "mcp": { "mcp-unity": { "type": "local", "enabled": true, "command": [...], "environment": {} } } } - /// - public static string GenerateOpenCodeConfigJson(bool useTabsIndentation, PathMode pathMode = PathMode.Absolute) - { - string indexJsPath = GetIndexJsPath(pathMode); - - var config = new Dictionary - { - { "$schema", "https://opencode.ai/config.json" }, - { "mcp", new Dictionary - { - { "mcp-unity", new Dictionary - { - { "type", "local" }, - { "enabled", true }, - { "command", new[] { "node", indexJsPath } }, - { "environment", new Dictionary() } - } - } - } - } - }; - - var stringWriter = new StringWriter(); - using (var jsonWriter = new JsonTextWriter(stringWriter)) - { - jsonWriter.Formatting = Formatting.Indented; - - if (useTabsIndentation) - { - jsonWriter.IndentChar = '\t'; - jsonWriter.Indentation = 1; - } - else - { - jsonWriter.IndentChar = ' '; - jsonWriter.Indentation = 2; - } - - var serializer = new JsonSerializer(); - serializer.Serialize(jsonWriter, config); - } - - return stringWriter.ToString().Replace("\\", "/").Replace("//", "/"); - } - - /// - /// Generates the MCP configuration TOML to setup the Unity MCP server in TOML-based AI Clients (e.g., Codex CLI) - /// - /// The TOML configuration string for mcp-unity server - public static string GenerateMcpConfigToml(PathMode pathMode = PathMode.Absolute) - { - string indexJsPath = GetIndexJsPath(pathMode); - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("[mcp_servers.mcp-unity]"); - sb.AppendLine("command = \"node\""); - sb.AppendLine($"args = [\"{indexJsPath}\"]"); - return sb.ToString(); - } - - /// - /// Returns the path to Server~/build/index.js rendered according to the given . - /// All returned paths use forward slashes. - /// - private static string GetIndexJsPath(PathMode mode) - { - string absoluteIndexJs = Path.Combine(GetServerPath(), "build", "index.js").Replace("\\", "/"); - - if (mode == PathMode.Absolute) - { - return absoluteIndexJs; - } - - string projectRoot = Directory.GetParent(Application.dataPath).FullName.Replace("\\", "/"); - string relativeIndexJs = Path.GetRelativePath(projectRoot, absoluteIndexJs).Replace("\\", "/"); - - if (mode == PathMode.VSCodeWorkspaceFolder) - { - return "${workspaceFolder}/" + relativeIndexJs; - } - - return relativeIndexJs; - } - - /// - /// 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 - /// - public static string GetServerPath() - { - // First, try to find the package info via Package Manager - var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath($"Packages/{McpUnitySettings.PackageName}"); - - if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) - { - string serverPath = Path.Combine(packageInfo.resolvedPath, "Server~"); - - return CleanPathPrefix(serverPath); - } - - string[] dirs = System.IO.Directory.GetDirectories("Assets", "Server~", System.IO.SearchOption.AllDirectories); - - for (int n=0; n - /// Cleans the path prefix by removing a leading "~" character if present on macOS. - /// - /// The path to clean. - /// The cleaned path. - private static string CleanPathPrefix(string path) - { - if (path.StartsWith("~")) - { - return path.Substring(1); - } - return path; - } - - /// - /// Encodes a file path for use in file:// URLs by replacing spaces with %20. - /// - /// The path to encode. - /// The encoded path suitable for file:// URLs. - public static string EncodePathForFileUrl(string path) - { - if (string.IsNullOrEmpty(path)) - return path; - - return path.Replace(" ", "%20"); - } - - /// - /// Validates the server path and returns true if valid. - /// - /// The server path to validate. - /// True if path is valid and usable, false if path has critical issues. - public static bool ValidateServerPath(string serverPath) - { - if (string.IsNullOrEmpty(serverPath)) - { - Debug.LogError("[MCP Unity] Server path is null or empty. Cannot validate."); - return false; - } - - // Verify the path exists - if (!Directory.Exists(serverPath)) - { - Debug.LogError($"[MCP Unity] Server path does not exist: {serverPath}"); - return false; - } - - // Verify required files exist - string packageJsonPath = Path.Combine(serverPath, "package.json"); - if (!File.Exists(packageJsonPath)) - { - Debug.LogError($"[MCP Unity] package.json not found in server path: {serverPath}"); - return false; - } - - return true; - } - - /// - /// Adds the MCP configuration to the Windsurf MCP config file - /// - public static bool AddToWindsurfIdeConfig(bool useTabsIndentation) - { - string configFilePath = GetWindsurfMcpConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "Windsurf"); - } - - /// - /// Adds the MCP configuration to the Claude Desktop config file - /// - public static bool AddToClaudeDesktopConfig(bool useTabsIndentation) - { - string configFilePath = GetClaudeDesktopConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "Claude Desktop"); - } - - /// - /// Adds the MCP configuration to the Cursor config file - /// - public static bool AddToCursorConfig(bool useTabsIndentation) - { - string configFilePath = GetCursorConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "Cursor"); - } - - /// - /// Adds the MCP configuration to the Claude Code config file - /// - public static bool AddToClaudeCodeConfig(bool useTabsIndentation) - { - string configFilePath = GetClaudeCodeConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "Claude Code"); - } - - /// - /// Adds the MCP configuration to the Google Antigravity config file - /// - public static bool AddToAntigravityConfig(bool useTabsIndentation) - { - string configFilePath = GetAntigravityConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "Google Antigravity"); - } - - /// - /// Adds the MCP configuration to the GitHub Copilot config file. - /// Uses ${workspaceFolder}-prefixed path so the config is portable across machines when committed to git. - /// - public static bool AddToGitHubCopilotConfig(bool useTabsIndentation) - { - string configFilePath = GetGitHubCopilotConfigPath(); - return AddToConfigFile(configFilePath, useTabsIndentation, "GitHub Copilot", PathMode.VSCodeWorkspaceFolder); - } - - /// - /// Adds the MCP configuration to the Codex CLI config file (TOML format) - /// - public static bool AddToCodexCliConfig(bool useTabsIndentation) - { - string configFilePath = GetCodexCliConfigPath(); - return AddToTomlConfigFile(configFilePath, "Codex CLI"); - } - - /// - /// Adds the MCP configuration to the OpenCode config file (opencode.json in project root). - /// OpenCode uses a custom JSON schema, so this does not reuse the standard mcpServers helpers. - /// Uses a project-relative path so the config is portable across machines when committed to git. - /// - public static bool AddToOpenCodeConfig(bool useTabsIndentation) - { - string configFilePath = GetOpenCodeConfigPath(); - return AddToOpenCodeConfigFile(configFilePath, useTabsIndentation, PathMode.ProjectRelative); - } - - /// - /// Adds the MCP configuration to the project-local Cursor config (/.cursor/mcp.json). - /// Uses a project-relative path so the config is portable across machines when committed to git. - /// - public static bool AddToCursorProjectConfig(bool useTabsIndentation) - { - return AddToConfigFile(GetCursorProjectConfigPath(), useTabsIndentation, "Cursor (Project)", PathMode.ProjectRelative); - } - - /// - /// Adds the MCP configuration to the project-local Claude Code config (/.mcp.json). - /// This file is Claude Code's team-shared MCP config and is intended to be committed to git. - /// Uses a project-relative path so the config is portable across machines. - /// - public static bool AddToClaudeCodeProjectConfig(bool useTabsIndentation) - { - return AddToConfigFile(GetClaudeCodeProjectConfigPath(), useTabsIndentation, "Claude Code (Project)", PathMode.ProjectRelative); - } - - /// - /// Adds the MCP configuration to the project-local Codex CLI config (/.codex/config.toml). - /// Codex layers this over the global ~/.codex/config.toml only when the project is marked trusted - /// (Codex prompts the user the first time they run `codex` from the project root). - /// Uses a project-relative path so the config is portable across machines. - /// - public static bool AddToCodexCliProjectConfig(bool useTabsIndentation) - { - return AddToTomlConfigFile(GetCodexCliProjectConfigPath(), "Codex CLI (Project)", PathMode.ProjectRelative); - } - - /// - /// Returns whether automatic MCP configuration is supported for the given product on the current platform. - /// - public static bool IsAutoConfigSupported(string productName) - { - switch (productName) - { - case "Claude Code": - case "Claude Code (Project)": - case "Codex CLI": - case "Codex CLI (Project)": - case "Cursor (Project)": - case "GitHub Copilot": - case "OpenCode": - return Application.platform == RuntimePlatform.WindowsEditor - || Application.platform == RuntimePlatform.OSXEditor - || Application.platform == RuntimePlatform.LinuxEditor; - case "Windsurf": - case "Claude Desktop": - case "Cursor": - case "Google Antigravity": - return Application.platform == RuntimePlatform.WindowsEditor - || Application.platform == RuntimePlatform.OSXEditor; - default: - return false; - } - } - - /// - /// Returns a human-readable reason when automatic MCP configuration is unsupported. - /// - public static string GetAutoConfigUnsupportedReason(string productName) - { - if (IsAutoConfigSupported(productName)) - { - return null; - } - - if (Application.platform == RuntimePlatform.LinuxEditor) - { - return $"Automatic {productName} configuration is currently available on Linux only for Claude Code, Codex CLI, Cursor (Project), GitHub Copilot, and OpenCode."; - } - - return $"Automatic {productName} configuration is not supported on {Application.platform}."; - } - - /// - /// Common method to add MCP configuration to a specified config file - /// - /// Path to the config file - /// Whether to use tabs for indentation - /// Name of the product (for error messages) - /// How to render the path to Server~/build/index.js - /// True if successfuly added the config, false otherwise - private static bool AddToConfigFile(string configFilePath, bool useTabsIndentation, string productName, PathMode pathMode = PathMode.Absolute) - { - if (string.IsNullOrEmpty(configFilePath)) - { - Debug.LogError($"{productName} config file not found. Please make sure {productName} is installed."); - return false; - } - - // Generate fresh MCP config JSON - string mcpConfigJson = GenerateMcpConfigJson(useTabsIndentation, pathMode); - - try - { - // Parse the MCP config JSON - JObject mcpConfig = JObject.Parse(mcpConfigJson); - - // Check if the file exists - if (File.Exists(configFilePath)) - { - return TryMergeMcpServers(configFilePath, mcpConfig, productName); - } - else if(Directory.Exists(Path.GetDirectoryName(configFilePath))) - { - // Create a new config file with just our config - File.WriteAllText(configFilePath, mcpConfigJson); - return true; - } - else - { - Debug.LogError($"Cannot find {productName} config file or {productName} is currently not installed. Expecting {productName} to be installed in the {configFilePath} path"); - } - } - catch (Exception ex) - { - Debug.LogError($"Failed to add MCP configuration to {productName}: {ex}"); - } - - return false; - } - - /// - /// Gets the path to the Windsurf MCP config file based on the current OS - /// - /// The path to the Windsurf MCP config file - private static string GetWindsurfMcpConfigPath() - { - // Base path depends on the OS - string basePath; - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Windows: %USERPROFILE%/.codeium/windsurf - basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium/windsurf"); - } - else if (Application.platform == RuntimePlatform.OSXEditor) - { - // macOS: ~/Library/Application Support/.codeium/windsurf - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - basePath = Path.Combine(homeDir, ".codeium/windsurf"); - } - else - { - // Unsupported platform - Debug.LogError("Unsupported platform for Windsurf MCP config"); - return null; - } - - // Return the path to the mcp_config.json file - return Path.Combine(basePath, "mcp_config.json"); - } - - /// - /// Gets the path to the Claude Desktop config file based on the current OS - /// - /// The path to the Claude Desktop config file - private static string GetClaudeDesktopConfigPath() - { - // Base path depends on the OS - string basePath; - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Windows: %USERPROFILE%/AppData/Roaming/Claude - basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude"); - } - else if (Application.platform == RuntimePlatform.OSXEditor) - { - // macOS: ~/Library/Application Support/Claude - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - basePath = Path.Combine(homeDir, "Library", "Application Support", "Claude"); - } - else - { - // Unsupported platform - Debug.LogError("Unsupported platform for Claude Desktop config"); - return null; - } - - // Return the path to the claude_desktop_config.json file - return Path.Combine(basePath, "claude_desktop_config.json"); - } - - /// - /// Gets the path to the Cursor config file based on the current OS - /// - /// The path to the Cursor config file - private static string GetCursorConfigPath() - { - // Base path depends on the OS - string basePath; - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Windows: %USERPROFILE%/.cursor - basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor"); - } - else if (Application.platform == RuntimePlatform.OSXEditor) - { - // macOS: ~/.cursor - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - basePath = Path.Combine(homeDir, ".cursor"); - } - else - { - // Unsupported platform - Debug.LogError("Unsupported platform for Cursor MCP config"); - return null; - } - - // Return the path to the mcp_config.json file - return Path.Combine(basePath, "mcp.json"); - } - - /// - /// Gets the path to the Claude Code config file based on the current OS - /// - /// The path to the Claude Code config file - private static string GetClaudeCodeConfigPath() - { - // Returns the absolute path to the global Claude configuration file. - // Windows: %USERPROFILE%\.claude.json - // macOS/Linux: $HOME/.claude.json - if (!TryGetUserHomeDirectory("Claude Code", out string homeDir)) - { - return null; - } - - return Path.Combine(homeDir, ".claude.json"); - } - - /// - /// Gets the path to the Google Antigravity MCP config file based on the current OS - /// - /// The path to the Google Antigravity MCP config file - private static string GetAntigravityConfigPath() - { - // Base path depends on the OS - string basePath; - - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Windows: %USERPROFILE%/.gemini/antigravity/mcp_config.json - basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity"); - } - else if (Application.platform == RuntimePlatform.OSXEditor) - { - // macOS: ~/Library/Application Support/.gemini/antigravity/mcp_config.json - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - basePath = Path.Combine(homeDir, "Library", "Application Support", ".gemini", "antigravity"); - } - else - { - // Unsupported platform - Debug.LogError("Unsupported platform for Google Antigravity MCP config"); - return null; - } - - // Return the path to the mcp_config.json file - return Path.Combine(basePath, "mcp_config.json"); - } - - /// - /// Gets the path to the GitHub Copilot config file (workspace .vscode/mcp.json) - /// - /// The path to the GitHub Copilot config file - private static string GetGitHubCopilotConfigPath() - { - // Default to current Unity project root/.vscode/mcp.json - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - string vscodeDir = Path.Combine(projectRoot, ".vscode"); - return Path.Combine(vscodeDir, "mcp.json"); - } - - /// - /// Gets the path to the OpenCode config file (opencode.json in the Unity project root). - /// OpenCode reads its config per-project, not per-user, so this path is OS-independent. - /// - /// The path to the OpenCode config file - private static string GetOpenCodeConfigPath() - { - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - return Path.Combine(projectRoot, "opencode.json"); - } - - /// - /// Adds the MCP configuration to the OpenCode config file. Preserves existing - /// `$schema` and any unrelated entries under `mcp`, only upserting `mcp["mcp-unity"]`. - /// - private static bool AddToOpenCodeConfigFile(string configFilePath, bool useTabsIndentation, PathMode pathMode = PathMode.Absolute) - { - const string productName = "OpenCode"; - - if (string.IsNullOrEmpty(configFilePath)) - { - Debug.LogError($"{productName} config file path could not be resolved."); - return false; - } - - try - { - string mcpConfigJson = GenerateOpenCodeConfigJson(useTabsIndentation, pathMode); - JObject mcpConfig = JObject.Parse(mcpConfigJson); - JToken newServerEntry = mcpConfig["mcp"]?["mcp-unity"]; - - if (newServerEntry == null) - { - Debug.LogError($"Failed to generate {productName} configuration: missing mcp-unity entry."); - return false; - } - - if (!File.Exists(configFilePath)) - { - File.WriteAllText(configFilePath, mcpConfigJson); - return true; - } - - string existingJson = File.ReadAllText(configFilePath); - JObject existingConfig = string.IsNullOrWhiteSpace(existingJson) - ? new JObject() - : JObject.Parse(existingJson); - - JObject mcpSection = existingConfig["mcp"] as JObject; - if (mcpSection == null) - { - mcpSection = new JObject(); - existingConfig["mcp"] = mcpSection; - } - - mcpSection["mcp-unity"] = newServerEntry; - - File.WriteAllText(configFilePath, existingConfig.ToString(Formatting.Indented)); - return true; - } - catch (Exception ex) - { - Debug.LogError($"Failed to add MCP configuration to {productName}: {ex}"); - return false; - } - } - - /// - /// Gets the path to the Codex CLI config file based on the current OS - /// - /// The path to the Codex CLI config file - private static string GetCodexCliConfigPath() - { - // Codex CLI uses ~/.codex/config.toml on all platforms - if (!TryGetUserHomeDirectory("Codex CLI", out string homeDir)) - { - return null; - } - - return Path.Combine(homeDir, ".codex", "config.toml"); - } - - /// - /// Gets the path to the project-local Cursor MCP config (/.cursor/mcp.json). - /// - private static string GetCursorProjectConfigPath() - { - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - return Path.Combine(projectRoot, ".cursor", "mcp.json"); - } - - /// - /// Gets the path to the project-local Claude Code MCP config (/.mcp.json). - /// This is the team-shared config that Claude Code reads in addition to ~/.claude.json. - /// - private static string GetClaudeCodeProjectConfigPath() - { - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - return Path.Combine(projectRoot, ".mcp.json"); - } - - /// - /// Gets the path to the project-local Codex CLI config (/.codex/config.toml). - /// Codex layers this over ~/.codex/config.toml only when the project is marked trusted. - /// - private static string GetCodexCliProjectConfigPath() - { - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - return Path.Combine(projectRoot, ".codex", "config.toml"); - } - - /// - /// Resolves the current user's home directory across supported Unity Editor platforms. - /// - private static bool TryGetUserHomeDirectory(string productName, out string homeDir) - { - if (Application.platform == RuntimePlatform.WindowsEditor) - { - homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return true; - } - - if (Application.platform == RuntimePlatform.OSXEditor - || Application.platform == RuntimePlatform.LinuxEditor) - { - homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - return true; - } - - Debug.LogError($"Unsupported platform for {productName} config"); - homeDir = null; - return false; - } - - /// - /// Common method to add MCP configuration to a TOML-based config file - /// - /// Path to the TOML config file - /// Name of the product (for error messages) - /// How to render the path to Server~/build/index.js - /// True if successfully added the config, false otherwise - private static bool AddToTomlConfigFile(string configFilePath, string productName, PathMode pathMode = PathMode.Absolute) - { - if (string.IsNullOrEmpty(configFilePath)) - { - Debug.LogError($"{productName} config file path not found. Please make sure {productName} is installed."); - return false; - } - - try - { - // Generate fresh MCP config TOML - string mcpServerConfig = "\n" + GenerateMcpConfigToml(pathMode); - - string directoryPath = Path.GetDirectoryName(configFilePath); - - // Check if the config file exists - if (File.Exists(configFilePath)) - { - return TryMergeMcpServersToml(configFilePath, mcpServerConfig, productName); - } - else if (Directory.Exists(directoryPath)) - { - // Create a new config file - File.WriteAllText(configFilePath, mcpServerConfig.TrimStart()); - return true; - } - else - { - // Create directory and file - Directory.CreateDirectory(directoryPath); - File.WriteAllText(configFilePath, mcpServerConfig.TrimStart()); - return true; - } - } - catch (Exception ex) - { - Debug.LogError($"Failed to add MCP configuration to {productName}: {ex}"); - return false; - } - } - - /// - /// Helper to merge mcp_servers.mcp-unity section into an existing TOML config file. - /// - /// Path to the existing TOML config file - /// The new mcp-unity TOML configuration to merge - /// Name of the product (for error messages) - /// True if successfully merged, false otherwise - private static bool TryMergeMcpServersToml(string configFilePath, string mcpServerConfig, string productName) - { - string existingContent = File.ReadAllText(configFilePath); - - // Check if mcp-unity is already configured - if (existingContent.Contains("[mcp_servers.mcp-unity]")) - { - // Update existing configuration - // Find the start of the mcp-unity section - int startIndex = existingContent.IndexOf("[mcp_servers.mcp-unity]", StringComparison.Ordinal); - - // Find the end of this section (next section header or end of file) - int endIndex = FindNextTomlSectionIndex(existingContent, startIndex + 23); - - string newContent = existingContent.Substring(0, startIndex) + - mcpServerConfig.TrimStart() + - existingContent.Substring(endIndex); - File.WriteAllText(configFilePath, newContent); - } - else - { - // Append the new configuration - File.AppendAllText(configFilePath, mcpServerConfig); - } - - return true; - } - - /// - /// Finds the index of the next TOML section header starting from the given position. - /// Returns the length of the content if no next section is found. - /// - /// The TOML content to search - /// The position to start searching from - /// The index of the next section header, or content length if not found - private static int FindNextTomlSectionIndex(string content, int startPosition) - { - // Look for patterns like [section] or [section.subsection] - int nextSectionIndex = content.IndexOf("\n[", startPosition, StringComparison.Ordinal); - - if (nextSectionIndex == -1) - { - // No more sections, return end of content - return content.Length; - } - - return nextSectionIndex; - } - - /// - /// Runs an npm command (such as install or build) in the specified working directory. - /// Handles cross-platform compatibility (Windows/macOS/Linux) for invoking npm. - /// Logs output and errors to the Unity console. - /// - /// Arguments to pass to npm (e.g., "install" or "run build"). - /// The working directory where the npm command should be executed. - public static void RunNpmCommand(string arguments, string workingDirectory) - { - string npmExecutable = McpUnitySettings.Instance.NpmExecutablePath; - bool useCustomNpmPath = !string.IsNullOrWhiteSpace(npmExecutable); - - System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo - { - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, // Important for redirection and direct execution - CreateNoWindow = true - }; - - if (useCustomNpmPath) - { - // Use the custom path directly - startInfo.FileName = npmExecutable; - startInfo.Arguments = arguments; - } - else if (Application.platform == RuntimePlatform.WindowsEditor) - { - // Fallback to cmd.exe to find 'npm' in PATH - startInfo.FileName = "cmd.exe"; - startInfo.Arguments = $"/c npm {arguments}"; - } - else // macOS / Linux - { - string userShell = Environment.GetEnvironmentVariable("SHELL") ?? "/bin/bash"; - string shellName = Path.GetFileName(userShell); - - // Source rc file to init version managers (nvm, fnm, volta) - GUI apps don't inherit shell env - string rcFile = shellName == "zsh" ? ".zshrc" : ".bashrc"; - - startInfo.FileName = userShell; - startInfo.Arguments = $"-c \"source ~/{rcFile} 2>/dev/null || true; npm {arguments}\""; - - // Fallback PATH for common npm locations - string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - string extraPaths = string.Join(":", - "/usr/local/bin", - "/opt/homebrew/bin", - $"{homeDir}/.nvm/versions/node/default/bin" // nvm default alias - ); - startInfo.EnvironmentVariables["PATH"] = $"{extraPaths}:{currentPath}"; - } - - try - { - using (var process = System.Diagnostics.Process.Start(startInfo)) - { - if (process == null) - { - Debug.LogError($"[MCP Unity] Failed to start npm process with arguments: {arguments} in {workingDirectory}. Process object is null."); - return; - } - - string output = process.StandardOutput.ReadToEnd(); - string error = process.StandardError.ReadToEnd(); - - process.WaitForExit(); - - if (process.ExitCode == 0) - { - Debug.Log($"[MCP Unity] npm {arguments} completed successfully in {workingDirectory}.\n{output}"); - } - else - { - Debug.LogError($"[MCP Unity] npm {arguments} failed in {workingDirectory}. Exit Code: {process.ExitCode}. Error: {error}"); - } - } - } - catch (Exception ex) - { - // Use commandToLog here - Debug.LogError($"[MCP Unity] Exception while running npm {arguments} in {workingDirectory}. Error: {ex.Message}"); - } - } - - /// - /// Returns the appropriate config JObject for merging MCP server settings, - /// with special handling for "Claude Code": - /// - For most products, returns the root config object. - /// - For "Claude Code", returns the project-specific config under "projects/[serverPathParent]". - /// Throws a MissingMemberException if the expected project entry does not exist. - /// - private static JObject GetMcpServersConfig(JObject existingConfig, string productName) - { - // For most products, use the root config object. - if (productName != "Claude Code") - { - return existingConfig; - } - - // For Claude Code, use the project-specific config. - if (existingConfig["projects"] == null) - { - throw new MissingMemberException("Claude Code config error: Could not find 'projects' entry in existing config."); - } - - string serverPath = GetServerPath(); - string serverPathParent = Path.GetDirectoryName(serverPath)?.Replace("\\", "/"); - var projectConfig = existingConfig["projects"][serverPathParent]; - - if (projectConfig == null) - { - throw new MissingMemberException( - $"Claude Code config error: Could not find project entry for parent directory '{serverPathParent}' in existing config." - ); - } - - return (JObject)projectConfig; - } - - /// - /// Helper to merge mcpServers from mcpConfig into the existing config file. - /// - private static bool TryMergeMcpServers(string configFilePath, JObject mcpConfig, string productName) - { - // Read the existing config - string existingConfigJson = File.ReadAllText(configFilePath); - JObject existingConfig = string.IsNullOrEmpty(existingConfigJson) ? new JObject() : JObject.Parse(existingConfigJson); - JObject mcpServersConfig = GetMcpServersConfig(existingConfig, productName); - - // Merge the mcpServers from our config into the existing config - if (mcpConfig["mcpServers"] != null && mcpConfig["mcpServers"] is JObject mcpServers) - { - // Create mcpServers object if it doesn't exist - if (mcpServersConfig["mcpServers"] == null) - { - mcpServersConfig["mcpServers"] = new JObject(); - } - - // Add or update the mcp-unity server config - if (mcpServers["mcp-unity"] != null) - { - ((JObject)mcpServersConfig["mcpServers"])["mcp-unity"] = mcpServers["mcp-unity"]; - } - - // Write the updated config back to the file - File.WriteAllText(configFilePath, existingConfig.ToString(Formatting.Indented)); - return true; - } - - return false; - } - - /// - /// Detects if the current Unity Editor instance is a Multiplayer Play Mode clone (additional editor). - /// Uses multiple detection methods in order of reliability: - /// 1. Command line arguments (-name Player2/3/4 indicates clone) - /// 2. Reflection on CurrentPlayer.IsMainEditor property - /// 3. Library path heuristics - /// Returns false if not a clone or detection fails (allowing normal operation). - /// - /// True if running as a clone instance, false if main editor or detection fails - public static bool IsMultiplayerPlayModeClone() - { - // Return cached result if available - if (_isMultiplayerPlayModeClone.HasValue) - { - return _isMultiplayerPlayModeClone.Value; - } - - try - { - // Method 1: Check command line arguments (most reliable) - // Unity MPPM passes "-name PlayerX" where X > 1 for clones - string[] args = Environment.GetCommandLineArgs(); - for (int i = 0; i < args.Length - 1; i++) - { - if (args[i] == "-name" || args[i] == "--name") - { - string playerName = args[i + 1]; - // Player1 is the main editor, Player2/3/4 are clones - if (playerName.StartsWith("Player") && playerName != "Player1") - { - _isMultiplayerPlayModeClone = true; - return true; - } - // Found -name argument but it's Player1 (main editor) - if (playerName == "Player1") - { - _isMultiplayerPlayModeClone = false; - return false; - } - } - } - - // Method 2: Check for MPPM-specific command line flags - foreach (string arg in args) - { - // Check for clone-specific flags that Unity might pass - if (arg.Contains("mppm") && arg.Contains("clone")) - { - _isMultiplayerPlayModeClone = true; - return true; - } - } - - // Method 3: Try reflection on CurrentPlayer.IsMainEditor (MPPM 1.4+) - Assembly mppmAssembly = null; - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - string assemblyName = assembly.GetName().Name; - if (assemblyName == "Unity.Multiplayer.Playmode" || - assemblyName == "Unity.Multiplayer.Playmode.Editor") - { - mppmAssembly = assembly; - break; - } - } - - if (mppmAssembly != null) - { - // Try to find CurrentPlayer class - Type currentPlayerType = mppmAssembly.GetType("Unity.Multiplayer.Playmode.CurrentPlayer"); - if (currentPlayerType != null) - { - // Try IsMainEditor property - PropertyInfo isMainEditorProperty = currentPlayerType.GetProperty( - "IsMainEditor", - BindingFlags.Public | BindingFlags.Static); - - if (isMainEditorProperty != null) - { - bool isMainEditor = (bool)isMainEditorProperty.GetValue(null); - _isMultiplayerPlayModeClone = !isMainEditor; - return !isMainEditor; - } - } - } - - // Method 4: Check if Unity's Library path indicates a VP (Virtual Player) subfolder - // Clone instances may use a modified library path - string libraryPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Library")); - if (IsVirtualPlayerLibraryPath(libraryPath)) - { - // Looks like we're in a virtual player's library folder - _isMultiplayerPlayModeClone = true; - return true; - } - - // Default: not a clone (or couldn't detect MPPM) - _isMultiplayerPlayModeClone = false; - return false; - } - catch (Exception ex) - { - // On any error, assume not a clone to avoid breaking functionality - Debug.LogWarning($"[MCP Unity] Error detecting Multiplayer Play Mode clone status: {ex.Message}"); - _isMultiplayerPlayModeClone = false; - return false; - } - } - - /// - /// Returns true when the path contains a "Library" segment followed by a "VP" segment. - /// This avoids false positives from names like "MVP" or "CountyLibraryApp". - /// - private static bool IsVirtualPlayerLibraryPath(string libraryPath) - { - if (string.IsNullOrEmpty(libraryPath)) - { - return false; - } - - string normalizedPath = libraryPath.Replace('\\', '/'); - string[] segments = normalizedPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); - - int libraryIndex = -1; - for (int i = 0; i < segments.Length; i++) - { - if (string.Equals(segments[i], "Library", StringComparison.OrdinalIgnoreCase)) - { - libraryIndex = i; - break; - } - } - - if (libraryIndex < 0) - { - return false; - } - - for (int i = libraryIndex + 1; i < segments.Length; i++) - { - if (string.Equals(segments[i], "VP", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - - /// - /// Resets the cached Multiplayer Play Mode clone detection result. - /// Useful for testing or when the state might have changed. - /// - public static void ResetMultiplayerPlayModeCloneCache() - { - _isMultiplayerPlayModeClone = null; - } - } -} diff --git a/Editor/Utils/McpUtils.cs.meta b/Editor/Utils/McpUtils.cs.meta deleted file mode 100644 index 7d4bdb1a..00000000 --- a/Editor/Utils/McpUtils.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: eb8f80389be8b844799e9e8ab5abc86f -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Utils/UnityObjectId.cs b/Editor/Utils/UnityObjectId.cs deleted file mode 100644 index 02247c23..00000000 --- a/Editor/Utils/UnityObjectId.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using UnityEditor; -using UnityEngine; - -namespace McpUnity.Utils -{ - public static class UnityObjectId - { -#if MCP_UNITY_ENTITY_ID_API - private static int _nextSyntheticId = 1; - private static readonly Dictionary PublicIdsByEntityId = new Dictionary(); - private static readonly Dictionary EntityIdsByPublicId = new Dictionary(); -#endif - - public static int GetObjectId(Object unityObject) - { - if (unityObject == null) - { - return 0; - } - -#if MCP_UNITY_ENTITY_ID_API - EntityId entityId = unityObject.GetEntityId(); - if (!PublicIdsByEntityId.TryGetValue(entityId, out int publicId)) - { - publicId = _nextSyntheticId++; - PublicIdsByEntityId[entityId] = publicId; - EntityIdsByPublicId[publicId] = entityId; - } - - return publicId; -#else - return unityObject.GetInstanceID(); -#endif - } - - public static Object ObjectFromId(int objectId) - { -#if MCP_UNITY_ENTITY_ID_API - return EntityIdsByPublicId.TryGetValue(objectId, out EntityId entityId) - ? EditorUtility.EntityIdToObject(entityId) - : null; -#else - return EditorUtility.InstanceIDToObject(objectId); -#endif - } - } -} diff --git a/Editor/Utils/UnityObjectId.cs.meta b/Editor/Utils/UnityObjectId.cs.meta deleted file mode 100644 index ec3ab4be..00000000 --- a/Editor/Utils/UnityObjectId.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e59c20bd238645158de51b1e7076cc51 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Editor/Utils/VsCodeWorkspaceUtils.cs b/Editor/Utils/VsCodeWorkspaceUtils.cs deleted file mode 100644 index 124dc554..00000000 --- a/Editor/Utils/VsCodeWorkspaceUtils.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.IO; -using System.Collections.Generic; -using UnityEngine; -using UnityEditor; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System.Linq; - -namespace McpUnity.Utils -{ - /// - /// Manages VSCode-like IDE workspace integration for Unity projects - /// - public class VsCodeWorkspaceUtils - { - /// - /// The default folder structure for code-workspace files - /// - private static readonly JArray DefaultFolders = JArray.Parse(@"[ - { - ""path"": ""Assets"" - }, - { - ""path"": ""Packages"" - }, - { - ""path"": ""Library/PackageCache"" - } - ]"); - - /// - /// Add the Library/PackageCache folder to the .code-workspace file if not already present - /// This ensures that the Unity cache is available to code intelligence tools - /// - public static bool AddPackageCacheToWorkspace() - { - try - { - // Get the project root directory - string projectRoot = Directory.GetParent(Application.dataPath).FullName; - - // Determine the workspace filename based on the project directory name - string projectDirName = new DirectoryInfo(projectRoot).Name; - string workspaceFilename = $"{projectDirName}.code-workspace"; - string workspacePath = Path.Combine(projectRoot, workspaceFilename); - JObject workspaceConfig = new JObject - { - ["folders"] = DefaultFolders.DeepClone(), - ["settings"] = new JObject() - }; - - // If file exists, update it rather than overwriting - if (File.Exists(workspacePath)) - { - string existingContent = File.ReadAllText(workspacePath); - JObject existingWorkspace = JObject.Parse(existingContent); - - // Merge the new config with the existing one - MergeWorkspaceConfigs(existingWorkspace, workspaceConfig); - workspaceConfig = existingWorkspace; - } - - // Write the updated workspace file - File.WriteAllText(workspacePath, workspaceConfig.ToString(Formatting.Indented)); - Debug.Log($"[MCP Unity] Updated workspace configuration in {workspacePath}"); - return true; - } - catch (Exception ex) - { - Debug.LogError($"[MCP Unity] Error updating workspace file: {ex.Message}"); - return false; - } - } - - /// - /// Merges a source workspace config into a target workspace config - /// Ensures folders are uniquely added based on path properties - /// - private static void MergeWorkspaceConfigs(JObject target, JObject source) - { - // Merge folders array if both exist - if (source["folders"] != null && source["folders"].Type == JTokenType.Array) - { - if (target["folders"] == null || target["folders"].Type != JTokenType.Array) - { - target["folders"] = new JArray(); - } - - // Get existing folder paths - var existingPaths = new HashSet(); - foreach (var folder in target["folders"]) - { - if (folder.Type == JTokenType.Object && folder["path"] != null) - { - existingPaths.Add(folder["path"].ToString()); - } - } - - // Add new folders if they don't exist - foreach (var folder in source["folders"]) - { - if (folder.Type == JTokenType.Object && folder["path"] != null) - { - string path = folder["path"].ToString(); - if (!existingPaths.Contains(path)) - { - ((JArray)target["folders"]).Add(folder.DeepClone()); - existingPaths.Add(path); - } - } - } - } - - // Merge settings if both exist - if (source["settings"] != null && source["settings"].Type == JTokenType.Object) - { - if (target["settings"] == null || target["settings"].Type != JTokenType.Object) - { - target["settings"] = new JObject(); - } - - // Deep merge settings - foreach (var property in (JObject)source["settings"]) - { - target["settings"][property.Key] = property.Value.DeepClone(); - } - } - - // Merge any other top-level properties - foreach (var property in source) - { - if (property.Key != "folders" && property.Key != "settings") - { - target[property.Key] = property.Value.DeepClone(); - } - } - } - } -} diff --git a/Editor/Utils/VsCodeWorkspaceUtils.cs.meta b/Editor/Utils/VsCodeWorkspaceUtils.cs.meta deleted file mode 100644 index e6c3ef96..00000000 --- a/Editor/Utils/VsCodeWorkspaceUtils.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e7f91a2b8d6f343c1b54a71d59e8fa12 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/package.json b/package.json index 2bd66856..3c992dfa 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,14 @@ { "name": "com.gamelovers.mcp-unity", - "displayName": "MCP Unity Server", + "displayName": "MCP Unity Extensions for Unity CLI", "author": "CoderGamester", - "version": "1.4.0", + "version": "2.0.0", "mcpname": "io.github.codergamester/mcp-unity", - "unity": "2022.3", + "unity": "6000.0", "license": "MIT", - "description": "The purpose of this package is to provide a MCP Unity Server for executing Unity operations and request Editor information from AI MCP enabled hosts", + "description": "MCP Unity Extensions for Unity CLI adds focused, typed Pipeline commands for Unity Editor authoring workflows.", "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.2.1", - "com.unity.editorcoroutines": "1.0.0", + "com.unity.pipeline": "0.3.1-exp.1", "com.unity.test-framework": "1.3.3" }, "type": "library", From 60deeff161ba3cf12bc931f8c1426dfa5e2cac34 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 24 Jul 2026 02:37:37 +0100 Subject: [PATCH 02/20] fix: bound inspection results and stabilize wire contracts --- Editor/Commands/CommandResults.cs | 128 +++++++++++ Editor/Commands/InspectGameObjectCommand.cs | 11 +- .../Commands/SerializedPropertyValueReader.cs | 199 +++++++++++++++++- .../Tests/DuplicateGameObjectCommandTests.cs | 62 ++++++ Editor/Tests/InspectionCommandTests.cs | 121 +++++++++++ Editor/Tests/PackageContractTests.cs | 108 ++++++++++ 6 files changed, 624 insertions(+), 5 deletions(-) diff --git a/Editor/Commands/CommandResults.cs b/Editor/Commands/CommandResults.cs index 48f493d5..2839b7fd 100644 --- a/Editor/Commands/CommandResults.cs +++ b/Editor/Commands/CommandResults.cs @@ -1,94 +1,222 @@ using System.Collections.Generic; +using System.Runtime.Serialization; using Unity.Pipeline.Models; namespace McpUnity.Extensions.Commands { + [DataContract] public sealed class InspectGameObjectResult { + [DataMember(Name = "root")] public GameObjectInspection Root { get; set; } + + [DataMember(Name = "maxDepth")] public int MaxDepth { get; set; } + + [DataMember(Name = "maxNodes")] public int MaxNodes { get; set; } + + [DataMember(Name = "maxPropertiesPerComponent")] public int MaxPropertiesPerComponent { get; set; } + + [DataMember(Name = "nodesReturned")] public int NodesReturned { get; set; } + + [DataMember(Name = "nodeLimitReached")] public bool NodeLimitReached { get; set; } } + [DataContract] public sealed class GameObjectInspection { + [DataMember(Name = "identity")] public AuthoringResult Identity { get; set; } + + [DataMember(Name = "name")] public string Name { get; set; } + + [DataMember(Name = "path")] public string Path { get; set; } + + [DataMember(Name = "scenePath")] public string ScenePath { get; set; } + + [DataMember(Name = "activeSelf")] public bool ActiveSelf { get; set; } + + [DataMember(Name = "activeInHierarchy")] public bool ActiveInHierarchy { get; set; } + + [DataMember(Name = "layer")] public int Layer { get; set; } + + [DataMember(Name = "layerName")] public string LayerName { get; set; } + + [DataMember(Name = "tag")] public string Tag { get; set; } + + [DataMember(Name = "isStatic")] public bool IsStatic { get; set; } + + [DataMember(Name = "transform")] public TransformInspection Transform { get; set; } + + [DataMember(Name = "childCount")] public int ChildCount { get; set; } + + [DataMember(Name = "children")] public List Children { get; set; } = new List(); + + [DataMember(Name = "childrenTruncated")] public bool ChildrenTruncated { get; set; } + + [DataMember(Name = "componentsIncluded")] public bool ComponentsIncluded { get; set; } + + [DataMember(Name = "componentCount")] public int ComponentCount { get; set; } + + [DataMember(Name = "components")] public List Components { get; set; } = new List(); } + [DataContract] public sealed class TransformInspection { + [DataMember(Name = "localPosition")] public Vector3Inspection LocalPosition { get; set; } + + [DataMember(Name = "localEulerAngles")] public Vector3Inspection LocalEulerAngles { get; set; } + + [DataMember(Name = "localScale")] public Vector3Inspection LocalScale { get; set; } + + [DataMember(Name = "worldPosition")] public Vector3Inspection WorldPosition { get; set; } + + [DataMember(Name = "worldEulerAngles")] public Vector3Inspection WorldEulerAngles { get; set; } } + [DataContract] public sealed class Vector3Inspection { + [DataMember(Name = "x")] public float X { get; set; } + + [DataMember(Name = "y")] public float Y { get; set; } + + [DataMember(Name = "z")] public float Z { get; set; } } + [DataContract] public sealed class ComponentInspection { + [DataMember(Name = "identity")] public AuthoringResult Identity { get; set; } + + [DataMember(Name = "type")] public string Type { get; set; } + + [DataMember(Name = "missing")] public bool Missing { get; set; } + + [DataMember(Name = "enabled")] public bool? Enabled { get; set; } + + [DataMember(Name = "propertiesIncluded")] public bool PropertiesIncluded { get; set; } + + [DataMember(Name = "serializedPropertyCount")] public int SerializedPropertyCount { get; set; } + + [DataMember(Name = "properties")] public List Properties { get; set; } = new List(); + + [DataMember(Name = "propertiesTruncated")] public bool PropertiesTruncated { get; set; } + + [DataMember(Name = "propertiesError")] public string PropertiesError { get; set; } } + [DataContract] public sealed class SerializedPropertyInspection { + [DataMember(Name = "name")] public string Name { get; set; } + + [DataMember(Name = "path")] public string Path { get; set; } + + [DataMember(Name = "type")] public string Type { get; set; } + + [DataMember(Name = "value")] public object Value { get; set; } + + [DataMember(Name = "valueTruncated")] + public bool ValueTruncated { get; set; } + + [DataMember(Name = "valueTruncations")] + public List ValueTruncations { get; set; } = + new List(); } + [DataContract] + public sealed class SerializationTruncationInspection + { + [DataMember(Name = "path")] + public string Path { get; set; } + + [DataMember(Name = "reason")] + public string Reason { get; set; } + + [DataMember(Name = "limit")] + public int Limit { get; set; } + + [DataMember(Name = "originalCount")] + public int? OriginalCount { get; set; } + } + + [DataContract] public sealed class UnloadSceneResult { + [DataMember(Name = "unloadedPath")] public string UnloadedPath { get; set; } + + [DataMember(Name = "activeSceneName")] public string ActiveSceneName { get; set; } + + [DataMember(Name = "activeScenePath")] public string ActiveScenePath { get; set; } } + [DataContract] public sealed class EditorStepResult { + [DataMember(Name = "isPlaying")] public bool IsPlaying { get; set; } + + [DataMember(Name = "isPaused")] public bool IsPaused { get; set; } } + [DataContract] public sealed class AssignMaterialResult { + [DataMember(Name = "gameObject")] public AuthoringResult GameObject { get; set; } + + [DataMember(Name = "material")] public AuthoringResult Material { get; set; } + + [DataMember(Name = "slot")] public int Slot { get; set; } } } diff --git a/Editor/Commands/InspectGameObjectCommand.cs b/Editor/Commands/InspectGameObjectCommand.cs index 407e7783..22ce2a99 100644 --- a/Editor/Commands/InspectGameObjectCommand.cs +++ b/Editor/Commands/InspectGameObjectCommand.cs @@ -159,22 +159,27 @@ private static void ReadProperties( if (iterator.propertyPath == "m_Script") continue; - if (!SerializedPropertyValueReader.TryRead(iterator, out var value)) + if (!SerializedPropertyValueReader.CanRead(iterator)) continue; summary.SerializedPropertyCount++; if (summary.Properties.Count >= maxProperties) { summary.PropertiesTruncated = true; - continue; + break; } + if (!SerializedPropertyValueReader.TryRead(iterator, out var readResult)) + continue; + summary.Properties.Add(new SerializedPropertyInspection { Name = iterator.displayName, Path = iterator.propertyPath, Type = iterator.propertyType.ToString(), - Value = value + Value = readResult.Value, + ValueTruncated = readResult.Truncations.Count > 0, + ValueTruncations = readResult.Truncations }); } } diff --git a/Editor/Commands/SerializedPropertyValueReader.cs b/Editor/Commands/SerializedPropertyValueReader.cs index e5efd6be..7249a3dd 100644 --- a/Editor/Commands/SerializedPropertyValueReader.cs +++ b/Editor/Commands/SerializedPropertyValueReader.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Unity.Pipeline.Editor.Authoring; using UnityEditor; @@ -7,9 +8,72 @@ namespace McpUnity.Extensions.Commands { internal static class SerializedPropertyValueReader { - public static bool TryRead(SerializedProperty property, out object value) + internal const int MaxStringLength = 4096; + internal const int MaxCollectionLength = 100; + internal const int MaxSerializationDepth = 4; + + internal static Action ConversionObserver { get; set; } + + public static bool CanRead(SerializedProperty property) + { + if (property.isArray && property.propertyType != SerializedPropertyType.String) + return true; + + switch (property.propertyType) + { + case SerializedPropertyType.Boolean: + case SerializedPropertyType.Integer: + case SerializedPropertyType.Float: + case SerializedPropertyType.String: + case SerializedPropertyType.Enum: + case SerializedPropertyType.Vector2: + case SerializedPropertyType.Vector3: + case SerializedPropertyType.Vector4: + case SerializedPropertyType.Vector2Int: + case SerializedPropertyType.Vector3Int: + case SerializedPropertyType.Color: + case SerializedPropertyType.Rect: + case SerializedPropertyType.RectInt: + case SerializedPropertyType.Bounds: + case SerializedPropertyType.BoundsInt: + case SerializedPropertyType.Quaternion: + case SerializedPropertyType.Hash128: + case SerializedPropertyType.Generic: + return true; + case SerializedPropertyType.ObjectReference: + return !(property.objectReferenceValue is MonoScript); + default: + return false; + } + } + + public static bool TryRead( + SerializedProperty property, + out SerializedPropertyReadResult result) + { + result = null; + if (!CanRead(property)) + return false; + + ConversionObserver?.Invoke(property.propertyPath); + var truncations = new List(); + if (!TryReadValue(property, 0, truncations, out var value)) + return false; + + result = new SerializedPropertyReadResult(value, truncations); + return true; + } + + private static bool TryReadValue( + SerializedProperty property, + int depth, + List truncations, + out object value) { value = null; + if (property.isArray && property.propertyType != SerializedPropertyType.String) + return TryReadArray(property, depth, truncations, out value); + switch (property.propertyType) { case SerializedPropertyType.Boolean: @@ -22,7 +86,20 @@ public static bool TryRead(SerializedProperty property, out object value) value = property.doubleValue; return true; case SerializedPropertyType.String: - value = property.stringValue; + var stringValue = property.stringValue ?? string.Empty; + if (stringValue.Length > MaxStringLength) + { + value = stringValue.Substring(0, MaxStringLength); + truncations.Add(Truncation( + property, + "stringLength", + MaxStringLength, + stringValue.Length)); + } + else + { + value = stringValue; + } return true; case SerializedPropertyType.Enum: value = property.enumValueIndex >= 0 && @@ -107,11 +184,115 @@ public static bool TryRead(SerializedProperty property, out object value) return false; value = referenced == null ? null : ObjectResolver.Describe(referenced); return true; + case SerializedPropertyType.Generic: + return TryReadObject(property, depth, truncations, out value); default: return false; } } + private static bool TryReadArray( + SerializedProperty property, + int depth, + List truncations, + out object value) + { + if (depth >= MaxSerializationDepth) + { + value = null; + truncations.Add(Truncation( + property, + "serializationDepth", + MaxSerializationDepth, + null)); + return true; + } + + var originalCount = property.arraySize; + var count = Mathf.Min(originalCount, MaxCollectionLength); + var values = new List(count); + for (var index = 0; index < count; index++) + { + var element = property.GetArrayElementAtIndex(index); + values.Add(TryReadValue(element, depth + 1, truncations, out var elementValue) + ? elementValue + : null); + } + + if (originalCount > MaxCollectionLength) + { + truncations.Add(Truncation( + property, + "collectionLength", + MaxCollectionLength, + originalCount)); + } + + value = values; + return true; + } + + private static bool TryReadObject( + SerializedProperty property, + int depth, + List truncations, + out object value) + { + if (depth >= MaxSerializationDepth) + { + value = null; + truncations.Add(Truncation( + property, + "serializationDepth", + MaxSerializationDepth, + null)); + return true; + } + + var values = new Dictionary(); + var iterator = property.Copy(); + var end = iterator.GetEndProperty(); + var enterChildren = true; + var supportedCount = 0; + while (iterator.NextVisible(enterChildren) && + !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + if (!CanRead(iterator)) + continue; + + if (supportedCount >= MaxCollectionLength) + { + truncations.Add(Truncation( + property, + "collectionLength", + MaxCollectionLength, + null)); + break; + } + + supportedCount++; + if (TryReadValue(iterator, depth + 1, truncations, out var childValue)) + values[iterator.name] = childValue; + } + + value = values; + return true; + } + + private static SerializationTruncationInspection Truncation( + SerializedProperty property, + string reason, + int limit, + int? originalCount) => + new SerializationTruncationInspection + { + Path = property.propertyPath, + Reason = reason, + Limit = limit, + OriginalCount = originalCount + }; + private static object[] Values(params object[] values) => values; private static Dictionary NamedValues( @@ -123,4 +304,18 @@ private static Dictionary NamedValues( return result; } } + + internal sealed class SerializedPropertyReadResult + { + public SerializedPropertyReadResult( + object value, + List truncations) + { + Value = value; + Truncations = truncations; + } + + public object Value { get; } + public List Truncations { get; } + } } diff --git a/Editor/Tests/DuplicateGameObjectCommandTests.cs b/Editor/Tests/DuplicateGameObjectCommandTests.cs index b8915919..ef638cae 100644 --- a/Editor/Tests/DuplicateGameObjectCommandTests.cs +++ b/Editor/Tests/DuplicateGameObjectCommandTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using McpUnity.Extensions.Commands; using NUnit.Framework; using Unity.Pipeline; @@ -7,11 +8,15 @@ using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; namespace McpUnity.Extensions.Tests { public class DuplicateGameObjectCommandTests { + private const string ActiveScenePath = "Assets/__McpUnityDuplicateActiveScene.unity"; + [SetUp] public void SetUp() { @@ -23,6 +28,7 @@ public void SetUp() public void TearDown() { Undo.ClearAll(); + AssetDatabase.DeleteAsset(ActiveScenePath); } [Test] @@ -107,6 +113,62 @@ public void Duplicate_RegistersCreatedObjectWithUndo() Assert.That(source, Is.Not.Null, "Undo should remove only the duplicate."); } + [UnityTest] + public IEnumerator Duplicate_UndoRedoRestoresConfiguredStateFromNonActiveSourceScene() + { + var activeScene = SceneManager.GetActiveScene(); + Assert.That(EditorSceneManager.SaveScene(activeScene, ActiveScenePath), Is.True); + var sourceScene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, + NewSceneMode.Additive); + Assert.That(SceneManager.SetActiveScene(activeScene), Is.True); + + var parent = new GameObject("DestinationParent"); + parent.transform.position = new Vector3(10f, 20f, 30f); + + var source = new GameObject("Source"); + source.transform.localPosition = new Vector3(1f, 2f, 3f); + source.transform.localRotation = Quaternion.Euler(15f, 25f, 35f); + source.transform.localScale = new Vector3(2f, 3f, 4f); + SceneManager.MoveGameObjectToScene(source, sourceScene); + Assert.That(SceneManager.GetActiveScene(), Is.Not.EqualTo(sourceScene)); + + DuplicateGameObjectCommand.Duplicate( + Ref(source), + parent: Ref(parent), + name: "RestoredCopy"); + yield return null; + + var duplicate = parent.transform.Find("RestoredCopy"); + Assert.That(duplicate, Is.Not.Null); + AssertConfiguredState(duplicate, activeScene, parent.transform, source.transform); + + Undo.PerformUndo(); + yield return null; + Assert.That(parent.transform.Find("RestoredCopy"), Is.Null); + + Undo.PerformRedo(); + yield return null; + var restored = parent.transform.Find("RestoredCopy"); + Assert.That(restored, Is.Not.Null); + AssertConfiguredState(restored, activeScene, parent.transform, source.transform); + } + + private static void AssertConfiguredState( + Transform actual, + Scene expectedScene, + Transform expectedParent, + Transform expectedTransform) + { + Assert.That(actual.gameObject.scene, Is.EqualTo(expectedScene)); + Assert.That(actual.parent, Is.SameAs(expectedParent)); + Assert.That(actual.name, Is.EqualTo("RestoredCopy")); + Assert.That(actual.localPosition, Is.EqualTo(expectedTransform.localPosition)); + Assert.That(Quaternion.Angle(actual.localRotation, expectedTransform.localRotation), + Is.LessThan(0.001f)); + Assert.That(actual.localScale, Is.EqualTo(expectedTransform.localScale)); + } + private static ObjectRef Ref(UnityEngine.Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) }; diff --git a/Editor/Tests/InspectionCommandTests.cs b/Editor/Tests/InspectionCommandTests.cs index 001700f6..aabf5552 100644 --- a/Editor/Tests/InspectionCommandTests.cs +++ b/Editor/Tests/InspectionCommandTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Linq; using System.Reflection; using McpUnity.Extensions.Commands; @@ -141,6 +142,89 @@ public void Inspect_BoundsSerializedPropertiesAndSkipsScriptReference() Does.Not.Contain("m_Script")); } + [Test] + public void Inspect_BoundsLargeStringsCollectionsAndNestedValuesWithExplicitMarkers() + { + var root = new GameObject("Root"); + var fixture = root.AddComponent(); + fixture.LargeString = new string('x', 5000); + fixture.LargeArray = Enumerable.Range(0, 150).ToArray(); + + var result = InspectGameObjectCommand.Inspect( + Ref(root), + includeProperties: true, + maxPropertiesPerComponent: 10); + + var components = Property(result.Root, "Components"); + var component = components.Cast() + .Single(item => Property(item, "Type") == nameof(LargeInspectionFixtureComponent)); + var properties = Property(component, "Properties").Cast().ToList(); + + var largeString = properties.Single(item => Property(item, "Path") == "LargeString"); + Assert.That(Property(largeString, "Value"), Has.Length.EqualTo(4096)); + AssertValueTruncation(largeString, "stringLength", 4096, 5000); + + var largeArray = properties.Single(item => Property(item, "Path") == "LargeArray"); + Assert.That(Property(largeArray, "Value"), Has.Count.EqualTo(100)); + AssertValueTruncation(largeArray, "collectionLength", 100, 150); + + var nested = properties.Single(item => Property(item, "Path") == "Nested"); + AssertValueTruncation(nested, "serializationDepth", 4, null); + } + + [Test] + public void Inspect_StopsConvertingValuesAfterFirstSupportedPropertyBeyondCap() + { + var root = new GameObject("Root"); + root.AddComponent(); + var readerType = typeof(InspectGameObjectCommand).Assembly.GetType( + "McpUnity.Extensions.Commands.SerializedPropertyValueReader", + throwOnError: true); + var observer = readerType.GetProperty( + "ConversionObserver", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(observer, Is.Not.Null, "Expected an internal value-conversion test seam."); + + var convertedPaths = new List(); + observer.SetValue(null, (Action)(path => convertedPaths.Add(path))); + try + { + var result = InspectGameObjectCommand.Inspect( + Ref(root), + includeProperties: true, + maxPropertiesPerComponent: 1); + + var component = result.Root.Components + .Single(item => item.Type == nameof(InspectionFixtureComponent)); + Assert.That(component.PropertiesTruncated, Is.True); + } + finally + { + observer.SetValue(null, null); + } + + Assert.That( + convertedPaths.Where(path => + path == "First" || + path == "Second" || + path == "Third"), + Is.EqualTo(new[] { "First" }), + "The omitted property and all later values must not be materialized."); + } + + private static void AssertValueTruncation( + object property, + string reason, + int limit, + int? originalCount) + { + Assert.That(Property(property, "ValueTruncated"), Is.True); + var markers = Property(property, "ValueTruncations").Cast().ToList(); + var marker = markers.Single(item => Property(item, "Reason") == reason); + Assert.That(Property(marker, "Limit"), Is.EqualTo(limit)); + Assert.That(Property(marker, "OriginalCount"), Is.EqualTo(originalCount)); + } + private static ObjectRef Ref(UnityEngine.Object obj) => new ObjectRef { InstanceId = PipelineUtils.GetObjectId(obj) }; @@ -162,4 +246,41 @@ public sealed class InspectionFixtureComponent : MonoBehaviour public Vector3 Third = Vector3.one; public AnimationCurve Unsupported = AnimationCurve.Linear(0f, 0f, 1f, 1f); } + + public sealed class LargeInspectionFixtureComponent : MonoBehaviour + { + public string LargeString; + public int[] LargeArray; + public InspectionNestedLevel1 Nested = new InspectionNestedLevel1(); + } + + [Serializable] + public sealed class InspectionNestedLevel1 + { + public InspectionNestedLevel2 Child = new InspectionNestedLevel2(); + } + + [Serializable] + public sealed class InspectionNestedLevel2 + { + public InspectionNestedLevel3 Child = new InspectionNestedLevel3(); + } + + [Serializable] + public sealed class InspectionNestedLevel3 + { + public InspectionNestedLevel4 Child = new InspectionNestedLevel4(); + } + + [Serializable] + public sealed class InspectionNestedLevel4 + { + public InspectionNestedLevel5 Child = new InspectionNestedLevel5(); + } + + [Serializable] + public sealed class InspectionNestedLevel5 + { + public int Value = 42; + } } diff --git a/Editor/Tests/PackageContractTests.cs b/Editor/Tests/PackageContractTests.cs index 7190273c..47eef44f 100644 --- a/Editor/Tests/PackageContractTests.cs +++ b/Editor/Tests/PackageContractTests.cs @@ -1,8 +1,13 @@ +using System; using System.IO; using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; using McpUnity.Extensions.Commands; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; +using Unity.Pipeline.Models; using UnityEditor.PackageManager; namespace McpUnity.Extensions.Tests @@ -49,5 +54,108 @@ public void EditorAssembly_IsEditorOnlyAndReferencesOnlyPipelineAssemblies() asmdef["references"].Values(), Is.EquivalentTo(new[] { "Unity.Pipeline", "Unity.Pipeline.Editor" })); } + + [Test] + public void PublicResultDtos_DeclareExplicitStableCamelCaseWireNames() + { + var dtoTypes = typeof(InspectGameObjectResult).Assembly.GetTypes() + .Where(type => + type.IsPublic && + type.IsClass && + type.Namespace == typeof(InspectGameObjectResult).Namespace && + type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Length > 0) + .ToList(); + + Assert.That(dtoTypes, Is.Not.Empty); + foreach (var type in dtoTypes) + { + Assert.That(type.GetCustomAttribute(), Is.Not.Null, + $"{type.Name} must declare DataContract."); + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var member = property.GetCustomAttribute(); + Assert.That(member, Is.Not.Null, + $"{type.Name}.{property.Name} must declare DataMember."); + Assert.That(member.Name, Is.EqualTo(CamelCase(property.Name)), + $"{type.Name}.{property.Name} must have a stable camelCase wire name."); + } + } + } + + [Test] + public void PipelineJsonSerialization_UsesStableInspectUnloadAndMaterialShapes() + { + var inspect = new InspectGameObjectResult + { + Root = new GameObjectInspection + { + Name = "Root", + Transform = new TransformInspection + { + LocalPosition = new Vector3Inspection { X = 1f, Y = 2f, Z = 3f } + }, + Components = + { + new ComponentInspection + { + Type = "Fixture", + Properties = + { + new SerializedPropertyInspection + { + Name = "Text", + Path = "Text", + Type = "String", + Value = "bounded" + } + } + } + } + } + }; + var unload = new UnloadSceneResult + { + UnloadedPath = "Assets/Old.unity", + ActiveSceneName = "Main", + ActiveScenePath = "Assets/Main.unity" + }; + var material = new AssignMaterialResult + { + GameObject = new AuthoringResult { HierarchyPath = "/Root" }, + Material = new AuthoringResult { AssetPath = "Assets/Material.mat" }, + Slot = 2 + }; + + var inspectJson = JObject.Parse(JsonConvert.SerializeObject(inspect)); + Assert.That(inspectJson.Properties().Select(property => property.Name), + Is.EquivalentTo(new[] + { + "root", + "maxDepth", + "maxNodes", + "maxPropertiesPerComponent", + "nodesReturned", + "nodeLimitReached" + })); + Assert.That((string)inspectJson["root"]["name"], Is.EqualTo("Root")); + Assert.That((float)inspectJson["root"]["transform"]["localPosition"]["x"], Is.EqualTo(1f)); + Assert.That((string)inspectJson["root"]["components"][0]["properties"][0]["value"], + Is.EqualTo("bounded")); + + var unloadJson = JObject.Parse(JsonConvert.SerializeObject(unload)); + Assert.That(unloadJson.Properties().Select(property => property.Name), + Is.EquivalentTo(new[] { "unloadedPath", "activeSceneName", "activeScenePath" })); + Assert.That((string)unloadJson["activeScenePath"], Is.EqualTo("Assets/Main.unity")); + + var materialJson = JObject.Parse(JsonConvert.SerializeObject(material)); + Assert.That(materialJson.Properties().Select(property => property.Name), + Is.EquivalentTo(new[] { "gameObject", "material", "slot" })); + Assert.That((string)materialJson["gameObject"]["hierarchyPath"], Is.EqualTo("/Root")); + Assert.That((string)materialJson["material"]["assetPath"], + Is.EqualTo("Assets/Material.mat")); + } + + private static string CamelCase(string name) => + char.ToLowerInvariant(name[0]) + name.Substring(1); } } From ce786241f43786a29d57d6c325d58e13fa6dc07e Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 24 Jul 2026 03:01:04 +0100 Subject: [PATCH 03/20] feat: add assisted Unity CLI setup --- Editor/Setup.meta | 8 + Editor/Setup/SystemUnityCliProcessRunner.cs | 71 ++++++ .../Setup/SystemUnityCliProcessRunner.cs.meta | 2 + Editor/Setup/UnityCliCheckService.cs | 87 ++++++++ Editor/Setup/UnityCliCheckService.cs.meta | 2 + Editor/Setup/UnityCliConfiguration.cs | 67 ++++++ Editor/Setup/UnityCliConfiguration.cs.meta | 2 + Editor/Setup/UnityCliPathResolver.cs | 54 +++++ Editor/Setup/UnityCliPathResolver.cs.meta | 2 + Editor/Setup/UnityCliPipelineStatus.cs | 37 ++++ Editor/Setup/UnityCliPipelineStatus.cs.meta | 2 + Editor/Setup/UnityCliSetupContent.cs | 14 ++ Editor/Setup/UnityCliSetupContent.cs.meta | 2 + Editor/Setup/UnityCliSetupWindow.cs | 121 ++++++++++ Editor/Setup/UnityCliSetupWindow.cs.meta | 2 + Editor/Setup/UnityCliVersionClassifier.cs | 130 +++++++++++ .../Setup/UnityCliVersionClassifier.cs.meta | 2 + Editor/Tests/UnityCliSetupServiceTests.cs | 206 ++++++++++++++++++ .../Tests/UnityCliSetupServiceTests.cs.meta | 2 + 19 files changed, 813 insertions(+) create mode 100644 Editor/Setup.meta create mode 100644 Editor/Setup/SystemUnityCliProcessRunner.cs create mode 100644 Editor/Setup/SystemUnityCliProcessRunner.cs.meta create mode 100644 Editor/Setup/UnityCliCheckService.cs create mode 100644 Editor/Setup/UnityCliCheckService.cs.meta create mode 100644 Editor/Setup/UnityCliConfiguration.cs create mode 100644 Editor/Setup/UnityCliConfiguration.cs.meta create mode 100644 Editor/Setup/UnityCliPathResolver.cs create mode 100644 Editor/Setup/UnityCliPathResolver.cs.meta create mode 100644 Editor/Setup/UnityCliPipelineStatus.cs create mode 100644 Editor/Setup/UnityCliPipelineStatus.cs.meta create mode 100644 Editor/Setup/UnityCliSetupContent.cs create mode 100644 Editor/Setup/UnityCliSetupContent.cs.meta create mode 100644 Editor/Setup/UnityCliSetupWindow.cs create mode 100644 Editor/Setup/UnityCliSetupWindow.cs.meta create mode 100644 Editor/Setup/UnityCliVersionClassifier.cs create mode 100644 Editor/Setup/UnityCliVersionClassifier.cs.meta create mode 100644 Editor/Tests/UnityCliSetupServiceTests.cs create mode 100644 Editor/Tests/UnityCliSetupServiceTests.cs.meta diff --git a/Editor/Setup.meta b/Editor/Setup.meta new file mode 100644 index 00000000..1cd730b9 --- /dev/null +++ b/Editor/Setup.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 58bb5e08f9fb44eeda3e654454b6ffba +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Setup/SystemUnityCliProcessRunner.cs b/Editor/Setup/SystemUnityCliProcessRunner.cs new file mode 100644 index 00000000..cd3153ea --- /dev/null +++ b/Editor/Setup/SystemUnityCliProcessRunner.cs @@ -0,0 +1,71 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace McpUnity.Extensions.Setup +{ + public sealed class SystemUnityCliProcessRunner : IUnityCliProcessRunner + { + public async Task RunAsync( + string executablePath, + string arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using (var process = new Process()) + { + process.StartInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + process.EnableRaisingEvents = true; + var exited = new TaskCompletionSource(); + process.Exited += (sender, eventArgs) => exited.TrySetResult(true); + + try + { + process.Start(); + } + catch (Exception exception) + { + return new UnityCliProcessResult(string.Empty, exception.Message, -1, false); + } + + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + if (process.HasExited) + { + exited.TrySetResult(true); + } + + var completed = await Task.WhenAny(exited.Task, Task.Delay(timeout, cancellationToken)); + if (completed != exited.Task) + { + if (!process.HasExited) + { + process.Kill(); + } + + await exited.Task; + return new UnityCliProcessResult( + await standardOutput, + await standardError, + process.ExitCode, + true); + } + + return new UnityCliProcessResult( + await standardOutput, + await standardError, + process.ExitCode, + false); + } + } + } +} diff --git a/Editor/Setup/SystemUnityCliProcessRunner.cs.meta b/Editor/Setup/SystemUnityCliProcessRunner.cs.meta new file mode 100644 index 00000000..3e53ae74 --- /dev/null +++ b/Editor/Setup/SystemUnityCliProcessRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0e5d63a2a325c40f0949a71835205006 \ No newline at end of file diff --git a/Editor/Setup/UnityCliCheckService.cs b/Editor/Setup/UnityCliCheckService.cs new file mode 100644 index 00000000..772789f4 --- /dev/null +++ b/Editor/Setup/UnityCliCheckService.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace McpUnity.Extensions.Setup +{ + public sealed class UnityCliProcessResult + { + public UnityCliProcessResult(string standardOutput, string standardError, int exitCode, bool timedOut) + { + StandardOutput = standardOutput ?? string.Empty; + StandardError = standardError ?? string.Empty; + ExitCode = exitCode; + TimedOut = timedOut; + } + + public string StandardOutput { get; } + + public string StandardError { get; } + + public int ExitCode { get; } + + public bool TimedOut { get; } + } + + public interface IUnityCliProcessRunner + { + Task RunAsync( + string executablePath, + string arguments, + TimeSpan timeout, + CancellationToken cancellationToken); + } + + public sealed class UnityCliCheckResult + { + public UnityCliCheckResult( + UnityCliPathResolution candidate, + UnityCliProcessResult process, + UnityCliCompatibilityResult compatibility) + { + Candidate = candidate; + Process = process; + Compatibility = compatibility; + } + + public UnityCliPathResolution Candidate { get; } + + public UnityCliProcessResult Process { get; } + + public UnityCliCompatibilityResult Compatibility { get; } + } + + public sealed class UnityCliCheckService + { + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + private readonly IUnityCliProcessRunner processRunner; + private readonly Func environmentPath; + + public UnityCliCheckService(IUnityCliProcessRunner processRunner, Func environmentPath) + { + this.processRunner = processRunner; + this.environmentPath = environmentPath; + } + + public async Task CheckAsync(string liveWindowPath, CancellationToken cancellationToken) + { + var candidate = UnityCliPathResolver.Resolve(liveWindowPath, environmentPath()); + UnityCliProcessResult process; + try + { + process = await processRunner.RunAsync(candidate.ExecutablePath, "--version", Timeout, cancellationToken); + } + catch (Exception exception) + { + process = new UnityCliProcessResult(string.Empty, exception.Message, -1, false); + } + + var output = process.StandardOutput + Environment.NewLine + process.StandardError; + var compatibility = UnityCliVersionClassifier.Classify( + output, + process.ExitCode == 0, + process.TimedOut); + return new UnityCliCheckResult(candidate, process, compatibility); + } + } +} diff --git a/Editor/Setup/UnityCliCheckService.cs.meta b/Editor/Setup/UnityCliCheckService.cs.meta new file mode 100644 index 00000000..21c4ae11 --- /dev/null +++ b/Editor/Setup/UnityCliCheckService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1397a7a0d3d4b4a7fa67592b66478508 \ No newline at end of file diff --git a/Editor/Setup/UnityCliConfiguration.cs b/Editor/Setup/UnityCliConfiguration.cs new file mode 100644 index 00000000..33fba4bc --- /dev/null +++ b/Editor/Setup/UnityCliConfiguration.cs @@ -0,0 +1,67 @@ +using System.Text; + +namespace McpUnity.Extensions.Setup +{ + public static class UnityCliConfiguration + { + public static string CreateOfficial(string executablePath, string projectPath) + { + return "{\n \"mcpServers\": {\n \"unity\": {\n" + + " \"command\": \"" + Escape(executablePath) + "\",\n" + + " \"args\": [\"mcp\", \"--project-path\", \"" + Escape(projectPath) + "\"]\n" + + " }\n }\n}"; + } + + public static string CreateCompanion( + string packagePath, + string projectPath, + string executablePath, + bool includeExplicitCliPath) + { + var serverPath = packagePath.TrimEnd('/', '\\') + "/Server~/build/index.js"; + var builder = new StringBuilder(); + builder.Append("{\n \"mcpServers\": {\n \"mcp-unity-companion\": {\n"); + builder.Append(" \"command\": \"node\",\n"); + builder.Append(" \"args\": [\"").Append(Escape(serverPath)).Append("\", \"--project-path\", \"") + .Append(Escape(projectPath)).Append("\"]"); + if (includeExplicitCliPath) + { + builder.Append(",\n \"env\": { \"UNITY_CLI_PATH\": \"") + .Append(Escape(executablePath)).Append("\" }"); + } + + builder.Append("\n }\n }\n}"); + return builder.ToString(); + } + + private static string Escape(string value) + { + var builder = new StringBuilder(); + foreach (var character in value ?? string.Empty) + { + switch (character) + { + case '\\': builder.Append("\\\\"); break; + case '\"': builder.Append("\\\""); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (character < 32) + { + builder.Append("\\u").Append(((int)character).ToString("x4")); + } + else + { + builder.Append(character); + } + break; + } + } + + return builder.ToString(); + } + } +} diff --git a/Editor/Setup/UnityCliConfiguration.cs.meta b/Editor/Setup/UnityCliConfiguration.cs.meta new file mode 100644 index 00000000..5f295659 --- /dev/null +++ b/Editor/Setup/UnityCliConfiguration.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 600ee04563e3c478ca78fab9f9cf096d \ No newline at end of file diff --git a/Editor/Setup/UnityCliPathResolver.cs b/Editor/Setup/UnityCliPathResolver.cs new file mode 100644 index 00000000..a8b53bfa --- /dev/null +++ b/Editor/Setup/UnityCliPathResolver.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +namespace McpUnity.Extensions.Setup +{ + public enum UnityCliPathSource + { + Window, + Environment, + Path + } + + public sealed class UnityCliPathResolution + { + public UnityCliPathResolution(string executablePath, UnityCliPathSource source) + { + ExecutablePath = executablePath; + Source = source; + } + + public string ExecutablePath { get; } + + public UnityCliPathSource Source { get; } + + public bool IsExplicitAbsolutePath => + Source == UnityCliPathSource.Window && IsAbsolutePath(ExecutablePath); + + private static bool IsAbsolutePath(string path) + { + return Path.IsPathRooted(path) || + (path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && + (path[2] == '\\' || path[2] == '/')) || + path.StartsWith("\\\\", StringComparison.Ordinal); + } + } + + public static class UnityCliPathResolver + { + public static UnityCliPathResolution Resolve(string liveWindowPath, string environmentPath) + { + if (!string.IsNullOrWhiteSpace(liveWindowPath)) + { + return new UnityCliPathResolution(liveWindowPath.Trim(), UnityCliPathSource.Window); + } + + if (!string.IsNullOrWhiteSpace(environmentPath)) + { + return new UnityCliPathResolution(environmentPath.Trim(), UnityCliPathSource.Environment); + } + + return new UnityCliPathResolution("unity", UnityCliPathSource.Path); + } + } +} diff --git a/Editor/Setup/UnityCliPathResolver.cs.meta b/Editor/Setup/UnityCliPathResolver.cs.meta new file mode 100644 index 00000000..ef4faabd --- /dev/null +++ b/Editor/Setup/UnityCliPathResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 20f66b2f36c374427834a5fe84b78e02 \ No newline at end of file diff --git a/Editor/Setup/UnityCliPipelineStatus.cs b/Editor/Setup/UnityCliPipelineStatus.cs new file mode 100644 index 00000000..31975cc2 --- /dev/null +++ b/Editor/Setup/UnityCliPipelineStatus.cs @@ -0,0 +1,37 @@ +namespace McpUnity.Extensions.Setup +{ + public enum UnityCliPipelineState + { + ExactSupported, + Missing, + DifferentUntested + } + + public static class UnityCliPipelineStatus + { + public static UnityCliPipelineState Classify(string version) + { + if (string.IsNullOrEmpty(version)) + { + return UnityCliPipelineState.Missing; + } + + return version == "0.3.1-exp.1" + ? UnityCliPipelineState.ExactSupported + : UnityCliPipelineState.DifferentUntested; + } + + public static string GetDisplayName(UnityCliPipelineState state) + { + switch (state) + { + case UnityCliPipelineState.ExactSupported: + return "exact supported"; + case UnityCliPipelineState.Missing: + return "missing"; + default: + return "different/untested"; + } + } + } +} diff --git a/Editor/Setup/UnityCliPipelineStatus.cs.meta b/Editor/Setup/UnityCliPipelineStatus.cs.meta new file mode 100644 index 00000000..6b774b6d --- /dev/null +++ b/Editor/Setup/UnityCliPipelineStatus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4abeb53d04f4a401f8d82f5f2d829d83 \ No newline at end of file diff --git a/Editor/Setup/UnityCliSetupContent.cs b/Editor/Setup/UnityCliSetupContent.cs new file mode 100644 index 00000000..2e60736d --- /dev/null +++ b/Editor/Setup/UnityCliSetupContent.cs @@ -0,0 +1,14 @@ +namespace McpUnity.Extensions.Setup +{ + public static class UnityCliSetupContent + { + public const string DocumentationUrl = "https://docs.unity.com/en-us/unity-cli/use-unity-cli"; + + public static string GetInstallCommand(bool isWindows) + { + return isWindows + ? "$env:UNITY_CLI_CHANNEL='beta'; irm https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1 | iex" + : "curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh | UNITY_CLI_CHANNEL=beta bash"; + } + } +} diff --git a/Editor/Setup/UnityCliSetupContent.cs.meta b/Editor/Setup/UnityCliSetupContent.cs.meta new file mode 100644 index 00000000..5ddc149e --- /dev/null +++ b/Editor/Setup/UnityCliSetupContent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6f3adfe06e8d44b0821ba9862281627 \ No newline at end of file diff --git a/Editor/Setup/UnityCliSetupWindow.cs b/Editor/Setup/UnityCliSetupWindow.cs new file mode 100644 index 00000000..376c7390 --- /dev/null +++ b/Editor/Setup/UnityCliSetupWindow.cs @@ -0,0 +1,121 @@ +using System; +using System.IO; +using System.Threading; +using McpUnity.Extensions.Commands; +using UnityEditor; +using UnityEngine; + +namespace McpUnity.Extensions.Setup +{ + public sealed class UnityCliSetupWindow : EditorWindow + { + private string cliPath = string.Empty; + private UnityCliCheckResult checkResult; + private bool isChecking; + + [MenuItem("Window/MCP Unity/Setup")] + private static void Open() + { + GetWindow("MCP Unity Setup"); + } + + private void OnGUI() + { + var projectPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + EditorGUILayout.LabelField("Unity project", projectPath); + DrawPipelineStatus(); + EditorGUILayout.Space(); + + cliPath = EditorGUILayout.TextField("Unity CLI executable (optional)", cliPath); + using (new EditorGUI.DisabledScope(isChecking)) + { + if (GUILayout.Button(isChecking ? "Checking Unity CLI..." : "Check Unity CLI")) + { + CheckUnityCli(); + } + } + + if (checkResult != null) + { + DrawCheckResult(projectPath); + } + } + + private void DrawPipelineStatus() + { + var package = UnityEditor.PackageManager.PackageInfo.FindForPackageName("com.unity.pipeline"); + var version = package == null ? null : package.version; + var state = UnityCliPipelineStatus.GetDisplayName(UnityCliPipelineStatus.Classify(version)); + EditorGUILayout.LabelField("com.unity.pipeline", version ?? "missing"); + EditorGUILayout.LabelField("Pipeline state", state); + } + + private void DrawCheckResult(string projectPath) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Unity CLI", checkResult.Candidate.ExecutablePath); + EditorGUILayout.LabelField("Status", checkResult.Compatibility.Status.ToString()); + EditorGUILayout.LabelField("Version", checkResult.Compatibility.Version ?? "not detected"); + if (!string.IsNullOrEmpty(checkResult.Process.StandardError)) + { + EditorGUILayout.HelpBox(checkResult.Process.StandardError, MessageType.Warning); + } + + if (checkResult.Compatibility.Status == UnityCliCompatibility.MissingOrFailed || + checkResult.Compatibility.Status == UnityCliCompatibility.Incompatible) + { + if (GUILayout.Button("Copy official install command")) + { + EditorGUIUtility.systemCopyBuffer = UnityCliSetupContent.GetInstallCommand(Application.platform == RuntimePlatform.WindowsEditor); + } + + if (GUILayout.Button("Open Unity CLI documentation")) + { + Application.OpenURL(UnityCliSetupContent.DocumentationUrl); + } + + return; + } + + if (GUILayout.Button("Copy official MCP configuration")) + { + EditorGUIUtility.systemCopyBuffer = UnityCliConfiguration.CreateOfficial( + checkResult.Candidate.ExecutablePath, + projectPath); + } + + if (GUILayout.Button("Copy companion configuration")) + { + var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly); + EditorGUIUtility.systemCopyBuffer = UnityCliConfiguration.CreateCompanion( + package.assetPath, + projectPath, + checkResult.Candidate.ExecutablePath, + checkResult.Candidate.IsExplicitAbsolutePath); + } + } + + private async void CheckUnityCli() + { + isChecking = true; + Repaint(); + var service = new UnityCliCheckService( + new SystemUnityCliProcessRunner(), + () => Environment.GetEnvironmentVariable("UNITY_CLI_PATH")); + var result = await service.CheckAsync(cliPath, CancellationToken.None); + EditorApplication.delayCall += () => ApplyCheckResult(result); + } + + private void ApplyCheckResult(UnityCliCheckResult result) + { + if (this == null) + { + return; + } + + checkResult = result; + isChecking = false; + Repaint(); + } + } +} diff --git a/Editor/Setup/UnityCliSetupWindow.cs.meta b/Editor/Setup/UnityCliSetupWindow.cs.meta new file mode 100644 index 00000000..8abb9d3f --- /dev/null +++ b/Editor/Setup/UnityCliSetupWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c61f1f1a7c81841bd9d6b180a3c05d35 \ No newline at end of file diff --git a/Editor/Setup/UnityCliVersionClassifier.cs b/Editor/Setup/UnityCliVersionClassifier.cs new file mode 100644 index 00000000..ef2664f2 --- /dev/null +++ b/Editor/Setup/UnityCliVersionClassifier.cs @@ -0,0 +1,130 @@ +using System; +using System.Text.RegularExpressions; + +namespace McpUnity.Extensions.Setup +{ + public enum UnityCliCompatibility + { + MissingOrFailed, + Incompatible, + Compatible, + UntestedNewer + } + + public sealed class UnityCliCompatibilityResult + { + public UnityCliCompatibilityResult(UnityCliCompatibility status, string version) + { + Status = status; + Version = version; + } + + public UnityCliCompatibility Status { get; } + + public string Version { get; } + } + + public static class UnityCliVersionClassifier + { + private static readonly Regex VersionPattern = new Regex( + @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z.-]+))?",
+            RegexOptions.Compiled);
+
+        public static UnityCliCompatibilityResult Classify(string output, bool processSucceeded, bool timedOut)
+        {
+            if (!processSucceeded || timedOut || string.IsNullOrEmpty(output))
+            {
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
+            }
+
+            var match = VersionPattern.Match(output);
+            if (!match.Success)
+            {
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
+            }
+
+            var version = match.Value;
+            var major = int.Parse(match.Groups["major"].Value);
+            if (major > 1)
+            {
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.UntestedNewer, version);
+            }
+
+            if (major < 1 || CompareToMinimum(match) < 0)
+            {
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.Incompatible, version);
+            }
+
+            return new UnityCliCompatibilityResult(UnityCliCompatibility.Compatible, version);
+        }
+
+        private static int CompareToMinimum(Match match)
+        {
+            var minor = int.Parse(match.Groups["minor"].Value);
+            if (minor != 0)
+            {
+                return minor;
+            }
+
+            var patch = int.Parse(match.Groups["patch"].Value);
+            if (patch != 0)
+            {
+                return patch;
+            }
+
+            var prerelease = match.Groups["pre"].Success ? match.Groups["pre"].Value : null;
+            if (prerelease == null)
+            {
+                return 1;
+            }
+
+            return ComparePrerelease(prerelease, "beta.2");
+        }
+
+        private static int ComparePrerelease(string left, string right)
+        {
+            var leftIdentifiers = left.Split('.');
+            var rightIdentifiers = right.Split('.');
+            var length = Math.Max(leftIdentifiers.Length, rightIdentifiers.Length);
+            for (var index = 0; index < length; index++)
+            {
+                if (index == leftIdentifiers.Length)
+                {
+                    return -1;
+                }
+
+                if (index == rightIdentifiers.Length)
+                {
+                    return 1;
+                }
+
+                int leftNumber;
+                int rightNumber;
+                var leftIsNumber = int.TryParse(leftIdentifiers[index], out leftNumber);
+                var rightIsNumber = int.TryParse(rightIdentifiers[index], out rightNumber);
+                if (leftIsNumber && rightIsNumber)
+                {
+                    if (leftNumber != rightNumber)
+                    {
+                        return leftNumber.CompareTo(rightNumber);
+                    }
+
+                    continue;
+                }
+
+                if (leftIsNumber != rightIsNumber)
+                {
+                    return leftIsNumber ? -1 : 1;
+                }
+
+                var comparison = string.CompareOrdinal(leftIdentifiers[index], rightIdentifiers[index]);
+                if (comparison != 0)
+                {
+                    return comparison;
+                }
+            }
+
+            return 0;
+        }
+    }
+}
diff --git a/Editor/Setup/UnityCliVersionClassifier.cs.meta b/Editor/Setup/UnityCliVersionClassifier.cs.meta
new file mode 100644
index 00000000..fe321b67
--- /dev/null
+++ b/Editor/Setup/UnityCliVersionClassifier.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 39ac19caed2384cea9518b94d480df48
\ No newline at end of file
diff --git a/Editor/Tests/UnityCliSetupServiceTests.cs b/Editor/Tests/UnityCliSetupServiceTests.cs
new file mode 100644
index 00000000..539c4582
--- /dev/null
+++ b/Editor/Tests/UnityCliSetupServiceTests.cs
@@ -0,0 +1,206 @@
+using System.Reflection;
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using McpUnity.Extensions.Commands;
+using McpUnity.Extensions.Setup;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using UnityEditor.PackageManager;
+using UnityEngine;
+
+namespace McpUnity.Extensions.Tests
+{
+    public class UnityCliSetupServiceTests
+    {
+        [Test]
+        public void CliPathResolver_PrefersTheLiveWindowPath()
+        {
+            var assembly = typeof(AssignMaterialCommand).Assembly;
+            var resolverType = assembly.GetType("McpUnity.Extensions.Setup.UnityCliPathResolver");
+
+            Assert.That(resolverType, Is.Not.Null);
+            var resolve = resolverType.GetMethod("Resolve", BindingFlags.Public | BindingFlags.Static);
+            var result = resolve.Invoke(null, new object[] { "/window/unity", "/environment/unity" });
+            var executablePath = (string)result.GetType().GetProperty("ExecutablePath").GetValue(result);
+
+            Assert.That(executablePath, Is.EqualTo("/window/unity"));
+        }
+
+        [Test]
+        public void CliPathResolver_UsesEnvironmentThenPathWhenTheLiveWindowPathIsEmpty()
+        {
+            var environment = UnityCliPathResolver.Resolve(" ", "/environment/unity");
+            var path = UnityCliPathResolver.Resolve(string.Empty, string.Empty);
+
+            Assert.That(environment.ExecutablePath, Is.EqualTo("/environment/unity"));
+            Assert.That(environment.Source, Is.EqualTo(UnityCliPathSource.Environment));
+            Assert.That(path.ExecutablePath, Is.EqualTo("unity"));
+            Assert.That(path.Source, Is.EqualTo(UnityCliPathSource.Path));
+        }
+
+        [Test]
+        public void CliPathResolver_RecognizesExplicitWindowsAbsolutePathsOnEveryEditorPlatform()
+        {
+            var resolution = UnityCliPathResolver.Resolve(@"C:\Program Files\Unity\unity.exe", null);
+
+            Assert.That(resolution.IsExplicitAbsolutePath, Is.True);
+        }
+
+        [Test]
+        public void PipelineStatus_IdentifiesExactMissingAndUntestedVersions()
+        {
+            Assert.That(UnityCliPipelineStatus.Classify("0.3.1-exp.1"), Is.EqualTo(UnityCliPipelineState.ExactSupported));
+            Assert.That(UnityCliPipelineStatus.Classify(null), Is.EqualTo(UnityCliPipelineState.Missing));
+            Assert.That(UnityCliPipelineStatus.Classify("0.3.2-exp.1"), Is.EqualTo(UnityCliPipelineState.DifferentUntested));
+            Assert.That(UnityCliPipelineStatus.GetDisplayName(UnityCliPipelineState.ExactSupported),
+                Is.EqualTo("exact supported"));
+            Assert.That(UnityCliPipelineStatus.GetDisplayName(UnityCliPipelineState.Missing), Is.EqualTo("missing"));
+            Assert.That(UnityCliPipelineStatus.GetDisplayName(UnityCliPipelineState.DifferentUntested),
+                Is.EqualTo("different/untested"));
+        }
+
+        [Test]
+        public void CliVersionClassifier_ParsesAndClassifiesCompatibilityBoundaries()
+        {
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-beta.1", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Incompatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-beta.2", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Compatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Compatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-rc.1", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Compatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-alpha.9", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Incompatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 2.0.0", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.UntestedNewer));
+            Assert.That(UnityCliVersionClassifier.Classify("unknown", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-beta.2", false, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+        }
+
+        [Test]
+        public void CliCheck_UsesOnlyVersionCommandWithFiveSecondTimeoutAndClassifiesFailures()
+        {
+            var runner = new RecordingRunner(new UnityCliProcessResult("", "not found", 127, false));
+            var service = new UnityCliCheckService(runner, () => "/environment/unity");
+
+            var result = service.CheckAsync(string.Empty, CancellationToken.None).GetAwaiter().GetResult();
+
+            Assert.That(runner.ExecutablePath, Is.EqualTo("/environment/unity"));
+            Assert.That(runner.Arguments, Is.EqualTo("--version"));
+            Assert.That(runner.Timeout, Is.EqualTo(TimeSpan.FromSeconds(5)));
+            Assert.That(result.Compatibility.Status, Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+        }
+
+        [Test]
+        public void CliCheck_ClassifiesRunnerTimeoutAsMissingOrFailed()
+        {
+            var runner = new RecordingRunner(new UnityCliProcessResult("", "", -1, true));
+            var service = new UnityCliCheckService(runner, () => null);
+
+            var result = service.CheckAsync("/opt/unity", CancellationToken.None).GetAwaiter().GetResult();
+
+            Assert.That(result.Compatibility.Status, Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(result.Process.TimedOut, Is.True);
+        }
+
+        [Test]
+        public void SetupContent_UsesOfficialPlatformInstallCommands()
+        {
+            Assert.That(UnityCliSetupContent.GetInstallCommand(false), Is.EqualTo(
+                "curl -fsSL https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.sh | UNITY_CLI_CHANNEL=beta bash"));
+            Assert.That(UnityCliSetupContent.GetInstallCommand(true), Is.EqualTo(
+                "$env:UNITY_CLI_CHANNEL='beta'; irm https://public-cdn.cloud.unity3d.com/hub/prod/cli/install.ps1 | iex"));
+            Assert.That(UnityCliSetupContent.DocumentationUrl, Is.EqualTo(
+                "https://docs.unity.com/en-us/unity-cli/use-unity-cli"));
+        }
+
+        [Test]
+        public void CliConfiguration_GeneratesEscapedOfficialAndCompanionEntries()
+        {
+            const string projectPath = "C:\\Project Space\\\"Quoted\"";
+            const string executablePath = "C:\\Program Files\\Unity\\unity.exe";
+            const string packagePath = "C:\\Package Space\\mcp-unity";
+
+            var official = JObject.Parse(UnityCliConfiguration.CreateOfficial(executablePath, projectPath));
+            var companion = JObject.Parse(UnityCliConfiguration.CreateCompanion(
+                packagePath, projectPath, executablePath, true));
+
+            Assert.That((string)official["mcpServers"]["unity"]["command"], Is.EqualTo(executablePath));
+            Assert.That(official["mcpServers"]["unity"]["args"].Values(), Is.EqualTo(new[]
+            {
+                "mcp", "--project-path", projectPath
+            }));
+            Assert.That((string)companion["mcpServers"]["mcp-unity-companion"]["command"], Is.EqualTo("node"));
+            Assert.That((string)companion["mcpServers"]["mcp-unity-companion"]["args"][0], Is.EqualTo(
+                "C:\\Package Space\\mcp-unity/Server~/build/index.js"));
+            Assert.That((string)companion["mcpServers"]["mcp-unity-companion"]["env"]["UNITY_CLI_PATH"],
+                Is.EqualTo(executablePath));
+            Assert.That(UnityCliConfiguration.CreateCompanion(packagePath, projectPath, executablePath, false),
+                Does.Not.Contain("UNITY_CLI_PATH"));
+        }
+
+        [Test]
+        public void SetupServices_DoNotWriteProjectSettingsOrConfigurationFiles()
+        {
+            var projectSettings = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "ProjectSettings");
+            var before = Directory.Exists(projectSettings)
+                ? Directory.GetFiles(projectSettings, "*", SearchOption.AllDirectories)
+                : Array.Empty();
+            var runner = new RecordingRunner(new UnityCliProcessResult("unity 1.0.0-beta.2", "", 0, false));
+            var service = new UnityCliCheckService(runner, () => null);
+
+            service.CheckAsync("/opt/unity", CancellationToken.None).GetAwaiter().GetResult();
+
+            var after = Directory.Exists(projectSettings)
+                ? Directory.GetFiles(projectSettings, "*", SearchOption.AllDirectories)
+                : Array.Empty();
+            Assert.That(after, Is.EquivalentTo(before));
+        }
+
+        [Test]
+        public void SetupWindow_IsUserInitiatedWithoutStaticInitialization()
+        {
+            var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly);
+            var windowPath = Path.Combine(package.assetPath, "Editor/Setup/UnityCliSetupWindow.cs");
+
+            Assert.That(File.Exists(windowPath), Is.True);
+            var source = File.ReadAllText(windowPath);
+            Assert.That(source, Does.Contain("[MenuItem(\"Window/MCP Unity/Setup\")]"));
+            Assert.That(source, Does.Not.Contain("InitializeOnLoad"));
+            Assert.That(source, Does.Not.Contain("[InitializeOnLoad"));
+        }
+
+        private sealed class RecordingRunner : IUnityCliProcessRunner
+        {
+            private readonly UnityCliProcessResult result;
+
+            public RecordingRunner(UnityCliProcessResult result)
+            {
+                this.result = result;
+            }
+
+            public string ExecutablePath { get; private set; }
+
+            public string Arguments { get; private set; }
+
+            public TimeSpan Timeout { get; private set; }
+
+            public Task RunAsync(
+                string executablePath,
+                string arguments,
+                TimeSpan timeout,
+                CancellationToken cancellationToken)
+            {
+                ExecutablePath = executablePath;
+                Arguments = arguments;
+                Timeout = timeout;
+                return Task.FromResult(result);
+            }
+        }
+    }
+}
diff --git a/Editor/Tests/UnityCliSetupServiceTests.cs.meta b/Editor/Tests/UnityCliSetupServiceTests.cs.meta
new file mode 100644
index 00000000..4320cab3
--- /dev/null
+++ b/Editor/Tests/UnityCliSetupServiceTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 57a475b1581b74609b7a8dbf14a334f6
\ No newline at end of file

From d3afc22ab68cc262d35afc29a1d8e7e0cd4d5005 Mon Sep 17 00:00:00 2001
From: CoderGamester 
Date: Fri, 24 Jul 2026 03:16:42 +0100
Subject: [PATCH 04/20] fix: harden Unity CLI setup checks

---
 Editor/Setup/SystemUnityCliProcessRunner.cs   |  60 +++-
 Editor/Setup/UnityCliCheckService.cs          |  12 +-
 Editor/Setup/UnityCliSetupWindow.cs           |   2 +-
 Editor/Setup/UnityCliVersionClassifier.cs     | 107 +++++--
 Editor/Tests/UnityCliSetupReviewTests.cs      | 286 ++++++++++++++++++
 Editor/Tests/UnityCliSetupReviewTests.cs.meta |   2 +
 6 files changed, 426 insertions(+), 43 deletions(-)
 create mode 100644 Editor/Tests/UnityCliSetupReviewTests.cs
 create mode 100644 Editor/Tests/UnityCliSetupReviewTests.cs.meta

diff --git a/Editor/Setup/SystemUnityCliProcessRunner.cs b/Editor/Setup/SystemUnityCliProcessRunner.cs
index cd3153ea..f6e3ad11 100644
--- a/Editor/Setup/SystemUnityCliProcessRunner.cs
+++ b/Editor/Setup/SystemUnityCliProcessRunner.cs
@@ -7,6 +7,8 @@ namespace McpUnity.Extensions.Setup
 {
     public sealed class SystemUnityCliProcessRunner : IUnityCliProcessRunner
     {
+        private static readonly TimeSpan CleanupGrace = TimeSpan.FromMilliseconds(250);
+
         public async Task RunAsync(
             string executablePath,
             string arguments,
@@ -44,28 +46,60 @@ public async Task RunAsync(
                     exited.TrySetResult(true);
                 }
 
-                var completed = await Task.WhenAny(exited.Task, Task.Delay(timeout, cancellationToken));
-                if (completed != exited.Task)
+                var timeoutTask = Task.Delay(timeout);
+                var cancellationTask = Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);
+                var completed = await Task.WhenAny(exited.Task, timeoutTask, cancellationTask);
+                if (completed == exited.Task)
                 {
-                    if (!process.HasExited)
-                    {
-                        process.Kill();
-                    }
-
-                    await exited.Task;
                     return new UnityCliProcessResult(
                         await standardOutput,
                         await standardError,
                         process.ExitCode,
-                        true);
+                        false);
                 }
 
+                var timedOut = completed == timeoutTask;
+                TryKillOwnedProcess(process);
+                await Task.WhenAny(exited.Task, Task.Delay(CleanupGrace));
+                await Task.WhenAny(Task.WhenAll(standardOutput, standardError), Task.Delay(CleanupGrace));
                 return new UnityCliProcessResult(
-                    await standardOutput,
-                    await standardError,
-                    process.ExitCode,
-                    false);
+                    GetCompletedResult(standardOutput),
+                    GetCompletedResult(standardError),
+                    TryGetExitCode(process),
+                    timedOut,
+                    !timedOut);
+            }
+        }
+
+        private static void TryKillOwnedProcess(Process process)
+        {
+            try
+            {
+                if (!process.HasExited)
+                {
+                    process.Kill();
+                }
+            }
+            catch (InvalidOperationException)
+            {
             }
         }
+
+        private static int TryGetExitCode(Process process)
+        {
+            try
+            {
+                return process.HasExited ? process.ExitCode : -1;
+            }
+            catch (InvalidOperationException)
+            {
+                return -1;
+            }
+        }
+
+        private static string GetCompletedResult(Task task)
+        {
+            return task.Status == TaskStatus.RanToCompletion ? task.Result : string.Empty;
+        }
     }
 }
diff --git a/Editor/Setup/UnityCliCheckService.cs b/Editor/Setup/UnityCliCheckService.cs
index 772789f4..441c84fa 100644
--- a/Editor/Setup/UnityCliCheckService.cs
+++ b/Editor/Setup/UnityCliCheckService.cs
@@ -6,12 +6,18 @@ namespace McpUnity.Extensions.Setup
 {
     public sealed class UnityCliProcessResult
     {
-        public UnityCliProcessResult(string standardOutput, string standardError, int exitCode, bool timedOut)
+        public UnityCliProcessResult(
+            string standardOutput,
+            string standardError,
+            int exitCode,
+            bool timedOut,
+            bool cancelled = false)
         {
             StandardOutput = standardOutput ?? string.Empty;
             StandardError = standardError ?? string.Empty;
             ExitCode = exitCode;
             TimedOut = timedOut;
+            Cancelled = cancelled;
         }
 
         public string StandardOutput { get; }
@@ -21,6 +27,8 @@ public UnityCliProcessResult(string standardOutput, string standardError, int ex
         public int ExitCode { get; }
 
         public bool TimedOut { get; }
+
+        public bool Cancelled { get; }
     }
 
     public interface IUnityCliProcessRunner
@@ -79,7 +87,7 @@ public async Task CheckAsync(string liveWindowPath, Cancell
             var output = process.StandardOutput + Environment.NewLine + process.StandardError;
             var compatibility = UnityCliVersionClassifier.Classify(
                 output,
-                process.ExitCode == 0,
+                process.ExitCode == 0 && !process.Cancelled,
                 process.TimedOut);
             return new UnityCliCheckResult(candidate, process, compatibility);
         }
diff --git a/Editor/Setup/UnityCliSetupWindow.cs b/Editor/Setup/UnityCliSetupWindow.cs
index 376c7390..c8ea0db6 100644
--- a/Editor/Setup/UnityCliSetupWindow.cs
+++ b/Editor/Setup/UnityCliSetupWindow.cs
@@ -88,7 +88,7 @@ private void DrawCheckResult(string projectPath)
             {
                 var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly);
                 EditorGUIUtility.systemCopyBuffer = UnityCliConfiguration.CreateCompanion(
-                    package.assetPath,
+                    package.resolvedPath,
                     projectPath,
                     checkResult.Candidate.ExecutablePath,
                     checkResult.Candidate.IsExplicitAbsolutePath);
diff --git a/Editor/Setup/UnityCliVersionClassifier.cs b/Editor/Setup/UnityCliVersionClassifier.cs
index ef2664f2..3ed7d0be 100644
--- a/Editor/Setup/UnityCliVersionClassifier.cs
+++ b/Editor/Setup/UnityCliVersionClassifier.cs
@@ -27,7 +27,7 @@ public UnityCliCompatibilityResult(UnityCliCompatibility status, string version)
     public static class UnityCliVersionClassifier
     {
         private static readonly Regex VersionPattern = new Regex(
-            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z.-]+))?",
+            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![0-9A-Za-z.-])",
             RegexOptions.Compiled);
 
         public static UnityCliCompatibilityResult Classify(string output, bool processSucceeded, bool timedOut)
@@ -37,39 +37,46 @@ public static UnityCliCompatibilityResult Classify(string output, bool processSu
                 return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
             }
 
-            var match = VersionPattern.Match(output);
-            if (!match.Success)
+            try
             {
-                return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
-            }
+                var match = VersionPattern.Match(output);
+                if (!match.Success || !HasValidPrerelease(match))
+                {
+                    return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
+                }
 
-            var version = match.Value;
-            var major = int.Parse(match.Groups["major"].Value);
-            if (major > 1)
-            {
-                return new UnityCliCompatibilityResult(UnityCliCompatibility.UntestedNewer, version);
-            }
+                var version = match.Value;
+                var majorComparison = CompareNumericIdentifier(match.Groups["major"].Value, "1");
+                if (majorComparison > 0)
+                {
+                    return new UnityCliCompatibilityResult(UnityCliCompatibility.UntestedNewer, version);
+                }
+
+                if (majorComparison < 0 || CompareToMinimum(match) < 0)
+                {
+                    return new UnityCliCompatibilityResult(UnityCliCompatibility.Incompatible, version);
+                }
 
-            if (major < 1 || CompareToMinimum(match) < 0)
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.Compatible, version);
+            }
+            catch
             {
-                return new UnityCliCompatibilityResult(UnityCliCompatibility.Incompatible, version);
+                return new UnityCliCompatibilityResult(UnityCliCompatibility.MissingOrFailed, null);
             }
-
-            return new UnityCliCompatibilityResult(UnityCliCompatibility.Compatible, version);
         }
 
         private static int CompareToMinimum(Match match)
         {
-            var minor = int.Parse(match.Groups["minor"].Value);
-            if (minor != 0)
+            var minorComparison = CompareNumericIdentifier(match.Groups["minor"].Value, "0");
+            if (minorComparison != 0)
             {
-                return minor;
+                return minorComparison;
             }
 
-            var patch = int.Parse(match.Groups["patch"].Value);
-            if (patch != 0)
+            var patchComparison = CompareNumericIdentifier(match.Groups["patch"].Value, "0");
+            if (patchComparison != 0)
             {
-                return patch;
+                return patchComparison;
             }
 
             var prerelease = match.Groups["pre"].Success ? match.Groups["pre"].Value : null;
@@ -98,15 +105,14 @@ private static int ComparePrerelease(string left, string right)
                     return 1;
                 }
 
-                int leftNumber;
-                int rightNumber;
-                var leftIsNumber = int.TryParse(leftIdentifiers[index], out leftNumber);
-                var rightIsNumber = int.TryParse(rightIdentifiers[index], out rightNumber);
+                var leftIsNumber = IsNumericIdentifier(leftIdentifiers[index]);
+                var rightIsNumber = IsNumericIdentifier(rightIdentifiers[index]);
                 if (leftIsNumber && rightIsNumber)
                 {
-                    if (leftNumber != rightNumber)
+                    var numericComparison = CompareNumericIdentifier(leftIdentifiers[index], rightIdentifiers[index]);
+                    if (numericComparison != 0)
                     {
-                        return leftNumber.CompareTo(rightNumber);
+                        return numericComparison;
                     }
 
                     continue;
@@ -126,5 +132,52 @@ private static int ComparePrerelease(string left, string right)
 
             return 0;
         }
+
+        private static bool HasValidPrerelease(Match match)
+        {
+            if (!match.Groups["pre"].Success)
+            {
+                return true;
+            }
+
+            foreach (var identifier in match.Groups["pre"].Value.Split('.'))
+            {
+                if (string.IsNullOrEmpty(identifier) ||
+                    (IsNumericIdentifier(identifier) && identifier.Length > 1 && identifier[0] == '0'))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private static bool IsNumericIdentifier(string value)
+        {
+            if (string.IsNullOrEmpty(value))
+            {
+                return false;
+            }
+
+            for (var index = 0; index < value.Length; index++)
+            {
+                if (value[index] < '0' || value[index] > '9')
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private static int CompareNumericIdentifier(string left, string right)
+        {
+            if (left.Length != right.Length)
+            {
+                return left.Length.CompareTo(right.Length);
+            }
+
+            return string.CompareOrdinal(left, right);
+        }
     }
 }
diff --git a/Editor/Tests/UnityCliSetupReviewTests.cs b/Editor/Tests/UnityCliSetupReviewTests.cs
new file mode 100644
index 00000000..5e265541
--- /dev/null
+++ b/Editor/Tests/UnityCliSetupReviewTests.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using McpUnity.Extensions.Commands;
+using McpUnity.Extensions.Setup;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using UnityEditor.PackageManager;
+using UnityEngine;
+
+namespace McpUnity.Extensions.Tests
+{
+    public class UnityCliSetupReviewTests
+    {
+        [Test]
+        public void CompanionConfiguration_UsesAbsoluteResolvedPackagePathThroughTheWindowBoundary()
+        {
+            var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly);
+            var resolvedPackagePath = Path.GetFullPath(package.resolvedPath);
+            var configuration = JObject.Parse(UnityCliConfiguration.CreateCompanion(
+                resolvedPackagePath,
+                "/absolute/project",
+                "/absolute/unity",
+                true));
+            var serverPath = (string)configuration["mcpServers"]["mcp-unity-companion"]["args"][0];
+            var windowSource = File.ReadAllText(Path.Combine(package.assetPath, "Editor/Setup/UnityCliSetupWindow.cs"));
+
+            Assert.That(Path.IsPathRooted(serverPath), Is.True);
+            Assert.That(serverPath, Is.EqualTo(
+                resolvedPackagePath.TrimEnd('/', '\\') + "/Server~/build/index.js"));
+            Assert.That(windowSource, Does.Contain("package.resolvedPath"));
+            Assert.That(windowSource, Does.Not.Contain("package.assetPath"));
+        }
+
+        [Test]
+        public void CliVersionClassifier_RejectsMalformedPrereleasesAndClassifiesHugeCoreIdentifiersWithoutThrowing()
+        {
+            var huge = new string('9', 128);
+
+            Assert.DoesNotThrow(() =>
+                UnityCliVersionClassifier.Classify("unity " + huge + ".0.0", true, false));
+            Assert.That(UnityCliVersionClassifier.Classify("unity " + huge + ".0.0", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.UntestedNewer));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-beta..2", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0-beta.02", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1." + huge + ".0", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Compatible));
+        }
+
+        [Test]
+        public void CliCheckService_ContainsMalformedAndLargeVersionOutput()
+        {
+            var huge = new string('9', 128);
+            var service = new UnityCliCheckService(
+                new FixedRunner(new UnityCliProcessResult("unity " + huge + ".0.0-beta.02", string.Empty, 0, false)),
+                () => null);
+
+            UnityCliCheckResult result = null;
+            Assert.DoesNotThrow(() => result = service.CheckAsync("/opt/unity", CancellationToken.None).GetAwaiter().GetResult());
+
+            Assert.That(result.Compatibility.Status, Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+        }
+
+        [Test]
+        public async Task ProcessRunner_TimeoutKillsItsOwnedProcessAndReturnsDespiteAChildHoldingPipes()
+        {
+            var helperDirectory = CreateHelperDirectory();
+            var parentPidPath = Path.Combine(helperDirectory, "parent.pid");
+            var childPidPath = Path.Combine(helperDirectory, "child.pid");
+            var runner = new SystemUnityCliProcessRunner();
+            var command = "echo $$ > '" + parentPidPath + "'; sleep 30 & echo $! > '" + childPidPath + "'; wait";
+
+            try
+            {
+                var stopwatch = Stopwatch.StartNew();
+                var task = runner.RunAsync(
+                    "/bin/sh",
+                    "-c \"" + command + "\"",
+                    TimeSpan.FromMilliseconds(100),
+                    CancellationToken.None);
+
+                Assert.That(WaitForFile(childPidPath, TimeSpan.FromSeconds(1)), Is.True);
+                Assert.That(await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))), Is.EqualTo(task));
+
+                var result = await task;
+                Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)));
+                Assert.That(result.TimedOut, Is.True);
+                Assert.That(result.Cancelled, Is.False);
+                Assert.That(IsProcessRunning(ReadPid(parentPidPath)), Is.False);
+            }
+            finally
+            {
+                KillIfRunning(ReadPid(childPidPath));
+                Directory.Delete(helperDirectory, true);
+            }
+        }
+
+        [Test]
+        public async Task ProcessRunner_ReportsExternalCancellationSeparatelyFromTimeout()
+        {
+            var helperDirectory = CreateHelperDirectory();
+            var parentPidPath = Path.Combine(helperDirectory, "parent.pid");
+            var runner = new SystemUnityCliProcessRunner();
+            var command = "echo $$ > '" + parentPidPath + "'; sleep 30";
+            var cancellation = new CancellationTokenSource();
+
+            try
+            {
+                var task = runner.RunAsync(
+                    "/bin/sh",
+                    "-c \"" + command + "\"",
+                    TimeSpan.FromSeconds(5),
+                    cancellation.Token);
+                Assert.That(WaitForFile(parentPidPath, TimeSpan.FromSeconds(1)), Is.True);
+
+                cancellation.Cancel();
+                Assert.That(await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))), Is.EqualTo(task));
+
+                var result = await task;
+                Assert.That(result.TimedOut, Is.False);
+                Assert.That(result.Cancelled, Is.True);
+                Assert.That(IsProcessRunning(ReadPid(parentPidPath)), Is.False);
+            }
+            finally
+            {
+                cancellation.Dispose();
+                KillIfRunning(ReadPid(parentPidPath));
+                Directory.Delete(helperDirectory, true);
+            }
+        }
+
+        [Test]
+        public void SetupServices_PreserveProjectSettingsContentAndEnvironment()
+        {
+            var projectSettingsDirectory = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "ProjectSettings");
+            var projectSettingsBefore = SnapshotProjectSettings(projectSettingsDirectory);
+            var environmentBefore = SnapshotEnvironment();
+            var service = new UnityCliCheckService(
+                new FixedRunner(new UnityCliProcessResult("unity 1.0.0-beta.2", string.Empty, 0, false)),
+                () => null);
+
+            service.CheckAsync("/opt/unity", CancellationToken.None).GetAwaiter().GetResult();
+
+            Assert.That(SnapshotProjectSettings(projectSettingsDirectory), Is.EqualTo(projectSettingsBefore));
+            Assert.That(SnapshotEnvironment(), Is.EqualTo(environmentBefore));
+        }
+
+        [Test]
+        public void SetupServices_StaticContractHasNoPersistentWriteApis()
+        {
+            var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly);
+            var setupDirectory = Path.Combine(package.assetPath, "Editor/Setup");
+            var source = string.Join("\n", Directory.GetFiles(setupDirectory, "*.cs")
+                .Select(File.ReadAllText)
+                .ToArray());
+
+            Assert.That(source, Does.Not.Contain("File.Write"));
+            Assert.That(source, Does.Not.Contain("File.Append"));
+            Assert.That(source, Does.Not.Contain("WriteAll"));
+            Assert.That(source, Does.Not.Contain("EditorPrefs.Set"));
+            Assert.That(source, Does.Not.Contain("Environment.SetEnvironmentVariable"));
+            Assert.That(source, Does.Contain("process.Start()"));
+        }
+
+        private sealed class FixedRunner : IUnityCliProcessRunner
+        {
+            private readonly UnityCliProcessResult result;
+
+            public FixedRunner(UnityCliProcessResult result)
+            {
+                this.result = result;
+            }
+
+            public Task RunAsync(
+                string executablePath,
+                string arguments,
+                TimeSpan timeout,
+                CancellationToken cancellationToken)
+            {
+                return Task.FromResult(result);
+            }
+        }
+
+        private static string CreateHelperDirectory()
+        {
+            var directory = Path.Combine(Path.GetTempPath(), "mcp-unity-cli-runner-" + Guid.NewGuid().ToString("N"));
+            Directory.CreateDirectory(directory);
+            return directory;
+        }
+
+        private static bool WaitForFile(string path, TimeSpan timeout)
+        {
+            var stopwatch = Stopwatch.StartNew();
+            while (!File.Exists(path) && stopwatch.Elapsed < timeout)
+            {
+                Thread.Sleep(10);
+            }
+
+            return File.Exists(path);
+        }
+
+        private static int ReadPid(string path)
+        {
+            int pid;
+            return File.Exists(path) && int.TryParse(File.ReadAllText(path).Trim(), out pid) ? pid : -1;
+        }
+
+        private static bool IsProcessRunning(int pid)
+        {
+            if (pid <= 0)
+            {
+                return false;
+            }
+
+            try
+            {
+                using (var process = Process.GetProcessById(pid))
+                {
+                    return !process.HasExited;
+                }
+            }
+            catch (ArgumentException)
+            {
+                return false;
+            }
+        }
+
+        private static void KillIfRunning(int pid)
+        {
+            if (!IsProcessRunning(pid))
+            {
+                return;
+            }
+
+            using (var process = Process.GetProcessById(pid))
+            {
+                process.Kill();
+            }
+        }
+
+        private static Dictionary SnapshotProjectSettings(string directory)
+        {
+            var snapshot = new Dictionary();
+            if (!Directory.Exists(directory))
+            {
+                return snapshot;
+            }
+
+            foreach (var path in Directory.GetFiles(directory, "*", SearchOption.AllDirectories))
+            {
+                snapshot[Path.GetRelativePath(directory, path)] = ComputeHash(path);
+            }
+
+            return snapshot;
+        }
+
+        private static Dictionary SnapshotEnvironment()
+        {
+            var snapshot = new Dictionary();
+            foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables())
+            {
+                snapshot[(string)variable.Key] = (string)variable.Value;
+            }
+
+            return snapshot;
+        }
+
+        private static string ComputeHash(string path)
+        {
+            using (var sha256 = SHA256.Create())
+            {
+                return BitConverter.ToString(sha256.ComputeHash(File.ReadAllBytes(path)));
+            }
+        }
+    }
+}
diff --git a/Editor/Tests/UnityCliSetupReviewTests.cs.meta b/Editor/Tests/UnityCliSetupReviewTests.cs.meta
new file mode 100644
index 00000000..02218f8d
--- /dev/null
+++ b/Editor/Tests/UnityCliSetupReviewTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fce13f04bd623431ab1a55384238a9e7
\ No newline at end of file

From 542f64ea52a8525e5b92cb96a561601498115f13 Mon Sep 17 00:00:00 2001
From: CoderGamester 
Date: Fri, 24 Jul 2026 03:33:03 +0100
Subject: [PATCH 05/20] fix: bound Unity CLI setup deadline

---
 Editor/Setup/SystemUnityCliProcessRunner.cs | 110 +++++++++++++-------
 Editor/Setup/UnityCliVersionClassifier.cs   |   2 +-
 Editor/Tests/UnityCliSetupReviewTests.cs    |  81 +++++++++++++-
 3 files changed, 153 insertions(+), 40 deletions(-)

diff --git a/Editor/Setup/SystemUnityCliProcessRunner.cs b/Editor/Setup/SystemUnityCliProcessRunner.cs
index f6e3ad11..f9e3c9a4 100644
--- a/Editor/Setup/SystemUnityCliProcessRunner.cs
+++ b/Editor/Setup/SystemUnityCliProcessRunner.cs
@@ -16,52 +16,86 @@ public async Task RunAsync(
             CancellationToken cancellationToken)
         {
             using (var process = new Process())
+            using (var deadlineSource = new CancellationTokenSource(timeout))
+            using (var interruptionSource = CancellationTokenSource.CreateLinkedTokenSource(
+                cancellationToken,
+                deadlineSource.Token))
             {
-                process.StartInfo = new ProcessStartInfo
-                {
-                    FileName = executablePath,
-                    Arguments = arguments,
-                    RedirectStandardOutput = true,
-                    RedirectStandardError = true,
-                    UseShellExecute = false,
-                    CreateNoWindow = true
-                };
-                process.EnableRaisingEvents = true;
-                var exited = new TaskCompletionSource();
-                process.Exited += (sender, eventArgs) => exited.TrySetResult(true);
-
                 try
                 {
-                    process.Start();
-                }
-                catch (Exception exception)
-                {
-                    return new UnityCliProcessResult(string.Empty, exception.Message, -1, false);
-                }
+                    process.StartInfo = new ProcessStartInfo
+                    {
+                        FileName = executablePath,
+                        Arguments = arguments,
+                        RedirectStandardOutput = true,
+                        RedirectStandardError = true,
+                        UseShellExecute = false,
+                        CreateNoWindow = true
+                    };
+                    process.EnableRaisingEvents = true;
+                    var exited = new TaskCompletionSource();
+                    process.Exited += (sender, eventArgs) => exited.TrySetResult(true);
 
-                var standardOutput = process.StandardOutput.ReadToEndAsync();
-                var standardError = process.StandardError.ReadToEndAsync();
-                if (process.HasExited)
-                {
-                    exited.TrySetResult(true);
-                }
+                    try
+                    {
+                        process.Start();
+                    }
+                    catch (Exception exception)
+                    {
+                        return new UnityCliProcessResult(string.Empty, exception.Message, -1, false);
+                    }
 
-                var timeoutTask = Task.Delay(timeout);
-                var cancellationTask = Task.Delay(System.Threading.Timeout.Infinite, cancellationToken);
-                var completed = await Task.WhenAny(exited.Task, timeoutTask, cancellationTask);
-                if (completed == exited.Task)
+                    var standardOutput = process.StandardOutput.ReadToEndAsync();
+                    var standardError = process.StandardError.ReadToEndAsync();
+                    if (process.HasExited)
+                    {
+                        exited.TrySetResult(true);
+                    }
+
+                    var interruptionTask = Task.Delay(System.Threading.Timeout.Infinite, interruptionSource.Token);
+                    var completed = await Task.WhenAny(exited.Task, interruptionTask);
+                    if (completed == exited.Task)
+                    {
+                        var streams = Task.WhenAll(standardOutput, standardError);
+                        if (await Task.WhenAny(streams, interruptionTask) == streams)
+                        {
+                            return new UnityCliProcessResult(
+                                await standardOutput,
+                                await standardError,
+                                process.ExitCode,
+                                false);
+                        }
+                    }
+
+                    var timedOut = deadlineSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested;
+                    TryKillOwnedProcess(process);
+                    return await CompleteInterruptedProcessAsync(
+                        process,
+                        exited.Task,
+                        standardOutput,
+                        standardError,
+                        timedOut);
+                }
+                finally
                 {
-                    return new UnityCliProcessResult(
-                        await standardOutput,
-                        await standardError,
-                        process.ExitCode,
-                        false);
+                    interruptionSource.Cancel();
+                    deadlineSource.Cancel();
                 }
+            }
+        }
 
-                var timedOut = completed == timeoutTask;
-                TryKillOwnedProcess(process);
-                await Task.WhenAny(exited.Task, Task.Delay(CleanupGrace));
-                await Task.WhenAny(Task.WhenAll(standardOutput, standardError), Task.Delay(CleanupGrace));
+        private static async Task CompleteInterruptedProcessAsync(
+            Process process,
+            Task exited,
+            Task standardOutput,
+            Task standardError,
+            bool timedOut)
+        {
+            using (var cleanupSource = new CancellationTokenSource(CleanupGrace))
+            {
+                var cleanupTask = Task.Delay(System.Threading.Timeout.Infinite, cleanupSource.Token);
+                await Task.WhenAny(exited, cleanupTask);
+                await Task.WhenAny(Task.WhenAll(standardOutput, standardError), cleanupTask);
                 return new UnityCliProcessResult(
                     GetCompletedResult(standardOutput),
                     GetCompletedResult(standardError),
diff --git a/Editor/Setup/UnityCliVersionClassifier.cs b/Editor/Setup/UnityCliVersionClassifier.cs
index 3ed7d0be..005c8fd3 100644
--- a/Editor/Setup/UnityCliVersionClassifier.cs
+++ b/Editor/Setup/UnityCliVersionClassifier.cs
@@ -27,7 +27,7 @@ public UnityCliCompatibilityResult(UnityCliCompatibility status, string version)
     public static class UnityCliVersionClassifier
     {
         private static readonly Regex VersionPattern = new Regex(
-            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?![0-9A-Za-z.-])",
+            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?=$|[^0-9A-Za-z.+!-])",
             RegexOptions.Compiled);
 
         public static UnityCliCompatibilityResult Classify(string output, bool processSucceeded, bool timedOut)
diff --git a/Editor/Tests/UnityCliSetupReviewTests.cs b/Editor/Tests/UnityCliSetupReviewTests.cs
index 5e265541..e2c74d7f 100644
--- a/Editor/Tests/UnityCliSetupReviewTests.cs
+++ b/Editor/Tests/UnityCliSetupReviewTests.cs
@@ -57,6 +57,27 @@ public void CliVersionClassifier_RejectsMalformedPrereleasesAndClassifiesHugeCor
                 Is.EqualTo(UnityCliCompatibility.Compatible));
         }
 
+        [Test]
+        public void CliVersionClassifier_ValidatesBuildMetadataWithoutChangingPrecedence()
+        {
+            var huge = new string('9', 128);
+
+            var release = UnityCliVersionClassifier.Classify("unity 1.0.0+build.7", true, false);
+            var prerelease = UnityCliVersionClassifier.Classify("unity 1.0.0-beta.2+build.7", true, false);
+
+            Assert.That(release.Status, Is.EqualTo(UnityCliCompatibility.Compatible));
+            Assert.That(release.Version, Is.EqualTo("1.0.0+build.7"));
+            Assert.That(prerelease.Status, Is.EqualTo(UnityCliCompatibility.Compatible));
+            Assert.That(UnityCliVersionClassifier.Classify("unity " + huge + ".0.0+build.7", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.UntestedNewer));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+build..7", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+build!", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+        }
+
         [Test]
         public void CliCheckService_ContainsMalformedAndLargeVersionOutput()
         {
@@ -74,11 +95,13 @@ public void CliCheckService_ContainsMalformedAndLargeVersionOutput()
         [Test]
         public async Task ProcessRunner_TimeoutKillsItsOwnedProcessAndReturnsDespiteAChildHoldingPipes()
         {
+            RequirePosixHelper();
             var helperDirectory = CreateHelperDirectory();
             var parentPidPath = Path.Combine(helperDirectory, "parent.pid");
             var childPidPath = Path.Combine(helperDirectory, "child.pid");
             var runner = new SystemUnityCliProcessRunner();
-            var command = "echo $$ > '" + parentPidPath + "'; sleep 30 & echo $! > '" + childPidPath + "'; wait";
+            var command = "echo $$ > '" + parentPidPath +
+                "'; exec 3>&1 4>&2; (while :; do sleep 1; done) >&3 2>&4 & echo $! > '" + childPidPath + "'; wait";
 
             try
             {
@@ -108,6 +131,7 @@ public async Task ProcessRunner_TimeoutKillsItsOwnedProcessAndReturnsDespiteAChi
         [Test]
         public async Task ProcessRunner_ReportsExternalCancellationSeparatelyFromTimeout()
         {
+            RequirePosixHelper();
             var helperDirectory = CreateHelperDirectory();
             var parentPidPath = Path.Combine(helperDirectory, "parent.pid");
             var runner = new SystemUnityCliProcessRunner();
@@ -139,6 +163,53 @@ public async Task ProcessRunner_ReportsExternalCancellationSeparatelyFromTimeout
             }
         }
 
+        [Test]
+        public async Task ProcessRunner_DeadlineBoundsDrainWhenTheParentExitsButChildKeepsPipesOpen()
+        {
+            RequirePosixHelper();
+            var helperDirectory = CreateHelperDirectory();
+            var childPidPath = Path.Combine(helperDirectory, "child.pid");
+            var runner = new SystemUnityCliProcessRunner();
+            var command = "exec 3>&1 4>&2; (while :; do sleep 1; done) >&3 2>&4 & echo $! > '" +
+                childPidPath + "'; exit 0";
+
+            try
+            {
+                var stopwatch = Stopwatch.StartNew();
+                var task = runner.RunAsync(
+                    "/bin/sh",
+                    "-c \"" + command + "\"",
+                    TimeSpan.FromMilliseconds(100),
+                    CancellationToken.None);
+                Assert.That(WaitForFile(childPidPath, TimeSpan.FromSeconds(1)), Is.True);
+
+                Assert.That(await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))), Is.EqualTo(task));
+
+                var result = await task;
+                Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)));
+                Assert.That(result.TimedOut, Is.True);
+                Assert.That(IsProcessRunning(ReadPid(childPidPath)), Is.True);
+            }
+            finally
+            {
+                KillIfRunning(ReadPid(childPidPath));
+                Directory.Delete(helperDirectory, true);
+            }
+        }
+
+        [Test]
+        public void ProcessRunner_UsesDisposableCancellationSourcesForDeadlineAndCleanup()
+        {
+            var package = PackageInfo.FindForAssembly(typeof(AssignMaterialCommand).Assembly);
+            var source = File.ReadAllText(Path.Combine(
+                package.assetPath,
+                "Editor/Setup/SystemUnityCliProcessRunner.cs"));
+
+            Assert.That(source, Does.Contain("new CancellationTokenSource(timeout)"));
+            Assert.That(source, Does.Contain("CancellationTokenSource.CreateLinkedTokenSource"));
+            Assert.That(source, Does.Not.Contain("Task.Delay(timeout)"));
+        }
+
         [Test]
         public void SetupServices_PreserveProjectSettingsContentAndEnvironment()
         {
@@ -198,6 +269,14 @@ private static string CreateHelperDirectory()
             return directory;
         }
 
+        private static void RequirePosixHelper()
+        {
+            if (Application.platform == RuntimePlatform.WindowsEditor)
+            {
+                Assert.Ignore("POSIX helper-process regression is not available on Windows.");
+            }
+        }
+
         private static bool WaitForFile(string path, TimeSpan timeout)
         {
             var stopwatch = Stopwatch.StartNew();

From 012b219d7d0a0a9c2c4e10f021c01e3c7fd41353 Mon Sep 17 00:00:00 2001
From: CoderGamester 
Date: Fri, 24 Jul 2026 03:40:05 +0100
Subject: [PATCH 06/20] fix: require complete Unity CLI version tokens

---
 Editor/Setup/UnityCliVersionClassifier.cs | 2 +-
 Editor/Tests/UnityCliSetupReviewTests.cs  | 6 ++++++
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/Editor/Setup/UnityCliVersionClassifier.cs b/Editor/Setup/UnityCliVersionClassifier.cs
index 005c8fd3..c64271ce 100644
--- a/Editor/Setup/UnityCliVersionClassifier.cs
+++ b/Editor/Setup/UnityCliVersionClassifier.cs
@@ -27,7 +27,7 @@ public UnityCliCompatibilityResult(UnityCliCompatibility status, string version)
     public static class UnityCliVersionClassifier
     {
         private static readonly Regex VersionPattern = new Regex(
-            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?=$|[^0-9A-Za-z.+!-])",
+            @"(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)(?:-(?
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?=$|[\s,;:()\[\]{}])",
             RegexOptions.Compiled);
 
         public static UnityCliCompatibilityResult Classify(string output, bool processSucceeded, bool timedOut)
diff --git a/Editor/Tests/UnityCliSetupReviewTests.cs b/Editor/Tests/UnityCliSetupReviewTests.cs
index e2c74d7f..9dab5124 100644
--- a/Editor/Tests/UnityCliSetupReviewTests.cs
+++ b/Editor/Tests/UnityCliSetupReviewTests.cs
@@ -76,6 +76,12 @@ public void CliVersionClassifier_ValidatesBuildMetadataWithoutChangingPrecedence
                 Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
             Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+build!", true, false).Status,
                 Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+build_meta", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("unity 1.0.0+build@meta", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.MissingOrFailed));
+            Assert.That(UnityCliVersionClassifier.Classify("Unity CLI version: 1.0.0+build.7 (stable)", true, false).Status,
+                Is.EqualTo(UnityCliCompatibility.Compatible));
         }
 
         [Test]

From c83c4bfaef00b3942536838a9eb09bed6d16ed92 Mon Sep 17 00:00:00 2001
From: CoderGamester 
Date: Fri, 24 Jul 2026 03:59:01 +0100
Subject: [PATCH 07/20] feat: replace Node bridge with Unity CLI companion

---
 Server~/.dockerignore                         |   33 -
 Server~/Dockerfile                            |   58 -
 Server~/build/cli/companionCli.d.ts           |   20 +
 Server~/build/cli/companionCli.js             |  135 ++
 Server~/build/companionEntrypoint.d.ts        |   21 +
 Server~/build/companionEntrypoint.js          |   38 +
 Server~/build/companionLifecycle.d.ts         |   16 +
 Server~/build/companionLifecycle.js           |   37 +
 Server~/build/companionServer.d.ts            |    3 +
 Server~/build/companionServer.js              |   84 +
 Server~/build/index.d.ts                      |    2 +
 Server~/build/index.js                        |   17 +
 Server~/build/prompts/companionPrompts.d.ts   |    2 +
 Server~/build/prompts/companionPrompts.js     |   32 +
 .../build/resources/companionResources.d.ts   |   15 +
 Server~/build/resources/companionResources.js |  194 ++
 .../build/resources/dashboardResource.d.ts    |    5 +
 Server~/build/resources/dashboardResource.js  |   20 +
 Server~/build/ui/unity-dashboard.html         |  144 ++
 .../build/unity/officialUnityMcpClient.d.ts   |   29 +
 Server~/build/unity/officialUnityMcpClient.js |  145 ++
 Server~/package-lock.json                     |  751 +++----
 Server~/package.json                          |   37 +-
 Server~/scripts/clean-build.mjs               |    9 +
 Server~/smithery.yaml                         |   24 -
 .../src/__tests__/batchExecuteTool.test.ts    |  230 ---
 Server~/src/__tests__/commandQueue.test.ts    |  412 ----
 Server~/src/__tests__/companionCli.test.ts    |   95 +
 .../src/__tests__/companionEntrypoint.test.ts |   35 +
 .../src/__tests__/companionLifecycle.test.ts  |   52 +
 .../companionPackageContract.test.ts          |   91 +
 .../src/__tests__/companionResources.test.ts  |  231 +++
 Server~/src/__tests__/companionServer.test.ts |  164 ++
 .../__tests__/dashboardSupportTools.test.ts   |  101 -
 Server~/src/__tests__/errors.test.ts          |   94 -
 Server~/src/__tests__/logger.test.ts          |  110 --
 Server~/src/__tests__/materialTools.test.ts   |  436 -----
 Server~/src/__tests__/mcpUnity.test.ts        |  273 ---
 .../__tests__/officialUnityMcpClient.test.ts  |  133 ++
 Server~/src/__tests__/playModeTools.test.ts   |  122 --
 Server~/src/__tests__/releaseMetadata.test.ts |   27 -
 Server~/src/__tests__/unityConnection.test.ts |  276 ---
 .../unityDashboardAppResource.test.ts         |  109 --
 Server~/src/cli/companionCli.ts               |  213 ++
 Server~/src/companionEntrypoint.ts            |   72 +
 Server~/src/companionLifecycle.ts             |   59 +
 Server~/src/companionServer.ts                |  118 ++
 Server~/src/index.ts                          |  203 +-
 Server~/src/prompts/companionPrompts.ts       |   45 +
 .../src/prompts/gameobjectHandlingPrompt.ts   |   59 -
 Server~/src/prompts/unityDashboardPrompt.ts   |   58 -
 Server~/src/resources/companionResources.ts   |  265 +++
 Server~/src/resources/dashboardResource.ts    |   22 +
 Server~/src/resources/getAssetsResource.ts    |   87 -
 .../src/resources/getConsoleLogsResource.ts   |  123 --
 .../src/resources/getGameObjectResource.ts    |  185 --
 Server~/src/resources/getMenuItemResource.ts  |   69 -
 Server~/src/resources/getPackagesResource.ts  |   98 -
 .../resources/getScenesHierarchyResource.ts   |   69 -
 Server~/src/resources/getTestsResource.ts     |  119 --
 .../resources/unityDashboardAppResource.ts    |  107 -
 Server~/src/tools/addAssetToSceneTool.ts      |   87 -
 Server~/src/tools/addPackageTool.ts           |   98 -
 Server~/src/tools/batchExecuteTool.ts         |  175 --
 Server~/src/tools/createPrefabTool.ts         |   84 -
 Server~/src/tools/createSceneTool.ts          |   85 -
 Server~/src/tools/deleteSceneTool.ts          |   68 -
 Server~/src/tools/gameObjectTools.ts          |  226 ---
 Server~/src/tools/getConsoleLogsTool.ts       |  125 --
 Server~/src/tools/getGameObjectTool.ts        |  117 --
 Server~/src/tools/getPlayModeStatusTool.ts    |   63 -
 Server~/src/tools/getSceneInfoTool.ts         |   73 -
 Server~/src/tools/getScenesHierarchyTool.ts   |   59 -
 Server~/src/tools/loadSceneTool.ts            |   70 -
 Server~/src/tools/materialTools.ts            |  354 ----
 Server~/src/tools/menuItemTool.ts             |   73 -
 Server~/src/tools/recompileScriptsTool.ts     |   89 -
 Server~/src/tools/runTestsTool.ts             |  108 --
 Server~/src/tools/saveSceneTool.ts            |   67 -
 Server~/src/tools/selectGameObjectTool.ts     |   82 -
 Server~/src/tools/sendConsoleLogTool.ts       |   78 -
 Server~/src/tools/setPlayModeStatusTool.ts    |   69 -
 Server~/src/tools/showUnityDashboardTool.ts   |   62 -
 Server~/src/tools/transformTools.ts           |  329 ----
 Server~/src/tools/unloadSceneTool.ts          |   70 -
 Server~/src/tools/updateComponentTool.ts      |  102 -
 Server~/src/tools/updateGameObjectTool.ts     |   98 -
 Server~/src/ui/unity-dashboard.html           | 1717 ++---------------
 Server~/src/unity/commandQueue.ts             |  321 ---
 Server~/src/unity/mcpUnity.ts                 |  541 ------
 Server~/src/unity/officialUnityMcpClient.ts   |  194 ++
 Server~/src/unity/unityConnection.ts          |  561 ------
 Server~/src/utils/errors.ts                   |   41 -
 Server~/src/utils/logger.ts                   |   77 -
 94 files changed, 3176 insertions(+), 10020 deletions(-)
 delete mode 100644 Server~/.dockerignore
 delete mode 100644 Server~/Dockerfile
 create mode 100644 Server~/build/cli/companionCli.d.ts
 create mode 100644 Server~/build/cli/companionCli.js
 create mode 100644 Server~/build/companionEntrypoint.d.ts
 create mode 100644 Server~/build/companionEntrypoint.js
 create mode 100644 Server~/build/companionLifecycle.d.ts
 create mode 100644 Server~/build/companionLifecycle.js
 create mode 100644 Server~/build/companionServer.d.ts
 create mode 100644 Server~/build/companionServer.js
 create mode 100644 Server~/build/index.d.ts
 create mode 100644 Server~/build/index.js
 create mode 100644 Server~/build/prompts/companionPrompts.d.ts
 create mode 100644 Server~/build/prompts/companionPrompts.js
 create mode 100644 Server~/build/resources/companionResources.d.ts
 create mode 100644 Server~/build/resources/companionResources.js
 create mode 100644 Server~/build/resources/dashboardResource.d.ts
 create mode 100644 Server~/build/resources/dashboardResource.js
 create mode 100644 Server~/build/ui/unity-dashboard.html
 create mode 100644 Server~/build/unity/officialUnityMcpClient.d.ts
 create mode 100644 Server~/build/unity/officialUnityMcpClient.js
 create mode 100644 Server~/scripts/clean-build.mjs
 delete mode 100644 Server~/smithery.yaml
 delete mode 100644 Server~/src/__tests__/batchExecuteTool.test.ts
 delete mode 100644 Server~/src/__tests__/commandQueue.test.ts
 create mode 100644 Server~/src/__tests__/companionCli.test.ts
 create mode 100644 Server~/src/__tests__/companionEntrypoint.test.ts
 create mode 100644 Server~/src/__tests__/companionLifecycle.test.ts
 create mode 100644 Server~/src/__tests__/companionPackageContract.test.ts
 create mode 100644 Server~/src/__tests__/companionResources.test.ts
 create mode 100644 Server~/src/__tests__/companionServer.test.ts
 delete mode 100644 Server~/src/__tests__/dashboardSupportTools.test.ts
 delete mode 100644 Server~/src/__tests__/errors.test.ts
 delete mode 100644 Server~/src/__tests__/logger.test.ts
 delete mode 100644 Server~/src/__tests__/materialTools.test.ts
 delete mode 100644 Server~/src/__tests__/mcpUnity.test.ts
 create mode 100644 Server~/src/__tests__/officialUnityMcpClient.test.ts
 delete mode 100644 Server~/src/__tests__/playModeTools.test.ts
 delete mode 100644 Server~/src/__tests__/releaseMetadata.test.ts
 delete mode 100644 Server~/src/__tests__/unityConnection.test.ts
 delete mode 100644 Server~/src/__tests__/unityDashboardAppResource.test.ts
 create mode 100644 Server~/src/cli/companionCli.ts
 create mode 100644 Server~/src/companionEntrypoint.ts
 create mode 100644 Server~/src/companionLifecycle.ts
 create mode 100644 Server~/src/companionServer.ts
 create mode 100644 Server~/src/prompts/companionPrompts.ts
 delete mode 100644 Server~/src/prompts/gameobjectHandlingPrompt.ts
 delete mode 100644 Server~/src/prompts/unityDashboardPrompt.ts
 create mode 100644 Server~/src/resources/companionResources.ts
 create mode 100644 Server~/src/resources/dashboardResource.ts
 delete mode 100644 Server~/src/resources/getAssetsResource.ts
 delete mode 100644 Server~/src/resources/getConsoleLogsResource.ts
 delete mode 100644 Server~/src/resources/getGameObjectResource.ts
 delete mode 100644 Server~/src/resources/getMenuItemResource.ts
 delete mode 100644 Server~/src/resources/getPackagesResource.ts
 delete mode 100644 Server~/src/resources/getScenesHierarchyResource.ts
 delete mode 100644 Server~/src/resources/getTestsResource.ts
 delete mode 100644 Server~/src/resources/unityDashboardAppResource.ts
 delete mode 100644 Server~/src/tools/addAssetToSceneTool.ts
 delete mode 100644 Server~/src/tools/addPackageTool.ts
 delete mode 100644 Server~/src/tools/batchExecuteTool.ts
 delete mode 100644 Server~/src/tools/createPrefabTool.ts
 delete mode 100644 Server~/src/tools/createSceneTool.ts
 delete mode 100644 Server~/src/tools/deleteSceneTool.ts
 delete mode 100644 Server~/src/tools/gameObjectTools.ts
 delete mode 100644 Server~/src/tools/getConsoleLogsTool.ts
 delete mode 100644 Server~/src/tools/getGameObjectTool.ts
 delete mode 100644 Server~/src/tools/getPlayModeStatusTool.ts
 delete mode 100644 Server~/src/tools/getSceneInfoTool.ts
 delete mode 100644 Server~/src/tools/getScenesHierarchyTool.ts
 delete mode 100644 Server~/src/tools/loadSceneTool.ts
 delete mode 100644 Server~/src/tools/materialTools.ts
 delete mode 100644 Server~/src/tools/menuItemTool.ts
 delete mode 100644 Server~/src/tools/recompileScriptsTool.ts
 delete mode 100644 Server~/src/tools/runTestsTool.ts
 delete mode 100644 Server~/src/tools/saveSceneTool.ts
 delete mode 100644 Server~/src/tools/selectGameObjectTool.ts
 delete mode 100644 Server~/src/tools/sendConsoleLogTool.ts
 delete mode 100644 Server~/src/tools/setPlayModeStatusTool.ts
 delete mode 100644 Server~/src/tools/showUnityDashboardTool.ts
 delete mode 100644 Server~/src/tools/transformTools.ts
 delete mode 100644 Server~/src/tools/unloadSceneTool.ts
 delete mode 100644 Server~/src/tools/updateComponentTool.ts
 delete mode 100644 Server~/src/tools/updateGameObjectTool.ts
 delete mode 100644 Server~/src/unity/commandQueue.ts
 delete mode 100644 Server~/src/unity/mcpUnity.ts
 create mode 100644 Server~/src/unity/officialUnityMcpClient.ts
 delete mode 100644 Server~/src/unity/unityConnection.ts
 delete mode 100644 Server~/src/utils/errors.ts
 delete mode 100644 Server~/src/utils/logger.ts

diff --git a/Server~/.dockerignore b/Server~/.dockerignore
deleted file mode 100644
index 1c77fea9..00000000
--- a/Server~/.dockerignore
+++ /dev/null
@@ -1,33 +0,0 @@
-# Node.js
-node_modules
-npm-debug.log
-yarn-debug.log
-yarn-error.log
-
-# Build outputs
-build
-dist
-.tsbuildinfo
-
-# Development files
-.git
-.github
-.vscode
-.idea
-*.md
-.gitignore
-.env
-.env.*
-*.log
-
-# Test files
-test
-__tests__
-*.test.ts
-*.spec.ts
-
-# Miscellaneous
-.DS_Store
-Thumbs.db
-*.swp
-*.swo
diff --git a/Server~/Dockerfile b/Server~/Dockerfile
deleted file mode 100644
index 8d92ee02..00000000
--- a/Server~/Dockerfile
+++ /dev/null
@@ -1,58 +0,0 @@
-# Multi-stage build for optimized production image
-FROM node:18-alpine AS builder
-
-# Set working directory
-WORKDIR /app
-
-# Copy package files for dependency installation
-COPY package.json package-lock.json ./
-
-# Install dependencies with cache optimization
-RUN --mount=type=cache,target=/root/.npm \
-    npm ci
-
-# Copy TypeScript configuration and source code
-COPY tsconfig.json ./
-COPY src ./src
-
-# Build the project
-RUN npm run build
-
-# Production stage with minimal dependencies
-FROM node:18-alpine AS production
-
-# Set working directory
-WORKDIR /app
-
-# Set production environment
-ENV NODE_ENV=production
-
-# Copy package files
-COPY package.json package-lock.json ./
-
-# Install production dependencies only
-RUN --mount=type=cache,target=/root/.npm \
-    npm ci --omit=dev
-
-# Copy built application from builder stage
-COPY --from=builder /app/build ./build
-
-# Create a non-root user to run the app
-RUN addgroup -g 1001 -S nodejs && \
-    adduser -S nodejs -u 1001 -G nodejs
-
-# Set ownership to the non-root user
-RUN chown -R nodejs:nodejs /app
-
-# Switch to non-root user
-USER nodejs
-
-# Expose WebSocket and HTTP ports
-EXPOSE 8090 3000
-
-# Health check to ensure the application is running
-HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
-    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
-
-# Command to run the MCP server
-ENTRYPOINT ["node", "build/index.js"]
diff --git a/Server~/build/cli/companionCli.d.ts b/Server~/build/cli/companionCli.d.ts
new file mode 100644
index 00000000..a9d86737
--- /dev/null
+++ b/Server~/build/cli/companionCli.d.ts
@@ -0,0 +1,20 @@
+export declare const CLI_DOCUMENTATION_URL = "https://docs.unity.com/en-us/unity-cli/use-unity-cli";
+export interface CompanionArguments {
+    projectPath: string;
+    unityCliPath?: string;
+}
+type PathValidator = (candidate: string) => boolean;
+export declare function parseCompanionArguments(argv: readonly string[], isUnityProject?: PathValidator): CompanionArguments;
+export declare function resolveUnityCliPath(explicitPath: string | undefined, environment?: NodeJS.ProcessEnv): string;
+export interface VersionCommandResult {
+    stdout: string;
+    stderr: string;
+}
+export type VersionRunner = (command: string, args: readonly string[]) => Promise;
+export interface CheckedUnityCli {
+    command: string;
+    version: string;
+    warning?: string;
+}
+export declare function checkUnityCli(command: string, runVersion?: VersionRunner): Promise;
+export {};
diff --git a/Server~/build/cli/companionCli.js b/Server~/build/cli/companionCli.js
new file mode 100644
index 00000000..869541cb
--- /dev/null
+++ b/Server~/build/cli/companionCli.js
@@ -0,0 +1,135 @@
+import { execFile } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { promisify } from 'node:util';
+export const CLI_DOCUMENTATION_URL = 'https://docs.unity.com/en-us/unity-cli/use-unity-cli';
+export function parseCompanionArguments(argv, isUnityProject = defaultUnityProjectValidator) {
+    let projectPath;
+    let unityCliPath;
+    for (let index = 0; index < argv.length; index += 2) {
+        const flag = argv[index];
+        const value = argv[index + 1];
+        if (flag !== '--project-path' && flag !== '--unity-cli-path') {
+            throw new Error(`Unknown argument: ${flag ?? ''}`);
+        }
+        if (!value || value.startsWith('--')) {
+            throw new Error(`${flag} requires a value.`);
+        }
+        if (flag === '--project-path') {
+            if (projectPath !== undefined) {
+                throw new Error('Duplicate --project-path argument.');
+            }
+            projectPath = value;
+        }
+        else {
+            if (unityCliPath !== undefined) {
+                throw new Error('Duplicate --unity-cli-path argument.');
+            }
+            unityCliPath = value;
+        }
+    }
+    if (!projectPath) {
+        throw new Error('--project-path  is required.');
+    }
+    if (!path.isAbsolute(projectPath)) {
+        throw new Error('--project-path must be absolute.');
+    }
+    if (!isUnityProject(projectPath)) {
+        throw new Error(`--project-path must identify an existing Unity project: ${projectPath}`);
+    }
+    return { projectPath: path.resolve(projectPath), unityCliPath };
+}
+export function resolveUnityCliPath(explicitPath, environment = process.env) {
+    return explicitPath || environment.UNITY_CLI_PATH || 'unity';
+}
+export async function checkUnityCli(command, runVersion = defaultVersionRunner) {
+    let output;
+    try {
+        output = await runVersion(command, ['--version']);
+    }
+    catch (error) {
+        throw actionableCliError(`Unity CLI could not be started at "${command}": ${errorMessage(error)}`);
+    }
+    const version = parseVersion(`${output.stdout}\n${output.stderr}`);
+    if (!version) {
+        throw actionableCliError(`Unity CLI returned an unrecognized version from "${command}".`);
+    }
+    if (compareVersion(version, MINIMUM_VERSION) < 0) {
+        throw actionableCliError(`Unity CLI ${version.raw} is incompatible; version ${MINIMUM_VERSION.raw} or newer is required.`);
+    }
+    return {
+        command,
+        version: version.raw,
+        warning: version.major > 1n
+            ? `Unity CLI ${version.raw} is newer than the tested major version 1.`
+            : undefined,
+    };
+}
+const VERSION_PATTERN = /(?:^|[^0-9])([0-9]+)\.([0-9]+)\.([0-9]+)(?:-(alpha|beta|rc)\.([0-9]+))?(?:\+[0-9A-Za-z.-]+)?(?:$|[^0-9A-Za-z.+-])/i;
+const MINIMUM_VERSION = {
+    raw: '1.0.0-beta.2',
+    major: 1n,
+    minor: 0n,
+    patch: 0n,
+    prerelease: { label: 'beta', number: 2n },
+};
+function parseVersion(output) {
+    const match = VERSION_PATTERN.exec(output);
+    if (!match) {
+        return undefined;
+    }
+    const prereleaseLabel = match[4]?.toLowerCase();
+    const raw = `${match[1]}.${match[2]}.${match[3]}${prereleaseLabel ? `-${prereleaseLabel}.${match[5]}` : ''}`;
+    return {
+        raw,
+        major: BigInt(match[1]),
+        minor: BigInt(match[2]),
+        patch: BigInt(match[3]),
+        prerelease: prereleaseLabel
+            ? { label: prereleaseLabel, number: BigInt(match[5]) }
+            : undefined,
+    };
+}
+function compareVersion(left, right) {
+    for (const key of ['major', 'minor', 'patch']) {
+        if (left[key] !== right[key]) {
+            return left[key] > right[key] ? 1 : -1;
+        }
+    }
+    if (!left.prerelease && !right.prerelease)
+        return 0;
+    if (!left.prerelease)
+        return 1;
+    if (!right.prerelease)
+        return -1;
+    const order = { alpha: 0, beta: 1, rc: 2 };
+    if (left.prerelease.label !== right.prerelease.label) {
+        return order[left.prerelease.label] > order[right.prerelease.label] ? 1 : -1;
+    }
+    if (left.prerelease.number === right.prerelease.number)
+        return 0;
+    return left.prerelease.number > right.prerelease.number ? 1 : -1;
+}
+function defaultUnityProjectValidator(candidate) {
+    try {
+        return (fs.statSync(candidate).isDirectory() &&
+            fs.statSync(path.join(candidate, 'Assets')).isDirectory() &&
+            fs.statSync(path.join(candidate, 'ProjectSettings')).isDirectory());
+    }
+    catch {
+        return false;
+    }
+}
+async function defaultVersionRunner(command, args) {
+    const result = await promisify(execFile)(command, [...args], {
+        encoding: 'utf8',
+        timeout: 10_000,
+    });
+    return { stdout: result.stdout, stderr: result.stderr };
+}
+function actionableCliError(message) {
+    return new Error(`${message} Install or update Unity CLI: ${CLI_DOCUMENTATION_URL}`);
+}
+function errorMessage(error) {
+    return error instanceof Error ? error.message : String(error);
+}
diff --git a/Server~/build/companionEntrypoint.d.ts b/Server~/build/companionEntrypoint.d.ts
new file mode 100644
index 00000000..96dea6b7
--- /dev/null
+++ b/Server~/build/companionEntrypoint.d.ts
@@ -0,0 +1,21 @@
+import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
+import { type CheckedUnityCli } from './cli/companionCli.js';
+import { type EventSource } from './companionLifecycle.js';
+import { OfficialUnityMcpClient } from './unity/officialUnityMcpClient.js';
+export interface CompanionEntrypointOptions {
+    argv: readonly string[];
+    environment: NodeJS.ProcessEnv;
+    isUnityProject?: (candidate: string) => boolean;
+    checkCli?: (command: string) => Promise;
+    transport: Transport;
+    signals: EventSource;
+    stdin: EventSource;
+    stderr: {
+        write(text: string): unknown;
+    };
+}
+export interface CompanionRuntime {
+    officialClient: OfficialUnityMcpClient;
+    shutdown(): Promise;
+}
+export declare function startCompanion(options: CompanionEntrypointOptions): Promise;
diff --git a/Server~/build/companionEntrypoint.js b/Server~/build/companionEntrypoint.js
new file mode 100644
index 00000000..4d3ba0a8
--- /dev/null
+++ b/Server~/build/companionEntrypoint.js
@@ -0,0 +1,38 @@
+import { checkUnityCli, parseCompanionArguments, resolveUnityCliPath, } from './cli/companionCli.js';
+import { installShutdownHandlers, } from './companionLifecycle.js';
+import { createCompanionServer } from './companionServer.js';
+import { CompanionResourceService } from './resources/companionResources.js';
+import { OfficialUnityMcpClient } from './unity/officialUnityMcpClient.js';
+export async function startCompanion(options) {
+    const args = parseCompanionArguments(options.argv, options.isUnityProject);
+    const cliPath = resolveUnityCliPath(args.unityCliPath, options.environment);
+    const checked = await (options.checkCli ?? checkUnityCli)(cliPath);
+    if (checked.warning) {
+        options.stderr.write(`Warning: ${checked.warning}\n`);
+    }
+    const officialClient = new OfficialUnityMcpClient({
+        cliPath: checked.command,
+        projectPath: args.projectPath,
+    });
+    const server = createCompanionServer(new CompanionResourceService(officialClient));
+    await server.connect(options.transport);
+    const handlers = installShutdownHandlers({
+        signals: options.signals,
+        stdin: options.stdin,
+        closeOfficialClient: () => officialClient.close(),
+        closeServer: () => server.close(),
+        onError: (error) => {
+            options.stderr.write(`Shutdown error: ${errorMessage(error)}\n`);
+        },
+    });
+    return {
+        officialClient,
+        shutdown: async () => {
+            await handlers.shutdown();
+            handlers.dispose();
+        },
+    };
+}
+function errorMessage(error) {
+    return error instanceof Error ? error.message : String(error);
+}
diff --git a/Server~/build/companionLifecycle.d.ts b/Server~/build/companionLifecycle.d.ts
new file mode 100644
index 00000000..f54541ec
--- /dev/null
+++ b/Server~/build/companionLifecycle.d.ts
@@ -0,0 +1,16 @@
+export interface EventSource {
+    on(event: string, listener: () => void): unknown;
+    off(event: string, listener: () => void): unknown;
+}
+export interface ShutdownHandlerOptions {
+    signals: EventSource;
+    stdin: EventSource;
+    closeOfficialClient(): Promise;
+    closeServer(): Promise;
+    onError?(error: unknown): void;
+}
+export interface ShutdownHandlers {
+    shutdown(): Promise;
+    dispose(): void;
+}
+export declare function installShutdownHandlers(options: ShutdownHandlerOptions): ShutdownHandlers;
diff --git a/Server~/build/companionLifecycle.js b/Server~/build/companionLifecycle.js
new file mode 100644
index 00000000..116918c0
--- /dev/null
+++ b/Server~/build/companionLifecycle.js
@@ -0,0 +1,37 @@
+export function installShutdownHandlers(options) {
+    let shutdownPromise;
+    const shutdown = () => {
+        shutdownPromise ??= (async () => {
+            const results = await Promise.allSettled([
+                options.closeOfficialClient(),
+                options.closeServer(),
+            ]);
+            for (const result of results) {
+                if (result.status === 'rejected') {
+                    options.onError?.(result.reason);
+                }
+            }
+        })();
+        return shutdownPromise;
+    };
+    const trigger = () => {
+        void shutdown();
+    };
+    const bindings = [
+        [options.signals, 'SIGINT'],
+        [options.signals, 'SIGTERM'],
+        [options.stdin, 'close'],
+        [options.stdin, 'end'],
+    ];
+    for (const [source, event] of bindings) {
+        source.on(event, trigger);
+    }
+    return {
+        shutdown,
+        dispose: () => {
+            for (const [source, event] of bindings) {
+                source.off(event, trigger);
+            }
+        },
+    };
+}
diff --git a/Server~/build/companionServer.d.ts b/Server~/build/companionServer.d.ts
new file mode 100644
index 00000000..222f8bf4
--- /dev/null
+++ b/Server~/build/companionServer.d.ts
@@ -0,0 +1,3 @@
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import type { CompanionResourceService } from './resources/companionResources.js';
+export declare function createCompanionServer(resources: CompanionResourceService): McpServer;
diff --git a/Server~/build/companionServer.js b/Server~/build/companionServer.js
new file mode 100644
index 00000000..562433d9
--- /dev/null
+++ b/Server~/build/companionServer.js
@@ -0,0 +1,84 @@
+import { registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server';
+import { McpServer, ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { registerCompanionPrompts } from './prompts/companionPrompts.js';
+import { DASHBOARD_URI, readDashboardHtml, } from './resources/dashboardResource.js';
+const RESOURCE_TEMPLATES = [
+    {
+        name: 'unity_logs',
+        template: 'unity://logs{?severity,limit}',
+        description: 'Recent Unity Editor logs.',
+    },
+    {
+        name: 'unity_scenes_hierarchy',
+        template: 'unity://scenes-hierarchy{?path,max_nodes}',
+        description: 'Bounded hierarchy of an open Unity scene.',
+    },
+    {
+        name: 'unity_gameobject',
+        template: 'unity://gameobject/{target}',
+        description: 'Bounded GameObject inspection.',
+    },
+    {
+        name: 'unity_packages',
+        template: 'unity://packages{?include_indirect}',
+        description: 'Installed Unity packages.',
+    },
+    {
+        name: 'unity_tests',
+        template: 'unity://tests/{mode}',
+        description: 'Available Unity tests.',
+    },
+];
+export function createCompanionServer(resources) {
+    const server = new McpServer({ name: 'MCP Unity Companion', version: '2.0.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
+    registerAppTool(server, 'show_unity_dashboard', {
+        description: 'Open the read-only Unity CLI and Pipeline dashboard.',
+        annotations: { readOnlyHint: true },
+        _meta: {
+            ui: {
+                resourceUri: DASHBOARD_URI,
+            },
+        },
+    }, async () => ({
+        content: [
+            {
+                type: 'text',
+                text: 'Unity dashboard opened. Its views are read-only.',
+            },
+        ],
+    }));
+    registerAppResource(server, 'unity_dashboard', DASHBOARD_URI, {
+        description: 'Read-only Unity CLI and Pipeline dashboard.',
+        _meta: { ui: { prefersBorder: true } },
+    }, async () => {
+        const dashboard = readDashboardHtml();
+        return {
+            contents: [
+                {
+                    uri: DASHBOARD_URI,
+                    mimeType: dashboard.mimeType,
+                    text: dashboard.text,
+                },
+            ],
+        };
+    });
+    for (const definition of RESOURCE_TEMPLATES) {
+        server.registerResource(definition.name, new ResourceTemplate(definition.template, { list: undefined }), {
+            description: definition.description,
+            mimeType: 'application/json',
+        }, async (uri) => {
+            const result = await resources.read(uri.toString());
+            return {
+                contents: [
+                    {
+                        uri: result.uri,
+                        mimeType: 'application/json',
+                        text: JSON.stringify(result.payload),
+                    },
+                ],
+            };
+        });
+    }
+    registerCompanionPrompts(server);
+    return server;
+}
diff --git a/Server~/build/index.d.ts b/Server~/build/index.d.ts
new file mode 100644
index 00000000..b7988016
--- /dev/null
+++ b/Server~/build/index.d.ts
@@ -0,0 +1,2 @@
+#!/usr/bin/env node
+export {};
diff --git a/Server~/build/index.js b/Server~/build/index.js
new file mode 100644
index 00000000..8b68583a
--- /dev/null
+++ b/Server~/build/index.js
@@ -0,0 +1,17 @@
+#!/usr/bin/env node
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
+import { startCompanion } from './companionEntrypoint.js';
+try {
+    await startCompanion({
+        argv: process.argv.slice(2),
+        environment: process.env,
+        transport: new StdioServerTransport(),
+        signals: process,
+        stdin: process.stdin,
+        stderr: process.stderr,
+    });
+}
+catch (error) {
+    process.stderr.write(`MCP Unity Companion could not start: ${error instanceof Error ? error.message : String(error)}\n`);
+    process.exitCode = 1;
+}
diff --git a/Server~/build/prompts/companionPrompts.d.ts b/Server~/build/prompts/companionPrompts.d.ts
new file mode 100644
index 00000000..857554eb
--- /dev/null
+++ b/Server~/build/prompts/companionPrompts.d.ts
@@ -0,0 +1,2 @@
+import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+export declare function registerCompanionPrompts(server: McpServer): void;
diff --git a/Server~/build/prompts/companionPrompts.js b/Server~/build/prompts/companionPrompts.js
new file mode 100644
index 00000000..843bb456
--- /dev/null
+++ b/Server~/build/prompts/companionPrompts.js
@@ -0,0 +1,32 @@
+const GAMEOBJECT_STRATEGY = `Use Pipeline read commands before mutations:
+1. Read get_scene_hierarchy or find_gameobjects to obtain a stable target.
+2. Use inspect_gameobject for bounded component and serialized-property inspection.
+3. Use official Pipeline authoring commands for standard changes.
+4. Use only the MCP Unity extensions when needed: duplicate_gameobject, unload_scene, editor_step, and assign_material.
+5. Re-read get_scene_hierarchy or inspect_gameobject to verify the result.`;
+const DASHBOARD_GUIDE = `Open show_unity_dashboard for a read-only project overview.
+The companion resources map to official Pipeline commands: get_console_logs, get_scene_hierarchy, package_list, and list_tests.
+GameObject details use inspect_gameobject. The other MCP Unity extensions are duplicate_gameobject, unload_scene, editor_step, and assign_material.
+The dashboard never invokes mutations; execute any authoring command explicitly through Unity CLI/Pipeline.`;
+export function registerCompanionPrompts(server) {
+    server.registerPrompt('gameobject_handling_strategy', {
+        description: 'A safe discovery, targeting, mutation, and verification workflow.',
+    }, async () => ({
+        messages: [
+            {
+                role: 'user',
+                content: { type: 'text', text: GAMEOBJECT_STRATEGY },
+            },
+        ],
+    }));
+    server.registerPrompt('unity_dashboard', {
+        description: 'Guidance for the read-only Unity dashboard and Pipeline commands.',
+    }, async () => ({
+        messages: [
+            {
+                role: 'user',
+                content: { type: 'text', text: DASHBOARD_GUIDE },
+            },
+        ],
+    }));
+}
diff --git a/Server~/build/resources/companionResources.d.ts b/Server~/build/resources/companionResources.d.ts
new file mode 100644
index 00000000..9b1e5f25
--- /dev/null
+++ b/Server~/build/resources/companionResources.d.ts
@@ -0,0 +1,15 @@
+import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
+export interface UnityReadClient {
+    readTool(name: string, args: Record): Promise;
+}
+export interface CompanionResourcePayload {
+    uri: string;
+    payload: Record;
+}
+export declare class CompanionResourceService {
+    private readonly client;
+    constructor(client: UnityReadClient);
+    read(uri: string): Promise;
+    private call;
+}
+export declare function decodeToolPayload(command: string, result: CallToolResult): Record;
diff --git a/Server~/build/resources/companionResources.js b/Server~/build/resources/companionResources.js
new file mode 100644
index 00000000..25c3c7b5
--- /dev/null
+++ b/Server~/build/resources/companionResources.js
@@ -0,0 +1,194 @@
+const LOG_SEVERITIES = new Set(['all', 'log', 'warning', 'error']);
+const TEST_MODES = new Set(['all', 'editor', 'playmode']);
+export class CompanionResourceService {
+    client;
+    constructor(client) {
+        this.client = client;
+    }
+    async read(uri) {
+        const parsed = parseResourceUri(uri);
+        switch (parsed.hostname) {
+            case 'logs':
+                return this.call(uri, 'get_console_logs', {
+                    severity: parseEnum(parsed.searchParams.get('severity') ?? 'all', LOG_SEVERITIES, 'severity'),
+                    limit: parseBoundedInteger(parsed.searchParams.get('limit'), 100, 1, 1000, 'limit'),
+                });
+            case 'scenes-hierarchy': {
+                const maxNodes = parseBoundedInteger(parsed.searchParams.get('max_nodes'), 500, 1, 2000, 'max_nodes');
+                const path = parsed.searchParams.get('path');
+                const args = path ? { path } : {};
+                const result = await this.call(uri, 'get_scene_hierarchy', args);
+                return {
+                    uri,
+                    payload: truncateHierarchy(result.payload, maxNodes),
+                };
+            }
+            case 'gameobject': {
+                const target = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
+                if (!target) {
+                    throw new Error('unity://gameobject/{target} requires a target.');
+                }
+                return this.call(uri, 'inspect_gameobject', {
+                    target,
+                    max_depth: 2,
+                    max_nodes: 200,
+                    include_components: true,
+                    include_properties: true,
+                    max_properties_per_component: 100,
+                });
+            }
+            case 'packages':
+                return this.call(uri, 'package_list', {
+                    scope: 'installed',
+                    include_indirect: parseBoolean(parsed.searchParams.get('include_indirect'), true, 'include_indirect'),
+                });
+            case 'tests': {
+                const mode = decodeURIComponent(parsed.pathname.replace(/^\/+/, ''));
+                parseEnum(mode, TEST_MODES, 'mode');
+                return this.call(uri, 'list_tests', { mode });
+            }
+            default:
+                throw new Error(`Unknown companion resource: ${uri}`);
+        }
+    }
+    async call(uri, command, args) {
+        let result;
+        try {
+            result = await this.client.readTool(command, args);
+        }
+        catch (error) {
+            throw new Error(`${command} failed: ${errorMessage(error)}`);
+        }
+        return {
+            uri,
+            payload: decodeToolPayload(command, result),
+        };
+    }
+}
+export function decodeToolPayload(command, result) {
+    if (result.isError) {
+        const detail = firstText(result) ?? 'Unity command returned an error.';
+        throw new Error(`${command} failed: ${detail}`);
+    }
+    if (isRecord(result.structuredContent)) {
+        return result.structuredContent;
+    }
+    const text = firstText(result);
+    if (text === undefined) {
+        throw new Error(`${command} returned no JSON payload.`);
+    }
+    try {
+        const parsed = JSON.parse(text);
+        if (!isRecord(parsed)) {
+            throw new Error('payload is not a JSON object');
+        }
+        return parsed;
+    }
+    catch (error) {
+        throw new Error(`${command} returned malformed JSON: ${errorMessage(error)}`);
+    }
+}
+function parseResourceUri(uri) {
+    let parsed;
+    try {
+        parsed = new URL(uri);
+    }
+    catch {
+        throw new Error(`Invalid companion resource URI: ${uri}`);
+    }
+    if (parsed.protocol !== 'unity:') {
+        throw new Error(`Unknown companion resource: ${uri}`);
+    }
+    return parsed;
+}
+function parseBoundedInteger(value, fallback, minimum, maximum, name) {
+    if (value === null || value === '')
+        return fallback;
+    if (!/^-?[0-9]+$/.test(value)) {
+        throw new Error(`${name} must be an integer.`);
+    }
+    const parsed = Number(value);
+    if (!Number.isSafeInteger(parsed)) {
+        return value.startsWith('-') ? minimum : maximum;
+    }
+    return Math.min(maximum, Math.max(minimum, parsed));
+}
+function parseBoolean(value, fallback, name) {
+    if (value === null || value === '')
+        return fallback;
+    if (value === 'true')
+        return true;
+    if (value === 'false')
+        return false;
+    throw new Error(`${name} must be true or false.`);
+}
+function parseEnum(value, allowed, name) {
+    if (!allowed.has(value)) {
+        throw new Error(`${name} must be one of: ${[...allowed].join(', ')}.`);
+    }
+    return value;
+}
+function firstText(result) {
+    const item = result.content.find((content) => content.type === 'text');
+    return item?.text;
+}
+function truncateHierarchy(hierarchy, maxNodes) {
+    const sourceRoots = Array.isArray(hierarchy.roots) ? hierarchy.roots : [];
+    const totalNodes = sourceRoots.reduce((total, root) => total + countHierarchyNodes(root), 0);
+    let returnedNodes = 0;
+    const cloneNode = (source) => {
+        if (!isRecord(source) || returnedNodes >= maxNodes)
+            return undefined;
+        returnedNodes++;
+        const sourceChildren = Array.isArray(source.children) ? source.children : [];
+        const children = [];
+        let omittedDescendants = 0;
+        for (let index = 0; index < sourceChildren.length; index++) {
+            if (returnedNodes >= maxNodes) {
+                omittedDescendants += sourceChildren
+                    .slice(index)
+                    .reduce((total, child) => total + countHierarchyNodes(child), 0);
+                break;
+            }
+            const child = cloneNode(sourceChildren[index]);
+            if (child)
+                children.push(child);
+        }
+        return {
+            ...source,
+            children,
+            childrenTruncated: source.childrenTruncated === true || omittedDescendants > 0,
+            ...(omittedDescendants > 0 ? { omittedDescendants } : {}),
+        };
+    };
+    const roots = [];
+    for (const root of sourceRoots) {
+        const cloned = cloneNode(root);
+        if (!cloned)
+            break;
+        roots.push(cloned);
+    }
+    return {
+        ...hierarchy,
+        roots,
+        truncation: {
+            truncated: returnedNodes < totalNodes,
+            maxNodes,
+            returnedNodes,
+            totalNodes,
+            omittedNodes: totalNodes - returnedNodes,
+        },
+    };
+}
+function countHierarchyNodes(value) {
+    if (!isRecord(value))
+        return 0;
+    const children = Array.isArray(value.children) ? value.children : [];
+    return 1 + children.reduce((total, child) => total + countHierarchyNodes(child), 0);
+}
+function isRecord(value) {
+    return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+function errorMessage(error) {
+    return error instanceof Error ? error.message : String(error);
+}
diff --git a/Server~/build/resources/dashboardResource.d.ts b/Server~/build/resources/dashboardResource.d.ts
new file mode 100644
index 00000000..f1f0e716
--- /dev/null
+++ b/Server~/build/resources/dashboardResource.d.ts
@@ -0,0 +1,5 @@
+export declare const DASHBOARD_URI = "ui://unity-dashboard";
+export declare function readDashboardHtml(): {
+    text: string;
+    mimeType: string;
+};
diff --git a/Server~/build/resources/dashboardResource.js b/Server~/build/resources/dashboardResource.js
new file mode 100644
index 00000000..1be11bd2
--- /dev/null
+++ b/Server~/build/resources/dashboardResource.js
@@ -0,0 +1,20 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
+export const DASHBOARD_URI = 'ui://unity-dashboard';
+export function readDashboardHtml() {
+    const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
+    const candidates = [
+        path.join(moduleDirectory, '..', 'ui', 'unity-dashboard.html'),
+        path.join(moduleDirectory, '..', '..', 'src', 'ui', 'unity-dashboard.html'),
+    ];
+    const dashboardPath = candidates.find((candidate) => fs.existsSync(candidate));
+    if (!dashboardPath) {
+        throw new Error(`Unity dashboard HTML is missing. Checked: ${candidates.join(', ')}`);
+    }
+    return {
+        text: fs.readFileSync(dashboardPath, 'utf8'),
+        mimeType: RESOURCE_MIME_TYPE,
+    };
+}
diff --git a/Server~/build/ui/unity-dashboard.html b/Server~/build/ui/unity-dashboard.html
new file mode 100644
index 00000000..f68733d6
--- /dev/null
+++ b/Server~/build/ui/unity-dashboard.html
@@ -0,0 +1,144 @@
+
+
+  
+    
+    
+    Unity Dashboard
+    
+  
+  
+    
+
+

Unity CLI + Pipeline

+
+ Not checked + Never refreshed +
+
+ +
+
+
+

Scene hierarchy

Waiting…
+

Console logs

Waiting…
+

Packages

Waiting…
+

Tests

Waiting…
+

GameObject inspector

Select a target URI such as unity://gameobject/%2FPlayer in your MCP client.
+

Companion resources

unity://logs{?severity,limit}
+unity://scenes-hierarchy{?path,max_nodes}
+unity://gameobject/{target}
+unity://packages{?include_indirect}
+unity://tests/{mode}
+ui://unity-dashboard
+
+ + + diff --git a/Server~/build/unity/officialUnityMcpClient.d.ts b/Server~/build/unity/officialUnityMcpClient.d.ts new file mode 100644 index 00000000..2494fae8 --- /dev/null +++ b/Server~/build/unity/officialUnityMcpClient.d.ts @@ -0,0 +1,29 @@ +import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +export interface OfficialUnitySession { + callTool(name: string, args: Record): Promise; + close(): Promise; +} +export interface OfficialUnitySessionOptions { + cliPath: string; + projectPath: string; +} +export type OfficialUnitySessionFactory = (options: OfficialUnitySessionOptions) => Promise; +export type UnityConnectionState = 'disconnected' | 'connecting' | 'connected' | 'closed'; +export interface OfficialUnityMcpClientOptions extends OfficialUnitySessionOptions { + sessionFactory?: OfficialUnitySessionFactory; +} +export declare class OfficialUnityMcpClient { + private readonly options; + private readonly sessionFactory; + private session?; + private sessionPromise?; + private connectionState; + private closed; + constructor(options: OfficialUnityMcpClientOptions); + get state(): UnityConnectionState; + readTool(name: string, args: Record): Promise; + close(): Promise; + private getSession; + private discardSession; + private assertOpen; +} diff --git a/Server~/build/unity/officialUnityMcpClient.js b/Server~/build/unity/officialUnityMcpClient.js new file mode 100644 index 00000000..00864f5f --- /dev/null +++ b/Server~/build/unity/officialUnityMcpClient.js @@ -0,0 +1,145 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { CallToolResultSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; +export class OfficialUnityMcpClient { + options; + sessionFactory; + session; + sessionPromise; + connectionState = 'disconnected'; + closed = false; + constructor(options) { + this.options = { + cliPath: options.cliPath, + projectPath: options.projectPath, + }; + this.sessionFactory = options.sessionFactory ?? createOfficialUnitySession; + } + get state() { + return this.connectionState; + } + async readTool(name, args) { + this.assertOpen(); + const firstSession = await this.getSession(); + try { + return await firstSession.callTool(name, args); + } + catch (firstError) { + if (!isTransportInterruption(firstError)) { + throw firstError; + } + await this.discardSession(firstSession); + this.assertOpen(); + const retrySession = await this.getSession(); + try { + return await retrySession.callTool(name, args); + } + catch (retryError) { + await this.discardSession(retrySession); + throw retryError; + } + } + } + async close() { + if (this.closed) + return; + this.closed = true; + this.connectionState = 'closed'; + const pending = this.sessionPromise; + this.sessionPromise = undefined; + const active = this.session; + this.session = undefined; + if (active) { + await active.close(); + return; + } + if (pending) { + try { + await (await pending).close(); + } + catch { + // Startup already failed; there is no live child to close. + } + } + } + async getSession() { + this.assertOpen(); + if (this.session) + return this.session; + if (this.sessionPromise) + return this.sessionPromise; + this.connectionState = 'connecting'; + const pending = this.sessionFactory(this.options); + this.sessionPromise = pending; + try { + const created = await pending; + if (this.closed) { + await created.close(); + throw new Error('Official Unity MCP client is closed.'); + } + this.session = created; + this.connectionState = 'connected'; + return created; + } + catch (error) { + if (!this.closed) { + this.connectionState = 'disconnected'; + } + throw error; + } + finally { + if (this.sessionPromise === pending) { + this.sessionPromise = undefined; + } + } + } + async discardSession(candidate) { + if (this.session === candidate) { + this.session = undefined; + } + if (!this.closed) { + this.connectionState = 'disconnected'; + } + try { + await candidate.close(); + } + catch { + // The transport is already broken; retry startup is still safe. + } + } + assertOpen() { + if (this.closed) { + throw new Error('Official Unity MCP client is closed.'); + } + } +} +function isTransportInterruption(error) { + if (error instanceof McpError && error.code === ErrorCode.ConnectionClosed) { + return true; + } + const code = error?.code; + if (typeof code === 'string' && + ['EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ENOTCONN', 'ERR_STREAM_DESTROYED'].includes(code)) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return /^(Connection closed|Not connected)$/i.test(message.trim()) || + /transport (?:is )?closed/i.test(message) || + /Unity CLI process exited/i.test(message); +} +async function createOfficialUnitySession(options) { + const transport = new StdioClientTransport({ + command: options.cliPath, + args: ['mcp', '--project-path', options.projectPath], + stderr: 'inherit', + }); + const client = new Client({ name: 'mcp-unity-companion', version: '2.0.0' }, { capabilities: {} }); + await client.connect(transport); + return { + callTool: async (name, args) => { + const result = await client.callTool({ name, arguments: args }, CallToolResultSchema); + return CallToolResultSchema.parse(result); + }, + close: () => client.close(), + }; +} diff --git a/Server~/package-lock.json b/Server~/package-lock.json index 2fb44f95..1fd4f56f 100644 --- a/Server~/package-lock.json +++ b/Server~/package-lock.json @@ -1,50 +1,38 @@ { - "name": "mcp-unity-server", - "version": "1.4.0", + "name": "mcp-unity-companion", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mcp-unity-server", - "version": "1.4.0", + "name": "mcp-unity-companion", + "version": "2.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.7.0", - "axios": "^1.8.4", - "cors": "^2.8.5", - "express": "^5.0.1", - "uuid": "^11.1.0", - "winreg": "^1.2.5", - "ws": "^8.18.1", - "zod": "^3.24.4", - "zod-to-json-schema": "^3.24.3" - }, - "bin": { - "mcp-unity-server": "build/index.js" + "@modelcontextprotocol/ext-apps": "1.0.1", + "@modelcontextprotocol/sdk": "1.26.0", + "zod": "3.25.76" }, "devDependencies": { "@modelcontextprotocol/inspector": "^0.20.0", - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.13.10", - "@types/uuid": "^10.0.0", - "@types/winreg": "^1.2.36", - "@types/ws": "^8.18.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "typescript": "^5.8.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -53,9 +41,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -63,22 +51,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -95,14 +82,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -112,14 +99,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -129,9 +116,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -139,29 +126,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -181,9 +168,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -191,9 +178,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -201,9 +188,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -211,27 +198,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -480,33 +467,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -514,14 +501,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -601,9 +588,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1200,7 +1187,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -1237,9 +1223,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2700,62 +2686,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", - "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", - "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -2766,13 +2696,6 @@ "@types/node": "*" } }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2811,13 +2734,6 @@ "pretty-format": "^29.0.0" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.13.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.16.tgz", @@ -2835,20 +2751,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -2860,29 +2762,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2890,30 +2769,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/winreg": { - "version": "1.2.36", - "resolved": "https://registry.npmjs.org/@types/winreg/-/winreg-1.2.36.tgz", - "integrity": "sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2971,9 +2826,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3005,9 +2860,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3112,23 +2967,6 @@ "node": ">=10" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3253,30 +3091,33 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -3286,10 +3127,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3311,9 +3165,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -3330,13 +3184,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3450,9 +3303,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -3613,18 +3466,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -3643,15 +3484,15 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -3887,15 +3728,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3963,9 +3795,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, @@ -4027,9 +3859,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4038,21 +3870,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4178,7 +3995,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4218,12 +4034,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -4249,9 +4066,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -4342,62 +4159,6 @@ "node": ">=8" } }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4592,9 +4353,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4635,21 +4396,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4663,11 +4409,10 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4791,9 +4536,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -5027,7 +4772,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5635,9 +5379,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -5903,9 +5647,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6002,11 +5746,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -6235,12 +5982,13 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "engines": { - "node": ">=16" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/picocolors": { @@ -6251,9 +5999,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6361,12 +6109,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6395,12 +6137,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6439,7 +6182,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6453,7 +6195,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6745,16 +6486,16 @@ } }, "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", - "minimatch": "3.1.2", + "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" @@ -6863,9 +6604,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -6911,14 +6652,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6930,13 +6671,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -7387,17 +7128,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -7530,19 +7288,6 @@ } } }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -7609,12 +7354,6 @@ "node": ">= 8" } }, - "node_modules/winreg": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.5.tgz", - "integrity": "sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw==", - "license": "BSD-2-Clause" - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -7661,9 +7400,10 @@ } }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7771,7 +7511,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/Server~/package.json b/Server~/package.json index 8dcb1184..11a62efd 100644 --- a/Server~/package.json +++ b/Server~/package.json @@ -1,18 +1,15 @@ { - "name": "mcp-unity-server", - "version": "1.4.0", - "description": "MCP Unity Server for executing Unity operations and request Editor information", - "main": "dist/index.js", + "name": "mcp-unity-companion", + "version": "2.0.0", + "description": "Optional read-oriented MCP companion for Unity CLI and Pipeline", + "main": "build/index.js", "type": "module", - "mcpName": "io.github.codergamester/mcp-unity", - "bin": { - "mcp-unity-server": "./build/index.js" + "private": true, + "engines": { + "node": ">=20" }, - "files": [ - "build" - ], "scripts": { - "build": "tsc && node scripts/copy-ui.mjs", + "build": "node scripts/clean-build.mjs && tsc && node scripts/copy-ui.mjs", "start": "node build/index.js", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js", @@ -30,27 +27,15 @@ "license": "MIT", "devDependencies": { "@modelcontextprotocol/inspector": "^0.20.0", - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.13.10", - "@types/uuid": "^10.0.0", - "@types/winreg": "^1.2.36", - "@types/ws": "^8.18.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "typescript": "^5.8.2" }, "dependencies": { - "@modelcontextprotocol/ext-apps": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.7.0", - "axios": "^1.8.4", - "cors": "^2.8.5", - "express": "^5.0.1", - "uuid": "^11.1.0", - "winreg": "^1.2.5", - "ws": "^8.18.1", - "zod": "^3.24.4", - "zod-to-json-schema": "^3.24.3" + "@modelcontextprotocol/ext-apps": "1.0.1", + "@modelcontextprotocol/sdk": "1.26.0", + "zod": "3.25.76" } } diff --git a/Server~/scripts/clean-build.mjs b/Server~/scripts/clean-build.mjs new file mode 100644 index 00000000..1bda6945 --- /dev/null +++ b/Server~/scripts/clean-build.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +fs.rmSync(path.join(scriptDirectory, '..', 'build'), { + recursive: true, + force: true, +}); diff --git a/Server~/smithery.yaml b/Server~/smithery.yaml deleted file mode 100644 index 8a7a3ece..00000000 --- a/Server~/smithery.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Smithery.ai configuration -startCommand: - type: stdio - configSchema: - # JSON Schema defining the configuration options for the MCP. - type: object - required: - - ABSOLUTE_PATH_TO_mcp-unity - properties: - ABSOLUTE_PATH_TO_mcp-unity - type: string - description: "The absolute folder path where your mcp-unity package is installed. Go to the Unity Editor MCP Server window (Tools > MCP Unity > Server Window)" - commandFunction: - # A function that produces the CLI command to start the MCP on stdio. - |- - (config) => ({ - "command": "node", - "args": [ - `${config.ABSOLUTE_PATH_TO_mcp-unity}/build/index.js` - ], - "env": { - "UNITY_PORT": "8090" - } - }) \ No newline at end of file diff --git a/Server~/src/__tests__/batchExecuteTool.test.ts b/Server~/src/__tests__/batchExecuteTool.test.ts deleted file mode 100644 index ae4051b8..00000000 --- a/Server~/src/__tests__/batchExecuteTool.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { registerBatchExecuteTool } from '../tools/batchExecuteTool.js'; - -// Mock the McpUnity class -const mockSendRequest = jest.fn(); -const mockMcpUnity = { - sendRequest: mockSendRequest -}; - -// Mock the Logger -const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() -}; - -// Mock the McpServer -const mockServerTool = jest.fn(); -const mockServer = { - tool: mockServerTool -}; - -describe('Batch Execute Tool', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('registerBatchExecuteTool', () => { - it('should register the batch_execute tool with the server', () => { - registerBatchExecuteTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(1); - expect(mockServerTool).toHaveBeenCalledWith( - 'batch_execute', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - expect(mockLogger.info).toHaveBeenCalledWith('Registering tool: batch_execute'); - }); - - it('should have correct tool description mentioning batch and performance', () => { - registerBatchExecuteTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - const [, description] = mockServerTool.mock.calls[0]; - expect(description).toContain('batch'); - expect(description).toContain('operations'); - }); - - it('should have correct schema with operations array', () => { - registerBatchExecuteTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - const [, , schema] = mockServerTool.mock.calls[0]; - expect(schema).toHaveProperty('operations'); - expect(schema).toHaveProperty('stopOnError'); - expect(schema).toHaveProperty('atomic'); - }); - }); - - describe('batch_execute handler', () => { - let toolHandler: (params: any) => Promise; - - beforeEach(() => { - registerBatchExecuteTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - toolHandler = mockServerTool.mock.calls[0][3]; - }); - - it('should send batch request to Unity with correct parameters', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Successfully executed 2/2 operations.', - results: [ - { index: 0, id: '0', success: true }, - { index: 1, id: '1', success: true } - ], - summary: { total: 2, succeeded: 2, failed: 0, executed: 2 } - }); - - const params = { - operations: [ - { tool: 'create_gameobject', params: { name: 'Test1' } }, - { tool: 'create_gameobject', params: { name: 'Test2' } } - ], - stopOnError: true, - atomic: false - }; - - const result = await toolHandler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'batch_execute', - params: expect.objectContaining({ - operations: expect.arrayContaining([ - expect.objectContaining({ tool: 'create_gameobject' }), - expect.objectContaining({ tool: 'create_gameobject' }) - ]), - stopOnError: true, - atomic: false - }) - }); - expect(result.content[0].text).toContain('Successfully'); - }); - - it('should throw error when operations array is empty', async () => { - const params = { - operations: [], - stopOnError: true - }; - - await expect(toolHandler(params)).rejects.toThrow(McpUnityError); - }); - - it('should throw error when nested batch_execute is detected', async () => { - const params = { - operations: [ - { tool: 'batch_execute', params: { operations: [] } } - ] - }; - - await expect(toolHandler(params)).rejects.toThrow('Cannot nest batch_execute'); - }); - - it('should handle partial failures with stopOnError=false', async () => { - mockSendRequest.mockResolvedValue({ - success: false, - type: 'text', - message: 'Batch execution completed with errors. 1/2 operations succeeded, 1 failed.', - results: [ - { index: 0, id: '0', success: true }, - { index: 1, id: '1', success: false, error: 'Tool failed' } - ], - summary: { total: 2, succeeded: 1, failed: 1, executed: 2 } - }); - - const params = { - operations: [ - { tool: 'tool1', params: {} }, - { tool: 'tool2', params: {} } - ], - stopOnError: false - }; - - // With stopOnError=false, should return result even with failures - const result = await toolHandler(params); - expect(result.content[0].text).toContain('1/2'); - expect(result.content[0].text).toContain('failed'); - }); - - it('should throw error on failure when stopOnError=true', async () => { - mockSendRequest.mockResolvedValue({ - success: false, - type: 'text', - message: 'Batch execution stopped on error. 0/2 operations succeeded.', - results: [ - { index: 0, id: '0', success: false, error: 'First tool failed' } - ], - summary: { total: 2, succeeded: 0, failed: 1, executed: 1 } - }); - - const params = { - operations: [ - { tool: 'tool1', params: {} }, - { tool: 'tool2', params: {} } - ], - stopOnError: true - }; - - await expect(toolHandler(params)).rejects.toThrow(McpUnityError); - }); - - it('should preserve operation ids in request', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Successfully executed 2/2 operations.', - results: [ - { index: 0, id: 'op1', success: true }, - { index: 1, id: 'op2', success: true } - ], - summary: { total: 2, succeeded: 2, failed: 0, executed: 2 } - }); - - const params = { - operations: [ - { tool: 'tool1', params: {}, id: 'op1' }, - { tool: 'tool2', params: {}, id: 'op2' } - ] - }; - - await toolHandler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'batch_execute', - params: expect.objectContaining({ - operations: expect.arrayContaining([ - expect.objectContaining({ id: 'op1' }), - expect.objectContaining({ id: 'op2' }) - ]) - }) - }); - }); - - it('should use default values for stopOnError and atomic', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Success', - results: [], - summary: { total: 1, succeeded: 1, failed: 0, executed: 1 } - }); - - const params = { - operations: [{ tool: 'tool1', params: {} }] - }; - - await toolHandler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'batch_execute', - params: expect.objectContaining({ - stopOnError: true, - atomic: false - }) - }); - }); - }); -}); diff --git a/Server~/src/__tests__/commandQueue.test.ts b/Server~/src/__tests__/commandQueue.test.ts deleted file mode 100644 index c62925c9..00000000 --- a/Server~/src/__tests__/commandQueue.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { CommandQueue, CommandQueueConfig, QueuedCommand } from '../unity/commandQueue'; -import { Logger, LogLevel } from '../utils/logger'; -import { McpUnityError, ErrorType } from '../utils/errors'; - -// Create a silent logger for tests -const createTestLogger = (): Logger => { - return new Logger('Test', LogLevel.ERROR); -}; - -describe('CommandQueue', () => { - let queue: CommandQueue; - let logger: Logger; - - beforeEach(() => { - logger = createTestLogger(); - queue = new CommandQueue(logger); - }); - - afterEach(() => { - queue.dispose(); - }); - - describe('constructor', () => { - it('should create with default configuration', () => { - const stats = queue.getStats(); - expect(stats.maxSize).toBe(100); - expect(stats.size).toBe(0); - }); - - it('should accept custom configuration', () => { - const customQueue = new CommandQueue(logger, { - maxSize: 50, - defaultTimeout: 30000, - cleanupInterval: 1000, - }); - - const stats = customQueue.getStats(); - expect(stats.maxSize).toBe(50); - - customQueue.dispose(); - }); - }); - - describe('enqueue', () => { - it('should successfully enqueue a command', () => { - const result = queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - expect(result.success).toBe(true); - expect(result.position).toBe(1); - expect(queue.size).toBe(1); - }); - - it('should enqueue multiple commands in order', () => { - for (let i = 1; i <= 3; i++) { - const result = queue.enqueue({ - id: `test-${i}`, - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - expect(result.position).toBe(i); - } - - expect(queue.size).toBe(3); - }); - - it('should reject commands when queue is full', () => { - const smallQueue = new CommandQueue(logger, { maxSize: 2 }); - const rejectFn = jest.fn(); - - // Fill the queue - smallQueue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - smallQueue.enqueue({ - id: 'test-2', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - // Try to add one more - const result = smallQueue.enqueue({ - id: 'test-3', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: rejectFn, - }); - - expect(result.success).toBe(false); - expect(result.reason).toContain('Queue is full'); - expect(rejectFn).toHaveBeenCalled(); - expect(smallQueue.getStats().droppedCount).toBe(1); - - smallQueue.dispose(); - }); - }); - - describe('size and isEmpty', () => { - it('should report correct size', () => { - expect(queue.size).toBe(0); - expect(queue.isEmpty).toBe(true); - - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - expect(queue.size).toBe(1); - expect(queue.isEmpty).toBe(false); - }); - - it('should report isFull correctly', () => { - const smallQueue = new CommandQueue(logger, { maxSize: 1 }); - - expect(smallQueue.isFull).toBe(false); - - smallQueue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - expect(smallQueue.isFull).toBe(true); - - smallQueue.dispose(); - }); - }); - - describe('drain', () => { - it('should return all queued commands and clear the queue', () => { - const resolve1 = jest.fn(); - const resolve2 = jest.fn(); - - queue.enqueue({ - id: 'test-1', - request: { method: 'method1', params: {} }, - resolve: resolve1, - reject: jest.fn(), - }); - queue.enqueue({ - id: 'test-2', - request: { method: 'method2', params: {} }, - resolve: resolve2, - reject: jest.fn(), - }); - - expect(queue.size).toBe(2); - - const commands = queue.drain(); - - expect(commands.length).toBe(2); - expect(commands[0].id).toBe('test-1'); - expect(commands[1].id).toBe('test-2'); - expect(queue.size).toBe(0); - expect(queue.isEmpty).toBe(true); - }); - - it('should return empty array when queue is empty', () => { - const commands = queue.drain(); - expect(commands).toEqual([]); - }); - }); - - describe('peek', () => { - it('should return the first command without removing it', () => { - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - const peeked = queue.peek(); - - expect(peeked).toBeDefined(); - expect(peeked?.id).toBe('test-1'); - expect(queue.size).toBe(1); // Still in queue - }); - - it('should return undefined for empty queue', () => { - const peeked = queue.peek(); - expect(peeked).toBeUndefined(); - }); - }); - - describe('clear', () => { - it('should clear all commands and reject them', () => { - const reject1 = jest.fn(); - const reject2 = jest.fn(); - - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: reject1, - }); - queue.enqueue({ - id: 'test-2', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: reject2, - }); - - queue.clear('Test clear'); - - expect(queue.size).toBe(0); - expect(reject1).toHaveBeenCalled(); - expect(reject2).toHaveBeenCalled(); - - // Check that rejection includes correct error type - const error1 = reject1.mock.calls[0][0] as McpUnityError; - expect(error1.type).toBe(ErrorType.CONNECTION); - expect(error1.message).toBe('Test clear'); - }); - - it('should handle clearing empty queue', () => { - expect(() => queue.clear()).not.toThrow(); - }); - }); - - describe('cleanupExpired', () => { - it('should remove expired commands', async () => { - const shortTimeoutQueue = new CommandQueue(logger, { - defaultTimeout: 50, // 50ms timeout - cleanupInterval: 100000, // Long interval so we control cleanup - }); - - const rejectFn = jest.fn(); - - shortTimeoutQueue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: rejectFn, - }); - - expect(shortTimeoutQueue.size).toBe(1); - - // Wait for expiration - await new Promise(resolve => setTimeout(resolve, 100)); - - const expiredCount = shortTimeoutQueue.cleanupExpired(); - - expect(expiredCount).toBe(1); - expect(shortTimeoutQueue.size).toBe(0); - expect(rejectFn).toHaveBeenCalled(); - - const error = rejectFn.mock.calls[0][0] as McpUnityError; - expect(error.type).toBe(ErrorType.TIMEOUT); - expect(shortTimeoutQueue.getStats().expiredCount).toBe(1); - - shortTimeoutQueue.dispose(); - }); - - it('should not remove non-expired commands', () => { - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - const expiredCount = queue.cleanupExpired(); - - expect(expiredCount).toBe(0); - expect(queue.size).toBe(1); - }); - - it('should respect per-command timeout', async () => { - const rejectFn = jest.fn(); - - // Default timeout is 60s, but we set a short custom timeout - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: rejectFn, - timeout: 50, // 50ms timeout - }); - - await new Promise(resolve => setTimeout(resolve, 100)); - - const expiredCount = queue.cleanupExpired(); - - expect(expiredCount).toBe(1); - expect(rejectFn).toHaveBeenCalled(); - }); - }); - - describe('statistics', () => { - it('should track statistics correctly', () => { - // Initial stats - let stats = queue.getStats(); - expect(stats.size).toBe(0); - expect(stats.droppedCount).toBe(0); - expect(stats.expiredCount).toBe(0); - expect(stats.replayedCount).toBe(0); - - // Add a command - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: jest.fn(), - }); - - stats = queue.getStats(); - expect(stats.size).toBe(1); - - // Record replay success - queue.recordReplaySuccess(); - stats = queue.getStats(); - expect(stats.replayedCount).toBe(1); - }); - - it('should reset statistics', () => { - queue.recordReplaySuccess(); - queue.recordReplaySuccess(); - - let stats = queue.getStats(); - expect(stats.replayedCount).toBe(2); - - queue.resetStats(); - - stats = queue.getStats(); - expect(stats.droppedCount).toBe(0); - expect(stats.expiredCount).toBe(0); - expect(stats.replayedCount).toBe(0); - }); - }); - - describe('updateConfig', () => { - it('should update configuration dynamically', () => { - queue.updateConfig({ maxSize: 50 }); - - const stats = queue.getStats(); - expect(stats.maxSize).toBe(50); - }); - }); - - describe('dispose', () => { - it('should clean up resources and reject pending commands', () => { - const rejectFn = jest.fn(); - - queue.enqueue({ - id: 'test-1', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: rejectFn, - }); - - queue.dispose(); - - expect(queue.size).toBe(0); - expect(rejectFn).toHaveBeenCalled(); - }); - }); - - describe('drain filters expired commands', () => { - it('should filter out expired commands when draining', async () => { - const shortTimeoutQueue = new CommandQueue(logger, { - defaultTimeout: 50, - cleanupInterval: 100000, - }); - - const rejectFn = jest.fn(); - const resolveFn = jest.fn(); - - // Add an expired command - shortTimeoutQueue.enqueue({ - id: 'expired', - request: { method: 'test', params: {} }, - resolve: jest.fn(), - reject: rejectFn, - timeout: 10, - }); - - // Add a non-expired command - shortTimeoutQueue.enqueue({ - id: 'valid', - request: { method: 'test', params: {} }, - resolve: resolveFn, - reject: jest.fn(), - timeout: 60000, - }); - - // Wait for first command to expire - await new Promise(resolve => setTimeout(resolve, 50)); - - const commands = shortTimeoutQueue.drain(); - - expect(commands.length).toBe(1); - expect(commands[0].id).toBe('valid'); - expect(rejectFn).toHaveBeenCalled(); - - shortTimeoutQueue.dispose(); - }); - }); -}); diff --git a/Server~/src/__tests__/companionCli.test.ts b/Server~/src/__tests__/companionCli.test.ts new file mode 100644 index 00000000..80986824 --- /dev/null +++ b/Server~/src/__tests__/companionCli.test.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import { + CLI_DOCUMENTATION_URL, + checkUnityCli, + parseCompanionArguments, + resolveUnityCliPath, +} from '../cli/companionCli.js'; + +describe('companion arguments', () => { + const projectPath = path.resolve('/tmp', 'unity-project'); + + test('requires an absolute existing project path', () => { + expect(() => parseCompanionArguments([], () => true)).toThrow('--project-path'); + expect(() => parseCompanionArguments(['--project-path', 'relative'], () => true)).toThrow('absolute'); + expect(() => parseCompanionArguments(['--project-path', projectPath], () => false)).toThrow('existing Unity project'); + }); + + test('accepts the required project path and optional CLI path', () => { + expect( + parseCompanionArguments( + ['--project-path', projectPath, '--unity-cli-path', '/opt/unity-cli'], + () => true, + ), + ).toEqual({ + projectPath, + unityCliPath: '/opt/unity-cli', + }); + }); + + test('rejects unknown, duplicate, or valueless arguments', () => { + expect(() => parseCompanionArguments(['--wat'], () => true)).toThrow('Unknown argument'); + expect(() => + parseCompanionArguments( + ['--project-path', projectPath, '--project-path', projectPath], + () => true, + ), + ).toThrow('Duplicate'); + expect(() => + parseCompanionArguments(['--project-path', projectPath, '--unity-cli-path'], () => true), + ).toThrow('requires a value'); + }); +}); + +describe('Unity CLI resolution and compatibility', () => { + test('resolves explicit argument, then environment, then PATH command', () => { + expect(resolveUnityCliPath('/explicit/unity', { UNITY_CLI_PATH: '/env/unity' })).toBe( + '/explicit/unity', + ); + expect(resolveUnityCliPath(undefined, { UNITY_CLI_PATH: '/env/unity' })).toBe('/env/unity'); + expect(resolveUnityCliPath(undefined, {})).toBe('unity'); + }); + + test.each([ + ['Unity CLI 1.0.0-beta.2', '1.0.0-beta.2'], + ['unity version 1.0.0', '1.0.0'], + ['2.4.1', '2.4.1'], + ])('accepts compatible version output %s', async (stdout, version) => { + const result = await checkUnityCli('/opt/unity', async (command, args) => { + expect(command).toBe('/opt/unity'); + expect(args).toEqual(['--version']); + return { stdout, stderr: '' }; + }); + + expect(result.version).toBe(version); + expect(result.warning).toBe(version.startsWith('2.') ? 'Unity CLI 2.4.1 is newer than the tested major version 1.' : undefined); + }); + + test.each(['0.9.9', '1.0.0-alpha.9', '1.0.0-beta.1'])( + 'rejects incompatible version %s with documentation', + async (version) => { + await expect( + checkUnityCli('unity', async () => ({ stdout: version, stderr: '' })), + ).rejects.toThrow(CLI_DOCUMENTATION_URL); + }, + ); + + test.each(['not a version', '1.0', '1.0.0-beta'])( + 'rejects malformed version output %s', + async (version) => { + await expect( + checkUnityCli('unity', async () => ({ stdout: version, stderr: '' })), + ).rejects.toThrow(CLI_DOCUMENTATION_URL); + }, + ); + + test('turns a missing executable into an actionable error', async () => { + const missing = Object.assign(new Error('spawn unity ENOENT'), { code: 'ENOENT' }); + + await expect( + checkUnityCli('unity', async () => { + throw missing; + }), + ).rejects.toThrow(CLI_DOCUMENTATION_URL); + }); +}); diff --git a/Server~/src/__tests__/companionEntrypoint.test.ts b/Server~/src/__tests__/companionEntrypoint.test.ts new file mode 100644 index 00000000..3a58ea94 --- /dev/null +++ b/Server~/src/__tests__/companionEntrypoint.test.ts @@ -0,0 +1,35 @@ +import { EventEmitter } from 'node:events'; +import { jest } from '@jest/globals'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { startCompanion } from '../companionEntrypoint.js'; + +describe('companion entrypoint', () => { + test('checks the resolved CLI before serving and reports newer-major warnings', async () => { + const checkCli = jest.fn(async (command: string) => ({ + command, + version: '2.0.0', + warning: 'Unity CLI 2.0.0 is newer than the tested major version 1.', + })); + const stderr = { write: jest.fn(() => true) }; + const projectPath = '/projects/game'; + const runtime = await startCompanion({ + argv: ['--project-path', projectPath], + environment: { UNITY_CLI_PATH: '/env/unity' }, + isUnityProject: () => true, + checkCli, + transport: new InMemoryTransport(), + signals: new EventEmitter(), + stdin: new EventEmitter(), + stderr, + }); + + expect(checkCli).toHaveBeenCalledWith('/env/unity'); + expect(stderr.write).toHaveBeenCalledWith( + 'Warning: Unity CLI 2.0.0 is newer than the tested major version 1.\n', + ); + expect(runtime.officialClient.state).toBe('disconnected'); + + await runtime.shutdown(); + expect(runtime.officialClient.state).toBe('closed'); + }); +}); diff --git a/Server~/src/__tests__/companionLifecycle.test.ts b/Server~/src/__tests__/companionLifecycle.test.ts new file mode 100644 index 00000000..c232da03 --- /dev/null +++ b/Server~/src/__tests__/companionLifecycle.test.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'node:events'; +import { jest } from '@jest/globals'; +import { installShutdownHandlers } from '../companionLifecycle.js'; + +describe('companion process shutdown', () => { + test.each(['SIGINT', 'SIGTERM'] as const)( + 'closes the official child/client and outer server on %s', + async (signal) => { + const signals = new EventEmitter(); + const stdin = new EventEmitter(); + const closeOfficialClient = jest.fn(async () => undefined); + const closeServer = jest.fn(async () => undefined); + const handlers = installShutdownHandlers({ + signals, + stdin, + closeOfficialClient, + closeServer, + }); + + signals.emit(signal); + await handlers.shutdown(); + + expect(closeOfficialClient).toHaveBeenCalledTimes(1); + expect(closeServer).toHaveBeenCalledTimes(1); + handlers.dispose(); + }, + ); + + test.each(['close', 'end'] as const)( + 'closes once when stdin emits %s', + async (event) => { + const signals = new EventEmitter(); + const stdin = new EventEmitter(); + const closeOfficialClient = jest.fn(async () => undefined); + const closeServer = jest.fn(async () => undefined); + const handlers = installShutdownHandlers({ + signals, + stdin, + closeOfficialClient, + closeServer, + }); + + stdin.emit(event); + stdin.emit(event); + await handlers.shutdown(); + + expect(closeOfficialClient).toHaveBeenCalledTimes(1); + expect(closeServer).toHaveBeenCalledTimes(1); + handlers.dispose(); + }, + ); +}); diff --git a/Server~/src/__tests__/companionPackageContract.test.ts b/Server~/src/__tests__/companionPackageContract.test.ts new file mode 100644 index 00000000..b56f1afa --- /dev/null +++ b/Server~/src/__tests__/companionPackageContract.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const packageJson = JSON.parse( + fs.readFileSync(path.join(serverRoot, 'package.json'), 'utf8'), +) as Record; + +describe('private companion package contract', () => { + test('uses exact private metadata and runtime dependencies', () => { + expect(packageJson).toMatchObject({ + name: 'mcp-unity-companion', + version: '2.0.0', + private: true, + engines: { node: '>=20' }, + dependencies: { + '@modelcontextprotocol/sdk': '1.26.0', + '@modelcontextprotocol/ext-apps': '1.0.1', + zod: '3.25.76', + }, + }); + expect(Object.keys(packageJson.dependencies as object).sort()).toEqual([ + '@modelcontextprotocol/ext-apps', + '@modelcontextprotocol/sdk', + 'zod', + ]); + expect(packageJson).not.toHaveProperty('bin'); + expect(packageJson).not.toHaveProperty('files'); + expect(packageJson).not.toHaveProperty('publishConfig'); + expect(packageJson).not.toHaveProperty('mcpName'); + }); + + test('contains no legacy transport, mutation proxy, Docker, or Smithery source', () => { + for (const obsoletePath of [ + 'Dockerfile', + '.dockerignore', + 'smithery.yaml', + 'src/tools', + 'src/unity/mcpUnity.ts', + 'src/unity/unityConnection.ts', + 'src/unity/commandQueue.ts', + ]) { + expect(fs.existsSync(path.join(serverRoot, obsoletePath))).toBe(false); + } + + const source = walkFiles(path.join(serverRoot, 'src')) + .filter( + (file) => + file.endsWith('.ts') && !file.includes(`${path.sep}__tests__${path.sep}`), + ) + .map((file) => fs.readFileSync(file, 'utf8')) + .join('\n'); + for (const forbidden of [ + 'WebSocket', + 'ws://', + 'localhost:8090', + 'set_play_mode_status', + 'update_gameobject', + 'add_package', + ]) { + expect(source).not.toContain(forbidden); + } + }); + + test('ships a companion-only build for Git/UPM installations', () => { + expect(fs.existsSync(path.join(serverRoot, 'build', 'index.js'))).toBe(true); + expect( + fs.existsSync(path.join(serverRoot, 'build', 'ui', 'unity-dashboard.html')), + ).toBe(true); + for (const obsoleteBuildPath of [ + 'build/tools', + 'build/unity/mcpUnity.js', + 'build/unity/unityConnection.js', + 'build/unity/commandQueue.js', + ]) { + expect(fs.existsSync(path.join(serverRoot, obsoleteBuildPath))).toBe(false); + } + }); +}); + +function walkFiles(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const resolved = path.join(directory, entry.name); + return entry.isDirectory() ? walkFiles(resolved) : [resolved]; + }); +} diff --git a/Server~/src/__tests__/companionResources.test.ts b/Server~/src/__tests__/companionResources.test.ts new file mode 100644 index 00000000..a2cb58e7 --- /dev/null +++ b/Server~/src/__tests__/companionResources.test.ts @@ -0,0 +1,231 @@ +import { jest } from '@jest/globals'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + CompanionResourceService, + type UnityReadClient, +} from '../resources/companionResources.js'; + +function toolResult(payload: unknown): CallToolResult { + return { + content: [], + structuredContent: payload as Record, + }; +} + +function textResult(payload: unknown): CallToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + }; +} + +function clientWith( + implementation: UnityReadClient['readTool'], +): UnityReadClient & { readTool: jest.MockedFunction } { + return { readTool: jest.fn(implementation) }; +} + +describe('companion resource mappings', () => { + test('maps logs with defaults and clamped limits', async () => { + const client = clientWith(async () => toolResult({ logs: [] })); + const resources = new CompanionResourceService(client); + + await resources.read('unity://logs'); + await resources.read('unity://logs?severity=warning&limit=9999'); + await resources.read('unity://logs?limit=0'); + + expect(client.readTool).toHaveBeenNthCalledWith(1, 'get_console_logs', { + severity: 'all', + limit: 100, + }); + expect(client.readTool).toHaveBeenNthCalledWith(2, 'get_console_logs', { + severity: 'warning', + limit: 1000, + }); + expect(client.readTool).toHaveBeenNthCalledWith(3, 'get_console_logs', { + severity: 'all', + limit: 1, + }); + }); + + test('validates log severity and numeric limits', async () => { + const resources = new CompanionResourceService(clientWith(async () => toolResult({}))); + + await expect(resources.read('unity://logs?severity=debug')).rejects.toThrow('severity'); + await expect(resources.read('unity://logs?limit=many')).rejects.toThrow('limit'); + }); + + test('maps scene hierarchy and deterministically truncates depth-first', async () => { + const hierarchy = { + sceneName: 'Main', + roots: [ + { + name: 'A', + children: [ + { name: 'A1', children: [{ name: 'A1a', children: [] }] }, + { name: 'A2', children: [] }, + ], + }, + { name: 'B', children: [] }, + ], + }; + const client = clientWith(async () => textResult(hierarchy)); + const resources = new CompanionResourceService(client); + + const result = await resources.read( + 'unity://scenes-hierarchy?path=Assets%2FMain.unity&max_nodes=3', + ); + + expect(client.readTool).toHaveBeenCalledWith('get_scene_hierarchy', { + path: 'Assets/Main.unity', + }); + expect(result.payload).toMatchObject({ + sceneName: 'Main', + roots: [ + { + name: 'A', + children: [ + { + name: 'A1', + children: [{ name: 'A1a', children: [] }], + childrenTruncated: false, + }, + ], + childrenTruncated: true, + omittedDescendants: 1, + }, + ], + truncation: { + truncated: true, + maxNodes: 3, + returnedNodes: 3, + totalNodes: 5, + omittedNodes: 2, + }, + }); + }); + + test('uses hierarchy defaults and clamps max_nodes', async () => { + const client = clientWith(async () => toolResult({ roots: [] })); + const resources = new CompanionResourceService(client); + + expect((await resources.read('unity://scenes-hierarchy')).payload).toMatchObject({ + truncation: { maxNodes: 500 }, + }); + expect( + (await resources.read('unity://scenes-hierarchy?max_nodes=99999')).payload, + ).toMatchObject({ truncation: { maxNodes: 2000 } }); + await expect( + resources.read('unity://scenes-hierarchy?max_nodes=nope'), + ).rejects.toThrow('max_nodes'); + }); + + test('maps a GameObject target to bounded inspect_gameobject defaults', async () => { + const client = clientWith(async () => toolResult({ name: 'Player' })); + const resources = new CompanionResourceService(client); + + await resources.read('unity://gameobject/%2FPlayer%2FCamera'); + + expect(client.readTool).toHaveBeenCalledWith('inspect_gameobject', { + target: '/Player/Camera', + max_depth: 2, + max_nodes: 200, + include_components: true, + include_properties: true, + max_properties_per_component: 100, + }); + await expect(resources.read('unity://gameobject/')).rejects.toThrow('target'); + }); + + test('maps installed packages and validates include_indirect', async () => { + const client = clientWith(async () => toolResult({ packages: [] })); + const resources = new CompanionResourceService(client); + + await resources.read('unity://packages'); + await resources.read('unity://packages?include_indirect=false'); + + expect(client.readTool).toHaveBeenNthCalledWith(1, 'package_list', { + scope: 'installed', + include_indirect: true, + }); + expect(client.readTool).toHaveBeenNthCalledWith(2, 'package_list', { + scope: 'installed', + include_indirect: false, + }); + await expect( + resources.read('unity://packages?include_indirect=sometimes'), + ).rejects.toThrow('include_indirect'); + }); + + test.each(['all', 'editor', 'playmode'])('maps test mode %s', async (mode) => { + const client = clientWith(async () => toolResult({ tests: [] })); + const resources = new CompanionResourceService(client); + + await resources.read(`unity://tests/${mode}`); + + expect(client.readTool).toHaveBeenCalledWith('list_tests', { mode }); + }); + + test('rejects invalid test modes and unknown resources', async () => { + const resources = new CompanionResourceService(clientWith(async () => toolResult({}))); + + await expect(resources.read('unity://tests/runtime')).rejects.toThrow('mode'); + await expect(resources.read('unity://assets')).rejects.toThrow('Unknown companion resource'); + }); +}); + +describe('official tool response decoding', () => { + test('prefers object structuredContent and falls back to the first JSON text item', async () => { + const structuredClient = clientWith(async () => + toolResult({ source: 'structured' }), + ); + const textClient = clientWith(async () => ({ + content: [ + { type: 'image', data: 'AA==', mimeType: 'image/png' }, + { type: 'text', text: '{"source":"text"}' }, + ], + })); + + await expect( + new CompanionResourceService(structuredClient).read('unity://logs'), + ).resolves.toMatchObject({ payload: { source: 'structured' } }); + await expect( + new CompanionResourceService(textClient).read('unity://logs'), + ).resolves.toMatchObject({ payload: { source: 'text' } }); + }); + + test('returns clear errors for tool errors and malformed payloads', async () => { + const toolError = clientWith(async () => ({ + isError: true, + content: [{ type: 'text', text: 'Editor is not connected' }], + })); + const malformed = clientWith(async () => ({ + content: [{ type: 'text', text: 'not-json' }], + })); + const empty = clientWith(async () => ({ content: [] })); + + await expect( + new CompanionResourceService(toolError).read('unity://logs'), + ).rejects.toThrow('Editor is not connected'); + await expect( + new CompanionResourceService(malformed).read('unity://logs'), + ).rejects.toThrow('malformed'); + await expect( + new CompanionResourceService(empty).read('unity://logs'), + ).rejects.toThrow('no JSON payload'); + }); + + test('adds actionable context for missing commands, Pipeline disconnects, and CLI exit', async () => { + for (const message of [ + 'Method not found: inspect_gameobject', + 'Pipeline connection refused', + 'Unity CLI process exited with code 1', + ]) { + const client = clientWith(async () => { + throw new Error(message); + }); + await expect( + new CompanionResourceService(client).read('unity://gameobject/Player'), + ).rejects.toThrow(`inspect_gameobject failed: ${message}`); + } + }); +}); diff --git a/Server~/src/__tests__/companionServer.test.ts b/Server~/src/__tests__/companionServer.test.ts new file mode 100644 index 00000000..055b7116 --- /dev/null +++ b/Server~/src/__tests__/companionServer.test.ts @@ -0,0 +1,164 @@ +import { jest } from '@jest/globals'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { createCompanionServer } from '../companionServer.js'; +import { + CompanionResourceService, + type UnityReadClient, +} from '../resources/companionResources.js'; + +async function connectedCompanion() { + const readTool = jest.fn(async (): Promise => ({ + content: [], + structuredContent: { ok: true }, + })); + const unityClient: UnityReadClient = { readTool }; + const server = createCompanionServer(new CompanionResourceService(unityClient)); + const client = new Client( + { name: 'companion-test', version: '1.0.0' }, + { capabilities: {} }, + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + return { client, server, readTool }; +} + +describe('outer MCP companion catalog', () => { + test('advertises exactly one dashboard tool, two prompts, and six resources', async () => { + const { client, server } = await connectedCompanion(); + try { + const tools = await client.listTools(); + const prompts = await client.listPrompts(); + const resources = await client.listResources(); + const templates = await client.listResourceTemplates(); + + expect(tools.tools.map((tool) => tool.name)).toEqual(['show_unity_dashboard']); + expect(prompts.prompts.map((prompt) => prompt.name).sort()).toEqual([ + 'gameobject_handling_strategy', + 'unity_dashboard', + ]); + expect(resources.resources.map((resource) => resource.uri)).toEqual([ + 'ui://unity-dashboard', + ]); + expect(templates.resourceTemplates.map((template) => template.uriTemplate).sort()).toEqual([ + 'unity://gameobject/{target}', + 'unity://logs{?severity,limit}', + 'unity://packages{?include_indirect}', + 'unity://scenes-hierarchy{?path,max_nodes}', + 'unity://tests/{mode}', + ]); + + const forbiddenMutationTools = [ + 'assign_material', + 'duplicate_gameobject', + 'editor_step', + 'execute_menu_item', + 'package_add', + 'run_tests', + 'unload_scene', + ]; + expect(tools.tools.map((tool) => tool.name)).not.toEqual( + expect.arrayContaining(forbiddenMutationTools), + ); + } finally { + await client.close(); + await server.close(); + } + }); + + test('routes concrete resource reads without advertising official tools', async () => { + const { client, server, readTool } = await connectedCompanion(); + try { + const result = await client.readResource({ + uri: 'unity://logs?severity=error&limit=4', + }); + expect(readTool).toHaveBeenCalledWith('get_console_logs', { + severity: 'error', + limit: 4, + }); + expect(JSON.parse(result.contents[0].text as string)).toEqual({ ok: true }); + } finally { + await client.close(); + await server.close(); + } + }); + + test('dashboard tool and app resource expose MCP App metadata and bundled HTML', async () => { + const { client, server } = await connectedCompanion(); + try { + const tools = await client.listTools(); + expect(tools.tools[0]._meta).toMatchObject({ + ui: { resourceUri: 'ui://unity-dashboard' }, + 'ui/resourceUri': 'ui://unity-dashboard', + }); + + const toolResult = await client.callTool({ name: 'show_unity_dashboard' }); + expect(toolResult.isError).not.toBe(true); + const app = await client.readResource({ uri: 'ui://unity-dashboard' }); + expect(app.contents[0].mimeType).toBe('text/html;profile=mcp-app'); + const html = app.contents[0].text as string; + for (const resource of [ + 'unity://logs', + 'unity://scenes-hierarchy', + 'unity://gameobject/', + 'unity://packages', + 'unity://tests/', + 'ui://unity-dashboard', + ]) { + expect(html).toContain(resource); + } + expect(html).toContain('refreshInFlight'); + expect(html).toContain('MIN_REFRESH_MS'); + expect(html).toContain('Pipeline'); + expect(html).toContain('truncation'); + expect(html).not.toContain('set_play_mode_status'); + expect(html).not.toContain('tools/call'); + } finally { + await client.close(); + await server.close(); + } + }); + + test('prompts use official Pipeline and five extension command names without aliases', async () => { + const { client, server } = await connectedCompanion(); + try { + const strategy = await client.getPrompt({ + name: 'gameobject_handling_strategy', + }); + const dashboard = await client.getPrompt({ name: 'unity_dashboard' }); + const promptText = [...strategy.messages, ...dashboard.messages] + .map((message) => + message.content.type === 'text' ? message.content.text : '', + ) + .join('\n'); + + for (const command of [ + 'get_scene_hierarchy', + 'package_list', + 'list_tests', + 'inspect_gameobject', + 'duplicate_gameobject', + 'unload_scene', + 'editor_step', + 'assign_material', + ]) { + expect(promptText).toContain(command); + } + for (const legacyAlias of [ + 'get_gameobject', + 'get_scene_info', + 'set_play_mode_status', + 'update_gameobject', + ]) { + expect(promptText).not.toContain(legacyAlias); + } + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/Server~/src/__tests__/dashboardSupportTools.test.ts b/Server~/src/__tests__/dashboardSupportTools.test.ts deleted file mode 100644 index 47c3eb5e..00000000 --- a/Server~/src/__tests__/dashboardSupportTools.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { ErrorType } from '../utils/errors.js'; -import { registerGetConsoleLogsTool } from '../tools/getConsoleLogsTool.js'; -import { registerGetScenesHierarchyTool } from '../tools/getScenesHierarchyTool.js'; - -const mockSendRequest = jest.fn(); -const mockMcpUnity = { - sendRequest: mockSendRequest -}; - -const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() -}; - -const mockServerTool = jest.fn(); -const mockServer = { - tool: mockServerTool -}; - -describe('Dashboard Support Tools', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('get_console_logs', () => { - it('forwards pagination options and returns structured log data', async () => { - registerGetConsoleLogsTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - const logs = [{ type: 'error', message: 'Boom' }]; - - mockSendRequest.mockResolvedValue({ - success: true, - logs - }); - - const result = await handler({ - logType: 'error', - offset: 10, - limit: 25, - includeStackTrace: false - }); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'get_console_logs', - params: { - logType: 'error', - offset: 10, - limit: 25, - includeStackTrace: false - } - }); - expect(result.data).toEqual({ - logs, - offset: 10, - limit: 25, - logType: 'error', - includeStackTrace: false - }); - }); - }); - - describe('get_scenes_hierarchy', () => { - it('forwards to Unity and returns hierarchy data', async () => { - registerGetScenesHierarchyTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - const hierarchy = [{ name: 'SampleScene', children: [] }]; - - mockSendRequest.mockResolvedValue({ - success: true, - hierarchy - }); - - const result = await handler({}); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'get_scenes_hierarchy', - params: {} - }); - expect(result.content[0].text).toContain('SampleScene'); - expect(result.data).toEqual({ hierarchy }); - }); - - it('throws a tool execution error when Unity fails', async () => { - registerGetScenesHierarchyTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - - mockSendRequest.mockResolvedValue({ - success: false, - message: 'Hierarchy unavailable' - }); - - await expect(handler({})).rejects.toMatchObject({ - type: ErrorType.TOOL_EXECUTION, - message: 'Hierarchy unavailable' - }); - }); - }); -}); diff --git a/Server~/src/__tests__/errors.test.ts b/Server~/src/__tests__/errors.test.ts deleted file mode 100644 index da619817..00000000 --- a/Server~/src/__tests__/errors.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { McpUnityError, ErrorType, handleError } from '../utils/errors.js'; - -describe('McpUnityError', () => { - describe('constructor', () => { - it('should create error with type and message', () => { - const error = new McpUnityError(ErrorType.CONNECTION, 'Connection failed'); - - expect(error.type).toBe(ErrorType.CONNECTION); - expect(error.message).toBe('Connection failed'); - expect(error.name).toBe('McpUnityError'); - expect(error.details).toBeUndefined(); - }); - - it('should create error with details', () => { - const details = { host: 'localhost', port: 8090 }; - const error = new McpUnityError(ErrorType.CONNECTION, 'Connection failed', details); - - expect(error.details).toEqual(details); - }); - - it('should be an instance of Error', () => { - const error = new McpUnityError(ErrorType.INTERNAL, 'Test error'); - - expect(error).toBeInstanceOf(Error); - expect(error).toBeInstanceOf(McpUnityError); - }); - }); - - describe('toJSON', () => { - it('should serialize error to JSON object', () => { - const error = new McpUnityError(ErrorType.VALIDATION, 'Invalid input', { field: 'name' }); - - const json = error.toJSON(); - - expect(json).toEqual({ - type: ErrorType.VALIDATION, - message: 'Invalid input', - details: { field: 'name' }, - }); - }); - - it('should serialize error without details', () => { - const error = new McpUnityError(ErrorType.TIMEOUT, 'Request timed out'); - - const json = error.toJSON(); - - expect(json).toEqual({ - type: ErrorType.TIMEOUT, - message: 'Request timed out', - details: undefined, - }); - }); - }); -}); - -describe('handleError', () => { - it('should return McpUnityError as-is', () => { - const originalError = new McpUnityError(ErrorType.TOOL_EXECUTION, 'Tool failed'); - - const result = handleError(originalError, 'test'); - - expect(result).toBe(originalError); - }); - - it('should wrap standard Error in McpUnityError', () => { - const standardError = new Error('Something went wrong'); - - const result = handleError(standardError, 'TestContext'); - - expect(result).toBeInstanceOf(McpUnityError); - expect(result.type).toBe(ErrorType.INTERNAL); - expect(result.message).toBe('TestContext error: Something went wrong'); - expect(result.details).toBe(standardError); - }); - - it('should handle error without message', () => { - const errorWithoutMessage = {}; - - const result = handleError(errorWithoutMessage, 'Context'); - - expect(result.message).toBe('Context error: Unknown error'); - }); -}); - -describe('ErrorType', () => { - it('should have all expected error types', () => { - expect(ErrorType.CONNECTION).toBe('connection_error'); - expect(ErrorType.TOOL_EXECUTION).toBe('tool_execution_error'); - expect(ErrorType.RESOURCE_FETCH).toBe('resource_fetch_error'); - expect(ErrorType.VALIDATION).toBe('validation_error'); - expect(ErrorType.INTERNAL).toBe('internal_error'); - expect(ErrorType.TIMEOUT).toBe('timeout_error'); - }); -}); diff --git a/Server~/src/__tests__/logger.test.ts b/Server~/src/__tests__/logger.test.ts deleted file mode 100644 index d6531f79..00000000 --- a/Server~/src/__tests__/logger.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { jest } from '@jest/globals'; -import { Logger, LogLevel } from '../utils/logger.js'; - -describe('Logger', () => { - let consoleSpy: jest.SpiedFunction; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - describe('constructor', () => { - it('should create logger with prefix and default level', () => { - const logger = new Logger('TestPrefix'); - - expect(logger).toBeInstanceOf(Logger); - }); - - it('should create logger with custom level', () => { - const logger = new Logger('TestPrefix', LogLevel.DEBUG); - - expect(logger).toBeInstanceOf(Logger); - }); - }); - - describe('log level filtering', () => { - it('should not log messages below the configured level', () => { - // Set environment variable for logging - const originalEnv = process.env.LOGGING; - process.env.LOGGING = 'true'; - - const logger = new Logger('Test', LogLevel.WARN); - - logger.debug('debug message'); - logger.info('info message'); - - expect(consoleSpy).not.toHaveBeenCalled(); - - process.env.LOGGING = originalEnv; - }); - - it('should respect log level hierarchy', () => { - // This test verifies the Logger respects log levels - // Note: Actual console output depends on LOGGING env var set at module load time - const logger = new Logger('Test', LogLevel.DEBUG); - - // All methods should be callable without throwing - expect(() => { - logger.debug('debug'); - logger.info('info'); - logger.warn('warn'); - logger.error('error'); - }).not.toThrow(); - }); - }); - - describe('isLoggingEnabled', () => { - it('should return a boolean value', () => { - const logger = new Logger('Test'); - expect(typeof logger.isLoggingEnabled()).toBe('boolean'); - }); - }); - - describe('isLoggingFileEnabled', () => { - it('should return a boolean value', () => { - const logger = new Logger('Test'); - expect(typeof logger.isLoggingFileEnabled()).toBe('boolean'); - }); - }); - - describe('logging methods', () => { - it('should have debug method', () => { - const logger = new Logger('Test'); - expect(typeof logger.debug).toBe('function'); - }); - - it('should have info method', () => { - const logger = new Logger('Test'); - expect(typeof logger.info).toBe('function'); - }); - - it('should have warn method', () => { - const logger = new Logger('Test'); - expect(typeof logger.warn).toBe('function'); - }); - - it('should have error method', () => { - const logger = new Logger('Test'); - expect(typeof logger.error).toBe('function'); - }); - }); -}); - -describe('LogLevel', () => { - it('should have correct numeric values for ordering', () => { - expect(LogLevel.DEBUG).toBe(0); - expect(LogLevel.INFO).toBe(1); - expect(LogLevel.WARN).toBe(2); - expect(LogLevel.ERROR).toBe(3); - }); - - it('should allow level comparison', () => { - expect(LogLevel.DEBUG < LogLevel.INFO).toBe(true); - expect(LogLevel.INFO < LogLevel.WARN).toBe(true); - expect(LogLevel.WARN < LogLevel.ERROR).toBe(true); - }); -}); diff --git a/Server~/src/__tests__/materialTools.test.ts b/Server~/src/__tests__/materialTools.test.ts deleted file mode 100644 index 4df4a044..00000000 --- a/Server~/src/__tests__/materialTools.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { - registerCreateMaterialTool, - registerAssignMaterialTool, - registerModifyMaterialTool, - registerGetMaterialInfoTool -} from '../tools/materialTools.js'; - -// Mock the McpUnity class -const mockSendRequest = jest.fn(); -const mockMcpUnity = { - sendRequest: mockSendRequest -}; - -// Mock the Logger -const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() -}; - -// Mock the McpServer -const mockServerTool = jest.fn(); -const mockServer = { - tool: mockServerTool -}; - -describe('Material Tools', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('registerCreateMaterialTool', () => { - it('should register the create_material tool with the server', () => { - registerCreateMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(1); - expect(mockServerTool).toHaveBeenCalledWith( - 'create_material', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - expect(mockLogger.info).toHaveBeenCalledWith('Registering tool: create_material'); - }); - - it('should have correct tool description', () => { - registerCreateMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - const [, description] = mockServerTool.mock.calls[0]; - expect(description).toContain('material'); - expect(description).toContain('shader'); - }); - }); - - describe('registerAssignMaterialTool', () => { - it('should register the assign_material tool with the server', () => { - registerAssignMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(1); - expect(mockServerTool).toHaveBeenCalledWith( - 'assign_material', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - expect(mockLogger.info).toHaveBeenCalledWith('Registering tool: assign_material'); - }); - }); - - describe('registerModifyMaterialTool', () => { - it('should register the modify_material tool with the server', () => { - registerModifyMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(1); - expect(mockServerTool).toHaveBeenCalledWith( - 'modify_material', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - expect(mockLogger.info).toHaveBeenCalledWith('Registering tool: modify_material'); - }); - }); - - describe('registerGetMaterialInfoTool', () => { - it('should register the get_material_info tool with the server', () => { - registerGetMaterialInfoTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(1); - expect(mockServerTool).toHaveBeenCalledWith( - 'get_material_info', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - expect(mockLogger.info).toHaveBeenCalledWith('Registering tool: get_material_info'); - }); - }); - - describe('create_material handler', () => { - let handler: Function; - - beforeEach(() => { - registerCreateMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - handler = mockServerTool.mock.calls[0][3]; - }); - - it('should throw validation error when name is missing', async () => { - const params = { savePath: 'Assets/Materials/Test.mat' }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('name') - }); - }); - - it('should throw validation error when savePath is missing', async () => { - const params = { name: 'TestMaterial' }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('savePath') - }); - }); - - it('should send request to Unity with correct parameters', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material created' - }); - - const params = { - name: 'TestMaterial', - shader: 'Standard', - savePath: 'Assets/Materials/Test.mat', - properties: { _Color: { r: 1, g: 0, b: 0, a: 1 } } - }; - - await handler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'create_material', - params: { - name: 'TestMaterial', - shader: 'Standard', - savePath: 'Assets/Materials/Test.mat', - properties: { _Color: { r: 1, g: 0, b: 0, a: 1 } } - } - }); - }); - - it('should use default shader when not specified', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material created' - }); - - const params = { - name: 'TestMaterial', - savePath: 'Assets/Materials/Test.mat' - }; - - await handler(params); - - // Shader should be undefined - Unity auto-detects based on render pipeline - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'create_material', - params: expect.objectContaining({ - shader: undefined - }) - }); - }); - - it('should throw tool execution error on Unity failure', async () => { - mockSendRequest.mockResolvedValue({ - success: false, - message: 'Shader not found' - }); - - const params = { - name: 'TestMaterial', - shader: 'NonExistentShader', - savePath: 'Assets/Materials/Test.mat' - }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.TOOL_EXECUTION - }); - }); - }); - - describe('assign_material handler', () => { - let handler: Function; - - beforeEach(() => { - registerAssignMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - handler = mockServerTool.mock.calls[0][3]; - }); - - it('should throw validation error when neither instanceId nor objectPath provided', async () => { - const params = { materialPath: 'Assets/Materials/Test.mat' }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('instanceId') - }); - }); - - it('should throw validation error when materialPath is missing', async () => { - const params = { objectPath: '/Player' }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('materialPath') - }); - }); - - it('should send request with instanceId', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material assigned' - }); - - const params = { - instanceId: 12345, - materialPath: 'Assets/Materials/Test.mat', - slot: 0 - }; - - await handler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'assign_material', - params: { - instanceId: 12345, - objectPath: undefined, - materialPath: 'Assets/Materials/Test.mat', - slot: 0 - } - }); - }); - - it('should send request with objectPath', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material assigned' - }); - - const params = { - objectPath: '/Player/Body', - materialPath: 'Assets/Materials/Test.mat' - }; - - await handler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'assign_material', - params: expect.objectContaining({ - objectPath: '/Player/Body', - slot: 0 - }) - }); - }); - - it('should use default slot of 0 when not specified', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material assigned' - }); - - const params = { - instanceId: 12345, - materialPath: 'Assets/Materials/Test.mat' - }; - - await handler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'assign_material', - params: expect.objectContaining({ - slot: 0 - }) - }); - }); - }); - - describe('modify_material handler', () => { - let handler: Function; - - beforeEach(() => { - registerModifyMaterialTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - handler = mockServerTool.mock.calls[0][3]; - }); - - it('should throw validation error when materialPath is missing', async () => { - const params = { properties: { _Color: { r: 1, g: 0, b: 0, a: 1 } } }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('materialPath') - }); - }); - - it('should throw validation error when properties is empty', async () => { - const params = { materialPath: 'Assets/Materials/Test.mat', properties: {} }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('properties') - }); - }); - - it('should throw validation error when properties is missing', async () => { - const params = { materialPath: 'Assets/Materials/Test.mat' }; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - }); - - it('should send request with correct properties', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material modified' - }); - - const params = { - materialPath: 'Assets/Materials/Test.mat', - properties: { - _Color: { r: 1, g: 0.5, b: 0, a: 1 }, - _Metallic: 0.5, - _MainTex: 'Assets/Textures/Wood.png' - } - }; - - await handler(params); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'modify_material', - params: { - materialPath: 'Assets/Materials/Test.mat', - properties: { - _Color: { r: 1, g: 0.5, b: 0, a: 1 }, - _Metallic: 0.5, - _MainTex: 'Assets/Textures/Wood.png' - } - } - }); - }); - }); - - describe('get_material_info handler', () => { - let handler: Function; - - beforeEach(() => { - registerGetMaterialInfoTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - handler = mockServerTool.mock.calls[0][3]; - }); - - it('should throw validation error when materialPath is missing', async () => { - const params = {}; - - await expect(handler(params)).rejects.toThrow(McpUnityError); - await expect(handler(params)).rejects.toMatchObject({ - type: ErrorType.VALIDATION, - message: expect.stringContaining('materialPath') - }); - }); - - it('should send request and return formatted response', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material info', - materialName: 'TestMaterial', - materialPath: 'Assets/Materials/Test.mat', - shaderName: 'Standard', - renderQueue: 2000, - renderQueueCategory: 'Geometry', - enableInstancing: false, - doubleSidedGI: false, - passCount: 1, - properties: [ - { name: '_Color', type: 'Color', value: { r: 1, g: 1, b: 1, a: 1 }, description: 'Main Color' }, - { name: '_Metallic', type: 'Float', value: 0, description: 'Metallic' } - ] - }); - - const params = { materialPath: 'Assets/Materials/Test.mat' }; - const result = await handler(params); - - expect(result.content[0].text).toContain('Material: TestMaterial'); - expect(result.content[0].text).toContain('Shader: Standard'); - expect(result.content[0].text).toContain('_Color'); - expect(result.content[0].text).toContain('_Metallic'); - }); - - it('should include data object in response', async () => { - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - message: 'Material info', - materialName: 'TestMaterial', - materialPath: 'Assets/Materials/Test.mat', - shaderName: 'Standard', - renderQueue: 2000, - renderQueueCategory: 'Geometry', - enableInstancing: false, - doubleSidedGI: false, - passCount: 1, - properties: [] - }); - - const params = { materialPath: 'Assets/Materials/Test.mat' }; - const result = await handler(params); - - expect(result.data).toBeDefined(); - expect(result.data.materialName).toBe('TestMaterial'); - expect(result.data.shaderName).toBe('Standard'); - }); - }); -}); diff --git a/Server~/src/__tests__/mcpUnity.test.ts b/Server~/src/__tests__/mcpUnity.test.ts deleted file mode 100644 index 605c2132..00000000 --- a/Server~/src/__tests__/mcpUnity.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { Logger, LogLevel } from '../utils/logger.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { McpUnity, ConnectionState } from '../unity/mcpUnity.js'; -import { registerTransformTools } from '../tools/transformTools.js'; -import path from 'path'; -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -describe('McpUnityError integration', () => { - it('should create proper error for connection issues', () => { - const error = new McpUnityError(ErrorType.CONNECTION, 'Failed to connect to Unity'); - - expect(error.type).toBe('connection_error'); - expect(error.message).toBe('Failed to connect to Unity'); - }); - - it('should create proper error for timeout', () => { - const error = new McpUnityError(ErrorType.TIMEOUT, 'Request timed out'); - - expect(error.type).toBe('timeout_error'); - }); -}); - -describe('Path handling in configuration', () => { - it('should handle paths with spaces in config file path', () => { - // The config path uses path.resolve which handles spaces correctly - const pathWithSpaces = '/Users/John Doe/My Project/ProjectSettings/McpUnitySettings.json'; - - // Verify path module handles spaces - const resolved = path.resolve(pathWithSpaces); - - expect(resolved).toContain('John Doe'); - expect(resolved).toContain('My Project'); - }); - - it('should handle Windows-style paths with spaces', () => { - const windowsPath = 'C:\\Users\\John Doe\\My Project\\ProjectSettings'; - - // path.normalize handles both styles - const normalized = path.normalize(windowsPath); - - expect(normalized).toContain('John Doe'); - }); - - it('should properly construct WebSocket URL', () => { - // WebSocket URLs don't need special encoding for host/port - const host = 'localhost'; - const port = 8090; - const wsUrl = `ws://${host}:${port}/McpUnity`; - - expect(wsUrl).toBe('ws://localhost:8090/McpUnity'); - }); - - it('should handle path.join with spaces', () => { - const basePath = '/Users/John Doe/Projects'; - const subPath = 'My Unity Game'; - const fileName = 'settings.json'; - - const fullPath = path.join(basePath, subPath, fileName); - - expect(fullPath).toContain('John Doe'); - expect(fullPath).toContain('My Unity Game'); - expect(fullPath).toContain('settings.json'); - }); - - it('should handle path.resolve with relative paths containing spaces', () => { - const cwd = '/Users/Test User/Current Dir'; - const relativePath = '../Other Project/file.txt'; - - // path.resolve will work correctly with spaces - const resolved = path.resolve(cwd, relativePath); - - expect(resolved).toContain('Test User'); - }); -}); - -describe('Logger with path-related messages', () => { - it('should log messages containing paths with spaces', () => { - const logger = new Logger('Test', LogLevel.ERROR); - const pathWithSpaces = '/Users/John Doe/My Project/file.txt'; - - // Logger should handle any string including paths with spaces - // This is a smoke test to ensure no exceptions are thrown - expect(() => { - logger.error(`Failed to read file: ${pathWithSpaces}`); - }).not.toThrow(); - }); -}); - -describe('Request timeout handling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('rejects timed out requests without forcing a reconnect', async () => { - const logger = new Logger('Test', LogLevel.ERROR); - const unity = new McpUnity(logger, { queueingEnabled: false }); - const connection = { - isConnected: true, - isConnecting: false, - connectionState: ConnectionState.Connected, - send: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - removeAllListeners: jest.fn(), - forceReconnect: jest.fn(), - getStats: jest.fn(() => ({ - state: ConnectionState.Connected, - reconnectAttempt: 0, - timeSinceLastPong: 0 - })) - }; - - (unity as any).connection = connection; - - const request = { - id: 'request-timeout', - method: 'run_tests', - params: { mode: 'edit' } - }; - - const promise = unity.sendRequest(request, { timeout: 50 }); - const timeoutResult = expect(promise).rejects.toMatchObject({ - type: ErrorType.TIMEOUT, - message: 'Request timed out' - }); - - expect(connection.send).toHaveBeenCalledWith(JSON.stringify(request)); - - await jest.advanceTimersByTimeAsync(50); - await timeoutResult; - - expect(connection.forceReconnect).not.toHaveBeenCalled(); - expect(unity.getConnectionStats().pendingRequests).toBe(0); - expect(unity.connectionState).toBe(ConnectionState.Connected); - - await unity.stop(); - }); -}); - -describe('Unity request/response diagnostics', () => { - const createMockLogger = () => ({ - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn() - }); - - it('logs request id and connection state when sending to Unity', async () => { - const logger = createMockLogger(); - const unity = new McpUnity(logger as any, { queueingEnabled: false }); - const connection = { - isConnected: true, - isConnecting: false, - connectionState: ConnectionState.Connected, - send: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - removeAllListeners: jest.fn(), - forceReconnect: jest.fn(), - getStats: jest.fn(() => ({ - state: ConnectionState.Connected, - reconnectAttempt: 0, - timeSinceLastPong: 0 - })) - }; - - (unity as any).connection = connection; - - const promise = unity.sendRequest({ - id: 'request-diagnostics', - method: 'get_scene_info', - params: {} - }); - - (unity as any).handleMessage(JSON.stringify({ - id: 'request-diagnostics', - result: { success: true } - })); - - await expect(promise).resolves.toEqual({ success: true }); - expect(logger.info).toHaveBeenCalledWith( - 'Sending Unity request request-diagnostics (get_scene_info) while connection state is connected; pending requests before send: 0' - ); - expect(logger.info).toHaveBeenCalledWith( - 'Received Unity response for request request-diagnostics; pending requests before match: 1' - ); - - await unity.stop(); - }); - - it('logs ignored Unity responses without a matching pending request', async () => { - const logger = createMockLogger(); - const unity = new McpUnity(logger as any, { queueingEnabled: false }); - - (unity as any).handleMessage(JSON.stringify({ - id: 'unknown-request', - result: { success: true } - })); - - expect(logger.warn).toHaveBeenCalledWith( - 'Ignoring Unity response for unknown request unknown-request; pending requests: 0' - ); - - await unity.stop(); - }); -}); - -describe('Transform schema compatibility', () => { - const mockSendRequest = jest.fn(); - const mockMcpUnity = { sendRequest: mockSendRequest }; - const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() - }; - const mockServerTool = jest.fn(); - const mockServer = { tool: mockServerTool }; - - function collectLocalPropertyRefs(node: unknown, refs: string[] = []): string[] { - if (Array.isArray(node)) { - for (const item of node) { - collectLocalPropertyRefs(item, refs); - } - return refs; - } - - if (!node || typeof node !== 'object') { - return refs; - } - - for (const [key, value] of Object.entries(node)) { - if (key === '$ref' && typeof value === 'string' && value.startsWith('#/properties/')) { - refs.push(value); - } - collectLocalPropertyRefs(value, refs); - } - - return refs; - } - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('registers transform tools', () => { - registerTransformTools(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledTimes(4); - expect(mockServerTool).toHaveBeenCalledWith('move_gameobject', expect.any(String), expect.any(Object), expect.any(Function)); - expect(mockServerTool).toHaveBeenCalledWith('rotate_gameobject', expect.any(String), expect.any(Object), expect.any(Function)); - expect(mockServerTool).toHaveBeenCalledWith('scale_gameobject', expect.any(String), expect.any(Object), expect.any(Function)); - expect(mockServerTool).toHaveBeenCalledWith('set_transform', expect.any(String), expect.any(Object), expect.any(Function)); - }); - - it('does not emit local #/properties refs for transform tool schemas', () => { - registerTransformTools(mockServer as any, mockMcpUnity as any, mockLogger as any); - - for (const call of mockServerTool.mock.calls) { - const paramsShape = call[2]; - const schemaJson = zodToJsonSchema(z.object(paramsShape), { strictUnions: true }); - const refs = collectLocalPropertyRefs(schemaJson); - - expect(refs).toEqual([]); - } - }); -}); diff --git a/Server~/src/__tests__/officialUnityMcpClient.test.ts b/Server~/src/__tests__/officialUnityMcpClient.test.ts new file mode 100644 index 00000000..b60e205d --- /dev/null +++ b/Server~/src/__tests__/officialUnityMcpClient.test.ts @@ -0,0 +1,133 @@ +import { jest } from '@jest/globals'; +import { + OfficialUnityMcpClient, + type OfficialUnitySession, + type OfficialUnitySessionFactory, +} from '../unity/officialUnityMcpClient.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +function session( + callTool: OfficialUnitySession['callTool'] = async () => ({ + content: [{ type: 'text', text: '{"ok":true}' }], + }), +): OfficialUnitySession & { close: jest.Mock } { + return { + callTool, + close: jest.fn(async () => undefined), + }; +} + +describe('OfficialUnityMcpClient', () => { + test('lazily starts exactly one official MCP process for concurrent first reads', async () => { + const gate = deferred(); + const factory: jest.MockedFunction = jest.fn(() => gate.promise); + const client = new OfficialUnityMcpClient({ + cliPath: '/opt/unity', + projectPath: '/projects/game', + sessionFactory: factory, + }); + + expect(factory).not.toHaveBeenCalled(); + + const first = client.readTool('get_console_logs', { limit: 10 }); + const second = client.readTool('package_list', { scope: 'installed' }); + expect(factory).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledWith({ + cliPath: '/opt/unity', + projectPath: '/projects/game', + }); + + gate.resolve(session()); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(factory).toHaveBeenCalledTimes(1); + expect(client.state).toBe('connected'); + }); + + test('reconnects once and retries the same read-only call exactly once', async () => { + const firstSession = session(jest.fn(async () => { + throw new Error('transport closed'); + })); + const secondCall = jest.fn(async () => ({ + content: [{ type: 'text' as const, text: '{"recovered":true}' }], + })); + const secondSession = session(secondCall); + const factory = jest + .fn, Parameters>() + .mockResolvedValueOnce(firstSession) + .mockResolvedValueOnce(secondSession); + const client = new OfficialUnityMcpClient({ + cliPath: 'unity', + projectPath: '/projects/game', + sessionFactory: factory, + }); + + await expect(client.readTool('list_tests', { mode: 'editor' })).resolves.toEqual({ + content: [{ type: 'text', text: '{"recovered":true}' }], + }); + expect(factory).toHaveBeenCalledTimes(2); + expect(firstSession.callTool).toHaveBeenCalledTimes(1); + expect(secondCall).toHaveBeenCalledTimes(1); + expect(secondCall).toHaveBeenCalledWith('list_tests', { mode: 'editor' }); + expect(firstSession.close).toHaveBeenCalledTimes(1); + }); + + test('does not retry a second failed read', async () => { + const factory = jest + .fn, Parameters>() + .mockResolvedValue(session(async () => { + throw new Error('Connection closed'); + })); + const client = new OfficialUnityMcpClient({ + cliPath: 'unity', + projectPath: '/projects/game', + sessionFactory: factory, + }); + + await expect(client.readTool('package_list', {})).rejects.toThrow('Connection closed'); + expect(factory).toHaveBeenCalledTimes(2); + }); + + test('does not reconnect for a non-transport command error', async () => { + const activeSession = session(async () => { + throw new Error('Method not found: inspect_gameobject'); + }); + const factory = jest.fn(async () => activeSession); + const client = new OfficialUnityMcpClient({ + cliPath: 'unity', + projectPath: '/projects/game', + sessionFactory: factory, + }); + + await expect(client.readTool('inspect_gameobject', {})).rejects.toThrow( + 'Method not found', + ); + expect(factory).toHaveBeenCalledTimes(1); + expect(activeSession.close).not.toHaveBeenCalled(); + }); + + test('closes the active child/client and prevents later startup', async () => { + const activeSession = session(); + const factory = jest.fn(async () => activeSession); + const client = new OfficialUnityMcpClient({ + cliPath: 'unity', + projectPath: '/projects/game', + sessionFactory: factory, + }); + await client.readTool('get_console_logs', {}); + + await client.close(); + await client.close(); + + expect(activeSession.close).toHaveBeenCalledTimes(1); + expect(client.state).toBe('closed'); + await expect(client.readTool('get_console_logs', {})).rejects.toThrow('closed'); + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Server~/src/__tests__/playModeTools.test.ts b/Server~/src/__tests__/playModeTools.test.ts deleted file mode 100644 index fef521ab..00000000 --- a/Server~/src/__tests__/playModeTools.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { ErrorType, McpUnityError } from '../utils/errors.js'; -import { registerGetPlayModeStatusTool } from '../tools/getPlayModeStatusTool.js'; -import { registerSetPlayModeStatusTool } from '../tools/setPlayModeStatusTool.js'; - -const mockSendRequest = jest.fn(); -const mockMcpUnity = { - sendRequest: mockSendRequest -}; - -const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() -}; - -const mockServerTool = jest.fn(); -const mockServer = { - tool: mockServerTool -}; - -describe('Play Mode Tools', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('get_play_mode_status', () => { - it('registers the tool with the MCP server', () => { - registerGetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledWith( - 'get_play_mode_status', - expect.any(String), - expect.any(Object), - expect.any(Function) - ); - }); - - it('returns structured play mode state from Unity', async () => { - registerGetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - - mockSendRequest.mockResolvedValue({ - success: true, - type: 'text', - isPlaying: true, - isPaused: false - }); - - const result = await handler({}); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'get_play_mode_status', - params: {} - }); - expect(result.content[0].text).toBe('Play mode'); - expect(result.data).toEqual({ isPlaying: true, isPaused: false }); - }); - - it('throws a tool execution error when Unity reports failure', async () => { - registerGetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - - mockSendRequest.mockResolvedValue({ - success: false, - message: 'Unity unavailable' - }); - - await expect(handler({})).rejects.toMatchObject({ - type: ErrorType.TOOL_EXECUTION, - message: 'Unity unavailable' - } as Partial); - }); - }); - - describe('set_play_mode_status', () => { - it('registers the tool with the MCP server', () => { - registerSetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - - expect(mockServerTool).toHaveBeenCalledWith( - 'set_play_mode_status', - expect.any(String), - expect.objectContaining({ action: expect.any(Object) }), - expect.any(Function) - ); - }); - - it('sends validated play mode actions to Unity', async () => { - registerSetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - - mockSendRequest.mockResolvedValue({ - success: true, - isPlaying: false, - isPaused: false, - action: 'stop' - }); - - const result = await handler({ action: 'stop' }); - - expect(mockSendRequest).toHaveBeenCalledWith({ - method: 'set_play_mode_status', - params: { action: 'stop' } - }); - expect(result.content[0].text).toContain("Play mode action 'stop'"); - expect(result.data).toEqual({ - action: 'stop', - isPlaying: false, - isPaused: false - }); - }); - - it('rejects invalid actions before calling Unity', async () => { - registerSetPlayModeStatusTool(mockServer as any, mockMcpUnity as any, mockLogger as any); - const handler = mockServerTool.mock.calls[0][3] as (params: any) => Promise; - - await expect(handler({ action: 'restart' })).rejects.toThrow(); - expect(mockSendRequest).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/Server~/src/__tests__/releaseMetadata.test.ts b/Server~/src/__tests__/releaseMetadata.test.ts deleted file mode 100644 index c80e0653..00000000 --- a/Server~/src/__tests__/releaseMetadata.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const serverDirectory = process.cwd(); -const repositoryDirectory = join(serverDirectory, '..'); - -describe('release 1.4.0 metadata', () => { - it('publishes 1.4.0 from both package manifests and the MCP protocol', () => { - const unityPackage = JSON.parse(readFileSync(join(repositoryDirectory, 'package.json'), 'utf8')); - const nodePackage = JSON.parse(readFileSync(join(serverDirectory, 'package.json'), 'utf8')); - const lockfile = JSON.parse(readFileSync(join(serverDirectory, 'package-lock.json'), 'utf8')); - const serverSource = readFileSync(join(serverDirectory, 'src', 'index.ts'), 'utf8'); - const dashboardSource = readFileSync(join(serverDirectory, 'src', 'ui', 'unity-dashboard.html'), 'utf8'); - - expect(unityPackage.version).toBe('1.4.0'); - expect(unityPackage.unity).toBe('2022.3'); - expect(nodePackage.version).toBe('1.4.0'); - expect(lockfile.version).toBe('1.4.0'); - expect(lockfile.packages[''].version).toBe('1.4.0'); - expect(serverSource).toContain('version: "1.4.0"'); - expect(dashboardSource).toContain("appInfo: { name: 'unity-dashboard', version: '1.4.0' }"); - }); - - it('does not keep a registry manifest for an unpublished npm package', () => { - expect(existsSync(join(repositoryDirectory, 'server.json'))).toBe(false); - }); -}); diff --git a/Server~/src/__tests__/unityConnection.test.ts b/Server~/src/__tests__/unityConnection.test.ts deleted file mode 100644 index 7d1d9c60..00000000 --- a/Server~/src/__tests__/unityConnection.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; - -// Mock WebSocket before importing modules that use it -const mockWebSocketInstances: any[] = []; - -const createMockWebSocket = (overrides: Record = {}) => ({ - readyState: 1, - onopen: null, - onclose: null, - onerror: null, - onmessage: null, - send: jest.fn(), - close: jest.fn(), - terminate: jest.fn(), - ping: jest.fn(), - on: jest.fn(), - removeAllListeners: jest.fn(), - ...overrides -}); - -const mockWebSocketConstructor = jest.fn(() => { - const socket = createMockWebSocket(); - mockWebSocketInstances.push(socket); - return socket; -}); - -const mockWebSocketModule = Object.assign(mockWebSocketConstructor, { - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3 -}); - -jest.unstable_mockModule('ws', () => ({ - default: mockWebSocketModule, - WebSocket: mockWebSocketModule -})); - -// Dynamic imports after mocking -const { UnityConnection, ConnectionState } = await import('../unity/unityConnection'); -const { Logger, LogLevel } = await import('../utils/logger'); -const { McpUnityError, ErrorType } = await import('../utils/errors'); - -// Type imports -import type { ConnectionStateChange } from '../unity/unityConnection'; - -// Create a logger that doesn't output anything (for testing) -const createTestLogger = () => { - process.env.LOGGING = 'false'; - process.env.LOGGING_FILE = 'false'; - return new Logger('Test', LogLevel.ERROR); -}; - -describe('UnityConnection', () => { - let connection: InstanceType; - let testLogger: InstanceType; - - beforeEach(() => { - testLogger = createTestLogger(); - mockWebSocketConstructor.mockImplementation(() => { - const socket = createMockWebSocket(); - mockWebSocketInstances.push(socket); - return socket; - }); - mockWebSocketConstructor.mockClear(); - mockWebSocketInstances.length = 0; - - connection = new UnityConnection(testLogger, { - host: 'localhost', - port: 8090, - requestTimeout: 5000, - clientName: 'TestClient', - minReconnectDelay: 100, - maxReconnectDelay: 1000, - heartbeatInterval: 0 - }); - }); - - afterEach(() => { - connection.disconnect(); - jest.clearAllMocks(); - }); - - describe('Initial State', () => { - it('should start in disconnected state', () => { - expect(connection.connectionState).toBe(ConnectionState.Disconnected); - }); - - it('should not be connected initially', () => { - expect(connection.isConnected).toBe(false); - }); - - it('should not be connecting initially', () => { - expect(connection.isConnecting).toBe(false); - }); - - it('should have -1 for timeSinceLastPong before any connection', () => { - expect(connection.timeSinceLastPong).toBe(-1); - }); - }); - - describe('State Change Events', () => { - it('should emit stateChange event when connect is called', (done) => { - let firstEvent = true; - connection.on('stateChange', (change: ConnectionStateChange) => { - // Only check the first state change event - if (firstEvent && change.currentState === ConnectionState.Connecting) { - firstEvent = false; - expect(change.previousState).toBe(ConnectionState.Disconnected); - expect(change.currentState).toBe(ConnectionState.Connecting); - done(); - } - }); - - connection.connect().catch(() => {}); - }); - - it('should include reason in state change', (done) => { - let eventReceived = false; - connection.on('stateChange', (change: ConnectionStateChange) => { - if (!eventReceived && change.currentState === ConnectionState.Connecting) { - eventReceived = true; - expect(change.reason).toBeDefined(); - done(); - } - }); - - connection.connect().catch(() => {}); - }); - }); - - describe('Configuration', () => { - it('should update configuration dynamically', () => { - connection.updateConfig({ heartbeatInterval: 60000 }); - expect(connection.connectionState).toBe(ConnectionState.Disconnected); - }); - }); - - describe('getStats', () => { - it('should return correct stats in initial state', () => { - const stats = connection.getStats(); - expect(stats.state).toBe(ConnectionState.Disconnected); - expect(stats.reconnectAttempt).toBe(0); - expect(stats.timeSinceLastPong).toBe(-1); - expect(stats.isAwaitingPong).toBe(false); - }); - }); - - describe('Disconnect', () => { - it('should set state to disconnected on manual disconnect', () => { - connection.disconnect('Test disconnect'); - expect(connection.connectionState).toBe(ConnectionState.Disconnected); - }); - - it('should emit stateChange event when disconnecting from connecting state', (done) => { - // First start connecting, then disconnect - connection.on('stateChange', (change: ConnectionStateChange) => { - if (change.currentState === ConnectionState.Disconnected && - change.previousState !== ConnectionState.Disconnected) { - done(); - } - }); - - // Start connection then immediately disconnect - connection.connect().catch(() => {}); - // Give time for the connecting state to be set - setTimeout(() => { - connection.disconnect('Test disconnect'); - }, 10); - }); - }); - - describe('Send', () => { - it('should throw error when not connected', () => { - expect(() => connection.send('test')).toThrow(McpUnityError); - }); - }); - - describe('WebSocket options', () => { - it('sends the MCP client name as a header without setting WebSocket origin', async () => { - const connectPromise = connection.connect(); - - expect(mockWebSocketConstructor).toHaveBeenCalledWith( - 'ws://localhost:8090/McpUnity', - { - headers: { - 'X-Client-Name': 'TestClient' - } - } - ); - - const [, options] = mockWebSocketConstructor.mock.calls[0]; - expect(options).not.toHaveProperty('origin'); - - mockWebSocketInstances[0].onopen(); - await connectPromise; - }); - }); - - describe('forceReconnect', () => { - it('should trigger connecting state', () => { - connection.forceReconnect(); - expect(connection.isConnecting).toBe(true); - }); - }); -}); - -describe('ConnectionState Enum', () => { - it('should have correct values', () => { - expect(ConnectionState.Disconnected).toBe('disconnected'); - expect(ConnectionState.Connecting).toBe('connecting'); - expect(ConnectionState.Connected).toBe('connected'); - expect(ConnectionState.Reconnecting).toBe('reconnecting'); - }); -}); - -describe('Exponential Backoff Configuration', () => { - it('should accept backoff configuration', () => { - const testLogger = createTestLogger(); - const connection = new UnityConnection(testLogger, { - host: 'localhost', - port: 8090, - requestTimeout: 5000, - minReconnectDelay: 1000, - maxReconnectDelay: 30000, - reconnectBackoffMultiplier: 2 - }); - - expect(connection.connectionState).toBe(ConnectionState.Disconnected); - connection.disconnect(); - }); -}); - -describe('Connection timeout handling', () => { - beforeEach(() => { - jest.useFakeTimers(); - mockWebSocketConstructor.mockImplementation(() => { - const socket = createMockWebSocket({ readyState: 0 }); - mockWebSocketInstances.push(socket); - return socket; - }); - mockWebSocketConstructor.mockClear(); - mockWebSocketInstances.length = 0; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('uses a dedicated connect timeout instead of the request timeout', async () => { - const testLogger = createTestLogger(); - const connection = new UnityConnection(testLogger, { - host: 'localhost', - port: 8090, - requestTimeout: 60000, - connectTimeout: 250, - minReconnectDelay: 100, - maxReconnectDelay: 1000, - heartbeatInterval: 0 - }); - - const connectPromise = connection.connect(); - const connectResult = expect(connectPromise).rejects.toMatchObject({ - type: ErrorType.CONNECTION, - message: 'Connection timeout' - }); - - await jest.advanceTimersByTimeAsync(250); - - await connectResult; - expect(mockWebSocketConstructor).toHaveBeenCalledTimes(1); - expect(connection.connectionState).toBe(ConnectionState.Reconnecting); - - connection.disconnect(); - }); -}); diff --git a/Server~/src/__tests__/unityDashboardAppResource.test.ts b/Server~/src/__tests__/unityDashboardAppResource.test.ts deleted file mode 100644 index d96a0a96..00000000 --- a/Server~/src/__tests__/unityDashboardAppResource.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { jest, describe, it, expect, beforeEach } from '@jest/globals'; -import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; -import { registerShowUnityDashboardTool } from '../tools/showUnityDashboardTool.js'; -import { - readUnityDashboardHtml, - registerUnityDashboardAppResource -} from '../resources/unityDashboardAppResource.js'; - -const mockLogger = { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn() -}; - -const mockRegisterTool = jest.fn(); -const mockRegisterResource = jest.fn(); -const mockResource = jest.fn(); - -describe('Unity Dashboard MCP App', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('loads the bundled dashboard HTML from a module-relative path', () => { - const result = readUnityDashboardHtml(); - - expect(result.mimeType).toBe(RESOURCE_MIME_TYPE); - expect(result.text).toContain('Unity Dashboard'); - expect(result.text).toContain('set_play_mode_status'); - }); - - it('registers app and legacy dashboard resources', async () => { - registerUnityDashboardAppResource({ - registerResource: mockRegisterResource, - resource: mockResource - } as any, mockLogger as any); - - expect(mockRegisterResource).toHaveBeenCalledWith( - 'unity_dashboard_app', - 'ui://unity-dashboard', - expect.objectContaining({ - description: expect.stringContaining('Unity dashboard') - }), - expect.any(Function) - ); - expect(mockResource).toHaveBeenCalledWith( - 'unity_dashboard_app_legacy', - 'unity://ui/dashboard', - expect.objectContaining({ - mimeType: RESOURCE_MIME_TYPE - }), - expect.any(Function) - ); - - const appRead = mockRegisterResource.mock.calls[0][3] as () => Promise; - const legacyRead = mockResource.mock.calls[0][3] as () => Promise; - - await expect(appRead()).resolves.toMatchObject({ - contents: [ - expect.objectContaining({ - uri: 'ui://unity-dashboard', - mimeType: RESOURCE_MIME_TYPE - }) - ] - }); - await expect(legacyRead()).resolves.toMatchObject({ - contents: [ - expect.objectContaining({ - uri: 'unity://ui/dashboard', - mimeType: RESOURCE_MIME_TYPE - }) - ] - }); - }); - - it('registers the show dashboard app tool with normalized UI metadata', async () => { - registerShowUnityDashboardTool({ - registerTool: mockRegisterTool - } as any, mockLogger as any); - - expect(mockRegisterTool).toHaveBeenCalledWith( - 'show_unity_dashboard', - expect.objectContaining({ - description: expect.stringContaining('Unity dashboard'), - _meta: expect.objectContaining({ - ui: expect.objectContaining({ resourceUri: 'ui://unity-dashboard' }), - 'ui/resourceUri': 'ui://unity-dashboard' - }) - }), - expect.any(Function) - ); - - const handler = mockRegisterTool.mock.calls[0][2] as () => Promise; - const result = await handler(); - - expect(result.content[0]).toMatchObject({ - type: 'resource', - resource: { - uri: 'ui://unity-dashboard', - mimeType: RESOURCE_MIME_TYPE - } - }); - expect(result._meta.ui).toEqual({ - resourceUri: 'ui://unity-dashboard', - title: 'Unity Dashboard' - }); - }); -}); diff --git a/Server~/src/cli/companionCli.ts b/Server~/src/cli/companionCli.ts new file mode 100644 index 00000000..293c2f75 --- /dev/null +++ b/Server~/src/cli/companionCli.ts @@ -0,0 +1,213 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +export const CLI_DOCUMENTATION_URL = + 'https://docs.unity.com/en-us/unity-cli/use-unity-cli'; + +export interface CompanionArguments { + projectPath: string; + unityCliPath?: string; +} + +type PathValidator = (candidate: string) => boolean; + +export function parseCompanionArguments( + argv: readonly string[], + isUnityProject: PathValidator = defaultUnityProjectValidator, +): CompanionArguments { + let projectPath: string | undefined; + let unityCliPath: string | undefined; + + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if (flag !== '--project-path' && flag !== '--unity-cli-path') { + throw new Error(`Unknown argument: ${flag ?? ''}`); + } + if (!value || value.startsWith('--')) { + throw new Error(`${flag} requires a value.`); + } + + if (flag === '--project-path') { + if (projectPath !== undefined) { + throw new Error('Duplicate --project-path argument.'); + } + projectPath = value; + } else { + if (unityCliPath !== undefined) { + throw new Error('Duplicate --unity-cli-path argument.'); + } + unityCliPath = value; + } + } + + if (!projectPath) { + throw new Error('--project-path is required.'); + } + if (!path.isAbsolute(projectPath)) { + throw new Error('--project-path must be absolute.'); + } + if (!isUnityProject(projectPath)) { + throw new Error(`--project-path must identify an existing Unity project: ${projectPath}`); + } + + return { projectPath: path.resolve(projectPath), unityCliPath }; +} + +export function resolveUnityCliPath( + explicitPath: string | undefined, + environment: NodeJS.ProcessEnv = process.env, +): string { + return explicitPath || environment.UNITY_CLI_PATH || 'unity'; +} + +export interface VersionCommandResult { + stdout: string; + stderr: string; +} + +export type VersionRunner = ( + command: string, + args: readonly string[], +) => Promise; + +export interface CheckedUnityCli { + command: string; + version: string; + warning?: string; +} + +export async function checkUnityCli( + command: string, + runVersion: VersionRunner = defaultVersionRunner, +): Promise { + let output: VersionCommandResult; + try { + output = await runVersion(command, ['--version']); + } catch (error) { + throw actionableCliError( + `Unity CLI could not be started at "${command}": ${errorMessage(error)}`, + ); + } + + const version = parseVersion(`${output.stdout}\n${output.stderr}`); + if (!version) { + throw actionableCliError( + `Unity CLI returned an unrecognized version from "${command}".`, + ); + } + + if (compareVersion(version, MINIMUM_VERSION) < 0) { + throw actionableCliError( + `Unity CLI ${version.raw} is incompatible; version ${MINIMUM_VERSION.raw} or newer is required.`, + ); + } + + return { + command, + version: version.raw, + warning: + version.major > 1n + ? `Unity CLI ${version.raw} is newer than the tested major version 1.` + : undefined, + }; +} + +interface ParsedVersion { + raw: string; + major: bigint; + minor: bigint; + patch: bigint; + prerelease?: { + label: 'alpha' | 'beta' | 'rc'; + number: bigint; + }; +} + +const VERSION_PATTERN = + /(?:^|[^0-9])([0-9]+)\.([0-9]+)\.([0-9]+)(?:-(alpha|beta|rc)\.([0-9]+))?(?:\+[0-9A-Za-z.-]+)?(?:$|[^0-9A-Za-z.+-])/i; + +const MINIMUM_VERSION: ParsedVersion = { + raw: '1.0.0-beta.2', + major: 1n, + minor: 0n, + patch: 0n, + prerelease: { label: 'beta', number: 2n }, +}; + +function parseVersion(output: string): ParsedVersion | undefined { + const match = VERSION_PATTERN.exec(output); + if (!match) { + return undefined; + } + + const prereleaseLabel = match[4]?.toLowerCase() as + | 'alpha' + | 'beta' + | 'rc' + | undefined; + const raw = `${match[1]}.${match[2]}.${match[3]}${ + prereleaseLabel ? `-${prereleaseLabel}.${match[5]}` : '' + }`; + return { + raw, + major: BigInt(match[1]), + minor: BigInt(match[2]), + patch: BigInt(match[3]), + prerelease: prereleaseLabel + ? { label: prereleaseLabel, number: BigInt(match[5]) } + : undefined, + }; +} + +function compareVersion(left: ParsedVersion, right: ParsedVersion): number { + for (const key of ['major', 'minor', 'patch'] as const) { + if (left[key] !== right[key]) { + return left[key] > right[key] ? 1 : -1; + } + } + + if (!left.prerelease && !right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + + const order = { alpha: 0, beta: 1, rc: 2 } as const; + if (left.prerelease.label !== right.prerelease.label) { + return order[left.prerelease.label] > order[right.prerelease.label] ? 1 : -1; + } + if (left.prerelease.number === right.prerelease.number) return 0; + return left.prerelease.number > right.prerelease.number ? 1 : -1; +} + +function defaultUnityProjectValidator(candidate: string): boolean { + try { + return ( + fs.statSync(candidate).isDirectory() && + fs.statSync(path.join(candidate, 'Assets')).isDirectory() && + fs.statSync(path.join(candidate, 'ProjectSettings')).isDirectory() + ); + } catch { + return false; + } +} + +async function defaultVersionRunner( + command: string, + args: readonly string[], +): Promise { + const result = await promisify(execFile)(command, [...args], { + encoding: 'utf8', + timeout: 10_000, + }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +function actionableCliError(message: string): Error { + return new Error(`${message} Install or update Unity CLI: ${CLI_DOCUMENTATION_URL}`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/Server~/src/companionEntrypoint.ts b/Server~/src/companionEntrypoint.ts new file mode 100644 index 00000000..d272eb15 --- /dev/null +++ b/Server~/src/companionEntrypoint.ts @@ -0,0 +1,72 @@ +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + checkUnityCli, + type CheckedUnityCli, + parseCompanionArguments, + resolveUnityCliPath, +} from './cli/companionCli.js'; +import { + installShutdownHandlers, + type EventSource, +} from './companionLifecycle.js'; +import { createCompanionServer } from './companionServer.js'; +import { CompanionResourceService } from './resources/companionResources.js'; +import { OfficialUnityMcpClient } from './unity/officialUnityMcpClient.js'; + +export interface CompanionEntrypointOptions { + argv: readonly string[]; + environment: NodeJS.ProcessEnv; + isUnityProject?: (candidate: string) => boolean; + checkCli?: (command: string) => Promise; + transport: Transport; + signals: EventSource; + stdin: EventSource; + stderr: { write(text: string): unknown }; +} + +export interface CompanionRuntime { + officialClient: OfficialUnityMcpClient; + shutdown(): Promise; +} + +export async function startCompanion( + options: CompanionEntrypointOptions, +): Promise { + const args = parseCompanionArguments(options.argv, options.isUnityProject); + const cliPath = resolveUnityCliPath(args.unityCliPath, options.environment); + const checked = await (options.checkCli ?? checkUnityCli)(cliPath); + if (checked.warning) { + options.stderr.write(`Warning: ${checked.warning}\n`); + } + + const officialClient = new OfficialUnityMcpClient({ + cliPath: checked.command, + projectPath: args.projectPath, + }); + const server = createCompanionServer( + new CompanionResourceService(officialClient), + ); + await server.connect(options.transport); + + const handlers = installShutdownHandlers({ + signals: options.signals, + stdin: options.stdin, + closeOfficialClient: () => officialClient.close(), + closeServer: () => server.close(), + onError: (error) => { + options.stderr.write(`Shutdown error: ${errorMessage(error)}\n`); + }, + }); + + return { + officialClient, + shutdown: async () => { + await handlers.shutdown(); + handlers.dispose(); + }, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/Server~/src/companionLifecycle.ts b/Server~/src/companionLifecycle.ts new file mode 100644 index 00000000..eaee8f79 --- /dev/null +++ b/Server~/src/companionLifecycle.ts @@ -0,0 +1,59 @@ +export interface EventSource { + on(event: string, listener: () => void): unknown; + off(event: string, listener: () => void): unknown; +} + +export interface ShutdownHandlerOptions { + signals: EventSource; + stdin: EventSource; + closeOfficialClient(): Promise; + closeServer(): Promise; + onError?(error: unknown): void; +} + +export interface ShutdownHandlers { + shutdown(): Promise; + dispose(): void; +} + +export function installShutdownHandlers( + options: ShutdownHandlerOptions, +): ShutdownHandlers { + let shutdownPromise: Promise | undefined; + + const shutdown = (): Promise => { + shutdownPromise ??= (async () => { + const results = await Promise.allSettled([ + options.closeOfficialClient(), + options.closeServer(), + ]); + for (const result of results) { + if (result.status === 'rejected') { + options.onError?.(result.reason); + } + } + })(); + return shutdownPromise; + }; + const trigger = (): void => { + void shutdown(); + }; + const bindings: Array<[EventSource, string]> = [ + [options.signals, 'SIGINT'], + [options.signals, 'SIGTERM'], + [options.stdin, 'close'], + [options.stdin, 'end'], + ]; + for (const [source, event] of bindings) { + source.on(event, trigger); + } + + return { + shutdown, + dispose: () => { + for (const [source, event] of bindings) { + source.off(event, trigger); + } + }, + }; +} diff --git a/Server~/src/companionServer.ts b/Server~/src/companionServer.ts new file mode 100644 index 00000000..d949d8b4 --- /dev/null +++ b/Server~/src/companionServer.ts @@ -0,0 +1,118 @@ +import { registerAppResource, registerAppTool } from '@modelcontextprotocol/ext-apps/server'; +import { + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerCompanionPrompts } from './prompts/companionPrompts.js'; +import type { CompanionResourceService } from './resources/companionResources.js'; +import { + DASHBOARD_URI, + readDashboardHtml, +} from './resources/dashboardResource.js'; + +const RESOURCE_TEMPLATES = [ + { + name: 'unity_logs', + template: 'unity://logs{?severity,limit}', + description: 'Recent Unity Editor logs.', + }, + { + name: 'unity_scenes_hierarchy', + template: 'unity://scenes-hierarchy{?path,max_nodes}', + description: 'Bounded hierarchy of an open Unity scene.', + }, + { + name: 'unity_gameobject', + template: 'unity://gameobject/{target}', + description: 'Bounded GameObject inspection.', + }, + { + name: 'unity_packages', + template: 'unity://packages{?include_indirect}', + description: 'Installed Unity packages.', + }, + { + name: 'unity_tests', + template: 'unity://tests/{mode}', + description: 'Available Unity tests.', + }, +] as const; + +export function createCompanionServer( + resources: CompanionResourceService, +): McpServer { + const server = new McpServer( + { name: 'MCP Unity Companion', version: '2.0.0' }, + { capabilities: { tools: {}, resources: {}, prompts: {} } }, + ); + + registerAppTool( + server, + 'show_unity_dashboard', + { + description: 'Open the read-only Unity CLI and Pipeline dashboard.', + annotations: { readOnlyHint: true }, + _meta: { + ui: { + resourceUri: DASHBOARD_URI, + }, + }, + }, + async () => ({ + content: [ + { + type: 'text', + text: 'Unity dashboard opened. Its views are read-only.', + }, + ], + }), + ); + + registerAppResource( + server, + 'unity_dashboard', + DASHBOARD_URI, + { + description: 'Read-only Unity CLI and Pipeline dashboard.', + _meta: { ui: { prefersBorder: true } }, + }, + async () => { + const dashboard = readDashboardHtml(); + return { + contents: [ + { + uri: DASHBOARD_URI, + mimeType: dashboard.mimeType, + text: dashboard.text, + }, + ], + }; + }, + ); + + for (const definition of RESOURCE_TEMPLATES) { + server.registerResource( + definition.name, + new ResourceTemplate(definition.template, { list: undefined }), + { + description: definition.description, + mimeType: 'application/json', + }, + async (uri) => { + const result = await resources.read(uri.toString()); + return { + contents: [ + { + uri: result.uri, + mimeType: 'application/json', + text: JSON.stringify(result.payload), + }, + ], + }; + }, + ); + } + + registerCompanionPrompts(server); + return server; +} diff --git a/Server~/src/index.ts b/Server~/src/index.ts index 3770e538..aed57991 100644 --- a/Server~/src/index.ts +++ b/Server~/src/index.ts @@ -1,186 +1,21 @@ -// Import MCP SDK components -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +#!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { McpUnity } from './unity/mcpUnity.js'; -import { Logger, LogLevel } from './utils/logger.js'; -import { registerCreateSceneTool } from './tools/createSceneTool.js'; -import { registerMenuItemTool } from './tools/menuItemTool.js'; -import { registerSelectGameObjectTool } from './tools/selectGameObjectTool.js'; -import { registerAddPackageTool } from './tools/addPackageTool.js'; -import { registerRunTestsTool } from './tools/runTestsTool.js'; -import { registerSendConsoleLogTool } from './tools/sendConsoleLogTool.js'; -import { registerGetConsoleLogsTool } from './tools/getConsoleLogsTool.js'; -import { registerUpdateComponentTool } from './tools/updateComponentTool.js'; -import { registerAddAssetToSceneTool } from './tools/addAssetToSceneTool.js'; -import { registerUpdateGameObjectTool } from './tools/updateGameObjectTool.js'; -import { registerCreatePrefabTool } from './tools/createPrefabTool.js'; -import { registerDeleteSceneTool } from './tools/deleteSceneTool.js'; -import { registerLoadSceneTool } from './tools/loadSceneTool.js'; -import { registerSaveSceneTool } from './tools/saveSceneTool.js'; -import { registerGetSceneInfoTool } from './tools/getSceneInfoTool.js'; -import { registerGetPlayModeStatusTool } from './tools/getPlayModeStatusTool.js'; -import { registerSetPlayModeStatusTool } from './tools/setPlayModeStatusTool.js'; -import { registerUnloadSceneTool } from './tools/unloadSceneTool.js'; -import { registerRecompileScriptsTool } from './tools/recompileScriptsTool.js'; -import { registerGetGameObjectTool } from './tools/getGameObjectTool.js'; -import { registerTransformTools } from './tools/transformTools.js'; -import { registerCreateMaterialTool, registerAssignMaterialTool, registerModifyMaterialTool, registerGetMaterialInfoTool } from './tools/materialTools.js'; -import { registerDuplicateGameObjectTool, registerDeleteGameObjectTool, registerReparentGameObjectTool } from './tools/gameObjectTools.js'; -import { registerBatchExecuteTool } from './tools/batchExecuteTool.js'; -import { registerShowUnityDashboardTool } from './tools/showUnityDashboardTool.js'; -import { registerGetScenesHierarchyTool } from './tools/getScenesHierarchyTool.js'; -import { registerGetMenuItemsResource } from './resources/getMenuItemResource.js'; -import { registerGetConsoleLogsResource } from './resources/getConsoleLogsResource.js'; -import { registerGetHierarchyResource } from './resources/getScenesHierarchyResource.js'; -import { registerGetPackagesResource } from './resources/getPackagesResource.js'; -import { registerGetAssetsResource } from './resources/getAssetsResource.js'; -import { registerGetTestsResource } from './resources/getTestsResource.js'; -import { registerGetGameObjectResource } from './resources/getGameObjectResource.js'; -import { registerUnityDashboardAppResource } from './resources/unityDashboardAppResource.js'; -import { registerGameObjectHandlingPrompt } from './prompts/gameobjectHandlingPrompt.js'; -import { registerUnityDashboardPrompt } from './prompts/unityDashboardPrompt.js'; - -// Initialize loggers -const serverLogger = new Logger('Server', LogLevel.INFO); -const unityLogger = new Logger('Unity', LogLevel.INFO); -const toolLogger = new Logger('Tools', LogLevel.INFO); -const resourceLogger = new Logger('Resources', LogLevel.INFO); - -// Initialize the MCP server -const server = new McpServer ( - { - name: "MCP Unity Server", - version: "1.4.0" - }, - { - capabilities: { - tools: {}, - resources: {}, - prompts: {}, - }, - } -); - -// Initialize MCP HTTP bridge with Unity editor -const mcpUnity = new McpUnity(unityLogger); - -// Register all tools into the MCP server -registerMenuItemTool(server, mcpUnity, toolLogger); -registerSelectGameObjectTool(server, mcpUnity, toolLogger); -registerAddPackageTool(server, mcpUnity, toolLogger); -registerRunTestsTool(server, mcpUnity, toolLogger); -registerSendConsoleLogTool(server, mcpUnity, toolLogger); -registerGetConsoleLogsTool(server, mcpUnity, toolLogger); -registerUpdateComponentTool(server, mcpUnity, toolLogger); -registerAddAssetToSceneTool(server, mcpUnity, toolLogger); -registerUpdateGameObjectTool(server, mcpUnity, toolLogger); -registerCreatePrefabTool(server, mcpUnity, toolLogger); -registerCreateSceneTool(server, mcpUnity, toolLogger); -registerDeleteSceneTool(server, mcpUnity, toolLogger); -registerLoadSceneTool(server, mcpUnity, toolLogger); -registerSaveSceneTool(server, mcpUnity, toolLogger); -registerGetSceneInfoTool(server, mcpUnity, toolLogger); -registerGetPlayModeStatusTool(server, mcpUnity, toolLogger); -registerSetPlayModeStatusTool(server, mcpUnity, toolLogger); -registerShowUnityDashboardTool(server, toolLogger); -registerGetScenesHierarchyTool(server, mcpUnity, toolLogger); -registerUnloadSceneTool(server, mcpUnity, toolLogger); -registerRecompileScriptsTool(server, mcpUnity, toolLogger); -registerGetGameObjectTool(server, mcpUnity, toolLogger); -registerTransformTools(server, mcpUnity, toolLogger); -registerDuplicateGameObjectTool(server, mcpUnity, toolLogger); -registerDeleteGameObjectTool(server, mcpUnity, toolLogger); -registerReparentGameObjectTool(server, mcpUnity, toolLogger); - -// Register Material Tools -registerCreateMaterialTool(server, mcpUnity, toolLogger); -registerAssignMaterialTool(server, mcpUnity, toolLogger); -registerModifyMaterialTool(server, mcpUnity, toolLogger); -registerGetMaterialInfoTool(server, mcpUnity, toolLogger); - -// Register Batch Execute Tool (high-priority for performance) -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); - -// Register all prompts into the MCP server -registerGameObjectHandlingPrompt(server); -registerUnityDashboardPrompt(server); - -// Server startup function -async function startServer() { - try { - // Initialize STDIO transport for MCP client communication - const stdioTransport = new StdioServerTransport(); - - // Connect the server to the transport - await server.connect(stdioTransport); - - serverLogger.info('MCP Server started'); - - // Get the client name from the MCP server - const clientName = server.server.getClientVersion()?.name || 'Unknown MCP Client'; - serverLogger.info(`Connected MCP client: ${clientName}`); - - // Start Unity Bridge connection with client name in headers - await mcpUnity.start(clientName); - - } catch (error) { - serverLogger.error('Failed to start server', error); - process.exit(1); - } +import { startCompanion } from './companionEntrypoint.js'; + +try { + await startCompanion({ + argv: process.argv.slice(2), + environment: process.env, + transport: new StdioServerTransport(), + signals: process, + stdin: process.stdin, + stderr: process.stderr, + }); +} catch (error) { + process.stderr.write( + `MCP Unity Companion could not start: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; } - -// Graceful shutdown handler -let isShuttingDown = false; -async function shutdown() { - if (isShuttingDown) return; - isShuttingDown = true; - - try { - serverLogger.info('Shutting down...'); - await mcpUnity.stop(); - await server.close(); - } catch (error) { - // Ignore errors during shutdown - } - process.exit(0); -} - -// Start the server -startServer(); - -// Handle shutdown signals -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); -process.on('SIGHUP', shutdown); - -// Handle stdin close (when MCP client disconnects) -process.stdin.on('close', shutdown); -process.stdin.on('end', shutdown); -process.stdin.on('error', shutdown); - -// Handle uncaught exceptions - exit cleanly if it's just a closed pipe -process.on('uncaughtException', (error: NodeJS.ErrnoException) => { - // EPIPE/EOF errors are expected when the MCP client disconnects - if (error.code === 'EPIPE' || error.code === 'EOF' || error.code === 'ERR_USE_AFTER_CLOSE') { - shutdown(); - return; - } - serverLogger.error('Uncaught exception', error); - process.exit(1); -}); - -// Handle unhandled promise rejections -process.on('unhandledRejection', (reason) => { - serverLogger.error('Unhandled rejection', reason); - process.exit(1); -}); diff --git a/Server~/src/prompts/companionPrompts.ts b/Server~/src/prompts/companionPrompts.ts new file mode 100644 index 00000000..3a68592a --- /dev/null +++ b/Server~/src/prompts/companionPrompts.ts @@ -0,0 +1,45 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const GAMEOBJECT_STRATEGY = `Use Pipeline read commands before mutations: +1. Read get_scene_hierarchy or find_gameobjects to obtain a stable target. +2. Use inspect_gameobject for bounded component and serialized-property inspection. +3. Use official Pipeline authoring commands for standard changes. +4. Use only the MCP Unity extensions when needed: duplicate_gameobject, unload_scene, editor_step, and assign_material. +5. Re-read get_scene_hierarchy or inspect_gameobject to verify the result.`; + +const DASHBOARD_GUIDE = `Open show_unity_dashboard for a read-only project overview. +The companion resources map to official Pipeline commands: get_console_logs, get_scene_hierarchy, package_list, and list_tests. +GameObject details use inspect_gameobject. The other MCP Unity extensions are duplicate_gameobject, unload_scene, editor_step, and assign_material. +The dashboard never invokes mutations; execute any authoring command explicitly through Unity CLI/Pipeline.`; + +export function registerCompanionPrompts(server: McpServer): void { + server.registerPrompt( + 'gameobject_handling_strategy', + { + description: 'A safe discovery, targeting, mutation, and verification workflow.', + }, + async () => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: GAMEOBJECT_STRATEGY }, + }, + ], + }), + ); + + server.registerPrompt( + 'unity_dashboard', + { + description: 'Guidance for the read-only Unity dashboard and Pipeline commands.', + }, + async () => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: DASHBOARD_GUIDE }, + }, + ], + }), + ); +} diff --git a/Server~/src/prompts/gameobjectHandlingPrompt.ts b/Server~/src/prompts/gameobjectHandlingPrompt.ts deleted file mode 100644 index 3079e2c6..00000000 --- a/Server~/src/prompts/gameobjectHandlingPrompt.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import * as z from "zod"; - -/** - * Registers the gameobject handling prompt with the MCP server. - * This prompt defines the proper workflow for handling GameObjects within the Unity Editor. - * - * @param server The McpServer instance to register the prompt with. - */ -export function registerGameObjectHandlingPrompt(server: McpServer) { - server.prompt( - 'gameobject_handling_strategy', - 'Defines the proper workflow for handling gameobjects in Unity', - { - gameObjectIdOrName: z.string().describe("The resource to identify the GameObject intended to handle. It can be the **instance ID**, the **name** or the **path** to the GameObject."), - }, - async ({ gameObjectIdOrName }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `You are an expert AI assistant integrated with Unity via a MCP server. - -When working directly with GameObjects or any of their components in Unity scenes, you have access to the following resources and tools: -- Resource "get_scenes_hierarchy" (unity://scenes_hierarchy) to list all GameObjects. -- Resource "get_gameobject" (unity://gameobject/{idOrName}) to fetch detailed GameObject info, with the *idOrName* being either the **instance ID**, the **name** or the **path** to the GameObject. -- Tool "select_gameobject" to select a GameObject by **instance ID**, the **name** or the **path** of the GameObject. -- Tool "update_gameobject" to update a GameObject's core properties (name, tag, layer, active state, static state), or create the GameObject if it does not exist. -- Tool "update_component" to update or add a component on a GameObject, including common frequently used components (e.g. Transform, RectTransform, BoxCollider, Rigidbody, etc). -- Tool "create_prefab" to create a prefab from a GameObject in the scene with optional MonoBehaviour script and serialized field values. - -Workflow: -1. Use "get_scenes_hierarchy" to confirm the GameObject ID, name or path for "${gameObjectIdOrName}". -2. If you need to update the GameObject's core properties (name, tag, layer, active state, static state), or create the GameObject if it does not exist, use "update_gameobject". -3. To focus the Unity Editor on the target GameObject, invoke "select_gameobject". -4. Optionally, use "unity://gameobject/${gameObjectIdOrName}" to retrieve detailed properties. -5. To update or add a component on the GameObject, use "update_component". -6. Confirm success and report any errors. - -Guidance: -- Use "update_gameobject" for creating GameObjects in the scene or to change a GameObject's core properties. -- Use "update_component" for adding or modifying components on an existing GameObject in the scene. -- Use "create_prefab" for creating prefabs from GameObjects in the scene. -- Component Scripts must be compiled in the Unity project before using "update_component" or "create_prefab". -- Always validate inputs and request clarification if the identifier is ambiguous.` - } - }, - { - role: 'user', - content: { - type: 'text', - text: `Handle GameObject "${gameObjectIdOrName}" through the above workflow.` - } - } - ] - }) - ); -} diff --git a/Server~/src/prompts/unityDashboardPrompt.ts b/Server~/src/prompts/unityDashboardPrompt.ts deleted file mode 100644 index 62c029a3..00000000 --- a/Server~/src/prompts/unityDashboardPrompt.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - -/** - * Registers the Unity dashboard prompt with the MCP server. - * This prompt provides an easy way to open and interact with the Unity dashboard MCP app. - * - * @param server The McpServer instance to register the prompt with. - */ -export function registerUnityDashboardPrompt(server: McpServer) { - server.prompt( - 'unity_dashboard', - 'Opens the Unity dashboard MCP app in VS Code with real-time Unity Editor information', - {}, - async () => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `You are an expert AI assistant integrated with Unity via an MCP server. - -The Unity Dashboard is a powerful MCP app that provides real-time access to Unity Editor information through an interactive interface in VS Code. - -Dashboard Features: -- **Play Mode Controls**: Start, pause, stop, and step through Play Mode -- **Scene Hierarchy**: Browse and interact with GameObjects in your scene -- **Console Logs**: View Unity console messages (info, warnings, errors) -- **Package Manager**: List installed packages and their versions -- **Scene Management**: View loaded scenes and their status - -To open the Unity Dashboard: -Use the "show_unity_dashboard" tool to launch the dashboard app in VS Code. - -Requirements: -- VS Code 1.109 or later (MCP Apps support) -- Unity Editor with MCP Unity server running and connected - -Usage Scenarios: -- Monitor Unity Editor state while working with AI -- Quick access to scene hierarchy and GameObject information -- Debug console logs without switching to Unity -- Control Play Mode from within your coding environment -- View package dependencies at a glance - -Once opened, the dashboard remains available in your VS Code editor tabs and updates in real-time as changes occur in Unity.` - } - }, - { - role: 'user', - content: { - type: 'text', - text: `Open the Unity dashboard app now.` - } - } - ] - }) - ); -} diff --git a/Server~/src/resources/companionResources.ts b/Server~/src/resources/companionResources.ts new file mode 100644 index 00000000..7755c217 --- /dev/null +++ b/Server~/src/resources/companionResources.ts @@ -0,0 +1,265 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export interface UnityReadClient { + readTool( + name: string, + args: Record, + ): Promise; +} + +export interface CompanionResourcePayload { + uri: string; + payload: Record; +} + +const LOG_SEVERITIES = new Set(['all', 'log', 'warning', 'error']); +const TEST_MODES = new Set(['all', 'editor', 'playmode']); + +export class CompanionResourceService { + constructor(private readonly client: UnityReadClient) {} + + async read(uri: string): Promise { + const parsed = parseResourceUri(uri); + switch (parsed.hostname) { + case 'logs': + return this.call(uri, 'get_console_logs', { + severity: parseEnum( + parsed.searchParams.get('severity') ?? 'all', + LOG_SEVERITIES, + 'severity', + ), + limit: parseBoundedInteger( + parsed.searchParams.get('limit'), + 100, + 1, + 1000, + 'limit', + ), + }); + case 'scenes-hierarchy': { + const maxNodes = parseBoundedInteger( + parsed.searchParams.get('max_nodes'), + 500, + 1, + 2000, + 'max_nodes', + ); + const path = parsed.searchParams.get('path'); + const args = path ? { path } : {}; + const result = await this.call(uri, 'get_scene_hierarchy', args); + return { + uri, + payload: truncateHierarchy(result.payload, maxNodes), + }; + } + case 'gameobject': { + const target = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')); + if (!target) { + throw new Error('unity://gameobject/{target} requires a target.'); + } + return this.call(uri, 'inspect_gameobject', { + target, + max_depth: 2, + max_nodes: 200, + include_components: true, + include_properties: true, + max_properties_per_component: 100, + }); + } + case 'packages': + return this.call(uri, 'package_list', { + scope: 'installed', + include_indirect: parseBoolean( + parsed.searchParams.get('include_indirect'), + true, + 'include_indirect', + ), + }); + case 'tests': { + const mode = decodeURIComponent(parsed.pathname.replace(/^\/+/, '')); + parseEnum(mode, TEST_MODES, 'mode'); + return this.call(uri, 'list_tests', { mode }); + } + default: + throw new Error(`Unknown companion resource: ${uri}`); + } + } + + private async call( + uri: string, + command: string, + args: Record, + ): Promise { + let result: CallToolResult; + try { + result = await this.client.readTool(command, args); + } catch (error) { + throw new Error(`${command} failed: ${errorMessage(error)}`); + } + return { + uri, + payload: decodeToolPayload(command, result), + }; + } +} + +export function decodeToolPayload( + command: string, + result: CallToolResult, +): Record { + if (result.isError) { + const detail = firstText(result) ?? 'Unity command returned an error.'; + throw new Error(`${command} failed: ${detail}`); + } + + if (isRecord(result.structuredContent)) { + return result.structuredContent; + } + + const text = firstText(result); + if (text === undefined) { + throw new Error(`${command} returned no JSON payload.`); + } + try { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) { + throw new Error('payload is not a JSON object'); + } + return parsed; + } catch (error) { + throw new Error(`${command} returned malformed JSON: ${errorMessage(error)}`); + } +} + +function parseResourceUri(uri: string): URL { + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + throw new Error(`Invalid companion resource URI: ${uri}`); + } + if (parsed.protocol !== 'unity:') { + throw new Error(`Unknown companion resource: ${uri}`); + } + return parsed; +} + +function parseBoundedInteger( + value: string | null, + fallback: number, + minimum: number, + maximum: number, + name: string, +): number { + if (value === null || value === '') return fallback; + if (!/^-?[0-9]+$/.test(value)) { + throw new Error(`${name} must be an integer.`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + return value.startsWith('-') ? minimum : maximum; + } + return Math.min(maximum, Math.max(minimum, parsed)); +} + +function parseBoolean( + value: string | null, + fallback: boolean, + name: string, +): boolean { + if (value === null || value === '') return fallback; + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error(`${name} must be true or false.`); +} + +function parseEnum( + value: string, + allowed: ReadonlySet, + name: string, +): string { + if (!allowed.has(value)) { + throw new Error(`${name} must be one of: ${[...allowed].join(', ')}.`); + } + return value; +} + +function firstText(result: CallToolResult): string | undefined { + const item = result.content.find( + (content): content is Extract<(typeof result.content)[number], { type: 'text' }> => + content.type === 'text', + ); + return item?.text; +} + +function truncateHierarchy( + hierarchy: Record, + maxNodes: number, +): Record { + const sourceRoots = Array.isArray(hierarchy.roots) ? hierarchy.roots : []; + const totalNodes = sourceRoots.reduce( + (total, root) => total + countHierarchyNodes(root), + 0, + ); + let returnedNodes = 0; + + const cloneNode = (source: unknown): Record | undefined => { + if (!isRecord(source) || returnedNodes >= maxNodes) return undefined; + returnedNodes++; + + const sourceChildren = Array.isArray(source.children) ? source.children : []; + const children: Record[] = []; + let omittedDescendants = 0; + for (let index = 0; index < sourceChildren.length; index++) { + if (returnedNodes >= maxNodes) { + omittedDescendants += sourceChildren + .slice(index) + .reduce((total, child) => total + countHierarchyNodes(child), 0); + break; + } + const child = cloneNode(sourceChildren[index]); + if (child) children.push(child); + } + + return { + ...source, + children, + childrenTruncated: + source.childrenTruncated === true || omittedDescendants > 0, + ...(omittedDescendants > 0 ? { omittedDescendants } : {}), + }; + }; + + const roots: Record[] = []; + for (const root of sourceRoots) { + const cloned = cloneNode(root); + if (!cloned) break; + roots.push(cloned); + } + + return { + ...hierarchy, + roots, + truncation: { + truncated: returnedNodes < totalNodes, + maxNodes, + returnedNodes, + totalNodes, + omittedNodes: totalNodes - returnedNodes, + }, + }; +} + +function countHierarchyNodes(value: unknown): number { + if (!isRecord(value)) return 0; + const children = Array.isArray(value.children) ? value.children : []; + return 1 + children.reduce((total, child) => total + countHierarchyNodes(child), 0); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/Server~/src/resources/dashboardResource.ts b/Server~/src/resources/dashboardResource.ts new file mode 100644 index 00000000..0a66e8dd --- /dev/null +++ b/Server~/src/resources/dashboardResource.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; + +export const DASHBOARD_URI = 'ui://unity-dashboard'; + +export function readDashboardHtml(): { text: string; mimeType: string } { + const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDirectory, '..', 'ui', 'unity-dashboard.html'), + path.join(moduleDirectory, '..', '..', 'src', 'ui', 'unity-dashboard.html'), + ]; + const dashboardPath = candidates.find((candidate) => fs.existsSync(candidate)); + if (!dashboardPath) { + throw new Error(`Unity dashboard HTML is missing. Checked: ${candidates.join(', ')}`); + } + return { + text: fs.readFileSync(dashboardPath, 'utf8'), + mimeType: RESOURCE_MIME_TYPE, + }; +} diff --git a/Server~/src/resources/getAssetsResource.ts b/Server~/src/resources/getAssetsResource.ts deleted file mode 100644 index 614dd875..00000000 --- a/Server~/src/resources/getAssetsResource.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the resource -const resourceName = 'get_assets'; -const resourceUri = 'unity://assets'; -const resourceMimeType = 'application/json'; - -/** - * Creates and registers the Assets resource with the MCP server - * This resource provides access to assets in the Unity project - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetAssetsResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceUri, - { - description: 'Retrieve assets from the Unity Asset Database', - mimeType: resourceMimeType - }, - async () => { - try { - return await resourceHandler(mcpUnity); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for asset information from Unity's Asset Database - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @returns A promise that resolves to the assets data - * @throws McpUnityError if the request to Unity fails - */ -async function resourceHandler(mcpUnity: McpUnity): Promise { - // Since we're using a non-templated ResourceDefinition, we need to handle all assets without parameters - const response = await mcpUnity.sendRequest({ - method: resourceName, - params: {} - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.RESOURCE_FETCH, - response.message || 'Failed to fetch assets from Unity Asset Database' - ); - } - - // Transform the data into a structured format - const assets = response.assets || []; - - const assetsData = { - assets: assets.map((asset: any) => ({ - name: asset.name, - filename: asset.filename, - path: asset.path, - type: asset.type, - extension: asset.extension, - guid: asset.guid, - size: asset.size - })) - }; - - return { - contents: [ - { - uri: resourceUri, - mimeType: resourceMimeType, - text: JSON.stringify(assetsData, null, 2) - } - ] - }; -} diff --git a/Server~/src/resources/getConsoleLogsResource.ts b/Server~/src/resources/getConsoleLogsResource.ts deleted file mode 100644 index 9cbd019d..00000000 --- a/Server~/src/resources/getConsoleLogsResource.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 { McpUnityError, ErrorType } from '../utils/errors.js'; -import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; - -// Constants for the resource -const resourceName = 'get_console_logs'; -const resourceMimeType = 'application/json'; -const resourceUri = 'unity://logs/{logType}?offset={offset}&limit={limit}&includeStackTrace={includeStackTrace}'; -const resourceTemplate = new ResourceTemplate(resourceUri, { - list: () => listLogTypes(resourceMimeType) -}); - -function listLogTypes(resourceMimeType: string) { - return { - resources: [ - { - uri: `unity://logs/?offset=0&limit=50&includeStackTrace=true`, - name: "All logs", - description: "All Unity console logs (newest first). ⚠️ Set includeStackTrace=false to save 80-90% tokens. Use limit=50 to avoid token limits.", - mimeType: resourceMimeType - }, - { - uri: `unity://logs/error?offset=0&limit=20&includeStackTrace=true`, - name: "Error logs", - description: "Error logs only. ⚠️ Start with includeStackTrace=false for quick overview, then true only if debugging specific errors.", - mimeType: resourceMimeType - }, - { - uri: `unity://logs/warning?offset=0&limit=30&includeStackTrace=true`, - name: "Warning logs", - description: "Warning logs only. ⚠️ Use includeStackTrace=false by default to save tokens.", - mimeType: resourceMimeType - }, - { - uri: `unity://logs/info?offset=0&limit=25&includeStackTrace=false`, - name: "Info logs", - description: "Info logs only. Stack traces excluded by default to minimize tokens.", - mimeType: resourceMimeType - } - ] - }; -} - -/** - * Registers the get_console_logs resource with the MCP server - */ -export function registerGetConsoleLogsResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - server.resource( - resourceName, - resourceTemplate, - { - description: 'Retrieve Unity console logs by type with pagination support. See individual log type descriptions for optimal settings.', - mimeType: resourceMimeType - }, - async (uri, variables) => { - try { - return await resourceHandler(mcpUnity, uri, variables, logger); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for Unity console logs by log type - */ -async function resourceHandler(mcpUnity: McpUnity, uri: URL, variables: Variables, logger: Logger): Promise { - // Extract and convert the parameter from the template variables - let logType = variables["logType"] ? decodeURIComponent(variables["logType"] as string) : undefined; - if (logType === '') logType = undefined; - - // Extract pagination parameters with validation - const offset = variables["offset"] ? parseInt(variables["offset"] as string, 10) : 0; - const limit = variables["limit"] ? parseInt(variables["limit"] as string, 10) : 100; - - // Extract includeStackTrace parameter - let includeStackTrace = true; // Default to true for backward compatibility - if (variables["includeStackTrace"] !== undefined) { - const value = variables["includeStackTrace"] as string; - includeStackTrace = value === 'true' || value === '1' || value === 'yes'; - } - - // Validate pagination parameters - if (isNaN(offset) || offset < 0) { - throw new McpUnityError(ErrorType.VALIDATION, 'Invalid offset parameter: must be a non-negative integer'); - } - if (isNaN(limit) || limit <= 0) { - throw new McpUnityError(ErrorType.VALIDATION, 'Invalid limit parameter: must be a positive integer'); - } - - // Send request to Unity - const response = await mcpUnity.sendRequest({ - method: resourceName, - params: { - logType: logType, - offset: offset, - limit: limit, - includeStackTrace: includeStackTrace - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.RESOURCE_FETCH, - response.message || 'Failed to fetch logs from Unity' - ); - } - - return { - contents: [{ - uri: `unity://logs/${logType ?? ''}?offset=${offset}&limit=${limit}&includeStackTrace=${includeStackTrace}`, - mimeType: resourceMimeType, - text: JSON.stringify(response, null, 2) - }] - }; -} diff --git a/Server~/src/resources/getGameObjectResource.ts b/Server~/src/resources/getGameObjectResource.ts deleted file mode 100644 index 48f14c55..00000000 --- a/Server~/src/resources/getGameObjectResource.ts +++ /dev/null @@ -1,185 +0,0 @@ -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'; -import { resourceName as hierarchyResourceName } from './getScenesHierarchyResource.js'; - -// Constants for the resource -const resourceName = 'get_gameobject'; -const resourceUri = 'unity://gameobject/{idOrName}'; -const resourceMimeType = 'application/json'; - -/** - * Creates and registers the GameObject resource with the MCP server - * This resource provides access to GameObjects in Unity scenes - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetGameObjectResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - // Create a resource template with the MCP SDK - 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) - } - ); - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceTemplate, - { - description: 'Retrieve a GameObject by instance ID, name, or hierarchical path (e.g., "Parent/Child/MyObject")', - mimeType: resourceMimeType - }, - async (uri, variables) => { - try { - return await resourceHandler(mcpUnity, uri, variables, logger); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} -/** - * Handles requests for GameObject information from Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param uri The requested resource URI - * @param variables Variables extracted from the URI template - * @param logger The logger instance for diagnostic information - * @returns A promise that resolves to the GameObject data - * @throws McpUnityError if the request to Unity fails - */ -async function resourceHandler(mcpUnity: McpUnity, uri: URL, variables: Variables, logger: Logger): Promise { - // Extract and convert the parameter from the template variables - const idOrName = decodeURIComponent(variables["idOrName"] as string); - - // Send request to Unity - const response = await mcpUnity.sendRequest({ - method: resourceName, - params: { - idOrName: idOrName - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.RESOURCE_FETCH, - response.message || 'Failed to fetch GameObject from Unity' - ); - } - - return { - contents: [{ - uri: `unity://gameobject/${idOrName}`, - mimeType: resourceMimeType, - text: JSON.stringify(response, null, 2) - }] - }; -} - -/** - * Get a list of all GameObjects in the scene - * @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 hierarchyResponse = await mcpUnity.sendRequest({ - method: hierarchyResourceName, - params: {} - }); - - if (!hierarchyResponse.success) { - logger.error(`Failed to fetch hierarchy: ${hierarchyResponse.message}`); - throw new Error(hierarchyResponse.message || 'Failed to fetch hierarchy'); - } - - // 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}`); - - // Create resources array with both instance ID and path URIs - const resources: Array<{ - uri: string; - name: string; - description: string; - mimeType: string; - }> = []; - - // Add resources for each GameObject - gameObjects.forEach(obj => { - // Add resource with instance ID URI - resources.push({ - uri: `unity://gameobject/${obj.instanceId}`, - name: obj.name, - description: `GameObject with instance ID ${obj.instanceId} at path: ${obj.path}`, - mimeType: resourceMimeType - }); - - // Add resource with path URI if path exists - if (obj.path) { - resources.push({ - uri: `unity://gameobject/${encodeURIComponent(obj.path)}`, - name: obj.name, - description: `GameObject with instance ID ${obj.instanceId} at path: ${obj.path}`, - mimeType: resourceMimeType - }); - } - }); - - return { resources }; -} - -/** - * Process the hierarchy data to create a list of GameObject references - * @param hierarchyData The hierarchy data from Unity - * @returns An array of GameObject references with their instance IDs and paths - */ -function processHierarchyToGameObjectList(hierarchyData: any): any[] { - const gameObjects: any[] = []; - - // Helper function to traverse the hierarchy recursively - function traverseHierarchy(node: any, path: string = ''): void { - if (!node) return; - - // Current path is parent path + node name - const currentPath = path ? `${path}/${node.name}` : node.name; - - // Add this GameObject to the list - gameObjects.push({ - instanceId: node.instanceId, - name: node.name, - path: currentPath, - active: node.active, - uri: `unity://gameobject/${node.instanceId}` - }); - - // Process children recursively - if (node.children && Array.isArray(node.children)) { - for (const child of node.children) { - traverseHierarchy(child, currentPath); - } - } - } - - // Start traversal with each root GameObject - if (Array.isArray(hierarchyData)) { - for (const rootNode of hierarchyData) { - traverseHierarchy(rootNode); - } - } - - return gameObjects; -} diff --git a/Server~/src/resources/getMenuItemResource.ts b/Server~/src/resources/getMenuItemResource.ts deleted file mode 100644 index 3ab4a80e..00000000 --- a/Server~/src/resources/getMenuItemResource.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the resource -const resourceName = 'get_menu_items'; -const resourceUri = 'unity://menu-items'; -const resourceMimeType = 'application/json'; - -/** - * Creates and registers the Menu Items resource with the MCP server - * This resource provides access to the Unity menu items - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetMenuItemsResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceUri, - { - description: 'List of available menu items in Unity to execute', - mimeType: resourceMimeType - }, - async () => { - try { - return await resourceHandler(mcpUnity); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for menu items information from Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @returns A promise that resolves to the menu items data - * @throws McpUnityError if the request to Unity fails - */ -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 menu items from Unity' - ); - } - - return { - contents: [{ - uri: resourceUri, - mimeType: resourceMimeType, - text: JSON.stringify(response.menuItems, null, 2) - }] - }; -} diff --git a/Server~/src/resources/getPackagesResource.ts b/Server~/src/resources/getPackagesResource.ts deleted file mode 100644 index c3c5fb25..00000000 --- a/Server~/src/resources/getPackagesResource.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the resource -const resourceName = 'get_packages'; -const resourceUri = 'unity://packages'; -const resourceMimeType = 'application/json'; - -/** - * Creates and registers the Packages resource with the MCP server - * This resource provides access to the Unity Package Manager packages - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetPackagesResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceUri, - { - description: 'Retrieve all packages from the Unity Package Manager', - mimeType: resourceMimeType - }, - async () => { - try { - return await resourceHandler(mcpUnity); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for package information from Unity Package Manager - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @returns A promise that resolves to the packages data - * @throws McpUnityError if the request to Unity fails - */ -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 packages from Unity Package Manager' - ); - } - - // Transform the data into a structured format - const projectPackages = response.projectPackages || []; - const registryPackages = response.registryPackages || []; - - const packagesData = { - projectPackages: projectPackages.map((pkg: any) => ({ - name: pkg.name, - displayName: pkg.displayName, - version: pkg.version, - description: pkg.description, - category: pkg.category, - source: pkg.source, - state: pkg.state, - author: pkg.author - })), - registryPackages: registryPackages.map((pkg: any) => ({ - name: pkg.name, - displayName: pkg.displayName, - version: pkg.version, - description: pkg.description, - category: pkg.category, - source: pkg.source, - state: pkg.state, - author: pkg.author - })) - }; - - return { - contents: [ - { - uri: resourceUri, - text: JSON.stringify(packagesData, null, 2), - mimeType: resourceMimeType - } - ] - }; -} diff --git a/Server~/src/resources/getScenesHierarchyResource.ts b/Server~/src/resources/getScenesHierarchyResource.ts deleted file mode 100644 index b61bcafc..00000000 --- a/Server~/src/resources/getScenesHierarchyResource.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the resource -export const resourceName = 'get_scenes_hierarchy'; -export const resourceUri = 'unity://scenes_hierarchy'; -export const resourceMimeType = 'application/json'; - -/** - * Creates and registers the Scenes Hierarchy resource with the MCP server - * This resource provides access to the Unity scene hierarchy - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetHierarchyResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceUri, - { - description: 'Retrieve all GameObjects in the Unity loaded scenes with their active state (scenes hierarchy)', - mimeType: resourceMimeType - }, - async () => { - try { - return await resourceHandler(mcpUnity); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for hierarchy information from Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @returns A promise that resolves to the hierarchy data - * @throws McpUnityError if the request to Unity fails - */ -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 hierarchy from Unity' - ); - } - - return { - contents: [{ - uri: resourceUri, - mimeType: resourceMimeType, - text: JSON.stringify(response.hierarchy, null, 2) - }] - }; -} diff --git a/Server~/src/resources/getTestsResource.ts b/Server~/src/resources/getTestsResource.ts deleted file mode 100644 index f46b06e5..00000000 --- a/Server~/src/resources/getTestsResource.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 { McpUnityError, ErrorType } from '../utils/errors.js'; -import { Variables } from '@modelcontextprotocol/sdk/shared/uriTemplate.js'; - -// Constants for the resource -const resourceName = 'get_tests'; -const resourceMimeType = 'application/json'; -const resourceUri = 'unity://tests/{testMode}'; -const resourceTemplate = new ResourceTemplate(resourceUri, { - list: () => listTestModes(resourceMimeType) -}); - -export interface TestItem { - name: string; - fullName: string; - path: string; - testMode: string; - runState: string; -} - -/** - * Get a list of all test modes (EditMode and PlayMode) - * @param resourceMimeType The MIME type for the resource - * @returns A list of resources for each test mode - */ -function listTestModes(resourceMimeType: string) { - return { - resources: [ - { - uri: `unity://tests/EditMode`, - name: "List only 'EditMode' tests", - description: "List only 'EditMode' tests from Unity's test runner", - mimeType: resourceMimeType - }, - { - uri: `unity://tests/PlayMode`, - name: "List only 'PlayMode' tests", - description: "List only 'PlayMode' tests from Unity's test runner", - mimeType: resourceMimeType - }, - { - uri: `unity://tests/`, - name: "List all tests", - description: "List of all tests in Unity's test runner, this includes PlayMode and EditMode tests", - mimeType: resourceMimeType - } - ] - }; -} - -/** - * Creates and registers the Tests resource with the MCP server - * This resource provides access to Unity's Test Runner tests - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetTestsResource(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - // Register this resource with the MCP server - server.resource( - resourceName, - resourceTemplate, - { - description: 'Retrieve tests from Unity\'s Test Runner', - mimeType: resourceMimeType - }, - async (uri, variables) => { - try { - return await resourceHandler(mcpUnity, uri, variables); - } catch (error) { - logger.error(`Error handling resource ${resourceName}: ${error}`); - throw error; - } - } - ); -} - -/** - * Handles requests for test information from Unity's Test Runner - * Retrieves tests filtered by test mode (EditMode or PlayMode) - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param uri The requested resource URI - * @param variables Variables extracted from the URI template - * @returns A promise that resolves to the test results - * @throws McpUnityError if the request to Unity fails - */ -async function resourceHandler(mcpUnity: McpUnity, uri: URL, variables: Variables): Promise { - // Convert the new handler signature to work with our existing code - const testMode = variables["testMode"]; - - const response = await mcpUnity.sendRequest({ - method: resourceName, - params: { - testMode - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.RESOURCE_FETCH, - response.message || `Failed to fetch the ${testMode} tests from Unity` - ); - } - - return { - contents: [{ - uri: `unity://tests/${testMode}`, - mimeType: resourceMimeType, - text: JSON.stringify(response, null, 2) - }] - }; -} \ No newline at end of file diff --git a/Server~/src/resources/unityDashboardAppResource.ts b/Server~/src/resources/unityDashboardAppResource.ts deleted file mode 100644 index f3a3a353..00000000 --- a/Server~/src/resources/unityDashboardAppResource.ts +++ /dev/null @@ -1,107 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; -import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; -import { Logger } from '../utils/logger.js'; - -const resourceName = 'unity_dashboard_app'; -const appResourceUri = 'ui://unity-dashboard'; -const legacyResourceName = 'unity_dashboard_app_legacy'; -const legacyResourceUri = 'unity://ui/dashboard'; -const resourceMimeType = RESOURCE_MIME_TYPE; - -export function registerUnityDashboardAppResource(server: McpServer, logger: Logger) { - logger.info(`Registering resource: ${resourceName}`); - - registerAppResource( - server, - resourceName, - appResourceUri, - { - description: 'Unity dashboard MCP App UI', - }, - async () => { - try { - return readDashboardHtml(); - } catch (error) { - logger.error(`Error reading dashboard HTML: ${error}`); - throw error; - } - } - ); - - // Legacy URI for compatibility with older hosts / docs that expect unity://ui/dashboard - server.resource( - legacyResourceName, - legacyResourceUri, - { - description: 'Unity dashboard MCP App UI (legacy resource URI)', - mimeType: resourceMimeType - }, - async () => { - try { - return readDashboardHtml(legacyResourceUri); - } catch (error) { - logger.error(`Error reading dashboard HTML (legacy uri): ${error}`); - throw error; - } - } - ); -} - -function readDashboardHtml(uriOverride?: string): ReadResourceResult { - const { text } = readUnityDashboardHtml(); - const uri = uriOverride ?? appResourceUri; - - return { - contents: [ - { - uri, - mimeType: resourceMimeType, - text, - _meta: { - // For hosts that still look for legacy view hints. - view: 'mcp-app', - ui: { - prefersBorder: true, - } - } - } - ] - }; -} - -export function readUnityDashboardHtml(): { text: string; mimeType: string } { - const htmlPath = resolveDashboardPath(); - const text = fs.readFileSync(htmlPath, 'utf8'); - - return { text, mimeType: resourceMimeType }; -} - -function resolveDashboardPath(): string { - // IMPORTANT: do not use process.cwd() here. - // VS Code runs MCP servers with the CWD of the *client workspace*, which may be - // unrelated to the server's install location. - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - - const candidates = [ - // Works when running TS directly (src/resources -> src/ui) - // and when running built JS with copied assets (build/resources -> build/ui) - path.join(moduleDir, '..', 'ui', 'unity-dashboard.html'), - - // Fallback for dev repos where build output exists but UI wasn't copied. - path.join(moduleDir, '..', '..', 'src', 'ui', 'unity-dashboard.html'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - - throw new Error( - `Unity dashboard UI file is missing. Checked: ${candidates.join(', ')}` - ); -} diff --git a/Server~/src/tools/addAssetToSceneTool.ts b/Server~/src/tools/addAssetToSceneTool.ts deleted file mode 100644 index 4a2e4e88..00000000 --- a/Server~/src/tools/addAssetToSceneTool.ts +++ /dev/null @@ -1,87 +0,0 @@ -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'; - -// Constants for the tool -const toolName = 'add_asset_to_scene'; -const toolDescription = 'Adds an asset from the AssetDatabase to the Unity scene'; - -// Parameter schema for the tool -const paramsSchema = z.object({ - assetPath: z.string().optional().describe('The path of the asset in the AssetDatabase'), - guid: z.string().optional().describe('The GUID of the asset'), - position: z.object({ - x: z.number().default(0).describe('X position in the scene'), - y: z.number().default(0).describe('Y position in the scene'), - z: z.number().default(0).describe('Z position in the scene') - }).optional().describe('Position in the scene (defaults to Vector3.zero)'), - parentPath: z.string().optional().describe('The path of the parent GameObject in the hierarchy'), - parentId: z.number().optional().describe('The instance ID of the parent GameObject') -}); - -/** - * Creates and registers the AddAssetToScene tool with the MCP server - * - * @param server The MCP server to register the tool with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerAddAssetToSceneTool(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; - } - } - ); -} - -/** - * Handler function for the AddAssetToScene tool - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The validated parameters for the tool - * @param logger The logger instance for diagnostic information - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if validation fails or the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any) { - if (!params.assetPath && !params.guid) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'assetPath' or 'guid' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to add asset to scene` - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully added asset to scene` - }] - }; -} diff --git a/Server~/src/tools/addPackageTool.ts b/Server~/src/tools/addPackageTool.ts deleted file mode 100644 index bc7d6de1..00000000 --- a/Server~/src/tools/addPackageTool.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'add_package'; -const toolDescription = 'Adds packages into the Unity Package Manager'; -const paramsSchema = z.object({ - source: z.string().describe('The source to use (registry, github, or disk) to add the package'), - packageName: z.string().optional().describe('The package name to add from Unity registry (e.g. com.unity.textmeshpro)'), - version: z.string().optional().describe('The version to use for registry packages (optional)'), - repositoryUrl: z.string().optional().describe('The GitHub repository URL (e.g. https://github.com/username/repo.git)'), - branch: z.string().optional().describe('The branch to use for GitHub packages (optional)'), - path: z.string().optional().describe('The path to use (folder path for disk method or subfolder for GitHub)') -}); - -/** - * Creates and registers the Add Package tool with the MCP server - * This tool allows adding packages to the Unity Package Manager - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerAddPackageTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles add package tool requests - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any): Promise { - const { source, packageName, version, repositoryUrl, branch, path } = params; - - // Validate required parameters based on method - if (source === 'registry' && !packageName) { - throw new McpUnityError( - ErrorType.VALIDATION, - 'Required parameter "packageName" not provided for registry source' - ); - } else if (source === 'github' && !repositoryUrl) { - throw new McpUnityError( - ErrorType.VALIDATION, - 'Required parameter "repositoryUrl" not provided for github source' - ); - } else if (source === 'disk' && !path) { - throw new McpUnityError( - ErrorType.VALIDATION, - 'Required parameter "path" not provided for disk source' - ); - } - - // Send to Unity - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to manage package with source: ${source}` - ); - } - - return { - content: [{ - type: response.type, - text: response.message - }] - }; -} diff --git a/Server~/src/tools/batchExecuteTool.ts b/Server~/src/tools/batchExecuteTool.ts deleted file mode 100644 index af36b0e7..00000000 --- a/Server~/src/tools/batchExecuteTool.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as z from 'zod'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -const toolName = 'batch_execute'; -const toolDescription = `Executes multiple tool operations in a single batch request. -Reduces network round-trips and enables atomic operations with rollback support. -Performance improvement: 10-100x for repetitive operations.`; - -const operationSchema = z.object({ - tool: z.string().describe('The name of the tool to execute'), - params: z.record(z.any()).optional().default({}).describe('Parameters to pass to the tool'), - id: z.string().optional().describe('Optional identifier for this operation (for tracking in results)') -}); - -const paramsSchema = z.object({ - operations: z.array(operationSchema) - .min(1, 'At least one operation is required') - .max(100, 'Maximum of 100 operations allowed per batch') - .describe('Array of operations to execute sequentially'), - stopOnError: z.boolean() - .default(true) - .describe('If true, stops execution on the first error. Default: true'), - atomic: z.boolean() - .default(false) - .describe('If true, rolls back all operations if any fails (uses Unity Undo system). Default: false') -}); - -/** - * Result of a single operation in the batch - */ -interface OperationResult { - index: number; - id: string; - success: boolean; - result?: any; - error?: string; -} - -/** - * Summary of batch execution - */ -interface BatchSummary { - total: number; - succeeded: number; - failed: number; - executed: number; -} - -/** - * Response from the batch execute tool - */ -interface BatchExecuteResponse { - success: boolean; - type: string; - message: string; - results: OperationResult[]; - summary: BatchSummary; -} - -/** - * Creates and registers the Batch Execute tool with the MCP server - */ -export function registerBatchExecuteTool(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}`, { - operationCount: params.operations?.length, - stopOnError: params.stopOnError, - atomic: params.atomic - }); - const result = await batchExecuteHandler(mcpUnity, params, logger); - logger.info(`Tool execution completed: ${toolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${toolName}`, error); - throw error; - } - } - ); -} - -async function batchExecuteHandler( - mcpUnity: McpUnity, - params: z.infer, - logger: Logger -): Promise { - // Validate operations array - if (!params.operations || params.operations.length === 0) { - throw new McpUnityError( - ErrorType.VALIDATION, - "The 'operations' array is required and must contain at least one operation" - ); - } - - if (params.operations.length > 100) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Maximum of 100 operations allowed per batch" - ); - } - - // Validate no nested batch_execute operations - for (const op of params.operations) { - if (op.tool === 'batch_execute') { - throw new McpUnityError( - ErrorType.VALIDATION, - "Cannot nest batch_execute operations" - ); - } - } - - logger.info(`Sending batch with ${params.operations.length} operations to Unity`); - - // Send the batch request to Unity - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - operations: params.operations.map((op, index) => ({ - tool: op.tool, - params: op.params ?? {}, - id: op.id ?? index.toString() - })), - stopOnError: params.stopOnError ?? true, - atomic: params.atomic ?? false - } - }) as BatchExecuteResponse; - - // Format the response message - let resultText = response.message || 'Batch execution completed'; - - // Add summary details - if (response.summary) { - resultText += `\n\nSummary: ${response.summary.succeeded}/${response.summary.total} succeeded`; - if (response.summary.failed > 0) { - resultText += `, ${response.summary.failed} failed`; - } - } - - // Add individual results if there are failures or detailed info - if (response.results && response.results.length > 0) { - const failures = response.results.filter(r => !r.success); - if (failures.length > 0) { - resultText += '\n\nFailed operations:'; - for (const failure of failures) { - resultText += `\n - [${failure.id}] ${failure.error || 'Unknown error'}`; - } - } - } - - // Determine if we should throw an error or return success - if (!response.success && params.stopOnError) { - // When stopOnError is true and we failed, throw to signal the error clearly - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - resultText - ); - } - - return { - content: [{ - type: 'text', - text: resultText - }] - }; -} diff --git a/Server~/src/tools/createPrefabTool.ts b/Server~/src/tools/createPrefabTool.ts deleted file mode 100644 index 400cf29c..00000000 --- a/Server~/src/tools/createPrefabTool.ts +++ /dev/null @@ -1,84 +0,0 @@ -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'; - -// Constants for the tool -const toolName = 'create_prefab'; -const toolDescription = 'Creates a prefab with optional MonoBehaviour script and serialized field values'; - -// Parameter schema for the tool -const paramsSchema = z.object({ - componentName: z.string().optional().describe('The name of the MonoBehaviour Component to add to the prefab (optional)'), - prefabName: z.string().describe('The name of the prefab to create'), - fieldValues: z.record(z.any()).optional().describe('Optional JSON object of serialized field values to apply to the prefab') -}); - -/** - * Creates and registers the CreatePrefab tool with the MCP server - * - * @param server The MCP server to register the tool with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerCreatePrefabTool(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; - } - } - ); -} - -/** - * Handler function for the CreatePrefab tool - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The validated parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if validation fails or the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any) { - if (!params.prefabName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "'prefabName' 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 prefab` - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully created prefab` - }], - // Include the prefab path in the result for programmatic access - data: { - prefabPath: response.prefabPath - } - }; -} diff --git a/Server~/src/tools/createSceneTool.ts b/Server~/src/tools/createSceneTool.ts deleted file mode 100644 index 3c804e69..00000000 --- a/Server~/src/tools/createSceneTool.ts +++ /dev/null @@ -1,85 +0,0 @@ -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, - }, - }; -} diff --git a/Server~/src/tools/deleteSceneTool.ts b/Server~/src/tools/deleteSceneTool.ts deleted file mode 100644 index 9b633393..00000000 --- a/Server~/src/tools/deleteSceneTool.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 = 'delete_scene'; -const toolDescription = 'Deletes a scene by path or name and removes it from Build Settings'; - -const paramsSchema = z.object({ - scenePath: z.string().optional().describe("Full asset path to the scene (e.g., 'Assets/Scenes/MyScene.unity')"), - sceneName: z.string().optional().describe('Scene name without extension (used if scenePath not provided)'), - folderPath: z.string().optional().describe("Optional folder scope to resolve sceneName under 'Assets'") -}); - -export function registerDeleteSceneTool(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.scenePath && !params.sceneName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'scenePath' or '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 delete scene' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'Successfully deleted scene' - }], - data: { - scenePath: response.scenePath - } - }; -} - - diff --git a/Server~/src/tools/gameObjectTools.ts b/Server~/src/tools/gameObjectTools.ts deleted file mode 100644 index 0dbfdd6b..00000000 --- a/Server~/src/tools/gameObjectTools.ts +++ /dev/null @@ -1,226 +0,0 @@ -import * as z from 'zod'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// ============================================================================ -// Duplicate GameObject Tool -// ============================================================================ - -const duplicateToolName = 'duplicate_gameobject'; -const duplicateToolDescription = 'Duplicates a GameObject in the Unity scene. Can create multiple copies and optionally rename or reparent them.'; -const duplicateParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to duplicate'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy to duplicate (alternative to instanceId)'), - newName: z.string().optional().describe('New name for the duplicated GameObject(s). If count > 1, numbers will be appended.'), - newParent: z.string().optional().describe('Path to the new parent GameObject. If not specified, uses the same parent as the original.'), - newParentId: z.number().optional().describe('Instance ID of the new parent GameObject (alternative to newParent path).'), - count: z.number().int().min(1).max(100).default(1).describe('Number of copies to create. Default: 1, Max: 100'), -}); - -/** - * Creates and registers the Duplicate GameObject tool with the MCP server - */ -export function registerDuplicateGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${duplicateToolName}`); - - server.tool( - duplicateToolName, - duplicateToolDescription, - duplicateParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${duplicateToolName}`, params); - const result = await duplicateHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${duplicateToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${duplicateToolName}`, error); - throw error; - } - } - ); -} - -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" - ); - } - - const response = await mcpUnity.sendRequest({ - method: duplicateToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - newName: params.newName, - newParent: params.newParent, - newParentId: params.newParentId, - count: params.count ?? 1, - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to duplicate the GameObject' - ); - } - - return { - content: [{ - type: response.type || 'text', - text: response.message || 'Successfully duplicated the GameObject' - }] - }; -} - -// ============================================================================ -// Delete GameObject Tool -// ============================================================================ - -const deleteToolName = 'delete_gameobject'; -const deleteToolDescription = 'Deletes a GameObject from the Unity scene. By default, also deletes all children.'; -const deleteParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to delete'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy to delete (alternative to instanceId)'), - includeChildren: z.boolean().default(true).describe('If true (default), deletes all children. If false, children are moved to the deleted object\'s parent.'), -}); - -/** - * Creates and registers the Delete GameObject tool with the MCP server - */ -export function registerDeleteGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${deleteToolName}`); - - server.tool( - deleteToolName, - deleteToolDescription, - deleteParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${deleteToolName}`, params); - const result = await deleteHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${deleteToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${deleteToolName}`, error); - throw error; - } - } - ); -} - -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" - ); - } - - const response = await mcpUnity.sendRequest({ - method: deleteToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - includeChildren: params.includeChildren ?? true, - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to delete the GameObject' - ); - } - - return { - content: [{ - type: response.type || 'text', - text: response.message || 'Successfully deleted the GameObject' - }] - }; -} - -// ============================================================================ -// Reparent GameObject Tool -// ============================================================================ - -const reparentToolName = 'reparent_gameobject'; -const reparentToolDescription = 'Changes the parent of a GameObject. Can move to a new parent or to the root level (null parent).'; -const reparentParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to reparent'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy to reparent (alternative to instanceId)'), - newParent: z.string().nullable().optional().describe('Path to the new parent GameObject. Use null to move to root level.'), - newParentId: z.number().nullable().optional().describe('Instance ID of the new parent GameObject. Use null to move to root level.'), - worldPositionStays: z.boolean().default(true).describe('If true (default), the world position is preserved. If false, local position is reset to zero.'), -}); - -/** - * Creates and registers the Reparent GameObject tool with the MCP server - */ -export function registerReparentGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${reparentToolName}`); - - server.tool( - reparentToolName, - reparentToolDescription, - reparentParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${reparentToolName}`, params); - const result = await reparentHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${reparentToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${reparentToolName}`, error); - throw error; - } - } - ); -} - -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" - ); - } - - const response = await mcpUnity.sendRequest({ - method: reparentToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - newParent: params.newParent, - newParentId: params.newParentId, - worldPositionStays: params.worldPositionStays ?? true, - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to reparent the GameObject' - ); - } - - return { - content: [{ - type: response.type || 'text', - text: response.message || 'Successfully reparented the GameObject' - }] - }; -} diff --git a/Server~/src/tools/getConsoleLogsTool.ts b/Server~/src/tools/getConsoleLogsTool.ts deleted file mode 100644 index b492d3bf..00000000 --- a/Server~/src/tools/getConsoleLogsTool.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as z from "zod"; -import { Logger } from "../utils/logger.js"; -import { McpUnity } from "../unity/mcpUnity.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpUnityError, ErrorType } from "../utils/errors.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; - -// Constants for the tool -const toolName = "get_console_logs"; -const toolDescription = "Retrieves logs from the Unity console with pagination support to avoid token limits"; -const paramsSchema = z.object({ - logType: z - .enum(["info", "warning", "error"]) - .optional() - .describe( - "The type of logs to retrieve (info, warning, error) - defaults to all logs if not specified" - ), - offset: z - .number() - .int() - .min(0) - .optional() - .describe("Starting index for pagination (0-based, defaults to 0)"), - limit: z - .number() - .int() - .min(1) - .max(500) - .optional() - .describe("Maximum number of logs to return (defaults to 50, max 500 to avoid token limits)"), - includeStackTrace: z - .boolean() - .optional() - .describe("Whether to include stack trace in logs. ⚠️ ALWAYS SET TO FALSE to save 80-90% tokens, unless you specifically need stack traces for debugging. Default: true (except info logs in resource)") -}); - -/** - * Creates and registers the Get Console Logs tool with the MCP server - * This tool allows retrieving messages from the Unity console - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetConsoleLogsTool( - server: McpServer, - mcpUnity: McpUnity, - logger: Logger -) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - server.tool( - toolName, - toolDescription, - paramsSchema.shape, - async (params: z.infer) => { - 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; - } - } - ); -} - -/** - * Handles requests for Unity console logs - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler( - mcpUnity: McpUnity, - params: z.infer -): Promise { - const { logType, offset = 0, limit = 50, includeStackTrace = true } = params; - - // Send request to Unity using the same method name as the resource - // This allows reusing the existing Unity-side implementation - const response = await mcpUnity.sendRequest({ - method: "get_console_logs", - params: { - logType: logType, - offset: offset, - limit: limit, - includeStackTrace: includeStackTrace, - }, - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || "Failed to fetch logs from Unity" - ); - } - - const logs = response.data ?? response.logs ?? response; - - return { - content: [ - { - type: "text", - text: JSON.stringify( - logs, - null, - 2 - ), - }, - ], - data: { - logs, - offset, - limit, - logType, - includeStackTrace, - }, - }; -} diff --git a/Server~/src/tools/getGameObjectTool.ts b/Server~/src/tools/getGameObjectTool.ts deleted file mode 100644 index e4b4f6ef..00000000 --- a/Server~/src/tools/getGameObjectTool.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as z from "zod"; -import { Logger } from "../utils/logger.js"; -import { McpUnity } from "../unity/mcpUnity.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { McpUnityError, ErrorType } from "../utils/errors.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; - -// Constants for the tool -const toolName = "get_gameobject"; -const toolDescription = - "Retrieves detailed information about a specific GameObject by instance ID, name, or hierarchical path (e.g., 'Parent/Child/MyObject'). Returns component info plus a scoped child hierarchy. Use 'maxDepth', 'includeComponents', and 'includeComponentProperties' to control response size and avoid token/response limits. If a node carries '_truncated: true' with reason 'depth_limit' or 'size_limit_exceeded', re-query that node directly with narrower parameters."; -const paramsSchema = z.object({ - idOrName: z - .string() - .describe( - "The instance ID (integer), name, or hierarchical path of the GameObject to retrieve. Use hierarchical paths like 'Canvas/Panel/Button' for nested objects." - ), - maxDepth: z - .number() - .int() - .min(0) - .max(50) - .optional() - .describe( - "Maximum child hierarchy depth to traverse. 0 = no children, 1 = direct children, 2 = grandchildren. Default: 2. Increase only when you actually need a deeper tree — large scenes can exceed the MCP 15MB response cap." - ), - includeComponents: z - .boolean() - .optional() - .describe( - "Include the component list on each node. Set false to get a hierarchy-only outline. Default: true." - ), - includeComponentProperties: z - .boolean() - .optional() - .describe( - "Include serialized property values for each component. Set false to keep component type names only (saves substantial tokens). Default: true." - ), -}); - -/** - * Creates and registers the Get GameObject tool with the MCP server - * This tool allows retrieving detailed information about GameObjects in Unity scenes - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerGetGameObjectTool( - server: McpServer, - mcpUnity: McpUnity, - logger: Logger -) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - server.tool( - toolName, - toolDescription, - paramsSchema.shape, - async (params: z.infer) => { - 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; - } - } - ); -} - -/** - * Handles requests for GameObject information from Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler( - mcpUnity: McpUnity, - params: z.infer -): Promise { - const { idOrName, maxDepth, includeComponents, includeComponentProperties } = params; - - // Send request to Unity - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - idOrName, - maxDepth, - includeComponents, - includeComponentProperties, - }, - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || "Failed to fetch GameObject from Unity" - ); - } - - return { - content: [ - { - type: "text", - text: JSON.stringify(response, null, 2), - }, - ], - }; -} - - diff --git a/Server~/src/tools/getPlayModeStatusTool.ts b/Server~/src/tools/getPlayModeStatusTool.ts deleted file mode 100644 index 6b2f1a1e..00000000 --- a/Server~/src/tools/getPlayModeStatusTool.ts +++ /dev/null @@ -1,63 +0,0 @@ -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'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -const toolName = 'get_play_mode_status'; -const toolDescription = 'Gets Unity play mode status (isPlaying, isPaused).'; - -const paramsSchema = z.object({}); - -export function registerGetPlayModeStatusTool(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): Promise { - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to get play mode status' - ); - } - - const statusText = response.isPlaying - ? (response.isPaused ? 'Play mode (paused)' : 'Play mode') - : 'Edit mode'; - - return { - content: [ - { - type: response.type as 'text', - text: statusText - } - ], - data: { - isPlaying: response.isPlaying, - isPaused: response.isPaused - } - }; -} diff --git a/Server~/src/tools/getSceneInfoTool.ts b/Server~/src/tools/getSceneInfoTool.ts deleted file mode 100644 index bef4d7eb..00000000 --- a/Server~/src/tools/getSceneInfoTool.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 = 'get_scene_info'; -const toolDescription = 'Gets information about the active scene including name, path, dirty state, root object count, and loaded state. Also returns info about all currently loaded scenes.'; - -const paramsSchema = z.object({}); - -export function registerGetSceneInfoTool(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) { - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to get scene info' - ); - } - - // Format the scene info for display - const activeScene = response.activeScene; - let text = `Active Scene: ${activeScene.name}\n`; - text += ` Path: ${activeScene.path || '(unsaved)'}\n`; - text += ` Build Index: ${activeScene.buildIndex}\n`; - text += ` Is Dirty: ${activeScene.isDirty}\n`; - text += ` Is Loaded: ${activeScene.isLoaded}\n`; - text += ` Root Count: ${activeScene.rootCount}\n`; - - if (response.loadedSceneCount > 1) { - text += `\nLoaded Scenes (${response.loadedSceneCount}):\n`; - for (const scene of response.loadedScenes) { - text += ` - ${scene.name}${scene.isActive ? ' (active)' : ''}: ${scene.path || '(unsaved)'}\n`; - } - } - - return { - content: [{ - type: response.type as "text", - text: text - }], - data: { - activeScene: response.activeScene, - loadedSceneCount: response.loadedSceneCount, - loadedScenes: response.loadedScenes - } - }; -} diff --git a/Server~/src/tools/getScenesHierarchyTool.ts b/Server~/src/tools/getScenesHierarchyTool.ts deleted file mode 100644 index e11897ec..00000000 --- a/Server~/src/tools/getScenesHierarchyTool.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as z from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; - -const toolName = 'get_scenes_hierarchy'; -const toolDescription = 'Retrieves all GameObjects in the Unity loaded scenes (scenes hierarchy).'; -const paramsSchema = z.object({}); - -export function registerGetScenesHierarchyTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - server.tool( - toolName, - toolDescription, - paramsSchema.shape, - async (params: z.infer) => { - try { - logger.info(`Executing tool: ${toolName}`, params); - const result = await toolHandler(mcpUnity); - logger.info(`Tool execution successful: ${toolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${toolName}`, error); - throw error; - } - } - ); -} - -async function toolHandler(mcpUnity: McpUnity): Promise { - const response = await mcpUnity.sendRequest({ - method: toolName, - params: {}, - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to fetch hierarchy from Unity' - ); - } - - const hierarchy = response.hierarchy ?? response.data ?? response; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(hierarchy, null, 2), - }, - ], - data: { - hierarchy, - }, - }; -} diff --git a/Server~/src/tools/loadSceneTool.ts b/Server~/src/tools/loadSceneTool.ts deleted file mode 100644 index b2203ebf..00000000 --- a/Server~/src/tools/loadSceneTool.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 = 'load_scene'; -const toolDescription = 'Loads a scene by path or name. Supports additive loading (default: false)'; - -const paramsSchema = z.object({ - scenePath: z.string().optional().describe("Full asset path to the scene (e.g., 'Assets/Scenes/MyScene.unity')"), - sceneName: z.string().optional().describe('Scene name without extension (used if scenePath not provided)'), - folderPath: z.string().optional().describe("Optional folder scope to resolve sceneName under 'Assets'"), - additive: z.boolean().optional().describe('Load additively if true; default false') -}); - -export function registerLoadSceneTool(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.scenePath && !params.sceneName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'scenePath' or '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 load scene' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'Successfully loaded scene' - }], - data: { - scenePath: response.scenePath, - additive: response.additive - } - }; -} - - diff --git a/Server~/src/tools/materialTools.ts b/Server~/src/tools/materialTools.ts deleted file mode 100644 index d21f69fb..00000000 --- a/Server~/src/tools/materialTools.ts +++ /dev/null @@ -1,354 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Color schema for material properties -const colorSchema = z.object({ - r: z.number().min(0).max(1).describe('Red component (0-1)'), - g: z.number().min(0).max(1).describe('Green component (0-1)'), - b: z.number().min(0).max(1).describe('Blue component (0-1)'), - a: z.number().min(0).max(1).optional().default(1).describe('Alpha component (0-1)') -}); - -// Vector4 schema for material properties -const vector4Schema = z.object({ - x: z.number().describe('X component'), - y: z.number().describe('Y component'), - z: z.number().describe('Z component'), - w: z.number().optional().default(0).describe('W component') -}); - -// ============================================================================ -// CREATE MATERIAL TOOL -// ============================================================================ - -const createMaterialToolName = 'create_material'; -const createMaterialToolDescription = 'Creates a new material with the specified shader and saves it to the project. Use the "color" parameter for easy color setting.'; -const createMaterialParamsSchema = z.object({ - name: z.string().describe('The name of the material'), - shader: z.string().optional().describe('The shader name. Auto-detects render pipeline if not specified (URP: "Universal Render Pipeline/Lit", Built-in: "Standard")'), - savePath: z.string().describe('The asset path to save the material (e.g., "Assets/Materials/MyMaterial.mat")'), - color: colorSchema.optional().describe('The base color of the material. Auto-detects correct property name (_BaseColor for URP, _Color for Standard)'), - properties: z.record(z.any()).optional().describe('Optional initial property values as key-value pairs (advanced usage)') -}); - -/** - * Registers the Create Material tool with the MCP server - */ -export function registerCreateMaterialTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${createMaterialToolName}`); - - server.tool( - createMaterialToolName, - createMaterialToolDescription, - createMaterialParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${createMaterialToolName}`, params); - const result = await createMaterialHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${createMaterialToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${createMaterialToolName}`, error); - throw error; - } - } - ); -} - -async function createMaterialHandler(mcpUnity: McpUnity, params: any): Promise { - if (!params.name) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'name' must be provided" - ); - } - - if (!params.savePath) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'savePath' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: createMaterialToolName, - params: { - name: params.name, - shader: params.shader, // Let Unity auto-detect if not specified - savePath: params.savePath, - color: params.color, // Auto-maps to correct shader property (_BaseColor, _Color, etc.) - properties: params.properties - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to create material' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully created material '${params.name}'` - }] - }; -} - -// ============================================================================ -// ASSIGN MATERIAL TOOL -// ============================================================================ - -const assignMaterialToolName = 'assign_material'; -const assignMaterialToolDescription = 'Assigns a material to a GameObject\'s Renderer component at a specific material slot'; -const assignMaterialParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy (alternative to instanceId)'), - materialPath: z.string().describe('The asset path to the material (e.g., "Assets/Materials/MyMaterial.mat")'), - slot: z.number().int().min(0).optional().default(0).describe('The material slot index (default: 0)') -}); - -/** - * Registers the Assign Material tool with the MCP server - */ -export function registerAssignMaterialTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${assignMaterialToolName}`); - - server.tool( - assignMaterialToolName, - assignMaterialToolDescription, - assignMaterialParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${assignMaterialToolName}`, params); - const result = await assignMaterialHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${assignMaterialToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${assignMaterialToolName}`, error); - throw error; - } - } - ); -} - -async function assignMaterialHandler(mcpUnity: McpUnity, params: any): Promise { - if ((params.instanceId === undefined || params.instanceId === null) && - (!params.objectPath || params.objectPath.trim() === '')) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'instanceId' or 'objectPath' must be provided" - ); - } - - if (!params.materialPath) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'materialPath' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: assignMaterialToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - materialPath: params.materialPath, - slot: params.slot ?? 0 - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to assign material' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully assigned material` - }] - }; -} - -// ============================================================================ -// MODIFY MATERIAL TOOL -// ============================================================================ - -const modifyMaterialToolName = 'modify_material'; -const modifyMaterialToolDescription = 'Modifies properties of an existing material. Supports colors (e.g., _Color), floats (e.g., _Metallic), and textures (e.g., _MainTex path)'; -const modifyMaterialParamsSchema = z.object({ - materialPath: z.string().describe('The asset path to the material (e.g., "Assets/Materials/MyMaterial.mat")'), - properties: z.record(z.any()).describe('Property name to value mapping. Colors: {r,g,b,a}, Vectors: {x,y,z,w}, Floats: number, Textures: asset path string') -}); - -/** - * Registers the Modify Material tool with the MCP server - */ -export function registerModifyMaterialTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${modifyMaterialToolName}`); - - server.tool( - modifyMaterialToolName, - modifyMaterialToolDescription, - modifyMaterialParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${modifyMaterialToolName}`, params); - const result = await modifyMaterialHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${modifyMaterialToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${modifyMaterialToolName}`, error); - throw error; - } - } - ); -} - -async function modifyMaterialHandler(mcpUnity: McpUnity, params: any): Promise { - if (!params.materialPath) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'materialPath' must be provided" - ); - } - - if (!params.properties || Object.keys(params.properties).length === 0) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'properties' must be provided and contain at least one property" - ); - } - - const response = await mcpUnity.sendRequest({ - method: modifyMaterialToolName, - params: { - materialPath: params.materialPath, - properties: params.properties - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to modify material' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully modified material` - }] - }; -} - -// ============================================================================ -// GET MATERIAL INFO TOOL -// ============================================================================ - -const getMaterialInfoToolName = 'get_material_info'; -const getMaterialInfoToolDescription = 'Gets detailed information about a material including its shader and all properties with current values'; -const getMaterialInfoParamsSchema = z.object({ - materialPath: z.string().describe('The asset path to the material (e.g., "Assets/Materials/MyMaterial.mat")') -}); - -/** - * Registers the Get Material Info tool with the MCP server - */ -export function registerGetMaterialInfoTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${getMaterialInfoToolName}`); - - server.tool( - getMaterialInfoToolName, - getMaterialInfoToolDescription, - getMaterialInfoParamsSchema.shape, - async (params: any) => { - try { - logger.info(`Executing tool: ${getMaterialInfoToolName}`, params); - const result = await getMaterialInfoHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${getMaterialInfoToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${getMaterialInfoToolName}`, error); - throw error; - } - } - ); -} - -async function getMaterialInfoHandler(mcpUnity: McpUnity, params: any): Promise { - if (!params.materialPath) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'materialPath' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: getMaterialInfoToolName, - params: { - materialPath: params.materialPath - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to get material info' - ); - } - - // Format the response with material details - let text = `Material: ${response.materialName}\n`; - text += `Shader: ${response.shaderName}\n`; - text += `Render Queue: ${response.renderQueue} (${response.renderQueueCategory})\n`; - text += `Instancing: ${response.enableInstancing}\n`; - text += `Pass Count: ${response.passCount}\n\n`; - text += `Properties:\n`; - - if (response.properties && Array.isArray(response.properties)) { - for (const prop of response.properties) { - let valueStr = ''; - if (prop.value === null || prop.value === undefined) { - valueStr = 'null'; - } else if (typeof prop.value === 'object') { - valueStr = JSON.stringify(prop.value); - } else { - valueStr = String(prop.value); - } - - text += ` ${prop.name} (${prop.type}): ${valueStr}`; - if (prop.description) { - text += ` - ${prop.description}`; - } - text += '\n'; - } - } - - return { - content: [{ - type: 'text', - text: text - }], - data: { - materialName: response.materialName, - materialPath: response.materialPath, - shaderName: response.shaderName, - renderQueue: response.renderQueue, - renderQueueCategory: response.renderQueueCategory, - enableInstancing: response.enableInstancing, - doubleSidedGI: response.doubleSidedGI, - passCount: response.passCount, - properties: response.properties - } - }; -} diff --git a/Server~/src/tools/menuItemTool.ts b/Server~/src/tools/menuItemTool.ts deleted file mode 100644 index d9c07d49..00000000 --- a/Server~/src/tools/menuItemTool.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'execute_menu_item'; -const toolDescription = 'Executes a Unity menu item by path'; -const paramsSchema = z.object({ - menuPath: z.string().describe('The path to the menu item to execute (e.g. "GameObject/Create Empty")') -}); - -/** - * Creates and registers the Menu Item tool with the MCP server - * This tool allows executing menu items in the Unity Editor - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerMenuItemTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles menu item execution requests - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any): Promise { - const { menuPath } = params; - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { menuPath } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to execute menu item: ${menuPath}` - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully executed menu item: ${menuPath}` - }] - }; -} diff --git a/Server~/src/tools/recompileScriptsTool.ts b/Server~/src/tools/recompileScriptsTool.ts deleted file mode 100644 index 4b121903..00000000 --- a/Server~/src/tools/recompileScriptsTool.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'recompile_scripts'; -const toolDescription = 'Recompiles all scripts in the Unity project.'; -const paramsSchema = z.object({ - returnWithLogs: z.boolean().optional().default(true).describe('Whether to return compilation logs'), - logsLimit: z.number().int().min(0).max(1000).optional().default(100).describe('Maximum number of compilation logs to return') -}); - -/** - * Creates and registers the Recompile Scripts tool with the MCP server - * This tool allows recompiling all scripts in the Unity project - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerRecompileScriptsTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles recompile scripts tool requests - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: z.infer): Promise { - // Validate and prepare parameters - const returnWithLogs = params.returnWithLogs ?? true; - const logsLimit = Math.max(0, Math.min(1000, params.logsLimit || 100)); - - // Send to Unity with validated parameters - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - returnWithLogs, - logsLimit - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to recompile scripts` - ); - } - - return { - content: [ - { - type: 'text', - text: response.message - }, - { - type: 'text', - text: JSON.stringify({ - logs: response.logs - }, null, 2) - } - ] - }; -} diff --git a/Server~/src/tools/runTestsTool.ts b/Server~/src/tools/runTestsTool.ts deleted file mode 100644 index f5ad715f..00000000 --- a/Server~/src/tools/runTestsTool.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'run_tests'; -const toolDescription = 'Runs Unity\'s Test Runner tests'; -const paramsSchema = z.object({ - testMode: z.string().optional().default('EditMode').describe('The test mode to run (EditMode or PlayMode) - defaults to EditMode (optional)'), - testFilter: z.string().optional().default('').describe('The specific test filter to run (e.g. specific test name or class name, must include namespace) (optional)'), - returnOnlyFailures: z.boolean().optional().default(true).describe('Whether to show only failed tests in the results (optional)'), - returnWithLogs: z.boolean().optional().default(false).describe('Whether to return the test logs in the results (optional)') -}); - -/** - * Creates and registers the Run Tests tool with the MCP server - * This tool allows running tests in the Unity Test Runner - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerRunTestsTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles running tests in Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any = {}): Promise { - const { - testMode = 'EditMode', - testFilter = '', - returnOnlyFailures = true, - returnWithLogs = false - } = params; - - // Create and wait for the test run - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - testMode, - testFilter, - returnOnlyFailures, - returnWithLogs - } - }); - - // Process the test results - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to run tests: Mode=${testMode}, Filter=${testFilter || 'none'}` - ); - } - - // Extract test results - const testResults = response.results || []; - const testCount = response.testCount || 0; - const passCount = response.passCount || 0; - const failCount = response.failCount || 0; - const skipCount = response.skipCount || 0; - - return { - content: [ - { - type: 'text', - text: response.message - }, - { - type: 'text', - text: JSON.stringify({ - testCount, - passCount, - failCount, - skipCount, - results: testResults - }, null, 2) - } - ] - }; -} diff --git a/Server~/src/tools/saveSceneTool.ts b/Server~/src/tools/saveSceneTool.ts deleted file mode 100644 index b58f2fc0..00000000 --- a/Server~/src/tools/saveSceneTool.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 = 'save_scene'; -const toolDescription = 'Saves the current active scene. Optionally saves to a new path (Save As)'; - -const paramsSchema = z.object({ - scenePath: z.string().optional().describe("The path to save the scene to (e.g., 'Assets/Scenes/MyScene.unity'). Required if saveAs is true"), - saveAs: z.boolean().optional().describe('If true, saves to a new path specified by scenePath. Default: false') -}); - -export function registerSaveSceneTool(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) { - // Validate that scenePath is provided when saveAs is true - if (params.saveAs && !params.scenePath) { - throw new McpUnityError( - ErrorType.VALIDATION, - "'scenePath' is required when 'saveAs' is true" - ); - } - - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to save scene' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'Successfully saved scene' - }], - data: { - scenePath: response.scenePath, - sceneName: response.sceneName - } - }; -} diff --git a/Server~/src/tools/selectGameObjectTool.ts b/Server~/src/tools/selectGameObjectTool.ts deleted file mode 100644 index 40818a5f..00000000 --- a/Server~/src/tools/selectGameObjectTool.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'select_gameobject'; -const toolDescription = 'Sets the selected GameObject in the Unity editor by path, name or instance ID'; -const paramsSchema = z.object({ - objectPath: z.string().optional().describe('The path or name of the GameObject to select (e.g. "Main Camera")'), - objectName: z.string().optional().describe('The name of the GameObject to select'), - instanceId: z.number().optional().describe('The instance ID of the GameObject to select') -}); - -/** - * Creates and registers the Select GameObject tool with the MCP server - * This tool allows selecting a GameObject in the Unity Editor - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerSelectGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles selecting a GameObject in Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any): Promise { - // Custom validation since we can't use refine/superRefine while maintaining ZodObject type - if (params.objectPath === undefined && params.objectName === undefined && params.instanceId === undefined) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'objectPath', 'objectName' or 'instanceId' must be provided" - ); - } - - const response = await mcpUnity.sendRequest({ - method: toolName, - params - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to select GameObject` - ); - } - - return { - content: [{ - type: response.type, - text: response.message || `Successfully selected GameObject` - }] - }; -} diff --git a/Server~/src/tools/sendConsoleLogTool.ts b/Server~/src/tools/sendConsoleLogTool.ts deleted file mode 100644 index 80010c32..00000000 --- a/Server~/src/tools/sendConsoleLogTool.ts +++ /dev/null @@ -1,78 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'send_console_log'; -const toolDescription = 'Sends console log messages to the Unity console'; -const paramsSchema = z.object({ - message: z.string().describe('The message to display in the Unity console'), - type: z.string().optional().describe('The type of message (info, warning, error) - defaults to info (optional)') -}); - -/** - * Creates and registers the Send Console Log tool with the MCP server - * This tool allows sending messages to the Unity console - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerSendConsoleLogTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles notification message requests - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(mcpUnity: McpUnity, params: any): Promise { - const { message, type = 'info' } = params; - // Send to Unity - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - message, - type - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to send message to Unity console` - ); - } - - return { - content: [{ - type: response.type, - text: response.message - }] - }; -} diff --git a/Server~/src/tools/setPlayModeStatusTool.ts b/Server~/src/tools/setPlayModeStatusTool.ts deleted file mode 100644 index 67ca0716..00000000 --- a/Server~/src/tools/setPlayModeStatusTool.ts +++ /dev/null @@ -1,69 +0,0 @@ -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'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -const toolName = 'set_play_mode_status'; -const toolDescription = "Controls Unity play mode. Actions: 'play' (start or unpause), 'pause' (toggle pause), 'stop' (exit play mode), 'step' (advance one frame while paused)."; - -const paramsSchema = z.object({ - action: z.enum(['play', 'pause', 'stop', 'step']).describe("The play mode action to execute: 'play', 'pause', 'stop', or 'step'") -}); - -export function registerSetPlayModeStatusTool(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): Promise { - const validatedParams = paramsSchema.parse(params); - - const response = await mcpUnity.sendRequest({ - method: toolName, - params: validatedParams - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to execute play mode action: ${validatedParams.action}` - ); - } - - const statusText = response.isPlaying - ? (response.isPaused ? 'Playing (paused)' : 'Playing') - : 'Edit mode'; - - return { - content: [ - { - type: 'text', - text: `Play mode action '${validatedParams.action}' executed successfully. Current state: ${statusText}` - } - ], - data: { - action: validatedParams.action, - isPlaying: response.isPlaying, - isPaused: response.isPaused - }, - isError: false - }; -} diff --git a/Server~/src/tools/showUnityDashboardTool.ts b/Server~/src/tools/showUnityDashboardTool.ts deleted file mode 100644 index e4cf26ca..00000000 --- a/Server~/src/tools/showUnityDashboardTool.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as z from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { Logger } from '../utils/logger.js'; -import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'; -import { readUnityDashboardHtml } from '../resources/unityDashboardAppResource.js'; - -const toolName = 'show_unity_dashboard'; -const toolDescription = 'Opens the Unity dashboard MCP App in VS Code.'; -const paramsSchema = z.object({}); - -export function registerShowUnityDashboardTool(server: McpServer, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - registerAppTool(server, toolName, { - description: toolDescription, - inputSchema: paramsSchema.shape, - _meta: { - ui: { - resourceUri: 'ui://unity-dashboard', - } - } - }, async () => { - try { - logger.info(`Executing tool: ${toolName}`); - const result = await toolHandler(); - logger.info(`Tool execution successful: ${toolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${toolName}`, error); - throw error; - } - }); -} - -async function toolHandler(): Promise { - const { text, mimeType } = readUnityDashboardHtml(); - const appUri = 'ui://unity-dashboard'; - - return { - content: [ - { - type: 'resource', - resource: { - uri: appUri, - mimeType, - text, - _meta: { - view: 'mcp-app', - ui: true - } - } - } - ], - _meta: { - ui: { - resourceUri: appUri, - title: 'Unity Dashboard' - } - } - }; -} diff --git a/Server~/src/tools/transformTools.ts b/Server~/src/tools/transformTools.ts deleted file mode 100644 index e3aa6bb3..00000000 --- a/Server~/src/tools/transformTools.ts +++ /dev/null @@ -1,329 +0,0 @@ -import * as z from 'zod'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Build a fresh Vector3 schema per field to avoid local JSON pointer refs -// like "#/properties/position" in generated JSON schema. -function createVector3Schema() { - return z.object({ - x: z.number().describe('X component'), - y: z.number().describe('Y component'), - z: z.number().describe('Z component') - }); -} - -// ============================================================================ -// move_gameobject Tool -// ============================================================================ - -const moveToolName = 'move_gameobject'; -const moveToolDescription = 'Moves a GameObject to a new position. Supports world/local space and absolute/relative positioning.'; -const moveParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to move'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy (alternative to instanceId)'), - position: createVector3Schema().describe('The target position'), - space: z.enum(['world', 'local']).default('world').describe('Coordinate space: "world" or "local"'), - relative: z.boolean().default(false).describe('If true, adds to current position instead of setting absolute position') -}); - -/** - * Registers the move_gameobject tool with the MCP server - */ -export function registerMoveGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${moveToolName}`); - - server.tool( - moveToolName, - moveToolDescription, - moveParamsSchema.shape, - async (params: z.infer) => { - try { - logger.info(`Executing tool: ${moveToolName}`, params); - const result = await moveToolHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${moveToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${moveToolName}`, error); - throw error; - } - } - ); -} - -async function moveToolHandler(mcpUnity: McpUnity, params: z.infer): Promise { - validateGameObjectIdentifier(params); - - const response = await mcpUnity.sendRequest({ - method: moveToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - position: params.position, - space: params.space, - relative: params.relative - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to move GameObject' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'GameObject moved successfully' - }] - }; -} - -// ============================================================================ -// rotate_gameobject Tool -// ============================================================================ - -const rotateToolName = 'rotate_gameobject'; -const rotateToolDescription = 'Rotates a GameObject using Euler angles. Supports world/local space and absolute/relative rotation.'; -const rotateParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to rotate'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy (alternative to instanceId)'), - rotation: createVector3Schema().describe('The rotation in Euler angles (degrees)'), - space: z.enum(['world', 'local']).default('world').describe('Coordinate space: "world" or "local"'), - relative: z.boolean().default(false).describe('If true, adds to current rotation instead of setting absolute rotation') -}); - -/** - * Registers the rotate_gameobject tool with the MCP server - */ -export function registerRotateGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${rotateToolName}`); - - server.tool( - rotateToolName, - rotateToolDescription, - rotateParamsSchema.shape, - async (params: z.infer) => { - try { - logger.info(`Executing tool: ${rotateToolName}`, params); - const result = await rotateToolHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${rotateToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${rotateToolName}`, error); - throw error; - } - } - ); -} - -async function rotateToolHandler(mcpUnity: McpUnity, params: z.infer): Promise { - validateGameObjectIdentifier(params); - - const response = await mcpUnity.sendRequest({ - method: rotateToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - rotation: params.rotation, - space: params.space, - relative: params.relative - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to rotate GameObject' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'GameObject rotated successfully' - }] - }; -} - -// ============================================================================ -// scale_gameobject Tool -// ============================================================================ - -const scaleToolName = 'scale_gameobject'; -const scaleToolDescription = 'Scales a GameObject. Supports absolute and relative (multiplicative) scaling.'; -const scaleParamsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to scale'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy (alternative to instanceId)'), - scale: createVector3Schema().describe('The scale values'), - relative: z.boolean().default(false).describe('If true, multiplies current scale instead of setting absolute scale') -}); - -/** - * Registers the scale_gameobject tool with the MCP server - */ -export function registerScaleGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${scaleToolName}`); - - server.tool( - scaleToolName, - scaleToolDescription, - scaleParamsSchema.shape, - async (params: z.infer) => { - try { - logger.info(`Executing tool: ${scaleToolName}`, params); - const result = await scaleToolHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${scaleToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${scaleToolName}`, error); - throw error; - } - } - ); -} - -async function scaleToolHandler(mcpUnity: McpUnity, params: z.infer): Promise { - validateGameObjectIdentifier(params); - - const response = await mcpUnity.sendRequest({ - method: scaleToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - scale: params.scale, - relative: params.relative - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to scale GameObject' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'GameObject scaled successfully' - }] - }; -} - -// ============================================================================ -// set_transform Tool -// ============================================================================ - -const setTransformToolName = 'set_transform'; -const setTransformToolDescription = 'Sets a GameObject\'s transform (position, rotation, scale) in one operation. All transform properties are optional.'; -function createSetTransformParamsShape() { - return { - instanceId: z.number().optional().describe('The instance ID of the GameObject'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy (alternative to instanceId)'), - position: createVector3Schema().optional().describe('The position to set'), - rotation: createVector3Schema().optional().describe('The rotation in Euler angles (degrees)'), - scale: createVector3Schema().optional().describe('The scale to set'), - space: z.enum(['world', 'local']).default('world').describe('Coordinate space for position and rotation: "world" or "local"') - }; -} - -const setTransformParamsSchema = z.object({ - ...createSetTransformParamsShape() -}).refine( - data => data.position !== undefined || data.rotation !== undefined || data.scale !== undefined, - { message: 'At least one of position, rotation, or scale must be provided' } -); - -/** - * Registers the set_transform tool with the MCP server - */ -export function registerSetTransformTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${setTransformToolName}`); - - server.tool( - setTransformToolName, - setTransformToolDescription, - // Use base shape without refine for MCP schema registration - createSetTransformParamsShape(), - async (params: any) => { - try { - logger.info(`Executing tool: ${setTransformToolName}`, params); - const result = await setTransformToolHandler(mcpUnity, params); - logger.info(`Tool execution successful: ${setTransformToolName}`); - return result; - } catch (error) { - logger.error(`Tool execution failed: ${setTransformToolName}`, error); - throw error; - } - } - ); -} - -async function setTransformToolHandler(mcpUnity: McpUnity, params: any): Promise { - validateGameObjectIdentifier(params); - - // Validate that at least one transform property is provided - if (!params.position && !params.rotation && !params.scale) { - throw new McpUnityError( - ErrorType.VALIDATION, - 'At least one of position, rotation, or scale must be provided' - ); - } - - const response = await mcpUnity.sendRequest({ - method: setTransformToolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - position: params.position, - rotation: params.rotation, - scale: params.scale, - space: params.space - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || 'Failed to set transform' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'Transform updated successfully' - }] - }; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Validates that either instanceId or objectPath is provided - */ -function validateGameObjectIdentifier(params: { instanceId?: number; objectPath?: string }) { - if ((params.instanceId === undefined || params.instanceId === null) && - (!params.objectPath || params.objectPath.trim() === '')) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'instanceId' or 'objectPath' must be provided" - ); - } -} - -/** - * Registers all transform tools with the MCP server - */ -export function registerTransformTools(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - registerMoveGameObjectTool(server, mcpUnity, logger); - registerRotateGameObjectTool(server, mcpUnity, logger); - registerScaleGameObjectTool(server, mcpUnity, logger); - registerSetTransformTool(server, mcpUnity, logger); -} diff --git a/Server~/src/tools/unloadSceneTool.ts b/Server~/src/tools/unloadSceneTool.ts deleted file mode 100644 index b9bc3752..00000000 --- a/Server~/src/tools/unloadSceneTool.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 = 'unload_scene'; -const toolDescription = 'Unloads a scene by path or name (does not delete the scene asset, just closes it from the hierarchy)'; - -const paramsSchema = z.object({ - scenePath: z.string().optional().describe("Full asset path to the scene (e.g., 'Assets/Scenes/MyScene.unity')"), - sceneName: z.string().optional().describe('Scene name without extension (used if scenePath not provided)'), - saveIfDirty: z.boolean().optional().describe('If true, saves the scene before unloading if it has unsaved changes. Default: true'), - removeScene: z.boolean().optional().describe('If true, removes the scene from the hierarchy. If false, keeps it but unloaded. Default: true') -}); - -export function registerUnloadSceneTool(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.scenePath && !params.sceneName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Either 'scenePath' or '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 unload scene' - ); - } - - return { - content: [{ - type: response.type, - text: response.message || 'Successfully unloaded scene' - }], - data: { - sceneName: response.sceneName, - scenePath: response.scenePath, - wasDirty: response.wasDirty, - removed: response.removed - } - }; -} diff --git a/Server~/src/tools/updateComponentTool.ts b/Server~/src/tools/updateComponentTool.ts deleted file mode 100644 index 37538502..00000000 --- a/Server~/src/tools/updateComponentTool.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as z from 'zod'; -import { Logger } from '../utils/logger.js'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'update_component'; -const toolDescription = 'Updates component fields on a GameObject or adds it to the GameObject if it does not contain the component'; -const paramsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to update'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy to update (alternative to instanceId)'), - componentName: z.string().describe('The name of the component to update or add'), - componentData: z.record(z.any()).optional().describe('An object containing the fields to update on the component (optional)') -}); - -/** - * Creates and registers the Update Component tool with the MCP server - * This tool allows updating or adding components to GameObjects in the Unity Editor - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerUpdateComponentTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} - -/** - * Handles updating or adding a component to a GameObject in Unity - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(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" - ); - } - - if (!params.componentName) { - throw new McpUnityError( - ErrorType.VALIDATION, - "Required parameter 'componentName' must be provided" - ); - } - - // Send request to Unity - const response = await mcpUnity.sendRequest({ - method: toolName, - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - componentName: params.componentName, - componentData: params.componentData - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to update component on GameObject` - ); - } - - // Create a description of which GameObject was targeted - const targetDescription = params.objectPath - ? `path '${params.objectPath}'` - : `ID ${params.instanceId}`; - - return { - content: [{ - type: response.type, - text: response.message || `Successfully updated component on GameObject with ${targetDescription}` - }] - }; -} diff --git a/Server~/src/tools/updateGameObjectTool.ts b/Server~/src/tools/updateGameObjectTool.ts deleted file mode 100644 index 46b2124e..00000000 --- a/Server~/src/tools/updateGameObjectTool.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as z from 'zod'; -import { McpUnity } from '../unity/mcpUnity.js'; -import { Logger } from '../utils/logger.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; - -// Constants for the tool -const toolName = 'update_gameobject'; -const toolDescription = 'Updates properties of a GameObject in the Unity scene by its instance ID or path. If the GameObject does not exist at the specified path, it will be created.'; -const paramsSchema = z.object({ - instanceId: z.number().optional().describe('The instance ID of the GameObject to update'), - objectPath: z.string().optional().describe('The path of the GameObject in the hierarchy to update (alternative to instanceId)'), - gameObjectData: z.object({ - name: z.string().optional().describe('New name for the GameObject'), - tag: z.string().optional().describe('New tag for the GameObject'), - layer: z.number().int().optional().describe('New layer for the GameObject'), - activeSelf: z.boolean().optional().describe('Set the active state of the GameObject (GameObject.SetActive(value))'), - isStatic: z.boolean().optional().describe('Set the static state of the GameObject (GameObject.isStatic = value)'), - }).describe('An object containing the fields to update on the GameObject. If the GameObject does not exist at objectPath, it will be created.') - .refine(data => Object.keys(data).length > 0, { message: 'gameObjectData must contain at least one property to update.' }), -}); - -/** - * Creates and registers the Update GameObject tool with the MCP server - * This tool allows updating or creating GameObjects into the Unity Editor active scene - * - * @param server The MCP server instance to register with - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param logger The logger instance for diagnostic information - */ -export function registerUpdateGameObjectTool(server: McpServer, mcpUnity: McpUnity, logger: Logger) { - logger.info(`Registering tool: ${toolName}`); - - // Register this tool with the MCP server - 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; - } - } - ); -} -/** - * Handles updating or creating GameObjects into the Unity Editor active scene - * - * @param mcpUnity The McpUnity instance to communicate with Unity - * @param params The parameters for the tool - * @returns A promise that resolves to the tool execution result - * @throws McpUnityError if the request to Unity fails - */ -async function toolHandler(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" - ); - } - - const response = await mcpUnity.sendRequest({ - method: 'update_gameobject', - params: { - instanceId: params.instanceId, - objectPath: params.objectPath, - gameObjectData: params.gameObjectData, - } - }); - - if (!response.success) { - throw new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.message || `Failed to update the GameObject` - ); - } - - // Create a description of which GameObject was targeted - const targetDescription = params.objectPath - ? `path '${params.objectPath}'` - : `ID ${params.instanceId}`; - - return { - content: [{ - type: response.type, - text: response.message || `Successfully updated the GameObject with ${targetDescription}` - }] - }; -} diff --git a/Server~/src/ui/unity-dashboard.html b/Server~/src/ui/unity-dashboard.html index 32ca3059..f68733d6 100644 --- a/Server~/src/ui/unity-dashboard.html +++ b/Server~/src/ui/unity-dashboard.html @@ -1,1573 +1,144 @@ - - - - - - Unity Dashboard - - - -
-
-

Unity Dashboard

-
-
- - - - - - - - - - 3s -
-
-
- -
-
-
-
Scene
-
Loading scene info...
-
- -
-
Hierarchy
-
- - -
-
Loading hierarchy...
-
-
- -
-
-
Inspector
-
Select a focused GameObject to inspect.
-
-
-
-
- - - - -
-
-
Console Logs
- -
-
-
- -
-
-
Debug Console
- -
-
-
- - - - + + + + + + Unity Dashboard + + + +
+
+

Unity CLI + Pipeline

+
+ Not checked + Never refreshed +
+
+ +
+
+
+

Scene hierarchy

Waiting…
+

Console logs

Waiting…
+

Packages

Waiting…
+

Tests

Waiting…
+

GameObject inspector

Select a target URI such as unity://gameobject/%2FPlayer in your MCP client.
+

Companion resources

unity://logs{?severity,limit}
+unity://scenes-hierarchy{?path,max_nodes}
+unity://gameobject/{target}
+unity://packages{?include_indirect}
+unity://tests/{mode}
+ui://unity-dashboard
+
+ + + diff --git a/Server~/src/unity/commandQueue.ts b/Server~/src/unity/commandQueue.ts deleted file mode 100644 index fb4ab389..00000000 --- a/Server~/src/unity/commandQueue.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { Logger } from '../utils/logger.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; - -/** - * Represents a queued command with its metadata - */ -export interface QueuedCommand { - /** Unique identifier for the command */ - id: string; - /** The request to send to Unity */ - request: { - id?: string; - method: string; - params: any; - }; - /** Resolve callback for the promise */ - resolve: (value: any) => void; - /** Reject callback for the promise */ - reject: (reason: any) => void; - /** Timestamp when the command was queued */ - queuedAt: number; - /** Optional custom timeout for this specific command (in ms) */ - timeout?: number; -} - -/** - * Configuration options for the CommandQueue - */ -export interface CommandQueueConfig { - /** Maximum number of commands to queue (default: 100) */ - maxSize?: number; - /** Default timeout in milliseconds for queued commands (default: 60000) */ - defaultTimeout?: number; - /** Interval in milliseconds to check for expired commands (default: 5000) */ - cleanupInterval?: number; -} - -/** - * Statistics about the command queue - */ -export interface CommandQueueStats { - /** Current number of queued commands */ - size: number; - /** Maximum queue size */ - maxSize: number; - /** Number of commands that were dropped due to queue overflow */ - droppedCount: number; - /** Number of commands that expired while queued */ - expiredCount: number; - /** Number of commands successfully replayed */ - replayedCount: number; -} - -/** - * Result of attempting to enqueue a command - */ -export interface EnqueueResult { - /** Whether the command was successfully queued */ - success: boolean; - /** Position in queue (1-indexed) if successful */ - position?: number; - /** Reason for failure if not successful */ - reason?: string; -} - -/** - * Default configuration values - */ -const DEFAULT_CONFIG = { - maxSize: 100, - defaultTimeout: 60000, // 60 seconds - cleanupInterval: 5000 // 5 seconds -}; - -/** - * CommandQueue manages commands that are queued when the Unity connection is unavailable. - * Commands are stored and replayed when the connection is restored. - */ -export class CommandQueue { - private queue: QueuedCommand[] = []; - private config: Required; - private cleanupTimer: NodeJS.Timeout | null = null; - private logger: Logger; - - // Statistics - private droppedCount: number = 0; - private expiredCount: number = 0; - private replayedCount: number = 0; - - constructor(logger: Logger, config: CommandQueueConfig = {}) { - this.logger = logger; - this.config = { - ...DEFAULT_CONFIG, - ...config - }; - - // Start cleanup timer - this.startCleanupTimer(); - } - - /** - * Enqueue a command to be sent when the connection is restored - * @param command The command to queue (without queuedAt, which will be added automatically) - * @returns Result indicating whether the command was queued successfully - */ - public enqueue(command: Omit): EnqueueResult { - // Check if queue is full - if (this.queue.length >= this.config.maxSize) { - this.droppedCount++; - this.logger.warn(`Command queue full (${this.config.maxSize}), dropping command: ${command.request.method}`); - - // Reject the command immediately - command.reject(new McpUnityError( - ErrorType.CONNECTION, - `Command queue full (${this.config.maxSize} commands). Try again later.` - )); - - return { - success: false, - reason: `Queue is full (max: ${this.config.maxSize})` - }; - } - - const queuedCommand: QueuedCommand = { - ...command, - queuedAt: Date.now(), - timeout: command.timeout ?? this.config.defaultTimeout - }; - - this.queue.push(queuedCommand); - const position = this.queue.length; - - this.logger.debug(`Queued command ${command.id} (${command.request.method}), position: ${position}/${this.config.maxSize}`); - - return { - success: true, - position - }; - } - - /** - * Get the number of commands currently in the queue - */ - public get size(): number { - return this.queue.length; - } - - /** - * Check if the queue is empty - */ - public get isEmpty(): boolean { - return this.queue.length === 0; - } - - /** - * Check if the queue is full - */ - public get isFull(): boolean { - return this.queue.length >= this.config.maxSize; - } - - /** - * Get all queued commands and clear the queue. - * Used when connection is restored to replay commands. - * Expired commands are filtered out and rejected before returning. - * @returns Array of valid (non-expired) queued commands - */ - public drain(): QueuedCommand[] { - // Clean up expired commands first - this.cleanupExpired(); - - const commands = [...this.queue]; - this.queue = []; - - if (commands.length > 0) { - this.logger.info(`Draining ${commands.length} commands from queue for replay`); - } - - return commands; - } - - /** - * Peek at the next command without removing it - * @returns The next command in the queue, or undefined if empty - */ - public peek(): QueuedCommand | undefined { - return this.queue[0]; - } - - /** - * Clear all queued commands, rejecting each with an error - * @param reason The reason for clearing the queue - */ - public clear(reason: string = 'Queue cleared'): void { - const count = this.queue.length; - - for (const command of this.queue) { - command.reject(new McpUnityError( - ErrorType.CONNECTION, - reason - )); - } - - this.queue = []; - - if (count > 0) { - this.logger.info(`Cleared ${count} commands from queue: ${reason}`); - } - } - - /** - * Remove expired commands from the queue - * @returns Number of commands that were expired and removed - */ - public cleanupExpired(): number { - const now = Date.now(); - const initialSize = this.queue.length; - - this.queue = this.queue.filter(command => { - const timeout = command.timeout ?? this.config.defaultTimeout; - const isExpired = (now - command.queuedAt) > timeout; - - if (isExpired) { - this.expiredCount++; - this.logger.debug(`Command ${command.id} (${command.request.method}) expired after ${timeout}ms`); - - command.reject(new McpUnityError( - ErrorType.TIMEOUT, - `Command expired after ${timeout}ms in queue` - )); - - return false; - } - - return true; - }); - - const expiredCount = initialSize - this.queue.length; - if (expiredCount > 0) { - this.logger.info(`Cleaned up ${expiredCount} expired commands from queue`); - } - - return expiredCount; - } - - /** - * Start the periodic cleanup timer - */ - private startCleanupTimer(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - } - - this.cleanupTimer = setInterval(() => { - this.cleanupExpired(); - }, this.config.cleanupInterval); - - // Don't prevent process exit - this.cleanupTimer.unref(); - } - - /** - * Stop the cleanup timer - */ - public stopCleanupTimer(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; - } - } - - /** - * Record that a command was successfully replayed - */ - public recordReplaySuccess(): void { - this.replayedCount++; - } - - /** - * Get statistics about the command queue - */ - public getStats(): CommandQueueStats { - return { - size: this.queue.length, - maxSize: this.config.maxSize, - droppedCount: this.droppedCount, - expiredCount: this.expiredCount, - replayedCount: this.replayedCount - }; - } - - /** - * Reset statistics counters - */ - public resetStats(): void { - this.droppedCount = 0; - this.expiredCount = 0; - this.replayedCount = 0; - } - - /** - * Update configuration dynamically - * Note: maxSize changes won't affect already-queued commands - */ - public updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - - // Restart cleanup timer if interval changed - if (config.cleanupInterval !== undefined) { - this.startCleanupTimer(); - } - } - - /** - * Clean up resources - */ - public dispose(): void { - this.stopCleanupTimer(); - this.clear('Command queue disposed'); - } -} diff --git a/Server~/src/unity/mcpUnity.ts b/Server~/src/unity/mcpUnity.ts deleted file mode 100644 index aabd80ff..00000000 --- a/Server~/src/unity/mcpUnity.ts +++ /dev/null @@ -1,541 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import { Logger } from '../utils/logger.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { UnityConnection, ConnectionState, ConnectionStateChange, UnityConnectionConfig } from './unityConnection.js'; -import { CommandQueue, CommandQueueConfig, CommandQueueStats, QueuedCommand } from './commandQueue.js'; - -// Top-level constant for the Unity settings JSON path -const MCP_UNITY_SETTINGS_PATH = path.resolve(process.cwd(), './ProjectSettings/McpUnitySettings.json'); - -interface PendingRequest { - resolve: (value: any) => void; - reject: (reason: any) => void; - timeout: NodeJS.Timeout; -} - -interface UnityRequest { - id?: string; - method: string; - params: any; -} - -interface UnityResponse { - jsonrpc: string; - id: string; - result?: any; - error?: { - message: string; - type: string; - details?: any; - }; -} - -/** - * Connection state change callback type - */ -export type ConnectionStateCallback = (change: ConnectionStateChange) => void; - -// Re-export connection types for consumers -export { ConnectionState, type ConnectionStateChange } from './unityConnection.js'; -export { type CommandQueueConfig, type CommandQueueStats } from './commandQueue.js'; - -/** - * Options for sending a request - */ -export interface SendRequestOptions { - /** If true, queue the command when disconnected instead of failing immediately (default: uses queueingEnabled setting) */ - queueIfDisconnected?: boolean; - /** Custom timeout for this request in milliseconds */ - timeout?: number; -} - -/** - * Configuration for McpUnity - */ -export interface McpUnityConfig { - /** Command queue configuration */ - queue?: CommandQueueConfig; - /** Whether command queuing is enabled by default (default: true) */ - queueingEnabled?: boolean; -} - -export class McpUnity { - private logger: Logger; - private port: number = 8090; - private host: string = 'localhost'; - private requestTimeout = 10000; - - private connection: UnityConnection | null = null; - private pendingRequests: Map = new Map(); - private clientName: string = ''; - - // Connection state listeners - private stateListeners: Set = new Set(); - - // Command queue for handling commands during disconnection - private commandQueue: CommandQueue; - private queueingEnabled: boolean; - - // Flag to track if we're currently replaying queued commands - private isReplayingQueue: boolean = false; - - constructor(logger: Logger, config?: McpUnityConfig) { - this.logger = logger; - this.commandQueue = new CommandQueue(logger, config?.queue); - this.queueingEnabled = config?.queueingEnabled ?? true; - } - - /** - * Enable or disable command queuing - */ - public setQueueingEnabled(enabled: boolean): void { - this.queueingEnabled = enabled; - this.logger.info(`Command queuing ${enabled ? 'enabled' : 'disabled'}`); - } - - /** - * Check if command queuing is enabled - */ - public get isQueueingEnabled(): boolean { - return this.queueingEnabled; - } - - /** - * Get command queue statistics - */ - public getQueueStats(): CommandQueueStats { - return this.commandQueue.getStats(); - } - - /** - * Get number of commands currently queued - */ - public get queuedCommandCount(): number { - return this.commandQueue.size; - } - - /** - * Start the Unity connection - * @param clientName Optional name of the MCP client connecting to Unity - */ - public async start(clientName?: string): Promise { - try { - this.logger.info('Attempting to read startup parameters...'); - await this.parseAndSetConfig(); - - this.clientName = clientName || ''; - - // Create connection with configuration - const config: UnityConnectionConfig = { - host: this.host, - port: this.port, - requestTimeout: this.requestTimeout, - clientName: this.clientName, - // Use defaults for reconnection and heartbeat from UnityConnection - }; - - this.connection = new UnityConnection(this.logger, config); - - // Set up event handlers - this.connection.on('stateChange', (change: ConnectionStateChange) => { - this.handleStateChange(change); - }); - - this.connection.on('message', (data: string) => { - this.handleMessage(data); - }); - - this.connection.on('error', (error: McpUnityError) => { - this.logger.error(`Connection error: ${error.message}`); - // Reject pending requests on connection error - this.rejectAllPendingRequests(error); - }); - - this.logger.info('Attempting to connect to Unity WebSocket...'); - await this.connection.connect(); - this.logger.info('Successfully connected to Unity WebSocket'); - - if (clientName) { - this.logger.info(`Client identified to Unity as: ${clientName}`); - } - } catch (error) { - this.logger.warn(`Could not connect to Unity WebSocket: ${error instanceof Error ? error.message : String(error)}`); - this.logger.warn('Will retry connection on next request (with automatic reconnection)'); - } - - return Promise.resolve(); - } - - /** - * Reads our configuration file and sets parameters of the server based on them. - */ - private async parseAndSetConfig() { - const config = await this.readConfigFileAsJson(); - - const configPort = config.Port; - this.port = configPort ? parseInt(configPort, 10) : 8090; - this.logger.info(`Using port: ${this.port} for Unity WebSocket connection`); - - // Check environment variable first, then config file, then default to localhost - const configHost = process.env.UNITY_HOST || config.Host; - this.host = configHost || 'localhost'; - - // Initialize timeout from environment variable (in seconds; it is the same as Cline) or use default (10 seconds) - const configTimeout = config.RequestTimeoutSeconds; - this.requestTimeout = configTimeout ? parseInt(configTimeout, 10) * 1000 : 10000; - this.logger.info(`Using request timeout: ${this.requestTimeout / 1000} seconds`); - } - - /** - * Handle connection state changes - */ - private handleStateChange(change: ConnectionStateChange): void { - this.logger.debug(`Connection state changed: ${change.previousState} -> ${change.currentState}`); - - // Notify all listeners - for (const listener of this.stateListeners) { - try { - listener(change); - } catch (err) { - this.logger.error(`Error in state listener: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // Handle specific state transitions - if (change.currentState === ConnectionState.Connected && - (change.previousState === ConnectionState.Reconnecting || - change.previousState === ConnectionState.Connecting)) { - // Connection restored - replay queued commands - this.replayQueuedCommands(); - } else if (change.currentState === ConnectionState.Disconnected) { - // Clear the queue when we're fully disconnected (not reconnecting) - // This happens when max reconnection attempts are reached - if (change.reason?.includes('Max reconnection attempts')) { - this.commandQueue.clear(change.reason); - } - // Reject all pending requests when disconnected - this.rejectAllPendingRequests( - new McpUnityError(ErrorType.CONNECTION, change.reason || 'Connection lost') - ); - } - } - - /** - * Replay queued commands after connection is restored - */ - private async replayQueuedCommands(): Promise { - if (this.isReplayingQueue) { - this.logger.debug('Already replaying queue, skipping'); - return; - } - - const commands = this.commandQueue.drain(); - - if (commands.length === 0) { - return; - } - - this.isReplayingQueue = true; - this.logger.info(`Replaying ${commands.length} queued commands`); - - for (const command of commands) { - try { - // Send the command directly using internal method - const result = await this.sendRequestInternal(command.request, command.timeout); - command.resolve(result); - this.commandQueue.recordReplaySuccess(); - } catch (error) { - command.reject(error); - } - } - - this.isReplayingQueue = false; - this.logger.info(`Finished replaying queued commands (${this.commandQueue.getStats().replayedCount} successful)`); - } - - /** - * Handle messages received from Unity - */ - private handleMessage(data: string): void { - try { - const response = JSON.parse(data) as UnityResponse; - const pendingCount = this.pendingRequests.size; - - if (!response.id) { - this.logger.warn(`Ignoring Unity WebSocket message without request id; pending requests: ${pendingCount}`); - return; - } - - if (!this.pendingRequests.has(response.id)) { - this.logger.warn(`Ignoring Unity response for unknown request ${response.id}; pending requests: ${pendingCount}`); - return; - } - - this.logger.info(`Received Unity response for request ${response.id}; pending requests before match: ${pendingCount}`); - const request = this.pendingRequests.get(response.id)!; - clearTimeout(request.timeout); - this.pendingRequests.delete(response.id); - - if (response.error) { - request.reject(new McpUnityError( - ErrorType.TOOL_EXECUTION, - response.error.message || 'Unknown error', - response.error.details - )); - } else { - request.resolve(response.result); - } - } catch (e) { - this.logger.error(`Error parsing WebSocket message: ${e instanceof Error ? e.message : String(e)}`); - } - } - - /** - * Reject all pending requests with an error - */ - private rejectAllPendingRequests(error: McpUnityError): void { - for (const [id, request] of this.pendingRequests.entries()) { - clearTimeout(request.timeout); - request.reject(error); - this.pendingRequests.delete(id); - } - } - - /** - * Stop the Unity connection and clean up resources - */ - public async stop(): Promise { - // Dispose the command queue - this.commandQueue.dispose(); - - if (this.connection) { - this.connection.disconnect('Server stopping'); - this.connection.removeAllListeners(); - this.connection = null; - } - this.rejectAllPendingRequests(new McpUnityError(ErrorType.CONNECTION, 'Server stopped')); - this.logger.info('Unity WebSocket client stopped'); - return Promise.resolve(); - } - - /** - * Send a request to the Unity server - * @param request The request to send - * @param options Optional settings for the request - */ - public async sendRequest(request: UnityRequest, options: SendRequestOptions = {}): Promise { - const { queueIfDisconnected = this.queueingEnabled, timeout } = options; - const requestId = request.id as string || uuidv4(); - const message: UnityRequest = { - ...request, - id: requestId - }; - - // If connected, send directly - if (this.isConnected) { - return this.sendRequestInternal(message, timeout); - } - - // If not started, throw error - if (!this.connection) { - throw new McpUnityError(ErrorType.CONNECTION, 'Not started - call start() first'); - } - - // If reconnecting and queuing is enabled, queue the command - if (queueIfDisconnected && this.connectionState === ConnectionState.Reconnecting) { - this.logger.debug(`Queuing command ${requestId} (${request.method}) - reconnecting...`); - - return new Promise((resolve, reject) => { - const result = this.commandQueue.enqueue({ - id: requestId, - request: message, - resolve, - reject, - timeout - }); - - if (result.success) { - this.logger.info(`Command ${requestId} queued at position ${result.position}`); - } - // If queuing failed, the command was already rejected by the queue - }); - } - - // If connecting and queuing is enabled, queue the command - if (queueIfDisconnected && this.connectionState === ConnectionState.Connecting) { - this.logger.debug(`Queuing command ${requestId} (${request.method}) - connecting...`); - - return new Promise((resolve, reject) => { - const result = this.commandQueue.enqueue({ - id: requestId, - request: message, - resolve, - reject, - timeout - }); - - if (result.success) { - this.logger.info(`Command ${requestId} queued at position ${result.position}`); - } - }); - } - - // Not connected - try to connect first - this.logger.info('Not connected to Unity, connecting first...'); - - try { - await this.connection.connect(); - // Connection successful, send the request - return this.sendRequestInternal(message, timeout); - } catch (error) { - // Connection failed - if queuing is enabled, queue the command - if (queueIfDisconnected) { - this.logger.debug(`Queuing command ${requestId} (${request.method}) - connection failed, will retry`); - - return new Promise((resolve, reject) => { - const result = this.commandQueue.enqueue({ - id: requestId, - request: message, - resolve, - reject, - timeout - }); - - if (result.success) { - this.logger.info(`Command ${requestId} queued at position ${result.position}, waiting for reconnection`); - } - }); - } - - throw new McpUnityError( - ErrorType.CONNECTION, - `Not connected to Unity: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - /** - * Internal method to send a request directly to Unity - * Bypasses queuing logic - assumes connection is already established - */ - private sendRequestInternal(request: UnityRequest, customTimeout?: number): Promise { - const requestId = request.id as string; - const timeoutMs = customTimeout ?? this.requestTimeout; - - return new Promise((resolve, reject) => { - if (!this.connection || !this.isConnected) { - reject(new McpUnityError(ErrorType.CONNECTION, 'Not connected to Unity')); - return; - } - - // Create timeout for the request - const timeout = setTimeout(() => { - if (this.pendingRequests.has(requestId)) { - this.logger.error(`Request ${requestId} timed out after ${timeoutMs}ms`); - this.pendingRequests.delete(requestId); - reject(new McpUnityError(ErrorType.TIMEOUT, 'Request timed out')); - } - }, timeoutMs); - - // Store pending request - const pendingBeforeSend = this.pendingRequests.size; - this.pendingRequests.set(requestId, { - resolve, - reject, - timeout - }); - - try { - this.logger.info(`Sending Unity request ${requestId} (${request.method}) while connection state is ${this.connectionState}; pending requests before send: ${pendingBeforeSend}`); - this.connection.send(JSON.stringify(request)); - this.logger.debug(`Request sent: ${requestId}`); - } catch (err) { - clearTimeout(timeout); - this.pendingRequests.delete(requestId); - reject(new McpUnityError(ErrorType.CONNECTION, `Send failed: ${err instanceof Error ? err.message : String(err)}`)); - } - }); - } - - /** - * Check if connected to Unity - * Only returns true if the connection is guaranteed to be active - */ - public get isConnected(): boolean { - return this.connection !== null && this.connection.isConnected; - } - - /** - * Get current connection state - */ - public get connectionState(): ConnectionState { - return this.connection?.connectionState ?? ConnectionState.Disconnected; - } - - /** - * Check if currently connecting or reconnecting - */ - public get isConnecting(): boolean { - return this.connection?.isConnecting ?? false; - } - - /** - * Add a listener for connection state changes - * @param callback Function to call when connection state changes - * @returns Function to remove the listener - */ - public onConnectionStateChange(callback: ConnectionStateCallback): () => void { - this.stateListeners.add(callback); - return () => { - this.stateListeners.delete(callback); - }; - } - - /** - * Force a reconnection to Unity - * Useful when Unity has reloaded and the connection may be stale - */ - public forceReconnect(): void { - if (this.connection) { - this.connection.forceReconnect(); - } else { - this.logger.warn('Cannot force reconnect - not started'); - } - } - - /** - * Get connection statistics - */ - public getConnectionStats(): { - state: ConnectionState; - pendingRequests: number; - reconnectAttempt?: number; - timeSinceLastPong?: number; - } { - const stats = this.connection?.getStats(); - return { - state: stats?.state ?? ConnectionState.Disconnected, - pendingRequests: this.pendingRequests.size, - reconnectAttempt: stats?.reconnectAttempt, - timeSinceLastPong: stats?.timeSinceLastPong - }; - } - - /** - * Read the McpUnitySettings.json file and return its contents as a JSON object. - * @returns a JSON object with the contents of the McpUnitySettings.json file. - */ - private async readConfigFileAsJson(): Promise { - const configPath = MCP_UNITY_SETTINGS_PATH; - try { - const content = await fs.readFile(configPath, 'utf-8'); - const json = JSON.parse(content); - return json; - } catch (err) { - this.logger.debug(`McpUnitySettings.json not found or unreadable: ${err instanceof Error ? err.message : String(err)}`); - return {}; - } - } -} diff --git a/Server~/src/unity/officialUnityMcpClient.ts b/Server~/src/unity/officialUnityMcpClient.ts new file mode 100644 index 00000000..58a61a33 --- /dev/null +++ b/Server~/src/unity/officialUnityMcpClient.ts @@ -0,0 +1,194 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { + CallToolResultSchema, + ErrorCode, + McpError, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; + +export interface OfficialUnitySession { + callTool(name: string, args: Record): Promise; + close(): Promise; +} + +export interface OfficialUnitySessionOptions { + cliPath: string; + projectPath: string; +} + +export type OfficialUnitySessionFactory = ( + options: OfficialUnitySessionOptions, +) => Promise; + +export type UnityConnectionState = + | 'disconnected' + | 'connecting' + | 'connected' + | 'closed'; + +export interface OfficialUnityMcpClientOptions extends OfficialUnitySessionOptions { + sessionFactory?: OfficialUnitySessionFactory; +} + +export class OfficialUnityMcpClient { + private readonly options: OfficialUnitySessionOptions; + private readonly sessionFactory: OfficialUnitySessionFactory; + private session?: OfficialUnitySession; + private sessionPromise?: Promise; + private connectionState: UnityConnectionState = 'disconnected'; + private closed = false; + + constructor(options: OfficialUnityMcpClientOptions) { + this.options = { + cliPath: options.cliPath, + projectPath: options.projectPath, + }; + this.sessionFactory = options.sessionFactory ?? createOfficialUnitySession; + } + + get state(): UnityConnectionState { + return this.connectionState; + } + + async readTool( + name: string, + args: Record, + ): Promise { + this.assertOpen(); + const firstSession = await this.getSession(); + try { + return await firstSession.callTool(name, args); + } catch (firstError) { + if (!isTransportInterruption(firstError)) { + throw firstError; + } + await this.discardSession(firstSession); + this.assertOpen(); + const retrySession = await this.getSession(); + try { + return await retrySession.callTool(name, args); + } catch (retryError) { + await this.discardSession(retrySession); + throw retryError; + } + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.connectionState = 'closed'; + + const pending = this.sessionPromise; + this.sessionPromise = undefined; + const active = this.session; + this.session = undefined; + + if (active) { + await active.close(); + return; + } + if (pending) { + try { + await (await pending).close(); + } catch { + // Startup already failed; there is no live child to close. + } + } + } + + private async getSession(): Promise { + this.assertOpen(); + if (this.session) return this.session; + if (this.sessionPromise) return this.sessionPromise; + + this.connectionState = 'connecting'; + const pending = this.sessionFactory(this.options); + this.sessionPromise = pending; + try { + const created = await pending; + if (this.closed) { + await created.close(); + throw new Error('Official Unity MCP client is closed.'); + } + this.session = created; + this.connectionState = 'connected'; + return created; + } catch (error) { + if (!this.closed) { + this.connectionState = 'disconnected'; + } + throw error; + } finally { + if (this.sessionPromise === pending) { + this.sessionPromise = undefined; + } + } + } + + private async discardSession(candidate: OfficialUnitySession): Promise { + if (this.session === candidate) { + this.session = undefined; + } + if (!this.closed) { + this.connectionState = 'disconnected'; + } + try { + await candidate.close(); + } catch { + // The transport is already broken; retry startup is still safe. + } + } + + private assertOpen(): void { + if (this.closed) { + throw new Error('Official Unity MCP client is closed.'); + } + } +} + +function isTransportInterruption(error: unknown): boolean { + if (error instanceof McpError && error.code === ErrorCode.ConnectionClosed) { + return true; + } + const code = (error as { code?: unknown } | null)?.code; + if ( + typeof code === 'string' && + ['EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ENOTCONN', 'ERR_STREAM_DESTROYED'].includes( + code, + ) + ) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + return /^(Connection closed|Not connected)$/i.test(message.trim()) || + /transport (?:is )?closed/i.test(message) || + /Unity CLI process exited/i.test(message); +} + +async function createOfficialUnitySession( + options: OfficialUnitySessionOptions, +): Promise { + const transport = new StdioClientTransport({ + command: options.cliPath, + args: ['mcp', '--project-path', options.projectPath], + stderr: 'inherit', + }); + const client = new Client( + { name: 'mcp-unity-companion', version: '2.0.0' }, + { capabilities: {} }, + ); + await client.connect(transport); + + return { + callTool: async (name, args) => { + const result = await client.callTool( + { name, arguments: args }, + CallToolResultSchema, + ); + return CallToolResultSchema.parse(result); + }, + close: () => client.close(), + }; +} diff --git a/Server~/src/unity/unityConnection.ts b/Server~/src/unity/unityConnection.ts deleted file mode 100644 index 7d311656..00000000 --- a/Server~/src/unity/unityConnection.ts +++ /dev/null @@ -1,561 +0,0 @@ -import WebSocket from 'ws'; -import { EventEmitter } from 'events'; -import { Logger } from '../utils/logger.js'; -import { McpUnityError, ErrorType } from '../utils/errors.js'; - -/** - * Connection states for the Unity WebSocket connection - */ -export enum ConnectionState { - Disconnected = 'disconnected', - Connecting = 'connecting', - Connected = 'connected', - Reconnecting = 'reconnecting' -} - -/** - * Custom WebSocket close codes for Unity-specific events - * Range 4000-4999 is reserved for application use - */ -export const UnityCloseCode = { - /** Unity is entering Play mode - use fast polling instead of backoff */ - PLAY_MODE: 4001 -} as const; - -/** - * Connection state change event data - */ -export interface ConnectionStateChange { - previousState: ConnectionState; - currentState: ConnectionState; - reason?: string; - attemptNumber?: number; -} - -/** - * Configuration for the Unity connection - */ -export interface UnityConnectionConfig { - host: string; - port: number; - requestTimeout: number; - connectTimeout?: number; - clientName?: string; - - // Reconnection settings - minReconnectDelay?: number; // Default: 1000ms - maxReconnectDelay?: number; // Default: 30000ms - reconnectBackoffMultiplier?: number; // Default: 2 - maxReconnectAttempts?: number; // Default: unlimited (-1) - - // Heartbeat settings - heartbeatInterval?: number; // Default: 30000ms (30 seconds) - heartbeatTimeout?: number; // Default: 5000ms (5 seconds) - - // Play mode settings - playModePollingInterval?: number; // Default: 3000ms (3 seconds) - used instead of backoff during Play mode -} - -/** - * Default configuration values - */ -const DEFAULT_CONFIG = { - connectTimeout: 5000, - minReconnectDelay: 1000, - maxReconnectDelay: 30000, - reconnectBackoffMultiplier: 2, - maxReconnectAttempts: 50, // Prevent unbounded file descriptor accumulation (see #110) - heartbeatInterval: 30000, - heartbeatTimeout: 5000, - playModePollingInterval: 3000 // Fixed 3 second polling during Play mode -}; - -/** - * UnityConnection manages the WebSocket connection to Unity Editor - * with automatic reconnection, exponential backoff, and heartbeat monitoring. - * - * Events: - * - 'stateChange': Emitted when connection state changes - * - 'message': Emitted when a message is received from Unity - * - 'error': Emitted when an error occurs - */ -export class UnityConnection extends EventEmitter { - private logger: Logger; - private config: Required; - private ws: WebSocket | null = null; - private state: ConnectionState = ConnectionState.Disconnected; - - // Reconnection state - private reconnectAttempt: number = 0; - private reconnectTimer: NodeJS.Timeout | null = null; - private connectionTimeoutTimer: NodeJS.Timeout | null = null; - private isManualDisconnect: boolean = false; - private isPlayModeReconnect: boolean = false; // True when reconnecting due to Unity Play mode - - // Heartbeat state - private heartbeatTimer: NodeJS.Timeout | null = null; - private heartbeatTimeoutTimer: NodeJS.Timeout | null = null; - private lastPongTime: number = 0; - private awaitingPong: boolean = false; - - constructor(logger: Logger, config: UnityConnectionConfig) { - super(); - this.logger = logger; - this.config = { - ...DEFAULT_CONFIG, - ...config - } as Required; - } - - /** - * Get the current connection state - */ - public get connectionState(): ConnectionState { - return this.state; - } - - /** - * Check if currently connected - */ - public get isConnected(): boolean { - return this.state === ConnectionState.Connected && - this.ws !== null && - this.ws.readyState === WebSocket.OPEN; - } - - /** - * Check if currently connecting or reconnecting - */ - public get isConnecting(): boolean { - return this.state === ConnectionState.Connecting || - this.state === ConnectionState.Reconnecting; - } - - /** - * Get time since last successful heartbeat response (pong) - */ - public get timeSinceLastPong(): number { - if (this.lastPongTime === 0) return -1; - return Date.now() - this.lastPongTime; - } - - /** - * Update configuration dynamically - */ - public updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; - } - - /** - * Connect to Unity WebSocket server - */ - public async connect(): Promise { - if (this.isConnected) { - this.logger.debug('Already connected to Unity'); - return; - } - - if (this.isConnecting) { - this.logger.debug('Connection already in progress'); - return; - } - - this.isManualDisconnect = false; - return this.doConnect(); - } - - /** - * Disconnect from Unity WebSocket server - */ - public disconnect(reason?: string): void { - this.isManualDisconnect = true; - this.stopReconnectTimer(); - this.stopHeartbeat(); - this.closeWebSocket(reason || 'Manual disconnect'); - this.setState(ConnectionState.Disconnected, reason || 'Manual disconnect'); - } - - /** - * Send a message to Unity - */ - public send(message: string): void { - if (!this.isConnected || !this.ws) { - throw new McpUnityError(ErrorType.CONNECTION, 'Not connected to Unity'); - } - - try { - this.ws.send(message); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - throw new McpUnityError(ErrorType.CONNECTION, `Send failed: ${errorMessage}`); - } - } - - /** - * Get WebSocket instance (for advanced use) - */ - public get webSocket(): WebSocket | null { - return this.ws; - } - - /** - * Internal: Perform the actual connection - */ - private async doConnect(): Promise { - const isReconnecting = this.reconnectAttempt > 0; - this.setState( - isReconnecting ? ConnectionState.Reconnecting : ConnectionState.Connecting, - isReconnecting ? `Reconnection attempt ${this.reconnectAttempt}` : 'Connecting' - ); - - return new Promise((resolve, reject) => { - const wsUrl = `ws://${this.config.host}:${this.config.port}/McpUnity`; - this.logger.debug(`Connecting to ${wsUrl}...`); - - // Create connection options with headers for client identification - const options: WebSocket.ClientOptions = { - headers: { - 'X-Client-Name': this.config.clientName || '' - } - }; - - // Clean up existing socket first - this.closeWebSocket('Preparing new connection'); - - // Create new WebSocket - this.ws = new WebSocket(wsUrl, options); - - // Connection timeout - this.clearConnectionTimeout(); - this.connectionTimeoutTimer = setTimeout(() => { - if (this.ws && this.ws.readyState === WebSocket.CONNECTING) { - this.logger.warn('Connection timeout'); - this.closeWebSocket('Connection timeout'); - - const error = new McpUnityError(ErrorType.CONNECTION, 'Connection timeout'); - this.handleConnectionFailure(error); - reject(error); - } - }, this.config.connectTimeout); - - this.ws.onopen = () => { - this.clearConnectionTimeout(); - this.logger.info('WebSocket connected to Unity'); - - // Reset reconnection state on successful connection - this.reconnectAttempt = 0; - this.isPlayModeReconnect = false; // Clear Play mode flag - this.lastPongTime = Date.now(); - - this.setState(ConnectionState.Connected, 'Connection established'); - this.startHeartbeat(); - resolve(); - }; - - this.ws.onerror = (err) => { - this.clearConnectionTimeout(); - const errorMessage = err.message || 'Unknown error'; - this.logger.error(`WebSocket error: ${errorMessage}`); - - const error = new McpUnityError(ErrorType.CONNECTION, `Connection failed: ${errorMessage}`); - this.emit('error', error); - - // Don't reject here - let onclose handle cleanup and reconnection - }; - - this.ws.onmessage = (event) => { - this.emit('message', event.data.toString()); - }; - - this.ws.onclose = (event) => { - this.clearConnectionTimeout(); - this.stopHeartbeat(); - - const reason = event.reason || `Code: ${event.code}`; - this.logger.debug(`WebSocket closed: ${reason}`); - - // Check if Unity is entering Play mode (custom close code 4001) - if (event.code === UnityCloseCode.PLAY_MODE) { - this.logger.info('Unity entering Play mode - using fast polling for reconnection'); - this.isPlayModeReconnect = true; - } - - // Clear WebSocket reference - this.ws = null; - - // Handle reconnection if not manual disconnect - if (!this.isManualDisconnect) { - this.handleConnectionFailure(new McpUnityError(ErrorType.CONNECTION, reason)); - } else { - this.setState(ConnectionState.Disconnected, reason); - } - - // Reject if we were in initial connection - if (this.state === ConnectionState.Connecting) { - reject(new McpUnityError(ErrorType.CONNECTION, reason)); - } - }; - - // Handle WebSocket ping/pong for heartbeat - this.ws.on('pong', () => { - this.handlePong(); - }); - }); - } - - /** - * Handle connection failure and schedule reconnection - */ - private handleConnectionFailure(error: McpUnityError): void { - if (this.isManualDisconnect) { - this.setState(ConnectionState.Disconnected, 'Manual disconnect'); - return; - } - - // Check max reconnect attempts (skip for Play mode - unlimited retries) - if (!this.isPlayModeReconnect && - this.config.maxReconnectAttempts !== -1 && - this.reconnectAttempt >= this.config.maxReconnectAttempts) { - this.logger.error(`Max reconnection attempts (${this.config.maxReconnectAttempts}) reached`); - this.setState(ConnectionState.Disconnected, 'Max reconnection attempts reached'); - this.emit('error', new McpUnityError(ErrorType.CONNECTION, 'Max reconnection attempts reached')); - return; - } - - // Use fixed polling interval for Play mode, exponential backoff otherwise - const delay = this.isPlayModeReconnect - ? this.config.playModePollingInterval - : this.calculateBackoffDelay(); - - this.reconnectAttempt++; - - const modeInfo = this.isPlayModeReconnect ? ' (Play mode polling)' : ''; - this.logger.info(`Scheduling reconnection attempt ${this.reconnectAttempt} in ${delay}ms${modeInfo}`); - this.setState(ConnectionState.Reconnecting, `Waiting ${delay}ms before attempt ${this.reconnectAttempt}${modeInfo}`); - - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.doConnect().catch((err) => { - this.logger.warn(`Reconnection attempt ${this.reconnectAttempt} failed: ${err.message}`); - }); - }, delay); - } - - /** - * Calculate exponential backoff delay - */ - private calculateBackoffDelay(): number { - const baseDelay = this.config.minReconnectDelay; - const multiplier = this.config.reconnectBackoffMultiplier; - const maxDelay = this.config.maxReconnectDelay; - - // Exponential backoff: base * multiplier^attempt - const delay = baseDelay * Math.pow(multiplier, this.reconnectAttempt); - - // Add jitter (0-20% random variation) to prevent thundering herd - const jitter = delay * 0.2 * Math.random(); - - return Math.min(delay + jitter, maxDelay); - } - - /** - * Stop reconnection timer - */ - private stopReconnectTimer(): void { - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.reconnectAttempt = 0; - } - - /** - * Start heartbeat monitoring - */ - private startHeartbeat(): void { - this.stopHeartbeat(); - - if (this.config.heartbeatInterval <= 0) { - this.logger.debug('Heartbeat disabled'); - return; - } - - this.logger.debug(`Starting heartbeat with ${this.config.heartbeatInterval}ms interval`); - - this.heartbeatTimer = setInterval(() => { - this.sendHeartbeat(); - }, this.config.heartbeatInterval); - } - - /** - * Stop heartbeat monitoring - */ - private stopHeartbeat(): void { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - if (this.heartbeatTimeoutTimer) { - clearTimeout(this.heartbeatTimeoutTimer); - this.heartbeatTimeoutTimer = null; - } - this.awaitingPong = false; - } - - /** - * Send heartbeat ping - */ - private sendHeartbeat(): void { - if (!this.isConnected || !this.ws) { - return; - } - - // If we're still waiting for a pong from the last ping, connection may be stale - if (this.awaitingPong) { - this.logger.warn('No pong received for previous ping, connection may be stale'); - this.handleStaleConnection(); - return; - } - - try { - this.awaitingPong = true; - this.ws.ping(); - this.logger.debug('Heartbeat ping sent'); - - // Set timeout for pong response - this.heartbeatTimeoutTimer = setTimeout(() => { - if (this.awaitingPong) { - this.logger.warn('Heartbeat timeout - no pong received'); - this.handleStaleConnection(); - } - }, this.config.heartbeatTimeout); - } catch (err) { - this.logger.error(`Failed to send heartbeat: ${err instanceof Error ? err.message : String(err)}`); - this.awaitingPong = false; - } - } - - /** - * Handle pong response - */ - private handlePong(): void { - this.awaitingPong = false; - this.lastPongTime = Date.now(); - - if (this.heartbeatTimeoutTimer) { - clearTimeout(this.heartbeatTimeoutTimer); - this.heartbeatTimeoutTimer = null; - } - - this.logger.debug('Heartbeat pong received'); - } - - /** - * Handle stale connection detected by heartbeat - */ - private handleStaleConnection(): void { - this.logger.warn('Stale connection detected, forcing reconnection'); - this.awaitingPong = false; - - // Force close and trigger reconnection - this.closeWebSocket('Stale connection detected'); - this.handleConnectionFailure(new McpUnityError(ErrorType.CONNECTION, 'Stale connection detected')); - } - - /** - * Close WebSocket immediately - * - * Always uses terminate() instead of close() to prevent file descriptor - * accumulation. A graceful close (ws.close()) leaves the socket alive - * during the TCP close handshake, which can overlap with the next - * connection attempt and accumulate file descriptors on the Unity side. - * websocket-sharp uses Mono's IOSelector/select(), which crashes when - * file descriptor values exceed ~1024 (POSIX FD_SETSIZE limit). - * See: https://github.com/CoderGamester/mcp-unity/issues/110 - */ - private closeWebSocket(reason?: string): void { - if (!this.ws) return; - - this.logger.debug(`Closing WebSocket: ${reason || 'No reason'}`); - this.clearConnectionTimeout(); - - // Capture reference and null the field first to prevent any - // event handler from seeing a stale socket during teardown - const socket = this.ws; - this.ws = null; - - // Remove all event handlers before terminating - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.removeAllListeners('pong'); - - try { - // Always terminate immediately — no graceful close handshake. - // This ensures the underlying socket FD is released right away. - socket.terminate(); - } catch (err) { - this.logger.error(`Error closing WebSocket: ${err instanceof Error ? err.message : String(err)}`); - } - } - - private clearConnectionTimeout(): void { - if (this.connectionTimeoutTimer) { - clearTimeout(this.connectionTimeoutTimer); - this.connectionTimeoutTimer = null; - } - } - - /** - * Set connection state and emit event - */ - private setState(newState: ConnectionState, reason?: string): void { - if (this.state === newState) return; - - const previousState = this.state; - this.state = newState; - - const change: ConnectionStateChange = { - previousState, - currentState: newState, - reason, - attemptNumber: this.reconnectAttempt > 0 ? this.reconnectAttempt : undefined - }; - - this.logger.debug(`Connection state: ${previousState} -> ${newState} (${reason || 'no reason'})`); - this.emit('stateChange', change); - } - - /** - * Force a reconnection (useful after Unity domain reload) - */ - public forceReconnect(): void { - this.logger.info('Forcing reconnection...'); - this.isManualDisconnect = false; - this.stopReconnectTimer(); - this.closeWebSocket('Force reconnect'); - this.reconnectAttempt = 0; // Reset attempts for fresh reconnect - - this.doConnect().catch((err) => { - this.logger.warn(`Force reconnect failed: ${err.message}`); - }); - } - - /** - * Get connection statistics - */ - public getStats(): { - state: ConnectionState; - reconnectAttempt: number; - timeSinceLastPong: number; - isAwaitingPong: boolean; - } { - return { - state: this.state, - reconnectAttempt: this.reconnectAttempt, - timeSinceLastPong: this.timeSinceLastPong, - isAwaitingPong: this.awaitingPong - }; - } -} diff --git a/Server~/src/utils/errors.ts b/Server~/src/utils/errors.ts deleted file mode 100644 index 793fdfb9..00000000 --- a/Server~/src/utils/errors.ts +++ /dev/null @@ -1,41 +0,0 @@ -export enum ErrorType { - CONNECTION = 'connection_error', - TOOL_EXECUTION = 'tool_execution_error', - RESOURCE_FETCH = 'resource_fetch_error', - VALIDATION = 'validation_error', - INTERNAL = 'internal_error', - TIMEOUT = 'timeout_error' -} - -export class McpUnityError extends Error { - type: ErrorType; - details?: any; - - constructor(type: ErrorType, message: string, details?: any) { - super(message); - this.type = type; - this.details = details; - this.name = 'McpUnityError'; - } - - toJSON() { - return { - type: this.type, - message: this.message, - details: this.details - }; - } -} - -export function handleError(error: any, context: string): McpUnityError { - if (error instanceof McpUnityError) { - return error; - } - - // Handle standard errors - return new McpUnityError( - ErrorType.INTERNAL, - `${context} error: ${error.message || 'Unknown error'}`, - error - ); -} diff --git a/Server~/src/utils/logger.ts b/Server~/src/utils/logger.ts deleted file mode 100644 index 5c515d8c..00000000 --- a/Server~/src/utils/logger.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { appendFileSync } from 'fs'; - -export enum LogLevel { - DEBUG = 0, - INFO = 1, - WARN = 2, - ERROR = 3 -} - -// Check environment variable for logging -const isLoggingEnabled = process.env.LOGGING === 'true'; - -// Check environment variable for logging in a file -const isLoggingFileEnabled = process.env.LOGGING_FILE === 'true'; - -export class Logger { - private level: LogLevel; - private prefix: string; - - constructor(prefix: string, level: LogLevel = LogLevel.INFO) { - this.prefix = prefix; - this.level = level; - } - - debug(message: string, data?: any) { - this.log(LogLevel.DEBUG, message, data); - } - - info(message: string, data?: any) { - this.log(LogLevel.INFO, message, data); - } - - warn(message: string, data?: any) { - this.log(LogLevel.WARN, message, data); - } - - error(message: string, error?: any) { - this.log(LogLevel.ERROR, message, error); - } - - isLoggingEnabled(): boolean { - return isLoggingEnabled; - } - - isLoggingFileEnabled(): boolean { - return isLoggingFileEnabled; - } - - private log(level: LogLevel, message: string, data?: any) { - if (level < this.level) return; - - const timestamp = new Date().toISOString(); - const levelStr = LogLevel[level]; - const logMessage = `[${timestamp}] [${levelStr}] [${this.prefix}] ${message}`; - - // Write to file if file logging is enabled - 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); - } - } - - // Write to console if logging is enabled - if (this.isLoggingEnabled()) { - if (data) { - console.log(logMessage, data); - } else { - console.log(logMessage); - } - } - } -} From 2f368ca903629788e1ba36e060dc474788060096 Mon Sep 17 00:00:00 2001 From: CoderGamester Date: Fri, 24 Jul 2026 04:20:04 +0100 Subject: [PATCH 08/20] fix: harden Unity CLI companion boundaries --- Server~/build/cli/companionCli.d.ts | 11 +- Server~/build/cli/companionCli.js | 198 ++++++++++++--- Server~/build/companionServer.js | 10 + Server~/build/resources/companionResources.js | 179 ++++++++++---- Server~/build/ui/unity-dashboard.html | 10 +- .../build/unity/officialUnityMcpClient.d.ts | 19 +- Server~/build/unity/officialUnityMcpClient.js | 173 ++++++++----- Server~/src/__tests__/companionCli.test.ts | 161 +++++++++++- .../src/__tests__/companionResources.test.ts | 67 +++++ Server~/src/__tests__/companionServer.test.ts | 27 ++ .../__tests__/officialUnityMcpClient.test.ts | 184 ++++++++++++-- Server~/src/cli/companionCli.ts | 233 +++++++++++++++--- Server~/src/companionServer.ts | 10 + Server~/src/resources/companionResources.ts | 194 ++++++++++++--- Server~/src/ui/unity-dashboard.html | 10 +- Server~/src/unity/officialUnityMcpClient.ts | 195 ++++++++++----- 16 files changed, 1379 insertions(+), 302 deletions(-) diff --git a/Server~/build/cli/companionCli.d.ts b/Server~/build/cli/companionCli.d.ts index a9d86737..b3afe5ef 100644 --- a/Server~/build/cli/companionCli.d.ts +++ b/Server~/build/cli/companionCli.d.ts @@ -1,3 +1,4 @@ +import { type ChildProcess, type SpawnOptions } from 'node:child_process'; export declare const CLI_DOCUMENTATION_URL = "https://docs.unity.com/en-us/unity-cli/use-unity-cli"; export interface CompanionArguments { projectPath: string; @@ -10,11 +11,17 @@ export interface VersionCommandResult { stdout: string; stderr: string; } -export type VersionRunner = (command: string, args: readonly string[]) => Promise; +export type VersionRunner = (command: string, args: readonly string[], options?: VersionRunOptions) => Promise; +export interface VersionRunOptions { + timeoutMs?: number; + signal?: AbortSignal; +} export interface CheckedUnityCli { command: string; version: string; warning?: string; } -export declare function checkUnityCli(command: string, runVersion?: VersionRunner): Promise; +export declare function checkUnityCli(command: string, runVersion?: VersionRunner, options?: VersionRunOptions): Promise; +type SpawnVersionProcess = (command: string, args: readonly string[], options: SpawnOptions) => Pick; +export declare function runUnityCliVersion(command: string, args: readonly string[], options?: VersionRunOptions, spawnProcess?: SpawnVersionProcess): Promise; export {}; diff --git a/Server~/build/cli/companionCli.js b/Server~/build/cli/companionCli.js index 869541cb..5ddc38a4 100644 --- a/Server~/build/cli/companionCli.js +++ b/Server~/build/cli/companionCli.js @@ -1,7 +1,6 @@ -import { execFile } from 'node:child_process'; +import { spawn, } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { promisify } from 'node:util'; export const CLI_DOCUMENTATION_URL = 'https://docs.unity.com/en-us/unity-cli/use-unity-cli'; export function parseCompanionArguments(argv, isUnityProject = defaultUnityProjectValidator) { let projectPath; @@ -37,15 +36,19 @@ export function parseCompanionArguments(argv, isUnityProject = defaultUnityProje if (!isUnityProject(projectPath)) { throw new Error(`--project-path must identify an existing Unity project: ${projectPath}`); } + if (unityCliPath && !path.isAbsolute(unityCliPath)) { + throw new Error('--unity-cli-path must be absolute.'); + } return { projectPath: path.resolve(projectPath), unityCliPath }; } export function resolveUnityCliPath(explicitPath, environment = process.env) { - return explicitPath || environment.UNITY_CLI_PATH || 'unity'; + const environmentPath = environment.UNITY_CLI_PATH?.trim(); + return explicitPath || environmentPath || 'unity'; } -export async function checkUnityCli(command, runVersion = defaultVersionRunner) { +export async function checkUnityCli(command, runVersion = runUnityCliVersion, options = {}) { let output; try { - output = await runVersion(command, ['--version']); + output = await runVersion(command, ['--version'], options); } catch (error) { throw actionableCliError(`Unity CLI could not be started at "${command}": ${errorMessage(error)}`); @@ -65,29 +68,57 @@ export async function checkUnityCli(command, runVersion = defaultVersionRunner) : undefined, }; } -const VERSION_PATTERN = /(?:^|[^0-9])([0-9]+)\.([0-9]+)\.([0-9]+)(?:-(alpha|beta|rc)\.([0-9]+))?(?:\+[0-9A-Za-z.-]+)?(?:$|[^0-9A-Za-z.+-])/i; +const SEMVER_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const MINIMUM_VERSION = { raw: '1.0.0-beta.2', major: 1n, minor: 0n, patch: 0n, - prerelease: { label: 'beta', number: 2n }, + prerelease: [ + { numeric: false, value: 'beta', raw: 'beta' }, + { numeric: true, value: 2n, raw: '2' }, + ], + build: [], }; function parseVersion(output) { - const match = VERSION_PATTERN.exec(output); - if (!match) { + for (const token of output.trim().split(/\s+/)) { + const parsed = parseSemVerToken(token); + if (parsed) + return parsed; + } + return undefined; +} +function parseSemVerToken(token) { + const match = SEMVER_PATTERN.exec(token); + if (!match) return undefined; + const prereleaseTokens = match[4]?.split('.') ?? []; + const prerelease = []; + for (const identifier of prereleaseTokens) { + if (/^[0-9]+$/.test(identifier)) { + if (identifier.length > 1 && identifier.startsWith('0')) + return undefined; + prerelease.push({ + numeric: true, + value: BigInt(identifier), + raw: identifier, + }); + } + else { + prerelease.push({ + numeric: false, + value: identifier, + raw: identifier, + }); + } } - const prereleaseLabel = match[4]?.toLowerCase(); - const raw = `${match[1]}.${match[2]}.${match[3]}${prereleaseLabel ? `-${prereleaseLabel}.${match[5]}` : ''}`; return { - raw, + raw: token, major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), - prerelease: prereleaseLabel - ? { label: prereleaseLabel, number: BigInt(match[5]) } - : undefined, + prerelease, + build: match[5]?.split('.') ?? [], }; } function compareVersion(left, right) { @@ -96,19 +127,35 @@ function compareVersion(left, right) { return left[key] > right[key] ? 1 : -1; } } - if (!left.prerelease && !right.prerelease) + if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; - if (!left.prerelease) + if (left.prerelease.length === 0) return 1; - if (!right.prerelease) + if (right.prerelease.length === 0) return -1; - const order = { alpha: 0, beta: 1, rc: 2 }; - if (left.prerelease.label !== right.prerelease.label) { - return order[left.prerelease.label] > order[right.prerelease.label] ? 1 : -1; + const identifierCount = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < identifierCount; index++) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (!leftIdentifier) + return -1; + if (!rightIdentifier) + return 1; + if (leftIdentifier.numeric && rightIdentifier.numeric) { + if (leftIdentifier.value === rightIdentifier.value) + continue; + return leftIdentifier.value > rightIdentifier.value ? 1 : -1; + } + if (leftIdentifier.numeric !== rightIdentifier.numeric) { + return leftIdentifier.numeric ? -1 : 1; + } + const leftValue = leftIdentifier.value; + const rightValue = rightIdentifier.value; + if (leftValue === rightValue) + continue; + return leftValue > rightValue ? 1 : -1; } - if (left.prerelease.number === right.prerelease.number) - return 0; - return left.prerelease.number > right.prerelease.number ? 1 : -1; + return 0; } function defaultUnityProjectValidator(candidate) { try { @@ -120,12 +167,105 @@ function defaultUnityProjectValidator(candidate) { return false; } } -async function defaultVersionRunner(command, args) { - const result = await promisify(execFile)(command, [...args], { - encoding: 'utf8', - timeout: 10_000, +export async function runUnityCliVersion(command, args, options = {}, spawnProcess = spawn) { + if (args.length !== 1 || args[0] !== '--version') { + throw new Error('Unity CLI validation may invoke only --version.'); + } + if (options.signal?.aborted) { + throw new Error('Unity CLI version check was cancelled.'); + } + const timeoutMs = options.timeoutMs ?? 10_000; + const detached = process.platform !== 'win32'; + return new Promise((resolve, reject) => { + let child; + try { + child = spawnProcess(command, [...args], { + shell: false, + detached, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } + catch (error) { + reject(error); + return; + } + let stdout = ''; + let stderr = ''; + let settled = false; + const maxOutputBytes = 64 * 1024; + const cleanup = () => { + clearTimeout(timeout); + options.signal?.removeEventListener('abort', cancel); + }; + const finish = (error, result) => { + if (settled) + return; + settled = true; + cleanup(); + if (error) + reject(error); + else + resolve(result ?? { stdout, stderr }); + }; + const terminate = () => { + child.stdout?.destroy(); + child.stderr?.destroy(); + if (detached && child.pid) { + try { + process.kill(-child.pid, 'SIGKILL'); + return; + } + catch { + // The process group may already have exited. + } + } + try { + child.kill('SIGKILL'); + } + catch { + // The child already exited. + } + }; + const append = (target, chunk) => { + if (settled) + return; + const value = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + if (target === 'stdout') + stdout += value; + else + stderr += value; + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxOutputBytes) { + terminate(); + finish(new Error('Unity CLI version output exceeded 64 KiB.')); + } + }; + const cancel = () => { + terminate(); + finish(new Error('Unity CLI version check was cancelled.')); + }; + const timeout = setTimeout(() => { + terminate(); + finish(new Error(`Unity CLI version check timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + child.stdout?.on('data', (chunk) => append('stdout', chunk)); + child.stderr?.on('data', (chunk) => append('stderr', chunk)); + child.once('error', (error) => finish(error)); + child.once('close', (code, signal) => { + if (code === 0) { + finish(undefined, { stdout, stderr }); + } + else { + finish(new Error(`Unity CLI --version exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}.`)); + } + }); + if (options.signal?.aborted) { + cancel(); + } + else { + options.signal?.addEventListener('abort', cancel, { once: true }); + } }); - return { stdout: result.stdout, stderr: result.stderr }; } function actionableCliError(message) { return new Error(`${message} Install or update Unity CLI: ${CLI_DOCUMENTATION_URL}`); diff --git a/Server~/build/companionServer.js b/Server~/build/companionServer.js index 562433d9..6de10140 100644 --- a/Server~/build/companionServer.js +++ b/Server~/build/companionServer.js @@ -58,6 +58,16 @@ export function createCompanionServer(resources) { uri: DASHBOARD_URI, mimeType: dashboard.mimeType, text: dashboard.text, + _meta: { + ui: { + csp: { + connectDomains: [], + resourceDomains: [], + frameDomains: [], + baseUriDomains: [], + }, + }, + }, }, ], }; diff --git a/Server~/build/resources/companionResources.js b/Server~/build/resources/companionResources.js index 25c3c7b5..225b3a7d 100644 --- a/Server~/build/resources/companionResources.js +++ b/Server~/build/resources/companionResources.js @@ -134,57 +134,152 @@ function firstText(result) { } function truncateHierarchy(hierarchy, maxNodes) { const sourceRoots = Array.isArray(hierarchy.roots) ? hierarchy.roots : []; - const totalNodes = sourceRoots.reduce((total, root) => total + countHierarchyNodes(root), 0); + const traversalBudget = Math.min(10_000, Math.max(maxNodes * 4, maxNodes + 1024)); + const roots = []; + const frames = []; + const omissionOwners = new Set(); + let rootIndex = 0; + let visitedNodes = 0; let returnedNodes = 0; - const cloneNode = (source) => { - if (!isRecord(source) || returnedNodes >= maxNodes) - return undefined; - returnedNodes++; - const sourceChildren = Array.isArray(source.children) ? source.children : []; - const children = []; - let omittedDescendants = 0; - for (let index = 0; index < sourceChildren.length; index++) { - if (returnedNodes >= maxNodes) { - omittedDescendants += sourceChildren - .slice(index) - .reduce((total, child) => total + countHierarchyNodes(child), 0); + let rootsTruncated = false; + while (visitedNodes < traversalBudget) { + let source; + let parentOutput; + let inheritedOmissionOwner; + let isRoot = false; + while (frames.length > 0) { + const frame = frames[frames.length - 1]; + if (frame.nextChild < frame.children.length) { + source = frame.children[frame.nextChild++]; + parentOutput = frame.output; + inheritedOmissionOwner = frame.omissionOwner; break; } - const child = cloneNode(sourceChildren[index]); - if (child) - children.push(child); + frames.pop(); + } + if (source === undefined) { + if (rootIndex >= sourceRoots.length) + break; + source = sourceRoots[rootIndex++]; + isRoot = true; + } + visitedNodes++; + if (!isRecord(source)) + continue; + let output; + let omissionOwner; + if (returnedNodes < maxNodes && (isRoot || parentOutput !== undefined)) { + output = projectHierarchyNode(source); + returnedNodes++; + if (parentOutput) { + parentOutput.children.push(output); + } + else { + roots.push(output); + } + } + else { + omissionOwner = parentOutput ?? inheritedOmissionOwner; + if (omissionOwner) { + omissionOwner.childrenTruncated = true; + omissionOwner.omittedDescendants = + (omissionOwner.omittedDescendants ?? 0) + 1; + omissionOwners.add(omissionOwner); + } + else { + rootsTruncated = true; + } + } + const children = Array.isArray(source.children) ? source.children : []; + if (children.length > 0) { + frames.push({ + children, + nextChild: 0, + output, + omissionOwner: output ? undefined : omissionOwner, + }); } - return { - ...source, - children, - childrenTruncated: source.childrenTruncated === true || omittedDescendants > 0, - ...(omittedDescendants > 0 ? { omittedDescendants } : {}), - }; - }; - const roots = []; - for (const root of sourceRoots) { - const cloned = cloneNode(root); - if (!cloned) - break; - roots.push(cloned); } - return { - ...hierarchy, - roots, - truncation: { - truncated: returnedNodes < totalNodes, + const hasRemaining = rootIndex < sourceRoots.length || + frames.some((frame) => frame.nextChild < frame.children.length); + const totalNodesKnown = !hasRemaining; + if (totalNodesKnown) { + for (const owner of omissionOwners) { + owner.omittedDescendantsKnown = true; + } + } + else { + if (rootIndex < sourceRoots.length) + rootsTruncated = true; + for (const owner of omissionOwners) { + owner.omittedDescendantsKnown = false; + } + for (const frame of frames) { + if (frame.nextChild >= frame.children.length) + continue; + const owner = frame.output ?? frame.omissionOwner; + if (owner) { + owner.childrenTruncated = true; + owner.omittedDescendantsKnown = false; + } + else { + rootsTruncated = true; + } + } + } + const truncation = totalNodesKnown + ? { + truncated: returnedNodes < visitedNodes, + maxNodes, + traversalBudget, + visitedNodes, + returnedNodes, + totalNodesKnown: true, + totalNodes: visitedNodes, + omittedNodes: visitedNodes - returnedNodes, + rootsTruncated, + } + : { + truncated: true, maxNodes, + traversalBudget, + visitedNodes, returnedNodes, - totalNodes, - omittedNodes: totalNodes - returnedNodes, - }, + totalNodesKnown: false, + totalNodesAtLeast: visitedNodes + 1, + omittedNodesAtLeast: visitedNodes + 1 - returnedNodes, + rootsTruncated, + }; + return { + ...projectHierarchyMetadata(hierarchy), + roots, + truncation, }; } -function countHierarchyNodes(value) { - if (!isRecord(value)) - return 0; - const children = Array.isArray(value.children) ? value.children : []; - return 1 + children.reduce((total, child) => total + countHierarchyNodes(child), 0); +function projectHierarchyNode(source) { + const output = { + children: [], + childrenTruncated: source.childrenTruncated === true, + }; + for (const field of [ + 'name', + 'instanceId', + 'hierarchyPath', + 'activeSelf', + 'components', + ]) { + if (field in source) + output[field] = source[field]; + } + return output; +} +function projectHierarchyMetadata(hierarchy) { + const metadata = {}; + for (const field of ['sceneName', 'scenePath', 'isDirty', 'isActive']) { + if (field in hierarchy) + metadata[field] = hierarchy[field]; + } + return metadata; } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/Server~/build/ui/unity-dashboard.html b/Server~/build/ui/unity-dashboard.html index f68733d6..1ec5a89e 100644 --- a/Server~/build/ui/unity-dashboard.html +++ b/Server~/build/ui/unity-dashboard.html @@ -49,7 +49,7 @@

Unity CLI + Pipeline

ui://unity-dashboard