From 3f3768286e82a4ed3a53e497e992545afe155729 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 16:47:36 -0400 Subject: [PATCH 01/28] Add a function to reload the domain Closes #357 --- MCPForUnity/Editor/Tools/ReloadDomain.cs | 35 +++++++++++++++++++ MCPForUnity/UnityMcpServer~/src/server.py | 6 ++-- .../src/tools/reload_domain.py | 24 +++++++++++++ Server/server.py | 6 ++-- Server/tools/reload_domain.py | 24 +++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/ReloadDomain.cs create mode 100644 MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py create mode 100644 Server/tools/reload_domain.py diff --git a/MCPForUnity/Editor/Tools/ReloadDomain.cs b/MCPForUnity/Editor/Tools/ReloadDomain.cs new file mode 100644 index 00000000..a17623a3 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReloadDomain.cs @@ -0,0 +1,35 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles domain reload operations to refresh Unity's script assemblies. + /// This is essential after creating or modifying scripts to make new types available. + /// + [McpForUnityTool("reload_domain")] + public static class ReloadDomain + { + /// + /// Main handler for domain reload command. + /// Triggers Unity to reload all script assemblies, which is necessary after + /// script changes before new components can be used. + /// + public static object HandleCommand(JObject @params) + { + try + { + McpLog.Info("[ReloadDomain] Requesting domain reload"); + EditorUtility.RequestScriptReload(); + return Response.Success("Domain reload requested. Unity will recompile scripts and refresh assemblies."); + } + catch (Exception ex) + { + McpLog.Error($"[ReloadDomain] Error requesting domain reload: {ex.Message}"); + return Response.Error($"Failed to request domain reload: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index 11053ac8..7f071f1f 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -168,8 +168,10 @@ def _emit_startup(): - `manage_gameobject`: Manages GameObjects in the scene.\n - `manage_script`: Manages C# script files.\n - `manage_asset`: Manages prefabs and assets.\n -- `manage_shader`: Manages shaders.\n\n -- Tips:\n +- `manage_shader`: Manages shaders.\n +- `reload_domain`: Triggers Unity domain reload to recompile scripts and refresh assemblies.\n\n +Tips:\n +- Always reload the domain after creating or modifying scripts before attempting to use them. Use `reload_domain` immediately after script changes, then check `read_console` for compile errors.\n - Create prefabs for reusable GameObjects.\n - Always include a camera and main light in your scenes.\n - Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n diff --git a/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py b/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py new file mode 100644 index 00000000..a25e46ad --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py @@ -0,0 +1,24 @@ +from fastmcp import Context +from models import MCPResponse +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Trigger a Unity domain reload to recompile scripts and refresh assemblies. Essential after creating or modifying scripts before new components can be used." +) +async def reload_domain(ctx: Context) -> MCPResponse: + """ + Request Unity to reload its domain (script assemblies). + This is necessary after: + + - Creating new C# scripts + - Modifying existing scripts + - Before attempting to add new components to GameObjects + + Returns immediately after triggering the reload request. + Unity will handle the actual recompilation asynchronously. + """ + await ctx.info("Requesting Unity domain reload") + result = await async_send_command_with_retry("reload_domain", {}) + return MCPResponse(**result) if isinstance(result, dict) else result diff --git a/Server/server.py b/Server/server.py index 11053ac8..7f071f1f 100644 --- a/Server/server.py +++ b/Server/server.py @@ -168,8 +168,10 @@ def _emit_startup(): - `manage_gameobject`: Manages GameObjects in the scene.\n - `manage_script`: Manages C# script files.\n - `manage_asset`: Manages prefabs and assets.\n -- `manage_shader`: Manages shaders.\n\n -- Tips:\n +- `manage_shader`: Manages shaders.\n +- `reload_domain`: Triggers Unity domain reload to recompile scripts and refresh assemblies.\n\n +Tips:\n +- Always reload the domain after creating or modifying scripts before attempting to use them. Use `reload_domain` immediately after script changes, then check `read_console` for compile errors.\n - Create prefabs for reusable GameObjects.\n - Always include a camera and main light in your scenes.\n - Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n diff --git a/Server/tools/reload_domain.py b/Server/tools/reload_domain.py new file mode 100644 index 00000000..a25e46ad --- /dev/null +++ b/Server/tools/reload_domain.py @@ -0,0 +1,24 @@ +from fastmcp import Context +from models import MCPResponse +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Trigger a Unity domain reload to recompile scripts and refresh assemblies. Essential after creating or modifying scripts before new components can be used." +) +async def reload_domain(ctx: Context) -> MCPResponse: + """ + Request Unity to reload its domain (script assemblies). + This is necessary after: + + - Creating new C# scripts + - Modifying existing scripts + - Before attempting to add new components to GameObjects + + Returns immediately after triggering the reload request. + Unity will handle the actual recompilation asynchronously. + """ + await ctx.info("Requesting Unity domain reload") + result = await async_send_command_with_retry("reload_domain", {}) + return MCPResponse(**result) if isinstance(result, dict) else result From 9b08dcb0026e64ccb2342bfa3727fd14c3a300bd Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 16:48:24 -0400 Subject: [PATCH 02/28] feat: restructure server instructions into workflow-focused format - Reorganized instructions from flat bullet list into categorized workflow sections - Emphasized critical script management workflow with numbered steps - Improved readability and scannability for AI agents using the MCP server It doesn't make sense to repeat the fucnction tools, they're already parsed --- MCPForUnity/UnityMcpServer~/src/server.py | 43 +++++++++++++---------- Server/server.py | 43 +++++++++++++---------- 2 files changed, 50 insertions(+), 36 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index 7f071f1f..d6f77e18 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -159,25 +159,32 @@ def _emit_startup(): name="mcp-for-unity-server", lifespan=server_lifespan, instructions=""" -This server provides tools to interact with the Unity Game Engine Editor.\n\n -Available tools:\n -- `manage_editor`: Controls editor state and queries info.\n -- `execute_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n -- `read_console`: Reads or clears Unity console messages, with filtering options.\n -- `manage_scene`: Manages scenes.\n -- `manage_gameobject`: Manages GameObjects in the scene.\n -- `manage_script`: Manages C# script files.\n -- `manage_asset`: Manages prefabs and assets.\n -- `manage_shader`: Manages shaders.\n -- `reload_domain`: Triggers Unity domain reload to recompile scripts and refresh assemblies.\n\n -Tips:\n -- Always reload the domain after creating or modifying scripts before attempting to use them. Use `reload_domain` immediately after script changes, then check `read_console` for compile errors.\n -- Create prefabs for reusable GameObjects.\n -- Always include a camera and main light in your scenes.\n -- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n -- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n -- Use `execute_menu_item` for interacting with Unity systems and third party tools like a user would.\n +This server provides tools to interact with the Unity Game Engine Editor. +Important Workflows: + +Script Management: +1. After creating or modifying scripts with `manage_script`, ALWAYS call `reload_domain` immediately +2. Wait for Unity to recompile (domain reload is asynchronous) +3. Use `read_console` to check for compilation errors before proceeding +4. Only after successful compilation can new components/types be used + +Scene Setup: +- Always include a Camera and main Light (Directional Light) in new scenes +- Create prefabs with `manage_asset` for reusable GameObjects +- Use `manage_scene` to load, save, and query scene information + +Path Conventions: +- Unless specified otherwise, all paths are relative to the project's `Assets/` folder +- Use forward slashes (/) in paths for cross-platform compatibility + +Console Monitoring: +- Check `read_console` regularly to catch errors, warnings, and compilation status +- Filter by log type (Error, Warning, Log) to focus on specific issues + +Menu Items: +- Use `execute_menu_item` when you have read the menu items resource +- This let's you interact with Unity's menu system and third-party tools """ ) diff --git a/Server/server.py b/Server/server.py index 7f071f1f..d6f77e18 100644 --- a/Server/server.py +++ b/Server/server.py @@ -159,25 +159,32 @@ def _emit_startup(): name="mcp-for-unity-server", lifespan=server_lifespan, instructions=""" -This server provides tools to interact with the Unity Game Engine Editor.\n\n -Available tools:\n -- `manage_editor`: Controls editor state and queries info.\n -- `execute_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n -- `read_console`: Reads or clears Unity console messages, with filtering options.\n -- `manage_scene`: Manages scenes.\n -- `manage_gameobject`: Manages GameObjects in the scene.\n -- `manage_script`: Manages C# script files.\n -- `manage_asset`: Manages prefabs and assets.\n -- `manage_shader`: Manages shaders.\n -- `reload_domain`: Triggers Unity domain reload to recompile scripts and refresh assemblies.\n\n -Tips:\n -- Always reload the domain after creating or modifying scripts before attempting to use them. Use `reload_domain` immediately after script changes, then check `read_console` for compile errors.\n -- Create prefabs for reusable GameObjects.\n -- Always include a camera and main light in your scenes.\n -- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n -- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n -- Use `execute_menu_item` for interacting with Unity systems and third party tools like a user would.\n +This server provides tools to interact with the Unity Game Engine Editor. +Important Workflows: + +Script Management: +1. After creating or modifying scripts with `manage_script`, ALWAYS call `reload_domain` immediately +2. Wait for Unity to recompile (domain reload is asynchronous) +3. Use `read_console` to check for compilation errors before proceeding +4. Only after successful compilation can new components/types be used + +Scene Setup: +- Always include a Camera and main Light (Directional Light) in new scenes +- Create prefabs with `manage_asset` for reusable GameObjects +- Use `manage_scene` to load, save, and query scene information + +Path Conventions: +- Unless specified otherwise, all paths are relative to the project's `Assets/` folder +- Use forward slashes (/) in paths for cross-platform compatibility + +Console Monitoring: +- Check `read_console` regularly to catch errors, warnings, and compilation status +- Filter by log type (Error, Warning, Log) to focus on specific issues + +Menu Items: +- Use `execute_menu_item` when you have read the menu items resource +- This let's you interact with Unity's menu system and third-party tools """ ) From c3186cdfcbffb792ede6268eaf057620c8b56f12 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 16:59:20 -0400 Subject: [PATCH 03/28] docs: reorder tool list alphabetically in README + add reload_domain tool --- README-zh.md | 12 +++++++----- README.md | 13 +++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README-zh.md b/README-zh.md index 9a61bfe7..f52133e7 100644 --- a/README-zh.md +++ b/README-zh.md @@ -38,14 +38,16 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 您的大语言模型可以使用以下功能: - * `read_console`: 获取控制台消息或清除控制台。 - * `manage_script`: 管理 C# 脚本(创建、读取、更新、删除)。 + * `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,"File/Save Project")。 + * `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 * `manage_editor`: 控制和查询编辑器的状态和设置。 + * `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 * `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 - * `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 + * `manage_script`: 管理 C# 脚本(创建、读取、更新、删除)。 * `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 - * `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 - * `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,执行"File/Save Project")。 + * `read_console`: 获取控制台消息或清除控制台。 + * `reload_domain`: 重新加载 Unity 域。 + * `run_test`: 在 Unity 编辑器中运行测试。 * `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 * `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 * `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 diff --git a/README.md b/README.md index f1ca1d39..5206f8c7 100644 --- a/README.md +++ b/README.md @@ -40,18 +40,19 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to Your LLM can use functions like: - * `read_console`: Gets messages from or clears the console. - * `manage_script`: Manages C# scripts (create, read, update, delete). + * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). + * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). * `manage_editor`: Controls and queries the editor's state and settings. + * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). - * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). + * `manage_script`: Manages C# scripts (create, read, update, delete). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). - * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. - * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). + * `read_console`: Gets messages from or clears the console. + * `reload_domain`: Reloads the Unity domain. + * `run_test`: Runs a tests in the Unity Editor. * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. * `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. - * `run_test`: Runs a tests in the Unity Editor. From 932703d02d0050d0868775651b0f736fa31d2478 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 18:40:10 -0400 Subject: [PATCH 04/28] feat: add Unity editor state and project info resources - Implemented resources for querying active tool, editor state, prefab stage, selection, and open windows - Added project configuration resources for layers and project metadata - Organized new resources into Editor and Project namespaces for better structure --- .../Editor/Resources/Editor.meta | 2 +- .../Editor/Resources/Editor/ActiveTool.cs | 64 ++++ .../Resources/Editor/ActiveTool.cs.meta | 11 + .../Editor/Resources/Editor/EditorState.cs | 40 +++ .../Resources/Editor/EditorState.cs.meta | 11 + .../Editor/Resources/Editor/PrefabStage.cs | 43 +++ .../Resources/Editor/PrefabStage.cs.meta | 11 + .../Editor/Resources/Editor/Selection.cs | 52 ++++ .../Editor/Resources/Editor/Selection.cs.meta | 11 + .../Editor/Resources/Editor/Windows.cs | 59 ++++ .../Editor/Resources/Editor/Windows.cs.meta | 11 + MCPForUnity/Editor/Resources/Project.meta | 8 + .../Editor/Resources/Project/Layers.cs | 39 +++ .../Editor/Resources/Project/Layers.cs.meta | 11 + .../Editor/Resources/Project/ProjectInfo.cs | 41 +++ .../Resources/Project/ProjectInfo.cs.meta | 11 + MCPForUnity/Editor/Resources/Project/Tags.cs | 27 ++ .../Editor/Resources/Project/Tags.cs.meta | 11 + .../Editor/Resources/Tests/GetTests.cs | 6 +- MCPForUnity/Editor/Tools/ManageEditor.cs | 285 +----------------- MCPForUnity/Editor/Tools/ReloadDomain.cs.meta | 11 + .../src/resources/active_tool.py | 37 +++ .../src/resources/editor_state.py | 32 ++ .../UnityMcpServer~/src/resources/layers.py | 19 ++ .../src/resources/prefab_stage.py | 29 ++ .../src/resources/project_info.py | 29 ++ .../src/resources/selection.py | 45 +++ .../UnityMcpServer~/src/resources/tags.py | 19 ++ .../UnityMcpServer~/src/resources/windows.py | 37 +++ MCPForUnity/UnityMcpServer~/src/server.py | 7 +- .../Tests/EditMode/MCPToolParameterTests.cs | 1 - .../EditMode/Tools/AIPropertyMatchingTests.cs | 2 - .../EditMode/Tools/CommandRegistryTests.cs | 4 - .../EditMode/Tools/ComponentResolverTests.cs | 1 - .../EditMode/Tools/ManageGameObjectTests.cs | 2 - .../EditMode/Tools/ManagePrefabsTests.cs | 7 +- .../Tools/MaterialMeshInstantiationTests.cs | 4 - 37 files changed, 741 insertions(+), 299 deletions(-) rename TestProjects/UnityMCPTests/Assets/Materials.meta => MCPForUnity/Editor/Resources/Editor.meta (77%) create mode 100644 MCPForUnity/Editor/Resources/Editor/ActiveTool.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/EditorState.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/PrefabStage.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/PrefabStage.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/Selection.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/Selection.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/Windows.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/Windows.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project.meta create mode 100644 MCPForUnity/Editor/Resources/Project/Layers.cs create mode 100644 MCPForUnity/Editor/Resources/Project/Layers.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project/ProjectInfo.cs create mode 100644 MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project/Tags.cs create mode 100644 MCPForUnity/Editor/Resources/Project/Tags.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ReloadDomain.cs.meta create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/active_tool.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/editor_state.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/layers.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/project_info.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/selection.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/tags.py create mode 100644 MCPForUnity/UnityMcpServer~/src/resources/windows.py diff --git a/TestProjects/UnityMCPTests/Assets/Materials.meta b/MCPForUnity/Editor/Resources/Editor.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Materials.meta rename to MCPForUnity/Editor/Resources/Editor.meta index 7ad588cf..5c252d17 100644 --- a/TestProjects/UnityMCPTests/Assets/Materials.meta +++ b/MCPForUnity/Editor/Resources/Editor.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: bacdb2f03a45d448888245e6ac9cca1b +guid: 266967ec2e1df44209bf46ec6037d61d folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs new file mode 100644 index 00000000..0a3fa860 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs @@ -0,0 +1,64 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides information about the currently active editor tool. + /// + [McpForUnityResource("get_active_tool")] + public static class ActiveTool + { + public static object HandleCommand(JObject @params) + { + try + { + Tool currentTool = UnityEditor.Tools.current; + string toolName = currentTool.ToString(); + bool customToolActive = UnityEditor.Tools.current == Tool.Custom; + string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; + + var toolInfo = new + { + activeTool = activeToolName, + isCustom = customToolActive, + pivotMode = UnityEditor.Tools.pivotMode.ToString(), + pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), + handleRotation = new + { + x = UnityEditor.Tools.handleRotation.eulerAngles.x, + y = UnityEditor.Tools.handleRotation.eulerAngles.y, + z = UnityEditor.Tools.handleRotation.eulerAngles.z + }, + handlePosition = new + { + x = UnityEditor.Tools.handlePosition.x, + y = UnityEditor.Tools.handlePosition.y, + z = UnityEditor.Tools.handlePosition.z + } + }; + + return Response.Success("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting active tool: {e.Message}"); + } + } + } + + // Helper class for custom tool names + internal static class EditorTools + { + public static string GetActiveToolName() + { + if (UnityEditor.Tools.current == Tool.Custom) + { + return "Unknown Custom Tool"; + } + return UnityEditor.Tools.current.ToString(); + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta new file mode 100644 index 00000000..a2f03abd --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e78b6227ab7742a8a4f679ee6a8a212 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/MCPForUnity/Editor/Resources/Editor/EditorState.cs new file mode 100644 index 00000000..fdcff7e6 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -0,0 +1,40 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides dynamic editor state information that changes frequently. + /// + [McpForUnityResource("get_editor_state")] + public static class EditorState + { + public static object HandleCommand(JObject @params) + { + try + { + var activeScene = EditorSceneManager.GetActiveScene(); + var state = new + { + isPlaying = EditorApplication.isPlaying, + isPaused = EditorApplication.isPaused, + isCompiling = EditorApplication.isCompiling, + isUpdating = EditorApplication.isUpdating, + timeSinceStartup = EditorApplication.timeSinceStartup, + activeSceneName = activeScene.name ?? "", + selectionCount = UnityEditor.Selection.count, + activeObjectName = UnityEditor.Selection.activeObject?.name + }; + + return Response.Success("Retrieved editor state.", state); + } + catch (Exception e) + { + return Response.Error($"Error getting editor state: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta b/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta new file mode 100644 index 00000000..c6c5efa1 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7c6df54e014c44fdb0cd3f65a479e37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs new file mode 100644 index 00000000..cd7389ab --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs @@ -0,0 +1,43 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor.SceneManagement; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides information about the current prefab editing context. + /// + [McpForUnityResource("get_prefab_stage")] + public static class PrefabStage + { + public static object HandleCommand(JObject @params) + { + try + { + PrefabStageUtility.GetCurrentPrefabStage(); + var stage = PrefabStageUtility.GetCurrentPrefabStage(); + + if (stage == null) + { + return Response.Success("No prefab stage is currently open.", new { isOpen = false }); + } + + var stageInfo = new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot?.name, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + }; + + return Response.Success("Prefab stage info retrieved.", stageInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting prefab stage info: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs.meta b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs.meta new file mode 100644 index 00000000..31bc264c --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a30b083e68bd4ae3b3d1ce5a45a9414 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs b/MCPForUnity/Editor/Resources/Editor/Selection.cs new file mode 100644 index 00000000..07bb34d8 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides detailed information about the current editor selection. + /// + [McpForUnityResource("get_selection")] + public static class Selection + { + public static object HandleCommand(JObject @params) + { + try + { + var selectionInfo = new + { + activeObject = UnityEditor.Selection.activeObject?.name, + activeGameObject = UnityEditor.Selection.activeGameObject?.name, + activeTransform = UnityEditor.Selection.activeTransform?.name, + activeInstanceID = UnityEditor.Selection.activeInstanceID, + count = UnityEditor.Selection.count, + objects = UnityEditor.Selection.objects + .Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceID() + }) + .ToList(), + gameObjects = UnityEditor.Selection.gameObjects + .Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceID() + }) + .ToList(), + assetGUIDs = UnityEditor.Selection.assetGUIDs + }; + + return Response.Success("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting selection: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta b/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta new file mode 100644 index 00000000..2066f11a --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7ea869623e094599a70be086ab4fc0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs b/MCPForUnity/Editor/Resources/Editor/Windows.cs new file mode 100644 index 00000000..a637c1e2 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides list of all open editor windows. + /// + [McpForUnityResource("get_windows")] + public static class Windows + { + public static object HandleCommand(JObject @params) + { + try + { + EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll(); + var openWindows = new List(); + + foreach (EditorWindow window in allWindows) + { + if (window == null) + continue; + + try + { + openWindows.Add(new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height + }, + instanceID = window.GetInstanceID() + }); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not get info for window {window.GetType().Name}: {ex.Message}"); + } + } + + return Response.Success("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return Response.Error($"Error getting editor windows: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta b/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta new file mode 100644 index 00000000..57dd9edc --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58a341e64bea440b29deaf859aaea552 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project.meta b/MCPForUnity/Editor/Resources/Project.meta new file mode 100644 index 00000000..1adf0443 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 538489f13d7914c4eba9a67e29001b43 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs b/MCPForUnity/Editor/Resources/Project/Layers.cs new file mode 100644 index 00000000..eb7f1a30 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides dictionary of layer indices to layer names. + /// + [McpForUnityResource("get_layers")] + public static class Layers + { + private const int TotalLayerCount = 32; + + public static object HandleCommand(JObject @params) + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) + { + layers.Add(i, layerName); + } + } + + return Response.Success("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve layers: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs.meta b/MCPForUnity/Editor/Resources/Project/Layers.cs.meta new file mode 100644 index 00000000..427a7e92 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 959ee428299454ac19a636275208ca00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs new file mode 100644 index 00000000..33069831 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides static project configuration information. + /// + [McpForUnityResource("get_project_info")] + public static class ProjectInfo + { + public static object HandleCommand(JObject @params) + { + try + { + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + string projectName = Path.GetFileName(projectRoot); + + var info = new + { + projectRoot = projectRoot ?? "", + projectName = projectName ?? "", + unityVersion = Application.unityVersion, + platform = EditorUserBuildSettings.activeBuildTarget.ToString(), + assetsPath = assetsPath + }; + + return Response.Success("Retrieved project info.", info); + } + catch (Exception e) + { + return Response.Error($"Error getting project info: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta new file mode 100644 index 00000000..a8eaf02a --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81b03415fcf93466e9ed667d19b58d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs b/MCPForUnity/Editor/Resources/Project/Tags.cs new file mode 100644 index 00000000..665e8d77 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs @@ -0,0 +1,27 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditorInternal; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides list of all tags in the project. + /// + [McpForUnityResource("get_tags")] + public static class Tags + { + public static object HandleCommand(JObject @params) + { + try + { + string[] tags = InternalEditorUtility.tags; + return Response.Success("Retrieved current tags.", tags); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve tags: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs.meta b/MCPForUnity/Editor/Resources/Project/Tags.cs.meta new file mode 100644 index 00000000..3529bea6 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2179ac5d98f264d1681e7d5c0d0ed341 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/MCPForUnity/Editor/Resources/Tests/GetTests.cs index 07a233ab..3efb1c6b 100644 --- a/MCPForUnity/Editor/Resources/Tests/GetTests.cs +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -87,19 +87,19 @@ internal static bool TryParse(string modeStr, out TestMode? mode, out string err return false; } - if (modeStr.Equals("edit", StringComparison.OrdinalIgnoreCase)) + if (modeStr.Equals("EditMode", StringComparison.OrdinalIgnoreCase)) { mode = TestMode.EditMode; return true; } - if (modeStr.Equals("play", StringComparison.OrdinalIgnoreCase)) + if (modeStr.Equals("PlayMode", StringComparison.OrdinalIgnoreCase)) { mode = TestMode.PlayMode; return true; } - error = $"Unknown test mode: '{modeStr}'. Use 'edit' or 'play'"; + error = $"Unknown test mode: '{modeStr}'. Use 'EditMode' or 'PlayMode'"; return false; } } diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 97a20a4e..87e4186f 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -1,19 +1,14 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management -using UnityEditor.SceneManagement; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools { /// - /// Handles operations related to controlling and querying the Unity Editor state, - /// including managing Tags and Layers. + /// Handles editor control actions including play mode control, tool selection, + /// and tag/layer management. For reading editor state, use MCP resources instead. /// [McpForUnityTool("manage_editor")] public static class ManageEditor @@ -89,19 +84,7 @@ public static object HandleCommand(JObject @params) return Response.Error($"Error stopping play mode: {e.Message}"); } - // Editor State/Info - case "get_state": - return GetEditorState(); - case "get_project_root": - return GetProjectRoot(); - case "get_windows": - return GetEditorWindows(); - case "get_active_tool": - return GetActiveTool(); - case "get_selection": - return GetSelection(); - case "get_prefab_stage": - return GetPrefabStageInfo(); + // Tool Control case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) @@ -117,9 +100,6 @@ public static object HandleCommand(JObject @params) if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); - case "get_tags": - return GetTags(); // Helper to list current tags - // Layer Management case "add_layer": if (string.IsNullOrEmpty(layerName)) @@ -129,9 +109,6 @@ public static object HandleCommand(JObject @params) if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); - case "get_layers": - return GetLayers(); // Helper to list current layers - // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject(); @@ -144,167 +121,12 @@ public static object HandleCommand(JObject @params) default: return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } - // --- Editor State/Info Methods --- - private static object GetEditorState() - { - try - { - var state = new - { - isPlaying = EditorApplication.isPlaying, - isPaused = EditorApplication.isPaused, - isCompiling = EditorApplication.isCompiling, - isUpdating = EditorApplication.isUpdating, - applicationPath = EditorApplication.applicationPath, - applicationContentsPath = EditorApplication.applicationContentsPath, - timeSinceStartup = EditorApplication.timeSinceStartup, - }; - return Response.Success("Retrieved editor state.", state); - } - catch (Exception e) - { - return Response.Error($"Error getting editor state: {e.Message}"); - } - } - - private static object GetProjectRoot() - { - try - { - // Application.dataPath points to /Assets - string assetsPath = Application.dataPath.Replace('\\', '/'); - string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); - if (string.IsNullOrEmpty(projectRoot)) - { - return Response.Error("Could not determine project root from Application.dataPath"); - } - return Response.Success("Project root resolved.", new { projectRoot }); - } - catch (Exception e) - { - return Response.Error($"Error getting project root: {e.Message}"); - } - } - - private static object GetEditorWindows() - { - try - { - // Get all types deriving from EditorWindow - var windowTypes = AppDomain - .CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) - .Where(type => type.IsSubclassOf(typeof(EditorWindow))) - .ToList(); - - var openWindows = new List(); - - // Find currently open instances - // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows - EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll(); - - foreach (EditorWindow window in allWindows) - { - if (window == null) - continue; // Skip potentially destroyed windows - - try - { - openWindows.Add( - new - { - title = window.titleContent.text, - typeName = window.GetType().FullName, - isFocused = EditorWindow.focusedWindow == window, - position = new - { - x = window.position.x, - y = window.position.y, - width = window.position.width, - height = window.position.height, - }, - instanceID = window.GetInstanceID(), - } - ); - } - catch (Exception ex) - { - Debug.LogWarning( - $"Could not get info for window {window.GetType().Name}: {ex.Message}" - ); - } - } - - return Response.Success("Retrieved list of open editor windows.", openWindows); - } - catch (Exception e) - { - return Response.Error($"Error getting editor windows: {e.Message}"); - } - } - - private static object GetPrefabStageInfo() - { - try - { - PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); - if (stage == null) - { - return Response.Success - ("No prefab stage is currently open.", new { isOpen = false }); - } - - return Response.Success( - "Prefab stage info retrieved.", - new - { - isOpen = true, - assetPath = stage.assetPath, - prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, - mode = stage.mode.ToString(), - isDirty = stage.scene.isDirty - } - ); - } - catch (Exception e) - { - return Response.Error($"Error getting prefab stage info: {e.Message}"); - } - } - - private static object GetActiveTool() - { - try - { - Tool currentTool = UnityEditor.Tools.current; - string toolName = currentTool.ToString(); // Enum to string - bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active - string activeToolName = customToolActive - ? EditorTools.GetActiveToolName() - : toolName; // Get custom name if needed - - var toolInfo = new - { - activeTool = activeToolName, - isCustom = customToolActive, - pivotMode = UnityEditor.Tools.pivotMode.ToString(), - pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), - handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity - handlePosition = UnityEditor.Tools.handlePosition, - }; - - return Response.Success("Retrieved active tool information.", toolInfo); - } - catch (Exception e) - { - return Response.Error($"Error getting active tool: {e.Message}"); - } - } + // --- Tool Control Methods --- private static object SetActiveTool(string toolName) { @@ -341,43 +163,6 @@ private static object SetActiveTool(string toolName) } } - private static object GetSelection() - { - try - { - var selectionInfo = new - { - activeObject = Selection.activeObject?.name, - activeGameObject = Selection.activeGameObject?.name, - activeTransform = Selection.activeTransform?.name, - activeInstanceID = Selection.activeInstanceID, - count = Selection.count, - objects = Selection - .objects.Select(obj => new - { - name = obj?.name, - type = obj?.GetType().FullName, - instanceID = obj?.GetInstanceID(), - }) - .ToList(), - gameObjects = Selection - .gameObjects.Select(go => new - { - name = go?.name, - instanceID = go?.GetInstanceID(), - }) - .ToList(), - assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view - }; - - return Response.Success("Retrieved current selection details.", selectionInfo); - } - catch (Exception e) - { - return Response.Error($"Error getting selection: {e.Message}"); - } - } - // --- Tag Management Methods --- private static object AddTag(string tagName) @@ -386,7 +171,7 @@ private static object AddTag(string tagName) return Response.Error("Tag name cannot be empty or whitespace."); // Check if tag already exists - if (InternalEditorUtility.tags.Contains(tagName)) + if (System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { return Response.Error($"Tag '{tagName}' already exists."); } @@ -413,7 +198,7 @@ private static object RemoveTag(string tagName) return Response.Error("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal - if (!InternalEditorUtility.tags.Contains(tagName)) + if (!System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) { return Response.Error($"Tag '{tagName}' does not exist."); } @@ -433,19 +218,6 @@ private static object RemoveTag(string tagName) } } - private static object GetTags() - { - try - { - string[] tags = InternalEditorUtility.tags; - return Response.Success("Retrieved current tags.", tags); - } - catch (Exception e) - { - return Response.Error($"Failed to retrieve tags: {e.Message}"); - } - } - // --- Layer Management Methods --- private static object AddLayer(string layerName) @@ -569,27 +341,6 @@ private static object RemoveLayer(string layerName) } } - private static object GetLayers() - { - try - { - var layers = new Dictionary(); - for (int i = 0; i < TotalLayerCount; i++) - { - string layerName = LayerMask.LayerToName(i); - if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names - { - layers.Add(i, layerName); - } - } - return Response.Success("Retrieved current named layers.", layers); - } - catch (Exception e) - { - return Response.Error($"Failed to retrieve layers: {e.Message}"); - } - } - // --- Helper Methods --- /// @@ -605,7 +356,7 @@ private static SerializedObject GetTagManager() ); if (tagManagerAssets == null || tagManagerAssets.Length == 0) { - Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); + McpLog.Error("[ManageEditor] TagManager.asset not found in ProjectSettings."); return null; } // The first object in the asset file should be the TagManager @@ -613,7 +364,7 @@ private static SerializedObject GetTagManager() } catch (Exception e) { - Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); + McpLog.Error($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); return null; } } @@ -624,22 +375,4 @@ private static object SetGameViewResolution(int width, int height) { ... } private static object SetQualityLevel(JToken qualityLevelToken) { ... } */ } - - // Helper class to get custom tool names (remains the same) - internal static class EditorTools - { - public static string GetActiveToolName() - { - // This is a placeholder. Real implementation depends on how custom tools - // are registered and tracked in the specific Unity project setup. - // It might involve checking static variables, calling methods on specific tool managers, etc. - if (UnityEditor.Tools.current == Tool.Custom) - { - // Example: Check a known custom tool manager - // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; - return "Unknown Custom Tool"; - } - return UnityEditor.Tools.current.ToString(); - } - } } diff --git a/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta b/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta new file mode 100644 index 00000000..f6605fb4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7ce9d740b73445d3a28d1df15891bdf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py new file mode 100644 index 00000000..15005630 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class Vector3(BaseModel): + """3D vector.""" + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + +class ActiveToolData(BaseModel): + """Active tool data fields.""" + activeTool: str = "" + isCustom: bool = False + pivotMode: str = "" + pivotRotation: str = "" + handleRotation: Vector3 = Vector3() + handlePosition: Vector3 = Vector3() + + +class ActiveToolResponse(MCPResponse): + """Information about the currently active editor tool.""" + data: ActiveToolData = ActiveToolData() + + +@mcp_for_unity_resource( + uri="unity://editor/active-tool", + name="editor_active_tool", + description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings." +) +async def get_active_tool() -> ActiveToolResponse: + """Get active editor tool information.""" + response = await async_send_command_with_retry("get_active_tool", {}) + return ActiveToolResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py new file mode 100644 index 00000000..09882621 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class EditorStateData(BaseModel): + """Editor state data fields.""" + isPlaying: bool = False + isPaused: bool = False + isCompiling: bool = False + isUpdating: bool = False + timeSinceStartup: float = 0.0 + activeSceneName: str = "" + selectionCount: int = 0 + activeObjectName: str | None = None + + +class EditorStateResponse(MCPResponse): + """Dynamic editor state information that changes frequently.""" + data: EditorStateData = EditorStateData() + + +@mcp_for_unity_resource( + uri="unity://editor/state", + name="editor_state", + description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information." +) +async def get_editor_state() -> EditorStateResponse: + """Get current editor runtime state.""" + response = await async_send_command_with_retry("get_editor_state", {}) + return EditorStateResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/layers.py b/MCPForUnity/UnityMcpServer~/src/resources/layers.py new file mode 100644 index 00000000..330460f9 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/layers.py @@ -0,0 +1,19 @@ +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class LayersResponse(MCPResponse): + """Dictionary of layer indices to layer names.""" + data: dict[int, str] = {} + + +@mcp_for_unity_resource( + uri="unity://project/layers", + name="project_layers", + description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools." +) +async def get_layers() -> LayersResponse: + """Get all project layers with their indices.""" + response = await async_send_command_with_retry("get_layers", {}) + return LayersResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py new file mode 100644 index 00000000..8222195b --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class PrefabStageData(BaseModel): + """Prefab stage data fields.""" + isOpen: bool = False + assetPath: str | None = None + prefabRootName: str | None = None + mode: str | None = None + isDirty: bool = False + + +class PrefabStageResponse(MCPResponse): + """Information about the current prefab editing context.""" + data: PrefabStageData = PrefabStageData() + + +@mcp_for_unity_resource( + uri="unity://editor/prefab-stage", + name="editor_prefab_stage", + description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited." +) +async def get_prefab_stage() -> PrefabStageResponse: + """Get current prefab stage information.""" + response = await async_send_command_with_retry("get_prefab_stage", {}) + return PrefabStageResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/project_info.py b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py new file mode 100644 index 00000000..59e41beb --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class ProjectInfoData(BaseModel): + """Project info data fields.""" + projectRoot: str = "" + projectName: str = "" + unityVersion: str = "" + platform: str = "" + assetsPath: str = "" + + +class ProjectInfoResponse(MCPResponse): + """Static project configuration information.""" + data: ProjectInfoData = ProjectInfoData() + + +@mcp_for_unity_resource( + uri="unity://project/info", + name="project_info", + description="Static project information including root path, Unity version, and platform. This data rarely changes." +) +async def get_project_info() -> ProjectInfoResponse: + """Get static project configuration information.""" + response = await async_send_command_with_retry("get_project_info", {}) + return ProjectInfoResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/selection.py b/MCPForUnity/UnityMcpServer~/src/resources/selection.py new file mode 100644 index 00000000..6aa09ab0 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/selection.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class SelectionObjectInfo(BaseModel): + """Information about a selected object.""" + name: str | None = None + type: str | None = None + instanceID: int | None = None + + +class SelectionGameObjectInfo(BaseModel): + """Information about a selected GameObject.""" + name: str | None = None + instanceID: int | None = None + + +class SelectionData(BaseModel): + """Selection data fields.""" + activeObject: str | None = None + activeGameObject: str | None = None + activeTransform: str | None = None + activeInstanceID: int = 0 + count: int = 0 + objects: list[SelectionObjectInfo] = [] + gameObjects: list[SelectionGameObjectInfo] = [] + assetGUIDs: list[str] = [] + + +class SelectionResponse(MCPResponse): + """Detailed information about the current editor selection.""" + data: SelectionData = SelectionData() + + +@mcp_for_unity_resource( + uri="unity://editor/selection", + name="editor_selection", + description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties." +) +async def get_selection() -> SelectionResponse: + """Get detailed editor selection information.""" + response = await async_send_command_with_retry("get_selection", {}) + return SelectionResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tags.py b/MCPForUnity/UnityMcpServer~/src/resources/tags.py new file mode 100644 index 00000000..713f30d2 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/tags.py @@ -0,0 +1,19 @@ +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class TagsResponse(MCPResponse): + """List of all tags in the project.""" + data: list[str] = [] + + +@mcp_for_unity_resource( + uri="unity://project/tags", + name="project_tags", + description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools." +) +async def get_tags() -> TagsResponse: + """Get all project tags.""" + response = await async_send_command_with_retry("get_tags", {}) + return TagsResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/windows.py b/MCPForUnity/UnityMcpServer~/src/resources/windows.py new file mode 100644 index 00000000..4d8b882b --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/resources/windows.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel +from models import MCPResponse +from registry import mcp_for_unity_resource +from unity_connection import async_send_command_with_retry + + +class WindowPosition(BaseModel): + """Window position and size.""" + x: float = 0.0 + y: float = 0.0 + width: float = 0.0 + height: float = 0.0 + + +class WindowInfo(BaseModel): + """Information about an editor window.""" + title: str = "" + typeName: str = "" + isFocused: bool = False + position: WindowPosition = WindowPosition() + instanceID: int = 0 + + +class WindowsResponse(MCPResponse): + """List of all open editor windows.""" + data: list[WindowInfo] = [] + + +@mcp_for_unity_resource( + uri="unity://editor/windows", + name="editor_windows", + description="All currently open editor windows with their titles, types, positions, and focus state." +) +async def get_windows() -> WindowsResponse: + """Get all open editor windows.""" + response = await async_send_command_with_retry("get_windows", {}) + return WindowsResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index d6f77e18..c8975746 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -163,9 +163,14 @@ def _emit_startup(): Important Workflows: +Resources vs Tools: +- Use RESOURCES to read editor state (editor_state, project_info, project_tags, tests, etc) +- Use TOOLS to perform actions and mutations (manage_editor for play mode control, tag/layer management) +- Always check related resources before modifying the engine state with tools + Script Management: 1. After creating or modifying scripts with `manage_script`, ALWAYS call `reload_domain` immediately -2. Wait for Unity to recompile (domain reload is asynchronous) +2. Wait for Unity to recompile (domain reload is asynchronous) by checking the `editor_state` resource 3. Use `read_console` to check for compilation errors before proceeding 4. Only after successful compilation can new components/types be used diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs index e18c014d..96ad9469 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs @@ -1,7 +1,6 @@ using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; -using System.Collections; using UnityEditor; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs index e52c7d0b..4512d919 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Tools; -using static MCPForUnity.Editor.Tools.ManageGameObject; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs index ed8ef3c6..1637a5b4 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using NUnit.Framework; using MCPForUnity.Editor.Tools; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs index 5ab03e80..e9af2580 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs @@ -2,7 +2,6 @@ using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Tools; -using static MCPForUnity.Editor.Tools.ManageGameObject; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs index 0df26d34..ee05c0c3 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Collections; using NUnit.Framework; using UnityEngine; -using UnityEditor; using UnityEngine.TestTools; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs index 44288457..a562f667 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -5,7 +5,6 @@ using UnityEditor.SceneManagement; using UnityEngine; using MCPForUnity.Editor.Tools.Prefabs; -using MCPForUnity.Editor.Tools; namespace MCPForUnityTests.Editor.Tools { @@ -53,11 +52,11 @@ public void OpenStage_OpensPrefabInIsolation() Assert.IsTrue(openResult.Value("success"), "open_stage should succeed for a valid prefab."); - PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); Assert.IsNotNull(stage, "Prefab stage should be open after open_stage."); Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path."); - var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" })); + var stageInfo = ToJObject(MCPForUnity.Editor.Resources.Editor.PrefabStage.HandleCommand(new JObject())); Assert.IsTrue(stageInfo.Value("success"), "get_prefab_stage should succeed when stage is open."); var data = stageInfo["data"] as JObject; @@ -125,7 +124,7 @@ public void SaveOpenStage_SavesDirtyChanges() ["prefabPath"] = prefabPath }); - PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + UnityEditor.SceneManagement.PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); Assert.IsNotNull(stage, "Stage should be open before modifying."); stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs index fb453ddc..7ff9c903 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs @@ -1,10 +1,6 @@ -using System; using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using UnityEngine; -using UnityEditor; -using UnityEngine.TestTools; using MCPForUnity.Editor.Helpers; namespace MCPForUnityTests.Editor.Tools From 2ea5cfaa35bbbe45113b9f1b052f2f616233eb1f Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 18:47:34 -0400 Subject: [PATCH 05/28] feat: clarify script management workflow in system prompt - Expanded guidance to include scripts created by any tool, not just manage_script - Added "etc" to tools examples for better clarity --- MCPForUnity/UnityMcpServer~/src/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index c8975746..00b01bbd 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -165,11 +165,11 @@ def _emit_startup(): Resources vs Tools: - Use RESOURCES to read editor state (editor_state, project_info, project_tags, tests, etc) -- Use TOOLS to perform actions and mutations (manage_editor for play mode control, tag/layer management) +- Use TOOLS to perform actions and mutations (manage_editor for play mode control, tag/layer management, etc) - Always check related resources before modifying the engine state with tools Script Management: -1. After creating or modifying scripts with `manage_script`, ALWAYS call `reload_domain` immediately +1. After creating or modifying scripts (by your own tools or the `manage_script` tool), ALWAYS call `reload_domain` immediately 2. Wait for Unity to recompile (domain reload is asynchronous) by checking the `editor_state` resource 3. Use `read_console` to check for compilation errors before proceeding 4. Only after successful compilation can new components/types be used From fb9f7e49adea255c97bd2343d6b91133dea31197 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 4 Nov 2025 19:27:23 -0400 Subject: [PATCH 06/28] refactor: remove reload_domain tool and update script management workflow - Removed reload_domain tool as Unity automatically recompiles scripts when modified - Updated script management instructions to rely on editor_state polling and console checking instead of manual domain reload - Simplified workflow by removing unnecessary manual recompilation step --- MCPForUnity/Editor/Tools/ReloadDomain.cs | 35 ------------------- MCPForUnity/Editor/Tools/ReloadDomain.cs.meta | 11 ------ MCPForUnity/UnityMcpServer~/src/server.py | 7 ++-- .../src/tools/reload_domain.py | 24 ------------- 4 files changed, 3 insertions(+), 74 deletions(-) delete mode 100644 MCPForUnity/Editor/Tools/ReloadDomain.cs delete mode 100644 MCPForUnity/Editor/Tools/ReloadDomain.cs.meta delete mode 100644 MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py diff --git a/MCPForUnity/Editor/Tools/ReloadDomain.cs b/MCPForUnity/Editor/Tools/ReloadDomain.cs deleted file mode 100644 index a17623a3..00000000 --- a/MCPForUnity/Editor/Tools/ReloadDomain.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using MCPForUnity.Editor.Helpers; -using Newtonsoft.Json.Linq; -using UnityEditor; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles domain reload operations to refresh Unity's script assemblies. - /// This is essential after creating or modifying scripts to make new types available. - /// - [McpForUnityTool("reload_domain")] - public static class ReloadDomain - { - /// - /// Main handler for domain reload command. - /// Triggers Unity to reload all script assemblies, which is necessary after - /// script changes before new components can be used. - /// - public static object HandleCommand(JObject @params) - { - try - { - McpLog.Info("[ReloadDomain] Requesting domain reload"); - EditorUtility.RequestScriptReload(); - return Response.Success("Domain reload requested. Unity will recompile scripts and refresh assemblies."); - } - catch (Exception ex) - { - McpLog.Error($"[ReloadDomain] Error requesting domain reload: {ex.Message}"); - return Response.Error($"Failed to request domain reload: {ex.Message}"); - } - } - } -} diff --git a/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta b/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta deleted file mode 100644 index f6605fb4..00000000 --- a/MCPForUnity/Editor/Tools/ReloadDomain.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: f7ce9d740b73445d3a28d1df15891bdf -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index 00b01bbd..74c93a6d 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -169,10 +169,9 @@ def _emit_startup(): - Always check related resources before modifying the engine state with tools Script Management: -1. After creating or modifying scripts (by your own tools or the `manage_script` tool), ALWAYS call `reload_domain` immediately -2. Wait for Unity to recompile (domain reload is asynchronous) by checking the `editor_state` resource -3. Use `read_console` to check for compilation errors before proceeding -4. Only after successful compilation can new components/types be used +- After creating or modifying scripts (by your own tools or the `manage_script` tool) use `read_console` to check for compilation errors before proceeding +- Only after successful compilation can new components/types be used +- You can poll the `editor_state` resource's `isCompiling` field to check if the domain reload is complete Scene Setup: - Always include a Camera and main Light (Directional Light) in new scenes diff --git a/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py b/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py deleted file mode 100644 index a25e46ad..00000000 --- a/MCPForUnity/UnityMcpServer~/src/tools/reload_domain.py +++ /dev/null @@ -1,24 +0,0 @@ -from fastmcp import Context -from models import MCPResponse -from registry import mcp_for_unity_tool -from unity_connection import async_send_command_with_retry - - -@mcp_for_unity_tool( - description="Trigger a Unity domain reload to recompile scripts and refresh assemblies. Essential after creating or modifying scripts before new components can be used." -) -async def reload_domain(ctx: Context) -> MCPResponse: - """ - Request Unity to reload its domain (script assemblies). - This is necessary after: - - - Creating new C# scripts - - Modifying existing scripts - - Before attempting to add new components to GameObjects - - Returns immediately after triggering the reload request. - Unity will handle the actual recompilation asynchronously. - """ - await ctx.info("Requesting Unity domain reload") - result = await async_send_command_with_retry("reload_domain", {}) - return MCPResponse(**result) if isinstance(result, dict) else result From 4d88c4eb94d072c39b3b300272a6e2568b5dab66 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 11:54:00 -0400 Subject: [PATCH 07/28] Change name of menu items resource as the LLM seems it --- MCPForUnity/UnityMcpServer~/src/resources/menu_items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py index d3724659..060984af 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py @@ -9,7 +9,7 @@ class GetMenuItemsResponse(MCPResponse): @mcp_for_unity_resource( uri="mcpforunity://menu-items", - name="get_menu_items", + name="menu_items", description="Provides a list of all menu items." ) async def get_menu_items() -> GetMenuItemsResponse: From bec63f49b86d03bc0a7342669c5d43591fc9e574 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:01:22 -0400 Subject: [PATCH 08/28] refactor: reorganize tests into src/tests/integration directory - Moved all test files from root tests/ to MCPForUnity/UnityMcpServer~/src/tests/integration/ for better organization - Added conftest.py with telemetry and dependency stubs to simplify test setup - Removed redundant path manipulation and module loading code from individual test files --- .../UnityMcpServer~/src/pyproject.toml | 2 +- .../UnityMcpServer~/src/tests/__init__.py | 0 .../src/tests/integration}/__init__.py | 0 .../src/tests/integration/conftest.py | 48 ++++++++++++ .../test_edit_normalization_and_noop.py | 24 +----- .../test_edit_strict_and_warnings.py | 51 +------------ .../integration}/test_find_in_file_minimal.py | 31 +------- .../src/tests/integration}/test_get_sha.py | 25 +------ .../src/tests/integration}/test_helpers.py | 0 .../test_improved_anchor_matching.py | 22 +----- .../integration}/test_json_parsing_simple.py | 0 .../tests/integration}/test_logging_stdout.py | 0 .../test_manage_asset_json_parsing.py | 0 .../test_manage_asset_param_coercion.py | 34 +++++++++ .../test_manage_gameobject_param_coercion.py | 31 ++++++++ .../integration}/test_manage_script_uri.py | 42 +---------- .../test_read_console_truncate.py | 24 +----- .../test_read_resource_minimal.py | 28 +------ .../tests/integration}/test_resources_api.py | 32 +------- .../tests/integration}/test_script_editing.py | 0 .../tests/integration}/test_script_tools.py | 26 +------ .../test_telemetry_endpoint_validation.py | 27 +------ .../test_telemetry_queue_worker.py | 53 +------------- .../integration}/test_telemetry_subaction.py | 5 +- .../integration}/test_transport_framing.py | 2 +- .../test_validate_script_summary.py | 24 +----- .../UnityMcpServer~/src/tests/pytest.ini | 8 ++ pytest.ini | 4 - tests/conftest.py | 28 ------- tests/test_manage_asset_param_coercion.py | 73 ------------------- .../test_manage_gameobject_param_coercion.py | 71 ------------------ 31 files changed, 141 insertions(+), 574 deletions(-) create mode 100644 MCPForUnity/UnityMcpServer~/src/tests/__init__.py rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/__init__.py (100%) create mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/conftest.py rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_edit_normalization_and_noop.py (89%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_edit_strict_and_warnings.py (62%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_find_in_file_minimal.py (61%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_get_sha.py (75%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_helpers.py (100%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_improved_anchor_matching.py (87%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_json_parsing_simple.py (100%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_logging_stdout.py (100%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_manage_asset_json_parsing.py (100%) create mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py create mode 100644 MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_manage_script_uri.py (80%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_read_console_truncate.py (79%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_read_resource_minimal.py (69%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_resources_api.py (71%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_script_editing.py (100%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_script_tools.py (90%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_telemetry_endpoint_validation.py (72%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_telemetry_queue_worker.py (52%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_telemetry_subaction.py (95%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_transport_framing.py (99%) rename {tests => MCPForUnity/UnityMcpServer~/src/tests/integration}/test_validate_script_summary.py (73%) create mode 100644 MCPForUnity/UnityMcpServer~/src/tests/pytest.ini delete mode 100644 pytest.ini delete mode 100644 tests/conftest.py delete mode 100644 tests/test_manage_asset_param_coercion.py delete mode 100644 tests/test_manage_gameobject_param_coercion.py diff --git a/MCPForUnity/UnityMcpServer~/src/pyproject.toml b/MCPForUnity/UnityMcpServer~/src/pyproject.toml index 6dd28065..1046ba72 100644 --- a/MCPForUnity/UnityMcpServer~/src/pyproject.toml +++ b/MCPForUnity/UnityMcpServer~/src/pyproject.toml @@ -37,4 +37,4 @@ py-modules = [ "telemetry_decorator", "unity_connection" ] -packages = ["tools", "resources", "registry"] +packages = ["tools", "resources", "registry", "tests", "tests.integration"] diff --git a/MCPForUnity/UnityMcpServer~/src/tests/__init__.py b/MCPForUnity/UnityMcpServer~/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/__init__.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/__init__.py similarity index 100% rename from tests/__init__.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/__init__.py diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/conftest.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/conftest.py new file mode 100644 index 00000000..9956702c --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/conftest.py @@ -0,0 +1,48 @@ +import os +import sys +import types + +# Ensure telemetry is disabled during test collection and execution to avoid +# any background network or thread startup that could slow or block pytest. +os.environ.setdefault("DISABLE_TELEMETRY", "true") +os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true") +os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true") + +# NOTE: These tests are integration tests for the MCP server Python code. +# They test tools, resources, and utilities without requiring Unity to be running. +# Tests can now import directly from the parent package since they're inside src/ +# To run: cd MCPForUnity/UnityMcpServer~/src && uv run pytest tests/integration/ -v + +# Stub telemetry modules to avoid file I/O during import of tools package +telemetry = types.ModuleType("telemetry") +def _noop(*args, **kwargs): + pass +class MilestoneType: + pass +telemetry.record_resource_usage = _noop +telemetry.record_tool_usage = _noop +telemetry.record_milestone = _noop +telemetry.MilestoneType = MilestoneType +telemetry.get_package_version = lambda: "0.0.0" +sys.modules.setdefault("telemetry", telemetry) + +telemetry_decorator = types.ModuleType("telemetry_decorator") +def telemetry_tool(*dargs, **dkwargs): + def _wrap(fn): + return fn + return _wrap +telemetry_decorator.telemetry_tool = telemetry_tool +sys.modules.setdefault("telemetry_decorator", telemetry_decorator) + +# Stub fastmcp module (not mcp.server.fastmcp) +fastmcp = types.ModuleType("fastmcp") + +class _DummyFastMCP: + pass + +class _DummyContext: + pass + +fastmcp.FastMCP = _DummyFastMCP +fastmcp.Context = _DummyContext +sys.modules.setdefault("fastmcp", fastmcp) diff --git a/tests/test_edit_normalization_and_noop.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py similarity index 89% rename from tests/test_edit_normalization_and_noop.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py index c2232fc4..fb37de3f 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py @@ -1,23 +1,4 @@ -import sys -import pathlib -import importlib.util - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") -manage_script_edits = _load( - SRC / "tools" / "script_apply_edits.py", "script_apply_edits_mod2") +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -28,9 +9,6 @@ def deco(fn): self.tools[fn.__name__] = fn; return fn return deco -from tests.test_helpers import DummyContext - - def setup_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/tests/test_edit_strict_and_warnings.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py similarity index 62% rename from tests/test_edit_strict_and_warnings.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py index ba5ed06b..5db5a50d 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py @@ -1,53 +1,4 @@ -import sys -import pathlib -import importlib.util -import types - -from tests.test_helpers import DummyContext - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - -# Stub telemetry modules to avoid file I/O during import of tools package -telemetry = types.ModuleType("telemetry") -def _noop(*args, **kwargs): - pass -class MilestoneType: - pass -telemetry.record_resource_usage = _noop -telemetry.record_tool_usage = _noop -telemetry.record_milestone = _noop -telemetry.MilestoneType = MilestoneType -telemetry.get_package_version = lambda: "0.0.0" -sys.modules.setdefault("telemetry", telemetry) - -telemetry_decorator = types.ModuleType("telemetry_decorator") -def telemetry_tool(*dargs, **dkwargs): - def _wrap(fn): - return fn - return _wrap -telemetry_decorator.telemetry_tool = telemetry_tool -sys.modules.setdefault("telemetry_decorator", telemetry_decorator) - -# stub mcp.server.fastmcp -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) +from tests.integration.test_helpers import DummyContext class DummyMCP: diff --git a/tests/test_find_in_file_minimal.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py similarity index 61% rename from tests/test_find_in_file_minimal.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py index 0d49dc09..74060d5d 100644 --- a/tests/test_find_in_file_minimal.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py @@ -1,36 +1,7 @@ -import sys -import pathlib -import importlib.util -import types import asyncio import pytest -from tests.test_helpers import DummyContext - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - -# Stub telemetry modules to avoid file I/O during import of tools package -telemetry = types.ModuleType("telemetry") -def _noop(*args, **kwargs): - pass -class MilestoneType: - pass -telemetry.record_resource_usage = _noop -telemetry.record_tool_usage = _noop -telemetry.record_milestone = _noop -telemetry.MilestoneType = MilestoneType -telemetry.get_package_version = lambda: "0.0.0" -sys.modules.setdefault("telemetry", telemetry) - -telemetry_decorator = types.ModuleType("telemetry_decorator") -def telemetry_tool(*dargs, **dkwargs): - def _wrap(fn): - return fn - return _wrap -telemetry_decorator.telemetry_tool = telemetry_tool -sys.modules.setdefault("telemetry_decorator", telemetry_decorator) +from tests.integration.test_helpers import DummyContext class DummyMCP: diff --git a/tests/test_get_sha.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py similarity index 75% rename from tests/test_get_sha.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py index 3e9a2261..eff47fab 100644 --- a/tests/test_get_sha.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py @@ -1,24 +1,4 @@ -import sys -import pathlib -import importlib.util - - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -manage_script = _load_module( - SRC / "tools" / "manage_script.py", "manage_script_mod") +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -32,9 +12,6 @@ def deco(fn): return deco -from tests.test_helpers import DummyContext - - def setup_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/tests/test_helpers.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_helpers.py similarity index 100% rename from tests/test_helpers.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_helpers.py diff --git a/tests/test_improved_anchor_matching.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_improved_anchor_matching.py similarity index 87% rename from tests/test_improved_anchor_matching.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_improved_anchor_matching.py index e56b8728..5f06cd17 100644 --- a/tests/test_improved_anchor_matching.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_improved_anchor_matching.py @@ -2,25 +2,9 @@ Test the improved anchor matching logic. """ -import sys -import pathlib -import importlib.util +import re -# add server src to path and load modules -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def load_module(path, name): - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -script_apply_edits_module = load_module( - SRC / "tools" / "script_apply_edits.py", "script_apply_edits_module") +import tools.script_apply_edits as script_apply_edits_module def test_improved_anchor_matching(): @@ -41,8 +25,6 @@ def test_improved_anchor_matching(): } }''' - import re - # Test the problematic anchor pattern anchor_pattern = r"\s*}\s*$" flags = re.MULTILINE diff --git a/tests/test_json_parsing_simple.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_json_parsing_simple.py similarity index 100% rename from tests/test_json_parsing_simple.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_json_parsing_simple.py diff --git a/tests/test_logging_stdout.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_logging_stdout.py similarity index 100% rename from tests/test_logging_stdout.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_logging_stdout.py diff --git a/tests/test_manage_asset_json_parsing.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_json_parsing.py similarity index 100% rename from tests/test_manage_asset_json_parsing.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_json_parsing.py diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py new file mode 100644 index 00000000..eb35d5d9 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py @@ -0,0 +1,34 @@ +import asyncio + +from tests.integration.test_helpers import DummyContext +import tools.manage_asset as manage_asset_mod + + +def test_manage_asset_pagination_coercion(monkeypatch): + captured = {} + + async def fake_async_send(cmd, params, loop=None): + captured["params"] = params + return {"success": True, "data": {}} + + monkeypatch.setattr(manage_asset_mod, "async_send_command_with_retry", fake_async_send) + + result = asyncio.run( + manage_asset_mod.manage_asset( + ctx=DummyContext(), + action="search", + path="Assets", + page_size="50", + page_number="2", + ) + ) + + assert result == {"success": True, "data": {}} + assert captured["params"]["pageSize"] == 50 + assert captured["params"]["pageNumber"] == 2 + + + + + + diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py new file mode 100644 index 00000000..bb4bdd1f --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py @@ -0,0 +1,31 @@ +from tests.integration.test_helpers import DummyContext +import tools.manage_gameobject as manage_go_mod + + +def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch): + captured = {} + + def fake_send(cmd, params): + captured["params"] = params + return {"success": True, "data": {}} + + monkeypatch.setattr(manage_go_mod, "send_command_with_retry", fake_send) + + # find by tag: allow tag to map to searchTerm + resp = manage_go_mod.manage_gameobject( + ctx=DummyContext(), + action="find", + search_method="by_tag", + tag="Player", + find_all="true", + search_inactive="0", + ) + # Loosen equality: wrapper may include a diagnostic message + assert resp.get("success") is True + assert "data" in resp + # ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already + assert captured["params"]["searchTerm"] == "Player" + assert captured["params"]["findAll"] == "true" or captured["params"]["findAll"] is True + assert captured["params"]["searchInactive"] in ("0", False, 0) + + diff --git a/tests/test_manage_script_uri.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py similarity index 80% rename from tests/test_manage_script_uri.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py index e5565834..c4e1c606 100644 --- a/tests/test_manage_script_uri.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py @@ -1,40 +1,6 @@ -# import triggers registration elsewhere; no direct use here -import sys -import types -from pathlib import Path - import pytest - -# Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) -ROOT = Path(__file__).resolve().parents[1] -candidates = [ - ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", - ROOT / "UnityMcpServer~" / "src", -] -SRC = next((p for p in candidates if p.exists()), None) -if SRC is None: - searched = "\n".join(str(p) for p in candidates) - pytest.skip( - "MCP for Unity server source not found. Tried:\n" + searched, - allow_module_level=True, - ) -sys.path.insert(0, str(SRC)) - -# Stub fastmcp to avoid real MCP deps -fastmcp_pkg = types.ModuleType("fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -sys.modules.setdefault("fastmcp", fastmcp_pkg) - - -# Import target module after path injection +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -48,12 +14,6 @@ def _decorator(fn): return _decorator -# (removed unused DummyCtx) - - -from tests.test_helpers import DummyContext - - def _register_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/tests/test_read_console_truncate.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py similarity index 79% rename from tests/test_read_console_truncate.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py index 850126b1..848d31b8 100644 --- a/tests/test_read_console_truncate.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py @@ -1,23 +1,4 @@ -import sys -import pathlib -import importlib.util - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -read_console_mod = _load_module( - SRC / "tools" / "read_console.py", "read_console_mod") +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -31,9 +12,6 @@ def deco(fn): return deco -from tests.test_helpers import DummyContext - - def setup_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/tests/test_read_resource_minimal.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py similarity index 69% rename from tests/test_read_resource_minimal.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py index 4d171926..7fcbeddf 100644 --- a/tests/test_read_resource_minimal.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py @@ -1,30 +1,7 @@ -import sys -import pathlib import asyncio -import types import pytest -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - -# Stub mcp.server.fastmcp to satisfy imports without full package -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -38,9 +15,6 @@ def deco(fn): return deco -from tests.test_helpers import DummyContext - - @pytest.fixture() def resource_tools(): mcp = DummyMCP() diff --git a/tests/test_resources_api.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py similarity index 71% rename from tests/test_resources_api.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py index 616df404..5d5a8c77 100644 --- a/tests/test_resources_api.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py @@ -1,33 +1,6 @@ -import sys -from pathlib import Path import pytest -import types -# locate server src dynamically to avoid hardcoded layout assumptions -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - -# Stub telemetry modules to avoid file I/O during import of tools package -telemetry = types.ModuleType("telemetry") -def _noop(*args, **kwargs): - pass -class MilestoneType: # minimal placeholder - pass -telemetry.record_resource_usage = _noop -telemetry.record_tool_usage = _noop -telemetry.record_milestone = _noop -telemetry.MilestoneType = MilestoneType -telemetry.get_package_version = lambda: "0.0.0" -sys.modules.setdefault("telemetry", telemetry) - -telemetry_decorator = types.ModuleType("telemetry_decorator") -def telemetry_tool(*_args, **_kwargs): - def _wrap(fn): - return fn - return _wrap -telemetry_decorator.telemetry_tool = telemetry_tool -sys.modules.setdefault("telemetry_decorator", telemetry_decorator) +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -41,9 +14,6 @@ def deco(fn): return deco -from tests.test_helpers import DummyContext - - @pytest.fixture() def resource_tools(): mcp = DummyMCP() diff --git a/tests/test_script_editing.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_editing.py similarity index 100% rename from tests/test_script_editing.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_editing.py diff --git a/tests/test_script_tools.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py similarity index 90% rename from tests/test_script_tools.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py index 43255722..ece9c1e0 100644 --- a/tests/test_script_tools.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py @@ -1,26 +1,9 @@ -import sys -import pathlib -import importlib.util import pytest import asyncio -# add server src to path and load modules without triggering package imports -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def load_module(path, name): - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -manage_script_module = load_module( - SRC / "tools" / "manage_script.py", "manage_script_module") -manage_asset_module = load_module( - SRC / "tools" / "manage_asset.py", "manage_asset_module") +from tests.integration.test_helpers import DummyContext +import tools.manage_script as manage_script_module +import tools.manage_asset as manage_asset_module class DummyMCP: @@ -34,9 +17,6 @@ def decorator(func): return decorator -from tests.test_helpers import DummyContext - - def setup_manage_script(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/tests/test_telemetry_endpoint_validation.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_endpoint_validation.py similarity index 72% rename from tests/test_telemetry_endpoint_validation.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_endpoint_validation.py index cccc0d6b..cbcc98a0 100644 --- a/tests/test_telemetry_endpoint_validation.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_endpoint_validation.py @@ -7,14 +7,7 @@ def test_endpoint_rejects_non_http(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd") - # Import the telemetry module from the correct path - import sys - import pathlib - ROOT = pathlib.Path(__file__).resolve().parents[1] - SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" - sys.path.insert(0, str(SRC)) - - monkeypatch.chdir(str(SRC)) + # Import the telemetry module telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) @@ -29,18 +22,10 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): monkeypatch.delenv("UNITY_MCP_TELEMETRY_ENDPOINT", raising=False) # Patch config.telemetry_endpoint via import mocking - import importlib - import sys - import pathlib - ROOT = pathlib.Path(__file__).resolve().parents[1] - SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" - sys.path.insert(0, str(SRC)) - cfg_mod = importlib.import_module("config") old_endpoint = cfg_mod.config.telemetry_endpoint cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry" try: - monkeypatch.chdir(str(SRC)) telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() @@ -50,7 +35,6 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): # Env should override config monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "https://override.example/ep") - monkeypatch.chdir(str(SRC)) importlib.reload(telemetry) tc2 = telemetry.TelemetryCollector() assert tc2.config.endpoint == "https://override.example/ep" @@ -61,14 +45,7 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) - # Import the telemetry module from the correct path - import sys - import pathlib - ROOT = pathlib.Path(__file__).resolve().parents[1] - SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" - sys.path.insert(0, str(SRC)) - - monkeypatch.chdir(str(SRC)) + # Import the telemetry module telemetry = importlib.import_module("telemetry") importlib.reload(telemetry) diff --git a/tests/test_telemetry_queue_worker.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_queue_worker.py similarity index 52% rename from tests/test_telemetry_queue_worker.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_queue_worker.py index d992440a..70b558bf 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_queue_worker.py @@ -1,60 +1,9 @@ -import sys -import pathlib -import importlib.util -import os import types import threading import time import queue as q - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - -# Stub mcp.server.fastmcp to satisfy imports without the full dependency -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - -# Ensure telemetry module has get_package_version stub before importing -telemetry_stub = types.ModuleType("telemetry") -telemetry_stub.get_package_version = lambda: "0.0.0" -sys.modules.setdefault("telemetry", telemetry_stub) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -# Load real telemetry on top of stub (it will reuse stubbed helpers) -# Note: CWD change required because telemetry.py calls get_package_version() -# at module load time, which reads pyproject.toml using a relative path. -# This is fragile but necessary given current telemetry module design. -_prev_cwd = os.getcwd() -os.chdir(str(SRC)) -try: - telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod") -finally: - os.chdir(_prev_cwd) +import telemetry def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog): diff --git a/tests/test_telemetry_subaction.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_subaction.py similarity index 95% rename from tests/test_telemetry_subaction.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_subaction.py index 38838a04..ca081b22 100644 --- a/tests/test_telemetry_subaction.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_telemetry_subaction.py @@ -6,10 +6,7 @@ def _get_decorator_module(): import sys import pathlib import types - ROOT = pathlib.Path(__file__).resolve().parents[1] - SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" - if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) + # Tests can now import directly from parent package # Remove any previously stubbed module to force real import sys.modules.pop("telemetry_decorator", None) # Preload a minimal telemetry stub to satisfy telemetry_decorator imports diff --git a/tests/test_transport_framing.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_transport_framing.py similarity index 99% rename from tests/test_transport_framing.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_transport_framing.py index a9a3158e..b35c645b 100644 --- a/tests/test_transport_framing.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_transport_framing.py @@ -23,7 +23,7 @@ "MCP for Unity server source not found. Tried:\n" + searched, allow_module_level=True, ) -sys.path.insert(0, str(SRC)) +# Tests can now import directly from parent package def start_dummy_server(greeting: bytes, respond_ping: bool = False): diff --git a/tests/test_validate_script_summary.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py similarity index 73% rename from tests/test_validate_script_summary.py rename to MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py index 23ccad6d..b2bebdcd 100644 --- a/tests/test_validate_script_summary.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py @@ -1,23 +1,4 @@ -import sys -import pathlib -import importlib.util - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -manage_script = _load_module( - SRC / "tools" / "manage_script.py", "manage_script_mod") +from tests.integration.test_helpers import DummyContext class DummyMCP: @@ -31,9 +12,6 @@ def deco(fn): return deco -from tests.test_helpers import DummyContext - - def setup_tools(): mcp = DummyMCP() # Import the tools module to trigger decorator registration diff --git a/MCPForUnity/UnityMcpServer~/src/tests/pytest.ini b/MCPForUnity/UnityMcpServer~/src/tests/pytest.ini new file mode 100644 index 00000000..42909991 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tests/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +markers = + integration: Integration tests that test multiple components together + unit: Unit tests for individual functions or classes diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index b287405b..00000000 --- a/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -testpaths = tests -norecursedirs = UnityMcpBridge MCPForUnity - diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 7c25bfae..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -import os - -# Ensure telemetry is disabled during test collection and execution to avoid -# any background network or thread startup that could slow or block pytest. -os.environ.setdefault("DISABLE_TELEMETRY", "true") -os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true") -os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true") - -# Avoid collecting tests under the two 'src' package folders to prevent -# duplicate-package import conflicts (two different 'src' packages). -collect_ignore = [ - "UnityMcpBridge/UnityMcpServer~/src", - "MCPForUnity/UnityMcpServer~/src", -] -collect_ignore_glob = [ - "UnityMcpBridge/UnityMcpServer~/src/*", - "MCPForUnity/UnityMcpServer~/src/*", -] - -def pytest_ignore_collect(path): - p = str(path) - norm = p.replace("\\", "/") - return ( - "/UnityMcpBridge/UnityMcpServer~/src/" in norm - or "/MCPForUnity/UnityMcpServer~/src/" in norm - or norm.endswith("UnityMcpBridge/UnityMcpServer~/src") - or norm.endswith("MCPForUnity/UnityMcpServer~/src") - ) diff --git a/tests/test_manage_asset_param_coercion.py b/tests/test_manage_asset_param_coercion.py deleted file mode 100644 index 5c7b0815..00000000 --- a/tests/test_manage_asset_param_coercion.py +++ /dev/null @@ -1,73 +0,0 @@ -import sys -import pathlib -import importlib.util -import types -import asyncio -import os - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -# Stub fastmcp to avoid real MCP deps -fastmcp_pkg = types.ModuleType("fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -sys.modules.setdefault("fastmcp", fastmcp_pkg) - - -from tests.test_helpers import DummyContext - - -def test_manage_asset_pagination_coercion(monkeypatch): - # Import with SRC as CWD to satisfy telemetry import side effects - _prev = os.getcwd() - os.chdir(str(SRC)) - try: - manage_asset_mod = _load_module(SRC / "tools" / "manage_asset.py", "manage_asset_mod") - finally: - os.chdir(_prev) - - captured = {} - - async def fake_async_send(cmd, params, loop=None): - captured["params"] = params - return {"success": True, "data": {}} - - monkeypatch.setattr(manage_asset_mod, "async_send_command_with_retry", fake_async_send) - - result = asyncio.run( - manage_asset_mod.manage_asset( - ctx=DummyContext(), - action="search", - path="Assets", - page_size="50", - page_number="2", - ) - ) - - assert result == {"success": True, "data": {}} - assert captured["params"]["pageSize"] == 50 - assert captured["params"]["pageNumber"] == 2 - - - - - - diff --git a/tests/test_manage_gameobject_param_coercion.py b/tests/test_manage_gameobject_param_coercion.py deleted file mode 100644 index d940b494..00000000 --- a/tests/test_manage_gameobject_param_coercion.py +++ /dev/null @@ -1,71 +0,0 @@ -import sys -import pathlib -import importlib.util -import types -import os - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" -sys.path.insert(0, str(SRC)) - - -def _load_module(path: pathlib.Path, name: str): - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load module {name} from {path}") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -# Stub fastmcp to avoid real MCP deps -fastmcp_pkg = types.ModuleType("fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -sys.modules.setdefault("fastmcp", fastmcp_pkg) - - -from tests.test_helpers import DummyContext - - -def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch): - # Import with SRC as CWD to satisfy telemetry import side effects - _prev = os.getcwd() - os.chdir(str(SRC)) - try: - manage_go_mod = _load_module(SRC / "tools" / "manage_gameobject.py", "manage_go_mod") - finally: - os.chdir(_prev) - - captured = {} - - def fake_send(cmd, params): - captured["params"] = params - return {"success": True, "data": {}} - - monkeypatch.setattr(manage_go_mod, "send_command_with_retry", fake_send) - - # find by tag: allow tag to map to searchTerm - resp = manage_go_mod.manage_gameobject( - ctx=DummyContext(), - action="find", - search_method="by_tag", - tag="Player", - find_all="true", - search_inactive="0", - ) - # Loosen equality: wrapper may include a diagnostic message - assert resp.get("success") is True - assert "data" in resp - # ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already - assert captured["params"]["searchTerm"] == "Player" - assert captured["params"]["findAll"] == "true" or captured["params"]["findAll"] is True - assert captured["params"]["searchInactive"] in ("0", False, 0) - - From 13274c9b16558da309c6375a3f267a34dcf5d87b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:01:35 -0400 Subject: [PATCH 09/28] feat: expand Unity test workflow triggers - Run tests on all branches instead of only main - Add pull request trigger to catch issues before merge - Maintain path filtering to run only when relevant files change --- .github/workflows/unity-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index bfd04055..312864ea 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -3,7 +3,12 @@ name: Unity Tests on: workflow_dispatch: {} push: - branches: [main] + branches: ['**'] + paths: + - TestProjects/UnityMCPTests/** + - MCPForUnity/Editor/** + - .github/workflows/unity-tests.yml + pull_request: paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** From 5e20bcb74321d20dc9aff6cf8d9726feecb995fa Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:01:49 -0400 Subject: [PATCH 10/28] chore: add GitHub Actions workflow for Python tests - Configured automated testing on push and pull requests using pytest - Set up uv for dependency management and Python 3.10 environment - Added test results artifact upload for debugging failed runs --- .github/workflows/python-tests.yml | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/python-tests.yml diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 00000000..a1a19d5a --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,42 @@ +name: Python Tests + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: {} + +jobs: + test: + name: Run Python Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.10 + + - name: Install dependencies + run: | + cd MCPForUnity/UnityMcpServer~/src + uv sync --dev + + - name: Run tests + run: | + cd MCPForUnity/UnityMcpServer~/src + uv run pytest tests/ -v --tb=short + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-results + path: | + MCPForUnity/UnityMcpServer~/src/.pytest_cache/ + tests/ From 2a1e6ac3b3aad0d03fd8d343292c6a4cbd84e523 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:02:03 -0400 Subject: [PATCH 11/28] refactor: update import path for fastmcp Context --- docs/CUSTOM_TOOLS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CUSTOM_TOOLS.md b/docs/CUSTOM_TOOLS.md index 5c9ef9ad..4a293b40 100644 --- a/docs/CUSTOM_TOOLS.md +++ b/docs/CUSTOM_TOOLS.md @@ -29,7 +29,7 @@ Create a Python file **anywhere in your Unity project**. For example, `Assets/Ed ```python from typing import Annotated, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @@ -127,7 +127,7 @@ Here's a complete example showing how to create a screenshot capture tool. ```python from typing import Annotated, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry From fd031b4452e0552b23d79bc862e6b3c58bada43b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:02:12 -0400 Subject: [PATCH 12/28] docs: update development setup instructions to use uv - Changed installation commands from pip to uv pip for better dependency management - Updated test running instructions to use uv run pytest - Added examples for running integration and unit tests separately --- docs/README-DEV.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/README-DEV.md b/docs/README-DEV.md index 9c1681d1..85e3c271 100644 --- a/docs/README-DEV.md +++ b/docs/README-DEV.md @@ -9,32 +9,44 @@ Welcome to the MCP for Unity development environment! This directory contains to ### Installing Development Dependencies -To contribute or run tests, you need to install the development dependencies: +To contribute or run tests, you need to install the development dependencies using `uv`: ```bash # Navigate to the server source directory cd MCPForUnity/UnityMcpServer~/src # Install the package in editable mode with dev dependencies -pip install -e .[dev] +uv pip install -e ".[dev]" ``` This installs: -- **Runtime dependencies**: `httpx`, `mcp`, `pydantic`, `tomli` -- **Development dependencies**: `pytest`, `pytest-anyio` +- **Runtime dependencies**: `httpx`, `fastmcp`, `mcp`, `pydantic`, `tomli` +- **Development dependencies**: `pytest`, `pytest-asyncio` ### Running Tests ```bash -# From the repo root -pytest tests/ -v +# From the server source directory +cd MCPForUnity/UnityMcpServer~/src +uv run pytest tests/ -v ``` -Or if you prefer using Python module syntax: +Or from the repo root: + +```bash +# Using uv from the server directory +cd MCPForUnity/UnityMcpServer~/src && uv run pytest tests/ -v +``` + +To run only integration tests: +```bash +uv run pytest tests/ -v -m integration +``` +To run only unit tests: ```bash -python -m pytest tests/ -v +uv run pytest tests/ -v -m unit ``` ## 🚀 Available Development Features From c7208ea79364a301e48c5a7bcdeddc8a1ba42ee5 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:03:47 -0400 Subject: [PATCH 13/28] Formatting [skip ci] --- .github/workflows/unity-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index 312864ea..fa9047f4 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -3,7 +3,7 @@ name: Unity Tests on: workflow_dispatch: {} push: - branches: ['**'] + branches: ["**"] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** From 2d02c54823167234458aa9a5c3471b72b785051b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:04:42 -0400 Subject: [PATCH 14/28] refactor: optimize CI workflow with path filters and dependency installation - Added path filters to only trigger tests when Python source or workflow files change - Split dependency installation into sync and dev install steps for better clarity - Fixed YAML indentation for improved readability --- .github/workflows/python-tests.yml | 71 ++++++++++++++++-------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index a1a19d5a..091a12ec 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -1,42 +1,49 @@ name: Python Tests on: - push: - branches: ["**"] - pull_request: - workflow_dispatch: {} + push: + branches: ["**"] + paths: + - MCPForUnity/UnityMcpServer~/src/** + - .github/workflows/python-tests.yml + pull_request: + paths: + - MCPForUnity/UnityMcpServer~/src/** + - .github/workflows/python-tests.yml + workflow_dispatch: {} jobs: - test: - name: Run Python Tests - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 + test: + name: Run Python Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - version: "latest" + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" - - name: Set up Python - run: uv python install 3.10 + - name: Set up Python + run: uv python install 3.10 - - name: Install dependencies - run: | - cd MCPForUnity/UnityMcpServer~/src - uv sync --dev + - name: Install dependencies + run: | + cd MCPForUnity/UnityMcpServer~/src + uv sync + uv pip install -e ".[dev]" - - name: Run tests - run: | - cd MCPForUnity/UnityMcpServer~/src - uv run pytest tests/ -v --tb=short + - name: Run tests + run: | + cd MCPForUnity/UnityMcpServer~/src + uv run pytest tests/ -v --tb=short - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: pytest-results - path: | - MCPForUnity/UnityMcpServer~/src/.pytest_cache/ - tests/ + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-results + path: | + MCPForUnity/UnityMcpServer~/src/.pytest_cache/ + tests/ From 0c60d1a2f786f4bb54a5e8995616a9af8b4672c8 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:23:59 -0400 Subject: [PATCH 15/28] Update .github/workflows/python-tests.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 091a12ec..124cc97d 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -46,4 +46,4 @@ jobs: name: pytest-results path: | MCPForUnity/UnityMcpServer~/src/.pytest_cache/ - tests/ + MCPForUnity/UnityMcpServer~/src/tests/ From 6cb77d67d8ec46b2cd61afe4d22f666f270bc603 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:28:12 -0400 Subject: [PATCH 16/28] fix: standardize test mode values to match Unity's naming convention - Changed default mode from "edit" to "EditMode" in C# code - Updated Python tool to use "EditMode" and "PlayMode" instead of lowercase variants --- MCPForUnity/Editor/Tools/RunTests.cs | 2 +- MCPForUnity/UnityMcpServer~/src/tools/run_tests.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 6eba6fda..74dac6a4 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -20,7 +20,7 @@ public static async Task HandleCommand(JObject @params) string modeStr = @params?["mode"]?.ToString(); if (string.IsNullOrWhiteSpace(modeStr)) { - modeStr = "edit"; + modeStr = "EditMode"; } if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) diff --git a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py index e70fd00c..67eabf10 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py @@ -41,8 +41,8 @@ class RunTestsResponse(MCPResponse): @mcp_for_unity_tool(description="Runs Unity tests for the specified mode") async def run_tests( ctx: Context, - mode: Annotated[Literal["edit", "play"], Field( - description="Unity test mode to run")] = "edit", + mode: Annotated[Literal["EditMode", "PlayMode"], Field( + description="Unity test mode to run")] = "EditMode", timeout_seconds: Annotated[str, Field( description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, ) -> RunTestsResponse: From 8681361328d86660d3bfd7caf42d8d5acb737eaa Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:33:47 -0400 Subject: [PATCH 17/28] refactor: convert test imports to relative imports - Changed absolute imports to relative imports in integration tests for better package structure - Removed test packages from pyproject.toml package list --- MCPForUnity/UnityMcpServer~/src/pyproject.toml | 2 +- .../src/tests/integration/test_edit_normalization_and_noop.py | 2 +- .../src/tests/integration/test_edit_strict_and_warnings.py | 2 +- .../src/tests/integration/test_find_in_file_minimal.py | 2 +- .../UnityMcpServer~/src/tests/integration/test_get_sha.py | 2 +- .../src/tests/integration/test_manage_asset_param_coercion.py | 2 +- .../tests/integration/test_manage_gameobject_param_coercion.py | 2 +- .../src/tests/integration/test_manage_script_uri.py | 2 +- .../src/tests/integration/test_read_console_truncate.py | 2 +- .../src/tests/integration/test_read_resource_minimal.py | 2 +- .../UnityMcpServer~/src/tests/integration/test_resources_api.py | 2 +- .../UnityMcpServer~/src/tests/integration/test_script_tools.py | 2 +- .../src/tests/integration/test_validate_script_summary.py | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/pyproject.toml b/MCPForUnity/UnityMcpServer~/src/pyproject.toml index 1046ba72..0089c064 100644 --- a/MCPForUnity/UnityMcpServer~/src/pyproject.toml +++ b/MCPForUnity/UnityMcpServer~/src/pyproject.toml @@ -37,4 +37,4 @@ py-modules = [ "telemetry_decorator", "unity_connection" ] -packages = ["tools", "resources", "registry", "tests", "tests.integration"] +packages = ["tools", "resources", "registry"] \ No newline at end of file diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py index fb37de3f..377a4c86 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_normalization_and_noop.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py index 5db5a50d..2914d7db 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_edit_strict_and_warnings.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py index 74060d5d..399deef5 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_find_in_file_minimal.py @@ -1,7 +1,7 @@ import asyncio import pytest -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py index eff47fab..bfd110d5 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_get_sha.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py index eb35d5d9..08609419 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_asset_param_coercion.py @@ -1,6 +1,6 @@ import asyncio -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext import tools.manage_asset as manage_asset_mod diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py index bb4bdd1f..f5c4d044 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_gameobject_param_coercion.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext import tools.manage_gameobject as manage_go_mod diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py index c4e1c606..7e2f0558 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_manage_script_uri.py @@ -1,6 +1,6 @@ import pytest -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py index 848d31b8..63143f74 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_console_truncate.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py index 7fcbeddf..cd3fa24a 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_read_resource_minimal.py @@ -1,7 +1,7 @@ import asyncio import pytest -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py index 5d5a8c77..d8bca76b 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_resources_api.py @@ -1,6 +1,6 @@ import pytest -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py index ece9c1e0..8b331b91 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py @@ -1,7 +1,7 @@ import pytest import asyncio -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext import tools.manage_script as manage_script_module import tools.manage_asset as manage_asset_module diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py index b2bebdcd..9f347f61 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_validate_script_summary.py @@ -1,4 +1,4 @@ -from tests.integration.test_helpers import DummyContext +from .test_helpers import DummyContext class DummyMCP: From 35637d37e5663f73795ff2ed8f3cf92219025448 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:33:55 -0400 Subject: [PATCH 18/28] refactor: use Field with default_factory for mutable default in TagsResponse --- MCPForUnity/UnityMcpServer~/src/resources/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tags.py b/MCPForUnity/UnityMcpServer~/src/resources/tags.py index 713f30d2..57ebf332 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/tags.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/tags.py @@ -1,3 +1,4 @@ +from pydantic import Field from models import MCPResponse from registry import mcp_for_unity_resource from unity_connection import async_send_command_with_retry @@ -5,7 +6,7 @@ class TagsResponse(MCPResponse): """List of all tags in the project.""" - data: list[str] = [] + data: list[str] = Field(default_factory=list) @mcp_for_unity_resource( From d3fa3d97791ee95fc317c1812237bd88f878c825 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:34:46 -0400 Subject: [PATCH 19/28] refactor: remove duplicate PrefabStageUtility call --- MCPForUnity/Editor/Resources/Editor/PrefabStage.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs index cd7389ab..2f66a01f 100644 --- a/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs +++ b/MCPForUnity/Editor/Resources/Editor/PrefabStage.cs @@ -15,7 +15,6 @@ public static object HandleCommand(JObject @params) { try { - PrefabStageUtility.GetCurrentPrefabStage(); var stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) From 1b40ce0181d40d33693a0a5a33e1d1a98640e56b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 13:37:50 -0400 Subject: [PATCH 20/28] Update this as well [skip ci] --- Server/tools/run_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/tools/run_tests.py b/Server/tools/run_tests.py index e70fd00c..67eabf10 100644 --- a/Server/tools/run_tests.py +++ b/Server/tools/run_tests.py @@ -41,8 +41,8 @@ class RunTestsResponse(MCPResponse): @mcp_for_unity_tool(description="Runs Unity tests for the specified mode") async def run_tests( ctx: Context, - mode: Annotated[Literal["edit", "play"], Field( - description="Unity test mode to run")] = "edit", + mode: Annotated[Literal["EditMode", "PlayMode"], Field( + description="Unity test mode to run")] = "EditMode", timeout_seconds: Annotated[str, Field( description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, ) -> RunTestsResponse: From 5bf3712582b5017557f912ac91649c4a21a4bd8c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:13:13 -0400 Subject: [PATCH 21/28] Update MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../UnityMcpServer~/src/tests/integration/test_script_tools.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py index 8b331b91..e8755f45 100644 --- a/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py +++ b/MCPForUnity/UnityMcpServer~/src/tests/integration/test_script_tools.py @@ -2,8 +2,6 @@ import asyncio from .test_helpers import DummyContext -import tools.manage_script as manage_script_module -import tools.manage_asset as manage_asset_module class DummyMCP: From 49d1fa9831684828f36d21a55864f09539a6740b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:06:58 -0400 Subject: [PATCH 22/28] chore: remove pull_request triggers from test workflows [skip ci] It's already covered by pushes --- .github/workflows/python-tests.yml | 4 ---- .github/workflows/unity-tests.yml | 5 ----- 2 files changed, 9 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 124cc97d..8364d1ba 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -6,10 +6,6 @@ on: paths: - MCPForUnity/UnityMcpServer~/src/** - .github/workflows/python-tests.yml - pull_request: - paths: - - MCPForUnity/UnityMcpServer~/src/** - - .github/workflows/python-tests.yml workflow_dispatch: {} jobs: diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index fa9047f4..954fff30 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -8,11 +8,6 @@ on: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** - .github/workflows/unity-tests.yml - pull_request: - paths: - - TestProjects/UnityMCPTests/** - - MCPForUnity/Editor/** - - .github/workflows/unity-tests.yml jobs: testAllModes: From 730092db5e76bbe756e80d5e656e3fb7d445ff6a Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:10:57 -0400 Subject: [PATCH 23/28] refactor: update resource function return types to include MCPResponse union --- MCPForUnity/UnityMcpServer~/src/resources/active_tool.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/editor_state.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/layers.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/menu_items.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/project_info.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/selection.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/tags.py | 2 +- MCPForUnity/UnityMcpServer~/src/resources/tests.py | 4 ++-- MCPForUnity/UnityMcpServer~/src/resources/windows.py | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py index 15005630..0ce88083 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py @@ -31,7 +31,7 @@ class ActiveToolResponse(MCPResponse): name="editor_active_tool", description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings." ) -async def get_active_tool() -> ActiveToolResponse: +async def get_active_tool() -> ActiveToolResponse | MCPResponse: """Get active editor tool information.""" response = await async_send_command_with_retry("get_active_tool", {}) return ActiveToolResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py index 09882621..cf1e30c1 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py @@ -26,7 +26,7 @@ class EditorStateResponse(MCPResponse): name="editor_state", description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information." ) -async def get_editor_state() -> EditorStateResponse: +async def get_editor_state() -> EditorStateResponse | MCPResponse: """Get current editor runtime state.""" response = await async_send_command_with_retry("get_editor_state", {}) return EditorStateResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/layers.py b/MCPForUnity/UnityMcpServer~/src/resources/layers.py index 330460f9..4a8a19af 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/layers.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/layers.py @@ -13,7 +13,7 @@ class LayersResponse(MCPResponse): name="project_layers", description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools." ) -async def get_layers() -> LayersResponse: +async def get_layers() -> LayersResponse | MCPResponse: """Get all project layers with their indices.""" response = await async_send_command_with_retry("get_layers", {}) return LayersResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py index 23163d68..4cf15208 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/menu_items.py @@ -15,7 +15,7 @@ class GetMenuItemsResponse(MCPResponse): name="menu_items", description="Provides a list of all menu items." ) -async def get_menu_items(ctx: Context) -> GetMenuItemsResponse: +async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse: """Provides a list of all menu items. """ unity_instance = get_unity_instance_from_context(ctx) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py index 8222195b..0a5e47f7 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py @@ -23,7 +23,7 @@ class PrefabStageResponse(MCPResponse): name="editor_prefab_stage", description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited." ) -async def get_prefab_stage() -> PrefabStageResponse: +async def get_prefab_stage() -> PrefabStageResponse | MCPResponse: """Get current prefab stage information.""" response = await async_send_command_with_retry("get_prefab_stage", {}) return PrefabStageResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/project_info.py b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py index 59e41beb..d3f9c902 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/project_info.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py @@ -23,7 +23,7 @@ class ProjectInfoResponse(MCPResponse): name="project_info", description="Static project information including root path, Unity version, and platform. This data rarely changes." ) -async def get_project_info() -> ProjectInfoResponse: +async def get_project_info() -> ProjectInfoResponse | MCPResponse: """Get static project configuration information.""" response = await async_send_command_with_retry("get_project_info", {}) return ProjectInfoResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/selection.py b/MCPForUnity/UnityMcpServer~/src/resources/selection.py index 6aa09ab0..5810f44b 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/selection.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/selection.py @@ -39,7 +39,7 @@ class SelectionResponse(MCPResponse): name="editor_selection", description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties." ) -async def get_selection() -> SelectionResponse: +async def get_selection() -> SelectionResponse | MCPResponse: """Get detailed editor selection information.""" response = await async_send_command_with_retry("get_selection", {}) return SelectionResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tags.py b/MCPForUnity/UnityMcpServer~/src/resources/tags.py index 57ebf332..0e5fd898 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/tags.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/tags.py @@ -14,7 +14,7 @@ class TagsResponse(MCPResponse): name="project_tags", description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools." ) -async def get_tags() -> TagsResponse: +async def get_tags() -> TagsResponse | MCPResponse: """Get all project tags.""" response = await async_send_command_with_retry("get_tags", {}) return TagsResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tests.py b/MCPForUnity/UnityMcpServer~/src/resources/tests.py index 7fcc056a..a229466b 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/tests.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/tests.py @@ -21,7 +21,7 @@ class GetTestsResponse(MCPResponse): @mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.") -async def get_tests(ctx: Context) -> GetTestsResponse: +async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse: """Provides a list of all tests. """ unity_instance = get_unity_instance_from_context(ctx) @@ -38,7 +38,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse: async def get_tests_for_mode( ctx: Context, mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")], -) -> GetTestsResponse: +) -> GetTestsResponse | MCPResponse: """Provides a list of tests for a specific mode. Args: diff --git a/MCPForUnity/UnityMcpServer~/src/resources/windows.py b/MCPForUnity/UnityMcpServer~/src/resources/windows.py index 4d8b882b..e39deddc 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/windows.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/windows.py @@ -31,7 +31,7 @@ class WindowsResponse(MCPResponse): name="editor_windows", description="All currently open editor windows with their titles, types, positions, and focus state." ) -async def get_windows() -> WindowsResponse: +async def get_windows() -> WindowsResponse | MCPResponse: """Get all open editor windows.""" response = await async_send_command_with_retry("get_windows", {}) return WindowsResponse(**response) if isinstance(response, dict) else response From d76e2b1c23944fd7b212b052d08c2cf16daabca3 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:12:13 -0400 Subject: [PATCH 24/28] refactor: remove manual domain reload tool - Removed reload_domain tool as Unity handles script recompilation automatically - Updated documentation to reflect automatic compilation workflow - Simplified script management workflow instructions in server description --- README-zh.md | 1 - README.md | 1 - Server/server.py | 19 +++++++++++-------- Server/tools/reload_domain.py | 24 ------------------------ 4 files changed, 11 insertions(+), 34 deletions(-) delete mode 100644 Server/tools/reload_domain.py diff --git a/README-zh.md b/README-zh.md index f52133e7..813c16af 100644 --- a/README-zh.md +++ b/README-zh.md @@ -46,7 +46,6 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 * `manage_script`: 管理 C# 脚本(创建、读取、更新、删除)。 * `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 * `read_console`: 获取控制台消息或清除控制台。 - * `reload_domain`: 重新加载 Unity 域。 * `run_test`: 在 Unity 编辑器中运行测试。 * `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 * `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 diff --git a/README.md b/README.md index 38891370..f0ad1a8c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to * `manage_script`: Manages C# scripts (create, read, update, delete). * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `read_console`: Gets messages from or clears the console. - * `reload_domain`: Reloads the Unity domain. * `run_test`: Runs a tests in the Unity Editor. * `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. diff --git a/Server/server.py b/Server/server.py index e36c3f6b..e222979a 100644 --- a/Server/server.py +++ b/Server/server.py @@ -108,12 +108,14 @@ def _emit_startup(): instances = _unity_connection_pool.discover_all_instances() if instances: - logger.info(f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}") + logger.info( + f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}") # Try to connect to default instance try: _unity_connection_pool.get_connection() - logger.info("Connected to default Unity instance on startup") + logger.info( + "Connected to default Unity instance on startup") # Record successful Unity connection (deferred) import threading as _t @@ -126,7 +128,8 @@ def _emit_startup(): } )).start() except Exception as e: - logger.warning("Could not connect to default Unity instance: %s", e) + logger.warning( + "Could not connect to default Unity instance: %s", e) else: logger.warning("No Unity instances found on startup") @@ -177,10 +180,9 @@ def _emit_startup(): Important Workflows: Script Management: -1. After creating or modifying scripts with `manage_script`, ALWAYS call `reload_domain` immediately -2. Wait for Unity to recompile (domain reload is asynchronous) -3. Use `read_console` to check for compilation errors before proceeding -4. Only after successful compilation can new components/types be used +1. After creating or modifying scripts with `manage_script` +2. Use `read_console` to check for compilation errors before proceeding +3. Only after successful compilation can new components/types be used Scene Setup: - Always include a Camera and main Light (Directional Light) in new scenes @@ -246,7 +248,8 @@ def main(): # Set environment variable if --default-instance is provided if args.default_instance: os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance - logger.info(f"Using default Unity instance from command-line: {args.default_instance}") + logger.info( + f"Using default Unity instance from command-line: {args.default_instance}") mcp.run(transport='stdio') diff --git a/Server/tools/reload_domain.py b/Server/tools/reload_domain.py deleted file mode 100644 index a25e46ad..00000000 --- a/Server/tools/reload_domain.py +++ /dev/null @@ -1,24 +0,0 @@ -from fastmcp import Context -from models import MCPResponse -from registry import mcp_for_unity_tool -from unity_connection import async_send_command_with_retry - - -@mcp_for_unity_tool( - description="Trigger a Unity domain reload to recompile scripts and refresh assemblies. Essential after creating or modifying scripts before new components can be used." -) -async def reload_domain(ctx: Context) -> MCPResponse: - """ - Request Unity to reload its domain (script assemblies). - This is necessary after: - - - Creating new C# scripts - - Modifying existing scripts - - Before attempting to add new components to GameObjects - - Returns immediately after triggering the reload request. - Unity will handle the actual recompilation asynchronously. - """ - await ctx.info("Requesting Unity domain reload") - result = await async_send_command_with_retry("reload_domain", {}) - return MCPResponse(**result) if isinstance(result, dict) else result From 399d27925c87c0ad8f03f4ebda0e790c2c325f7d Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:23:44 -0400 Subject: [PATCH 25/28] refactor: add context support to resource handlers - Updated all resource handlers to accept Context parameter for Unity instance routing - Replaced direct async_send_command_with_retry calls with async_send_with_unity_instance wrapper - Added imports for get_unity_instance_from_context and async_send_with_unity_instance helpers --- .../UnityMcpServer~/src/resources/active_tool.py | 13 +++++++++++-- .../UnityMcpServer~/src/resources/editor_state.py | 13 +++++++++++-- MCPForUnity/UnityMcpServer~/src/resources/layers.py | 13 +++++++++++-- .../UnityMcpServer~/src/resources/prefab_stage.py | 13 +++++++++++-- .../UnityMcpServer~/src/resources/project_info.py | 13 +++++++++++-- .../UnityMcpServer~/src/resources/selection.py | 13 +++++++++++-- MCPForUnity/UnityMcpServer~/src/resources/tags.py | 13 +++++++++++-- .../UnityMcpServer~/src/resources/windows.py | 13 +++++++++++-- 8 files changed, 88 insertions(+), 16 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py index 0ce88083..ed267f72 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/active_tool.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -31,7 +34,13 @@ class ActiveToolResponse(MCPResponse): name="editor_active_tool", description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings." ) -async def get_active_tool() -> ActiveToolResponse | MCPResponse: +async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse: """Get active editor tool information.""" - response = await async_send_command_with_retry("get_active_tool", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_active_tool", + {} + ) return ActiveToolResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py index cf1e30c1..b4e26689 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/editor_state.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -26,7 +29,13 @@ class EditorStateResponse(MCPResponse): name="editor_state", description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information." ) -async def get_editor_state() -> EditorStateResponse | MCPResponse: +async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse: """Get current editor runtime state.""" - response = await async_send_command_with_retry("get_editor_state", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_editor_state", + {} + ) return EditorStateResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/layers.py b/MCPForUnity/UnityMcpServer~/src/resources/layers.py index 4a8a19af..c9f754a5 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/layers.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/layers.py @@ -1,5 +1,8 @@ +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -13,7 +16,13 @@ class LayersResponse(MCPResponse): name="project_layers", description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools." ) -async def get_layers() -> LayersResponse | MCPResponse: +async def get_layers(ctx: Context) -> LayersResponse | MCPResponse: """Get all project layers with their indices.""" - response = await async_send_command_with_retry("get_layers", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_layers", + {} + ) return LayersResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py index 0a5e47f7..14ef693a 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/prefab_stage.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -23,7 +26,13 @@ class PrefabStageResponse(MCPResponse): name="editor_prefab_stage", description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited." ) -async def get_prefab_stage() -> PrefabStageResponse | MCPResponse: +async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse: """Get current prefab stage information.""" - response = await async_send_command_with_retry("get_prefab_stage", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_prefab_stage", + {} + ) return PrefabStageResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/project_info.py b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py index d3f9c902..ea7691f8 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/project_info.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/project_info.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -23,7 +26,13 @@ class ProjectInfoResponse(MCPResponse): name="project_info", description="Static project information including root path, Unity version, and platform. This data rarely changes." ) -async def get_project_info() -> ProjectInfoResponse | MCPResponse: +async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse: """Get static project configuration information.""" - response = await async_send_command_with_retry("get_project_info", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_project_info", + {} + ) return ProjectInfoResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/selection.py b/MCPForUnity/UnityMcpServer~/src/resources/selection.py index 5810f44b..76567cb4 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/selection.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/selection.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -39,7 +42,13 @@ class SelectionResponse(MCPResponse): name="editor_selection", description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties." ) -async def get_selection() -> SelectionResponse | MCPResponse: +async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse: """Get detailed editor selection information.""" - response = await async_send_command_with_retry("get_selection", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_selection", + {} + ) return SelectionResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/tags.py b/MCPForUnity/UnityMcpServer~/src/resources/tags.py index 0e5fd898..d4fec612 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/tags.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/tags.py @@ -1,6 +1,9 @@ from pydantic import Field +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -14,7 +17,13 @@ class TagsResponse(MCPResponse): name="project_tags", description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools." ) -async def get_tags() -> TagsResponse | MCPResponse: +async def get_tags(ctx: Context) -> TagsResponse | MCPResponse: """Get all project tags.""" - response = await async_send_command_with_retry("get_tags", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_tags", + {} + ) return TagsResponse(**response) if isinstance(response, dict) else response diff --git a/MCPForUnity/UnityMcpServer~/src/resources/windows.py b/MCPForUnity/UnityMcpServer~/src/resources/windows.py index e39deddc..c52d58c9 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/windows.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/windows.py @@ -1,6 +1,9 @@ from pydantic import BaseModel +from fastmcp import Context + from models import MCPResponse from registry import mcp_for_unity_resource +from tools import get_unity_instance_from_context, async_send_with_unity_instance from unity_connection import async_send_command_with_retry @@ -31,7 +34,13 @@ class WindowsResponse(MCPResponse): name="editor_windows", description="All currently open editor windows with their titles, types, positions, and focus state." ) -async def get_windows() -> WindowsResponse | MCPResponse: +async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse: """Get all open editor windows.""" - response = await async_send_command_with_retry("get_windows", {}) + unity_instance = get_unity_instance_from_context(ctx) + response = await async_send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_windows", + {} + ) return WindowsResponse(**response) if isinstance(response, dict) else response From d78ea82164de39a985cee20e72876e06da5ee203 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:24:14 -0400 Subject: [PATCH 26/28] fix: correct grammar in menu items documentation --- MCPForUnity/UnityMcpServer~/src/server.py | 2 +- Server/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index dee6931a..c7d06c32 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -201,7 +201,7 @@ def _emit_startup(): Menu Items: - Use `execute_menu_item` when you have read the menu items resource -- This let's you interact with Unity's menu system and third-party tools +- This lets you interact with Unity's menu system and third-party tools """ ) diff --git a/Server/server.py b/Server/server.py index e222979a..c491e93d 100644 --- a/Server/server.py +++ b/Server/server.py @@ -199,7 +199,7 @@ def _emit_startup(): Menu Items: - Use `execute_menu_item` when you have read the menu items resource -- This let's you interact with Unity's menu system and third-party tools +- This lets you interact with Unity's menu system and third-party tools """ ) From bbe1fb654693e8625bf0cca65852a26636733106 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 14:30:52 -0400 Subject: [PATCH 27/28] docs: update README with expanded tools and resources documentation - Added new tools: manage_prefabs, create_script, delete_script, get_sha - Added new resources: editor state, windows, project info, layers, and tags - Clarified manage_script as compatibility router with recommendation to use newer edit tools - Fixed run_test to run_tests for consistency --- README-zh.md | 48 ++++++++++++++++++++++++++++++++++++------------ README.md | 44 ++++++++++++++++++++++++++++---------------- 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/README-zh.md b/README-zh.md index 813c16af..df84bfa1 100644 --- a/README-zh.md +++ b/README-zh.md @@ -38,18 +38,42 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 您的大语言模型可以使用以下功能: - * `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,"File/Save Project")。 - * `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 - * `manage_editor`: 控制和查询编辑器的状态和设置。 - * `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 - * `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 - * `manage_script`: 管理 C# 脚本(创建、读取、更新、删除)。 - * `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 - * `read_console`: 获取控制台消息或清除控制台。 - * `run_test`: 在 Unity 编辑器中运行测试。 - * `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 - * `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 - * `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 +* `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,"File/Save Project")。 +* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 +* `manage_editor`: 控制和查询编辑器的状态和设置。 +* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 +* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。 +* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 +* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits` 或 `script_apply_edits` 进行编辑。 +* `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 +* `read_console`: 获取控制台消息或清除控制台。 +* `run_tests`: 在 Unity 编辑器中运行测试。 +* `set_active_instance`: 将后续工具调用路由到特定的 Unity 实例(当运行多个实例时)。 +* `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 +* `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 +* `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 +* `create_script`: 在给定的项目路径创建新的 C# 脚本。 +* `delete_script`: 通过 URI 或 Assets 相对路径删除 C# 脚本。 +* `get_sha`: 获取 Unity C# 脚本的 SHA256 和基本元数据,而不返回文件内容。 + + + +
+ 可用资源 + + 您的大语言模型可以检索以下资源: + +* `unity_instances`: 列出所有正在运行的 Unity 编辑器实例及其详细信息(名称、路径、端口、状态)。 +* `menu_items`: 检索 Unity 编辑器中所有可用的菜单项。 +* `tests`: 检索 Unity 编辑器中所有可用的测试。可以选择特定类型的测试(例如,"EditMode"、"PlayMode")。 +* `editor_active_tool`: 当前活动的编辑器工具(移动、旋转、缩放等)和变换手柄设置。 +* `editor_prefab_stage`: 如果预制件在隔离模式下打开,则为当前预制件编辑上下文。 +* `editor_selection`: 有关编辑器中当前选定对象的详细信息。 +* `editor_state`: 当前编辑器运行时状态,包括播放模式、编译状态、活动场景和选择摘要。 +* `editor_windows`: 所有当前打开的编辑器窗口及其标题、类型、位置和焦点状态。 +* `project_info`: 静态项目信息,包括根路径、Unity 版本和平台。 +* `project_layers`: 项目 TagManager 中定义的所有层及其索引(0-31)。 +* `project_tags`: 项目 TagManager 中定义的所有标签。
--- diff --git a/README.md b/README.md index f0ad1a8c..6f8a255e 100644 --- a/README.md +++ b/README.md @@ -40,19 +40,23 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to Your LLM can use functions like: - * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). - * `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). - * `manage_editor`: Controls and queries the editor's state and settings. - * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. - * `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). - * `manage_script`: Manages C# scripts (create, read, update, delete). - * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). - * `read_console`: Gets messages from or clears the console. - * `run_test`: Runs a tests in the Unity Editor. - * `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). - * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. - * `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. - * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. +* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). +* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.). +* `manage_editor`: Controls and queries the editor's state and settings. +* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. +* `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.). +* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.). +* `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits. +* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). +* `read_console`: Gets messages from or clears the console. +* `run_tests`: Runs tests in the Unity Editor. +* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). +* `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. +* `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. +* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. +* `create_script`: Create a new C# script at the given project path. +* `delete_script`: Delete a C# script by URI or Assets-relative path. +* `get_sha`: Get SHA256 and basic metadata for a Unity C# script without returning file contents. @@ -61,9 +65,17 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to Your LLM can retrieve the following resources: - * `unity_instances`: Lists all running Unity Editor instances with their details (name, path, port, status). - * `menu_items`: Retrieves all available menu items in the Unity Editor. - * `tests`: Retrieves all available tests in the Unity Editor. Can select tests of a specific type (e.g., "EditMode", "PlayMode"). +* `unity_instances`: Lists all running Unity Editor instances with their details (name, path, port, status). +* `menu_items`: Retrieves all available menu items in the Unity Editor. +* `tests`: Retrieves all available tests in the Unity Editor. Can select tests of a specific type (e.g., "EditMode", "PlayMode"). +* `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings. +* `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode. +* `editor_selection`: Detailed information about currently selected objects in the editor. +* `editor_state`: Current editor runtime state including play mode, compilation status, active scene, and selection summary. +* `editor_windows`: All currently open editor windows with their titles, types, positions, and focus state. +* `project_info`: Static project information including root path, Unity version, and platform. +* `project_layers`: All layers defined in the project's TagManager with their indices (0-31). +* `project_tags`: All tags defined in the project's TagManager. --- From a734fe6dfc3882660a0fbd6f07b5edbe7387bbf4 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 5 Nov 2025 16:04:40 -0400 Subject: [PATCH 28/28] refactor: convert unity_instances function to async [skip ci] - Changed function signature from synchronous to async - Added await keywords to ctx.info() and ctx.error() calls to properly handle async context methods --- .../UnityMcpServer~/src/resources/unity_instances.py | 6 +++--- Server/resources/unity_instances.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MCPForUnity/UnityMcpServer~/src/resources/unity_instances.py b/MCPForUnity/UnityMcpServer~/src/resources/unity_instances.py index 0d2df784..c716ea35 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/unity_instances.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/unity_instances.py @@ -13,7 +13,7 @@ name="unity_instances", description="Lists all running Unity Editor instances with their details." ) -def unity_instances(ctx: Context) -> dict[str, Any]: +async def unity_instances(ctx: Context) -> dict[str, Any]: """ List all available Unity Editor instances. @@ -30,7 +30,7 @@ def unity_instances(ctx: Context) -> dict[str, Any]: Returns: Dictionary containing list of instances and metadata """ - ctx.info("Listing Unity instances") + await ctx.info("Listing Unity instances") try: pool = get_unity_connection_pool() @@ -58,7 +58,7 @@ def unity_instances(ctx: Context) -> dict[str, Any]: return result except Exception as e: - ctx.error(f"Error listing Unity instances: {e}") + await ctx.error(f"Error listing Unity instances: {e}") return { "success": False, "error": f"Failed to list Unity instances: {str(e)}", diff --git a/Server/resources/unity_instances.py b/Server/resources/unity_instances.py index 0d2df784..c716ea35 100644 --- a/Server/resources/unity_instances.py +++ b/Server/resources/unity_instances.py @@ -13,7 +13,7 @@ name="unity_instances", description="Lists all running Unity Editor instances with their details." ) -def unity_instances(ctx: Context) -> dict[str, Any]: +async def unity_instances(ctx: Context) -> dict[str, Any]: """ List all available Unity Editor instances. @@ -30,7 +30,7 @@ def unity_instances(ctx: Context) -> dict[str, Any]: Returns: Dictionary containing list of instances and metadata """ - ctx.info("Listing Unity instances") + await ctx.info("Listing Unity instances") try: pool = get_unity_connection_pool() @@ -58,7 +58,7 @@ def unity_instances(ctx: Context) -> dict[str, Any]: return result except Exception as e: - ctx.error(f"Error listing Unity instances: {e}") + await ctx.error(f"Error listing Unity instances: {e}") return { "success": False, "error": f"Failed to list Unity instances: {str(e)}",