This is page 6 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/ExecPath.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Runtime.InteropServices; 7 | using UnityEditor; 8 | 9 | namespace MCPForUnity.Editor.Helpers 10 | { 11 | internal static class ExecPath 12 | { 13 | private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; 14 | 15 | // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. 16 | internal static string ResolveClaude() 17 | { 18 | try 19 | { 20 | string pref = EditorPrefs.GetString(PrefClaude, string.Empty); 21 | if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; 22 | } 23 | catch { } 24 | 25 | string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); 26 | if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; 27 | 28 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 29 | { 30 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 31 | string[] candidates = 32 | { 33 | "/opt/homebrew/bin/claude", 34 | "/usr/local/bin/claude", 35 | Path.Combine(home, ".local", "bin", "claude"), 36 | }; 37 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 38 | // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude 39 | string nvmClaude = ResolveClaudeFromNvm(home); 40 | if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; 41 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 42 | return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); 43 | #else 44 | return null; 45 | #endif 46 | } 47 | 48 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 49 | { 50 | #if UNITY_EDITOR_WIN 51 | // Common npm global locations 52 | string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; 53 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; 54 | string[] candidates = 55 | { 56 | // Prefer .cmd (most reliable from non-interactive processes) 57 | Path.Combine(appData, "npm", "claude.cmd"), 58 | Path.Combine(localAppData, "npm", "claude.cmd"), 59 | // Fall back to PowerShell shim if only .ps1 is present 60 | Path.Combine(appData, "npm", "claude.ps1"), 61 | Path.Combine(localAppData, "npm", "claude.ps1"), 62 | }; 63 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 64 | string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); 65 | if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; 66 | #endif 67 | return null; 68 | } 69 | 70 | // Linux 71 | { 72 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 73 | string[] candidates = 74 | { 75 | "/usr/local/bin/claude", 76 | "/usr/bin/claude", 77 | Path.Combine(home, ".local", "bin", "claude"), 78 | }; 79 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 80 | // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude 81 | string nvmClaude = ResolveClaudeFromNvm(home); 82 | if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; 83 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 84 | return Which("claude", "/usr/local/bin:/usr/bin:/bin"); 85 | #else 86 | return null; 87 | #endif 88 | } 89 | } 90 | 91 | // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version 92 | private static string ResolveClaudeFromNvm(string home) 93 | { 94 | try 95 | { 96 | if (string.IsNullOrEmpty(home)) return null; 97 | string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); 98 | if (!Directory.Exists(nvmNodeDir)) return null; 99 | 100 | string bestPath = null; 101 | Version bestVersion = null; 102 | foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) 103 | { 104 | string name = Path.GetFileName(versionDir); 105 | if (string.IsNullOrEmpty(name)) continue; 106 | if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) 107 | { 108 | // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 109 | string versionStr = name.Substring(1); 110 | int dashIndex = versionStr.IndexOf('-'); 111 | if (dashIndex > 0) 112 | { 113 | versionStr = versionStr.Substring(0, dashIndex); 114 | } 115 | if (Version.TryParse(versionStr, out Version parsed)) 116 | { 117 | string candidate = Path.Combine(versionDir, "bin", "claude"); 118 | if (File.Exists(candidate)) 119 | { 120 | if (bestVersion == null || parsed > bestVersion) 121 | { 122 | bestVersion = parsed; 123 | bestPath = candidate; 124 | } 125 | } 126 | } 127 | } 128 | } 129 | return bestPath; 130 | } 131 | catch { return null; } 132 | } 133 | 134 | // Explicitly set the Claude CLI absolute path override in EditorPrefs 135 | internal static void SetClaudeCliPath(string absolutePath) 136 | { 137 | try 138 | { 139 | if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) 140 | { 141 | EditorPrefs.SetString(PrefClaude, absolutePath); 142 | } 143 | } 144 | catch { } 145 | } 146 | 147 | // Clear any previously set Claude CLI override path 148 | internal static void ClearClaudeCliPath() 149 | { 150 | try 151 | { 152 | if (EditorPrefs.HasKey(PrefClaude)) 153 | { 154 | EditorPrefs.DeleteKey(PrefClaude); 155 | } 156 | } 157 | catch { } 158 | } 159 | 160 | // Use existing UV resolver; returns absolute path or null. 161 | internal static string ResolveUv() 162 | { 163 | return ServerInstaller.FindUvPath(); 164 | } 165 | 166 | internal static bool TryRun( 167 | string file, 168 | string args, 169 | string workingDir, 170 | out string stdout, 171 | out string stderr, 172 | int timeoutMs = 15000, 173 | string extraPathPrepend = null) 174 | { 175 | stdout = string.Empty; 176 | stderr = string.Empty; 177 | try 178 | { 179 | // Handle PowerShell scripts on Windows by invoking through powershell.exe 180 | bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && 181 | file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); 182 | 183 | var psi = new ProcessStartInfo 184 | { 185 | FileName = isPs1 ? "powershell.exe" : file, 186 | Arguments = isPs1 187 | ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() 188 | : args, 189 | WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, 190 | UseShellExecute = false, 191 | RedirectStandardOutput = true, 192 | RedirectStandardError = true, 193 | CreateNoWindow = true, 194 | }; 195 | if (!string.IsNullOrEmpty(extraPathPrepend)) 196 | { 197 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 198 | psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) 199 | ? extraPathPrepend 200 | : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); 201 | } 202 | 203 | using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; 204 | 205 | var so = new StringBuilder(); 206 | var se = new StringBuilder(); 207 | process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; 208 | process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; 209 | 210 | if (!process.Start()) return false; 211 | 212 | process.BeginOutputReadLine(); 213 | process.BeginErrorReadLine(); 214 | 215 | if (!process.WaitForExit(timeoutMs)) 216 | { 217 | try { process.Kill(); } catch { } 218 | return false; 219 | } 220 | 221 | // Ensure async buffers are flushed 222 | process.WaitForExit(); 223 | 224 | stdout = so.ToString(); 225 | stderr = se.ToString(); 226 | return process.ExitCode == 0; 227 | } 228 | catch 229 | { 230 | return false; 231 | } 232 | } 233 | 234 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 235 | private static string Which(string exe, string prependPath) 236 | { 237 | try 238 | { 239 | var psi = new ProcessStartInfo("/usr/bin/which", exe) 240 | { 241 | UseShellExecute = false, 242 | RedirectStandardOutput = true, 243 | CreateNoWindow = true, 244 | }; 245 | string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 246 | psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); 247 | using var p = Process.Start(psi); 248 | string output = p?.StandardOutput.ReadToEnd().Trim(); 249 | p?.WaitForExit(1500); 250 | return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; 251 | } 252 | catch { return null; } 253 | } 254 | #endif 255 | 256 | #if UNITY_EDITOR_WIN 257 | private static string Where(string exe) 258 | { 259 | try 260 | { 261 | var psi = new ProcessStartInfo("where", exe) 262 | { 263 | UseShellExecute = false, 264 | RedirectStandardOutput = true, 265 | CreateNoWindow = true, 266 | }; 267 | using var p = Process.Start(psi); 268 | string first = p?.StandardOutput.ReadToEnd() 269 | .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) 270 | .FirstOrDefault(); 271 | p?.WaitForExit(1500); 272 | return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; 273 | } 274 | catch { return null; } 275 | } 276 | #endif 277 | } 278 | } 279 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/PortManager.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEditor; 4 | using System.Net; 5 | using System.Net.Sockets; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading; 9 | using Newtonsoft.Json; 10 | using UnityEngine; 11 | 12 | namespace MCPForUnity.Editor.Helpers 13 | { 14 | /// <summary> 15 | /// Manages dynamic port allocation and persistent storage for MCP for Unity 16 | /// </summary> 17 | public static class PortManager 18 | { 19 | private static bool IsDebugEnabled() 20 | { 21 | try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } 22 | catch { return false; } 23 | } 24 | 25 | private const int DefaultPort = 6400; 26 | private const int MaxPortAttempts = 100; 27 | private const string RegistryFileName = "unity-mcp-port.json"; 28 | 29 | [Serializable] 30 | public class PortConfig 31 | { 32 | public int unity_port; 33 | public string created_date; 34 | public string project_path; 35 | } 36 | 37 | /// <summary> 38 | /// Get the port to use - either from storage or discover a new one 39 | /// Will try stored port first, then fallback to discovering new port 40 | /// </summary> 41 | /// <returns>Port number to use</returns> 42 | public static int GetPortWithFallback() 43 | { 44 | // Try to load stored port first, but only if it's from the current project 45 | var storedConfig = GetStoredPortConfig(); 46 | if (storedConfig != null && 47 | storedConfig.unity_port > 0 && 48 | string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && 49 | IsPortAvailable(storedConfig.unity_port)) 50 | { 51 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using stored port {storedConfig.unity_port} for current project"); 52 | return storedConfig.unity_port; 53 | } 54 | 55 | // If stored port exists but is currently busy, wait briefly for release 56 | if (storedConfig != null && storedConfig.unity_port > 0) 57 | { 58 | if (WaitForPortRelease(storedConfig.unity_port, 1500)) 59 | { 60 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} became available after short wait"); 61 | return storedConfig.unity_port; 62 | } 63 | // Prefer sticking to the same port; let the caller handle bind retries/fallbacks 64 | return storedConfig.unity_port; 65 | } 66 | 67 | // If no valid stored port, find a new one and save it 68 | int newPort = FindAvailablePort(); 69 | SavePort(newPort); 70 | return newPort; 71 | } 72 | 73 | /// <summary> 74 | /// Discover and save a new available port (used by Auto-Connect button) 75 | /// </summary> 76 | /// <returns>New available port</returns> 77 | public static int DiscoverNewPort() 78 | { 79 | int newPort = FindAvailablePort(); 80 | SavePort(newPort); 81 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Discovered and saved new port: {newPort}"); 82 | return newPort; 83 | } 84 | 85 | /// <summary> 86 | /// Find an available port starting from the default port 87 | /// </summary> 88 | /// <returns>Available port number</returns> 89 | private static int FindAvailablePort() 90 | { 91 | // Always try default port first 92 | if (IsPortAvailable(DefaultPort)) 93 | { 94 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using default port {DefaultPort}"); 95 | return DefaultPort; 96 | } 97 | 98 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Default port {DefaultPort} is in use, searching for alternative..."); 99 | 100 | // Search for alternatives 101 | for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) 102 | { 103 | if (IsPortAvailable(port)) 104 | { 105 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Found available port {port}"); 106 | return port; 107 | } 108 | } 109 | 110 | throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); 111 | } 112 | 113 | /// <summary> 114 | /// Check if a specific port is available for binding 115 | /// </summary> 116 | /// <param name="port">Port to check</param> 117 | /// <returns>True if port is available</returns> 118 | public static bool IsPortAvailable(int port) 119 | { 120 | try 121 | { 122 | var testListener = new TcpListener(IPAddress.Loopback, port); 123 | testListener.Start(); 124 | testListener.Stop(); 125 | return true; 126 | } 127 | catch (SocketException) 128 | { 129 | return false; 130 | } 131 | } 132 | 133 | /// <summary> 134 | /// Check if a port is currently being used by MCP for Unity 135 | /// This helps avoid unnecessary port changes when Unity itself is using the port 136 | /// </summary> 137 | /// <param name="port">Port to check</param> 138 | /// <returns>True if port appears to be used by MCP for Unity</returns> 139 | public static bool IsPortUsedByMCPForUnity(int port) 140 | { 141 | try 142 | { 143 | // Try to make a quick connection to see if it's an MCP for Unity server 144 | using var client = new TcpClient(); 145 | var connectTask = client.ConnectAsync(IPAddress.Loopback, port); 146 | if (connectTask.Wait(100)) // 100ms timeout 147 | { 148 | // If connection succeeded, it's likely the MCP for Unity server 149 | return client.Connected; 150 | } 151 | return false; 152 | } 153 | catch 154 | { 155 | return false; 156 | } 157 | } 158 | 159 | /// <summary> 160 | /// Wait for a port to become available for a limited amount of time. 161 | /// Used to bridge the gap during domain reload when the old listener 162 | /// hasn't released the socket yet. 163 | /// </summary> 164 | private static bool WaitForPortRelease(int port, int timeoutMs) 165 | { 166 | int waited = 0; 167 | const int step = 100; 168 | while (waited < timeoutMs) 169 | { 170 | if (IsPortAvailable(port)) 171 | { 172 | return true; 173 | } 174 | 175 | // If the port is in use by an MCP instance, continue waiting briefly 176 | if (!IsPortUsedByMCPForUnity(port)) 177 | { 178 | // In use by something else; don't keep waiting 179 | return false; 180 | } 181 | 182 | Thread.Sleep(step); 183 | waited += step; 184 | } 185 | return IsPortAvailable(port); 186 | } 187 | 188 | /// <summary> 189 | /// Save port to persistent storage 190 | /// </summary> 191 | /// <param name="port">Port to save</param> 192 | private static void SavePort(int port) 193 | { 194 | try 195 | { 196 | var portConfig = new PortConfig 197 | { 198 | unity_port = port, 199 | created_date = DateTime.UtcNow.ToString("O"), 200 | project_path = Application.dataPath 201 | }; 202 | 203 | string registryDir = GetRegistryDirectory(); 204 | Directory.CreateDirectory(registryDir); 205 | 206 | string registryFile = GetRegistryFilePath(); 207 | string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); 208 | // Write to hashed, project-scoped file 209 | File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); 210 | // Also write to legacy stable filename to avoid hash/case drift across reloads 211 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 212 | File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); 213 | 214 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Saved port {port} to storage"); 215 | } 216 | catch (Exception ex) 217 | { 218 | Debug.LogWarning($"Could not save port to storage: {ex.Message}"); 219 | } 220 | } 221 | 222 | /// <summary> 223 | /// Load port from persistent storage 224 | /// </summary> 225 | /// <returns>Stored port number, or 0 if not found</returns> 226 | private static int LoadStoredPort() 227 | { 228 | try 229 | { 230 | string registryFile = GetRegistryFilePath(); 231 | 232 | if (!File.Exists(registryFile)) 233 | { 234 | // Backwards compatibility: try the legacy file name 235 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 236 | if (!File.Exists(legacy)) 237 | { 238 | return 0; 239 | } 240 | registryFile = legacy; 241 | } 242 | 243 | string json = File.ReadAllText(registryFile); 244 | var portConfig = JsonConvert.DeserializeObject<PortConfig>(json); 245 | 246 | return portConfig?.unity_port ?? 0; 247 | } 248 | catch (Exception ex) 249 | { 250 | Debug.LogWarning($"Could not load port from storage: {ex.Message}"); 251 | return 0; 252 | } 253 | } 254 | 255 | /// <summary> 256 | /// Get the current stored port configuration 257 | /// </summary> 258 | /// <returns>Port configuration if exists, null otherwise</returns> 259 | public static PortConfig GetStoredPortConfig() 260 | { 261 | try 262 | { 263 | string registryFile = GetRegistryFilePath(); 264 | 265 | if (!File.Exists(registryFile)) 266 | { 267 | // Backwards compatibility: try the legacy file 268 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 269 | if (!File.Exists(legacy)) 270 | { 271 | return null; 272 | } 273 | registryFile = legacy; 274 | } 275 | 276 | string json = File.ReadAllText(registryFile); 277 | return JsonConvert.DeserializeObject<PortConfig>(json); 278 | } 279 | catch (Exception ex) 280 | { 281 | Debug.LogWarning($"Could not load port config: {ex.Message}"); 282 | return null; 283 | } 284 | } 285 | 286 | private static string GetRegistryDirectory() 287 | { 288 | return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); 289 | } 290 | 291 | private static string GetRegistryFilePath() 292 | { 293 | string dir = GetRegistryDirectory(); 294 | string hash = ComputeProjectHash(Application.dataPath); 295 | string fileName = $"unity-mcp-port-{hash}.json"; 296 | return Path.Combine(dir, fileName); 297 | } 298 | 299 | private static string ComputeProjectHash(string input) 300 | { 301 | try 302 | { 303 | using SHA1 sha1 = SHA1.Create(); 304 | byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); 305 | byte[] hashBytes = sha1.ComputeHash(bytes); 306 | var sb = new StringBuilder(); 307 | foreach (byte b in hashBytes) 308 | { 309 | sb.Append(b.ToString("x2")); 310 | } 311 | return sb.ToString()[..8]; // short, sufficient for filenames 312 | } 313 | catch 314 | { 315 | return "default"; 316 | } 317 | } 318 | } 319 | } 320 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/PortManager.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEditor; 4 | using System.Net; 5 | using System.Net.Sockets; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading; 9 | using Newtonsoft.Json; 10 | using UnityEngine; 11 | 12 | namespace MCPForUnity.Editor.Helpers 13 | { 14 | /// <summary> 15 | /// Manages dynamic port allocation and persistent storage for MCP for Unity 16 | /// </summary> 17 | public static class PortManager 18 | { 19 | private static bool IsDebugEnabled() 20 | { 21 | try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } 22 | catch { return false; } 23 | } 24 | 25 | private const int DefaultPort = 6400; 26 | private const int MaxPortAttempts = 100; 27 | private const string RegistryFileName = "unity-mcp-port.json"; 28 | 29 | [Serializable] 30 | public class PortConfig 31 | { 32 | public int unity_port; 33 | public string created_date; 34 | public string project_path; 35 | } 36 | 37 | /// <summary> 38 | /// Get the port to use - either from storage or discover a new one 39 | /// Will try stored port first, then fallback to discovering new port 40 | /// </summary> 41 | /// <returns>Port number to use</returns> 42 | public static int GetPortWithFallback() 43 | { 44 | // Try to load stored port first, but only if it's from the current project 45 | var storedConfig = GetStoredPortConfig(); 46 | if (storedConfig != null && 47 | storedConfig.unity_port > 0 && 48 | string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && 49 | IsPortAvailable(storedConfig.unity_port)) 50 | { 51 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using stored port {storedConfig.unity_port} for current project"); 52 | return storedConfig.unity_port; 53 | } 54 | 55 | // If stored port exists but is currently busy, wait briefly for release 56 | if (storedConfig != null && storedConfig.unity_port > 0) 57 | { 58 | if (WaitForPortRelease(storedConfig.unity_port, 1500)) 59 | { 60 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Stored port {storedConfig.unity_port} became available after short wait"); 61 | return storedConfig.unity_port; 62 | } 63 | // Prefer sticking to the same port; let the caller handle bind retries/fallbacks 64 | return storedConfig.unity_port; 65 | } 66 | 67 | // If no valid stored port, find a new one and save it 68 | int newPort = FindAvailablePort(); 69 | SavePort(newPort); 70 | return newPort; 71 | } 72 | 73 | /// <summary> 74 | /// Discover and save a new available port (used by Auto-Connect button) 75 | /// </summary> 76 | /// <returns>New available port</returns> 77 | public static int DiscoverNewPort() 78 | { 79 | int newPort = FindAvailablePort(); 80 | SavePort(newPort); 81 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Discovered and saved new port: {newPort}"); 82 | return newPort; 83 | } 84 | 85 | /// <summary> 86 | /// Find an available port starting from the default port 87 | /// </summary> 88 | /// <returns>Available port number</returns> 89 | private static int FindAvailablePort() 90 | { 91 | // Always try default port first 92 | if (IsPortAvailable(DefaultPort)) 93 | { 94 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Using default port {DefaultPort}"); 95 | return DefaultPort; 96 | } 97 | 98 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Default port {DefaultPort} is in use, searching for alternative..."); 99 | 100 | // Search for alternatives 101 | for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) 102 | { 103 | if (IsPortAvailable(port)) 104 | { 105 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Found available port {port}"); 106 | return port; 107 | } 108 | } 109 | 110 | throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); 111 | } 112 | 113 | /// <summary> 114 | /// Check if a specific port is available for binding 115 | /// </summary> 116 | /// <param name="port">Port to check</param> 117 | /// <returns>True if port is available</returns> 118 | public static bool IsPortAvailable(int port) 119 | { 120 | try 121 | { 122 | var testListener = new TcpListener(IPAddress.Loopback, port); 123 | testListener.Start(); 124 | testListener.Stop(); 125 | return true; 126 | } 127 | catch (SocketException) 128 | { 129 | return false; 130 | } 131 | } 132 | 133 | /// <summary> 134 | /// Check if a port is currently being used by MCP for Unity 135 | /// This helps avoid unnecessary port changes when Unity itself is using the port 136 | /// </summary> 137 | /// <param name="port">Port to check</param> 138 | /// <returns>True if port appears to be used by MCP for Unity</returns> 139 | public static bool IsPortUsedByMCPForUnity(int port) 140 | { 141 | try 142 | { 143 | // Try to make a quick connection to see if it's an MCP for Unity server 144 | using var client = new TcpClient(); 145 | var connectTask = client.ConnectAsync(IPAddress.Loopback, port); 146 | if (connectTask.Wait(100)) // 100ms timeout 147 | { 148 | // If connection succeeded, it's likely the MCP for Unity server 149 | return client.Connected; 150 | } 151 | return false; 152 | } 153 | catch 154 | { 155 | return false; 156 | } 157 | } 158 | 159 | /// <summary> 160 | /// Wait for a port to become available for a limited amount of time. 161 | /// Used to bridge the gap during domain reload when the old listener 162 | /// hasn't released the socket yet. 163 | /// </summary> 164 | private static bool WaitForPortRelease(int port, int timeoutMs) 165 | { 166 | int waited = 0; 167 | const int step = 100; 168 | while (waited < timeoutMs) 169 | { 170 | if (IsPortAvailable(port)) 171 | { 172 | return true; 173 | } 174 | 175 | // If the port is in use by an MCP instance, continue waiting briefly 176 | if (!IsPortUsedByMCPForUnity(port)) 177 | { 178 | // In use by something else; don't keep waiting 179 | return false; 180 | } 181 | 182 | Thread.Sleep(step); 183 | waited += step; 184 | } 185 | return IsPortAvailable(port); 186 | } 187 | 188 | /// <summary> 189 | /// Save port to persistent storage 190 | /// </summary> 191 | /// <param name="port">Port to save</param> 192 | private static void SavePort(int port) 193 | { 194 | try 195 | { 196 | var portConfig = new PortConfig 197 | { 198 | unity_port = port, 199 | created_date = DateTime.UtcNow.ToString("O"), 200 | project_path = Application.dataPath 201 | }; 202 | 203 | string registryDir = GetRegistryDirectory(); 204 | Directory.CreateDirectory(registryDir); 205 | 206 | string registryFile = GetRegistryFilePath(); 207 | string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); 208 | // Write to hashed, project-scoped file 209 | File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); 210 | // Also write to legacy stable filename to avoid hash/case drift across reloads 211 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 212 | File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); 213 | 214 | if (IsDebugEnabled()) Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Saved port {port} to storage"); 215 | } 216 | catch (Exception ex) 217 | { 218 | Debug.LogWarning($"Could not save port to storage: {ex.Message}"); 219 | } 220 | } 221 | 222 | /// <summary> 223 | /// Load port from persistent storage 224 | /// </summary> 225 | /// <returns>Stored port number, or 0 if not found</returns> 226 | private static int LoadStoredPort() 227 | { 228 | try 229 | { 230 | string registryFile = GetRegistryFilePath(); 231 | 232 | if (!File.Exists(registryFile)) 233 | { 234 | // Backwards compatibility: try the legacy file name 235 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 236 | if (!File.Exists(legacy)) 237 | { 238 | return 0; 239 | } 240 | registryFile = legacy; 241 | } 242 | 243 | string json = File.ReadAllText(registryFile); 244 | var portConfig = JsonConvert.DeserializeObject<PortConfig>(json); 245 | 246 | return portConfig?.unity_port ?? 0; 247 | } 248 | catch (Exception ex) 249 | { 250 | Debug.LogWarning($"Could not load port from storage: {ex.Message}"); 251 | return 0; 252 | } 253 | } 254 | 255 | /// <summary> 256 | /// Get the current stored port configuration 257 | /// </summary> 258 | /// <returns>Port configuration if exists, null otherwise</returns> 259 | public static PortConfig GetStoredPortConfig() 260 | { 261 | try 262 | { 263 | string registryFile = GetRegistryFilePath(); 264 | 265 | if (!File.Exists(registryFile)) 266 | { 267 | // Backwards compatibility: try the legacy file 268 | string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); 269 | if (!File.Exists(legacy)) 270 | { 271 | return null; 272 | } 273 | registryFile = legacy; 274 | } 275 | 276 | string json = File.ReadAllText(registryFile); 277 | return JsonConvert.DeserializeObject<PortConfig>(json); 278 | } 279 | catch (Exception ex) 280 | { 281 | Debug.LogWarning($"Could not load port config: {ex.Message}"); 282 | return null; 283 | } 284 | } 285 | 286 | private static string GetRegistryDirectory() 287 | { 288 | return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); 289 | } 290 | 291 | private static string GetRegistryFilePath() 292 | { 293 | string dir = GetRegistryDirectory(); 294 | string hash = ComputeProjectHash(Application.dataPath); 295 | string fileName = $"unity-mcp-port-{hash}.json"; 296 | return Path.Combine(dir, fileName); 297 | } 298 | 299 | private static string ComputeProjectHash(string input) 300 | { 301 | try 302 | { 303 | using SHA1 sha1 = SHA1.Create(); 304 | byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); 305 | byte[] hashBytes = sha1.ComputeHash(bytes); 306 | var sb = new StringBuilder(); 307 | foreach (byte b in hashBytes) 308 | { 309 | sb.Append(b.ToString("x2")); 310 | } 311 | return sb.ToString()[..8]; // short, sufficient for filenames 312 | } 313 | catch 314 | { 315 | return "default"; 316 | } 317 | } 318 | } 319 | } 320 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/TestRunnerService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using MCPForUnity.Editor.Helpers; 7 | using UnityEditor; 8 | using UnityEditor.TestTools.TestRunner.Api; 9 | using UnityEngine; 10 | 11 | namespace MCPForUnity.Editor.Services 12 | { 13 | /// <summary> 14 | /// Concrete implementation of <see cref="ITestRunnerService"/>. 15 | /// Coordinates Unity Test Runner operations and produces structured results. 16 | /// </summary> 17 | internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable 18 | { 19 | private static readonly TestMode[] AllModes = { TestMode.EditMode, TestMode.PlayMode }; 20 | 21 | private readonly TestRunnerApi _testRunnerApi; 22 | private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); 23 | private readonly List<ITestResultAdaptor> _leafResults = new List<ITestResultAdaptor>(); 24 | private TaskCompletionSource<TestRunResult> _runCompletionSource; 25 | 26 | public TestRunnerService() 27 | { 28 | _testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>(); 29 | _testRunnerApi.RegisterCallbacks(this); 30 | } 31 | 32 | public async Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode) 33 | { 34 | await _operationLock.WaitAsync().ConfigureAwait(false); 35 | try 36 | { 37 | var modes = mode.HasValue ? new[] { mode.Value } : AllModes; 38 | 39 | var results = new List<Dictionary<string, string>>(); 40 | var seen = new HashSet<string>(StringComparer.Ordinal); 41 | 42 | foreach (var m in modes) 43 | { 44 | var root = await RetrieveTestRootAsync(m).ConfigureAwait(true); 45 | if (root != null) 46 | { 47 | CollectFromNode(root, m, results, seen, new List<string>()); 48 | } 49 | } 50 | 51 | return results; 52 | } 53 | finally 54 | { 55 | _operationLock.Release(); 56 | } 57 | } 58 | 59 | public async Task<TestRunResult> RunTestsAsync(TestMode mode) 60 | { 61 | await _operationLock.WaitAsync().ConfigureAwait(false); 62 | Task<TestRunResult> runTask; 63 | try 64 | { 65 | if (_runCompletionSource != null && !_runCompletionSource.Task.IsCompleted) 66 | { 67 | throw new InvalidOperationException("A Unity test run is already in progress."); 68 | } 69 | 70 | _leafResults.Clear(); 71 | _runCompletionSource = new TaskCompletionSource<TestRunResult>(TaskCreationOptions.RunContinuationsAsynchronously); 72 | 73 | var filter = new Filter { testMode = mode }; 74 | _testRunnerApi.Execute(new ExecutionSettings(filter)); 75 | 76 | runTask = _runCompletionSource.Task; 77 | } 78 | catch 79 | { 80 | _operationLock.Release(); 81 | throw; 82 | } 83 | 84 | try 85 | { 86 | return await runTask.ConfigureAwait(true); 87 | } 88 | finally 89 | { 90 | _operationLock.Release(); 91 | } 92 | } 93 | 94 | public void Dispose() 95 | { 96 | try 97 | { 98 | _testRunnerApi?.UnregisterCallbacks(this); 99 | } 100 | catch 101 | { 102 | // Ignore cleanup errors 103 | } 104 | 105 | if (_testRunnerApi != null) 106 | { 107 | ScriptableObject.DestroyImmediate(_testRunnerApi); 108 | } 109 | 110 | _operationLock.Dispose(); 111 | } 112 | 113 | #region TestRunnerApi callbacks 114 | 115 | public void RunStarted(ITestAdaptor testsToRun) 116 | { 117 | _leafResults.Clear(); 118 | } 119 | 120 | public void RunFinished(ITestResultAdaptor result) 121 | { 122 | if (_runCompletionSource == null) 123 | { 124 | return; 125 | } 126 | 127 | var payload = TestRunResult.Create(result, _leafResults); 128 | _runCompletionSource.TrySetResult(payload); 129 | _runCompletionSource = null; 130 | } 131 | 132 | public void TestStarted(ITestAdaptor test) 133 | { 134 | // No-op 135 | } 136 | 137 | public void TestFinished(ITestResultAdaptor result) 138 | { 139 | if (result == null) 140 | { 141 | return; 142 | } 143 | 144 | if (!result.HasChildren) 145 | { 146 | _leafResults.Add(result); 147 | } 148 | } 149 | 150 | #endregion 151 | 152 | #region Test list helpers 153 | 154 | private async Task<ITestAdaptor> RetrieveTestRootAsync(TestMode mode) 155 | { 156 | var tcs = new TaskCompletionSource<ITestAdaptor>(TaskCreationOptions.RunContinuationsAsynchronously); 157 | 158 | _testRunnerApi.RetrieveTestList(mode, root => 159 | { 160 | tcs.TrySetResult(root); 161 | }); 162 | 163 | // Ensure the editor pumps at least one additional update in case the window is unfocused. 164 | EditorApplication.QueuePlayerLoopUpdate(); 165 | 166 | var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(true); 167 | if (completed != tcs.Task) 168 | { 169 | McpLog.Warn($"[TestRunnerService] Timeout waiting for test retrieval callback for {mode}"); 170 | return null; 171 | } 172 | 173 | try 174 | { 175 | return await tcs.Task.ConfigureAwait(true); 176 | } 177 | catch (Exception ex) 178 | { 179 | McpLog.Error($"[TestRunnerService] Error retrieving tests for {mode}: {ex.Message}\n{ex.StackTrace}"); 180 | return null; 181 | } 182 | } 183 | 184 | private static void CollectFromNode( 185 | ITestAdaptor node, 186 | TestMode mode, 187 | List<Dictionary<string, string>> output, 188 | HashSet<string> seen, 189 | List<string> path) 190 | { 191 | if (node == null) 192 | { 193 | return; 194 | } 195 | 196 | bool hasName = !string.IsNullOrEmpty(node.Name); 197 | if (hasName) 198 | { 199 | path.Add(node.Name); 200 | } 201 | 202 | bool hasChildren = node.HasChildren && node.Children != null; 203 | 204 | if (!hasChildren) 205 | { 206 | string fullName = string.IsNullOrEmpty(node.FullName) ? node.Name ?? string.Empty : node.FullName; 207 | string key = $"{mode}:{fullName}"; 208 | 209 | if (!string.IsNullOrEmpty(fullName) && seen.Add(key)) 210 | { 211 | string computedPath = path.Count > 0 ? string.Join("/", path) : fullName; 212 | output.Add(new Dictionary<string, string> 213 | { 214 | ["name"] = node.Name ?? fullName, 215 | ["full_name"] = fullName, 216 | ["path"] = computedPath, 217 | ["mode"] = mode.ToString(), 218 | }); 219 | } 220 | } 221 | else if (node.Children != null) 222 | { 223 | foreach (var child in node.Children) 224 | { 225 | CollectFromNode(child, mode, output, seen, path); 226 | } 227 | } 228 | 229 | if (hasName && path.Count > 0) 230 | { 231 | path.RemoveAt(path.Count - 1); 232 | } 233 | } 234 | 235 | #endregion 236 | } 237 | 238 | /// <summary> 239 | /// Summary of a Unity test run. 240 | /// </summary> 241 | public sealed class TestRunResult 242 | { 243 | internal TestRunResult(TestRunSummary summary, IReadOnlyList<TestRunTestResult> results) 244 | { 245 | Summary = summary; 246 | Results = results; 247 | } 248 | 249 | public TestRunSummary Summary { get; } 250 | public IReadOnlyList<TestRunTestResult> Results { get; } 251 | 252 | public int Total => Summary.Total; 253 | public int Passed => Summary.Passed; 254 | public int Failed => Summary.Failed; 255 | public int Skipped => Summary.Skipped; 256 | 257 | public object ToSerializable(string mode) 258 | { 259 | return new 260 | { 261 | mode, 262 | summary = Summary.ToSerializable(), 263 | results = Results.Select(r => r.ToSerializable()).ToList(), 264 | }; 265 | } 266 | 267 | internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList<ITestResultAdaptor> tests) 268 | { 269 | var materializedTests = tests.Select(TestRunTestResult.FromAdaptor).ToList(); 270 | 271 | int passed = summary?.PassCount 272 | ?? materializedTests.Count(t => string.Equals(t.State, "Passed", StringComparison.OrdinalIgnoreCase)); 273 | int failed = summary?.FailCount 274 | ?? materializedTests.Count(t => string.Equals(t.State, "Failed", StringComparison.OrdinalIgnoreCase)); 275 | int skipped = summary?.SkipCount 276 | ?? materializedTests.Count(t => string.Equals(t.State, "Skipped", StringComparison.OrdinalIgnoreCase)); 277 | 278 | double duration = summary?.Duration 279 | ?? materializedTests.Sum(t => t.DurationSeconds); 280 | 281 | int total = summary != null ? passed + failed + skipped : materializedTests.Count; 282 | 283 | var summaryPayload = new TestRunSummary( 284 | total, 285 | passed, 286 | failed, 287 | skipped, 288 | duration, 289 | summary?.ResultState ?? "Unknown"); 290 | 291 | return new TestRunResult(summaryPayload, materializedTests); 292 | } 293 | } 294 | 295 | public sealed class TestRunSummary 296 | { 297 | internal TestRunSummary(int total, int passed, int failed, int skipped, double durationSeconds, string resultState) 298 | { 299 | Total = total; 300 | Passed = passed; 301 | Failed = failed; 302 | Skipped = skipped; 303 | DurationSeconds = durationSeconds; 304 | ResultState = resultState; 305 | } 306 | 307 | public int Total { get; } 308 | public int Passed { get; } 309 | public int Failed { get; } 310 | public int Skipped { get; } 311 | public double DurationSeconds { get; } 312 | public string ResultState { get; } 313 | 314 | internal object ToSerializable() 315 | { 316 | return new 317 | { 318 | total = Total, 319 | passed = Passed, 320 | failed = Failed, 321 | skipped = Skipped, 322 | durationSeconds = DurationSeconds, 323 | resultState = ResultState, 324 | }; 325 | } 326 | } 327 | 328 | public sealed class TestRunTestResult 329 | { 330 | internal TestRunTestResult( 331 | string name, 332 | string fullName, 333 | string state, 334 | double durationSeconds, 335 | string message, 336 | string stackTrace, 337 | string output) 338 | { 339 | Name = name; 340 | FullName = fullName; 341 | State = state; 342 | DurationSeconds = durationSeconds; 343 | Message = message; 344 | StackTrace = stackTrace; 345 | Output = output; 346 | } 347 | 348 | public string Name { get; } 349 | public string FullName { get; } 350 | public string State { get; } 351 | public double DurationSeconds { get; } 352 | public string Message { get; } 353 | public string StackTrace { get; } 354 | public string Output { get; } 355 | 356 | internal object ToSerializable() 357 | { 358 | return new 359 | { 360 | name = Name, 361 | fullName = FullName, 362 | state = State, 363 | durationSeconds = DurationSeconds, 364 | message = Message, 365 | stackTrace = StackTrace, 366 | output = Output, 367 | }; 368 | } 369 | 370 | internal static TestRunTestResult FromAdaptor(ITestResultAdaptor adaptor) 371 | { 372 | if (adaptor == null) 373 | { 374 | return new TestRunTestResult(string.Empty, string.Empty, "Unknown", 0.0, string.Empty, string.Empty, string.Empty); 375 | } 376 | 377 | return new TestRunTestResult( 378 | adaptor.Name, 379 | adaptor.FullName, 380 | adaptor.ResultState, 381 | adaptor.Duration, 382 | adaptor.Message, 383 | adaptor.StackTrace, 384 | adaptor.Output); 385 | } 386 | } 387 | } 388 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageShader.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEngine; 8 | using MCPForUnity.Editor.Helpers; 9 | 10 | namespace MCPForUnity.Editor.Tools 11 | { 12 | /// <summary> 13 | /// Handles CRUD operations for shader files within the Unity project. 14 | /// </summary> 15 | [McpForUnityTool("manage_shader")] 16 | public static class ManageShader 17 | { 18 | /// <summary> 19 | /// Main handler for shader management actions. 20 | /// </summary> 21 | public static object HandleCommand(JObject @params) 22 | { 23 | // Extract parameters 24 | string action = @params["action"]?.ToString().ToLower(); 25 | string name = @params["name"]?.ToString(); 26 | string path = @params["path"]?.ToString(); // Relative to Assets/ 27 | string contents = null; 28 | 29 | // Check if we have base64 encoded contents 30 | bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false; 31 | if (contentsEncoded && @params["encodedContents"] != null) 32 | { 33 | try 34 | { 35 | contents = DecodeBase64(@params["encodedContents"].ToString()); 36 | } 37 | catch (Exception e) 38 | { 39 | return Response.Error($"Failed to decode shader contents: {e.Message}"); 40 | } 41 | } 42 | else 43 | { 44 | contents = @params["contents"]?.ToString(); 45 | } 46 | 47 | // Validate required parameters 48 | if (string.IsNullOrEmpty(action)) 49 | { 50 | return Response.Error("Action parameter is required."); 51 | } 52 | if (string.IsNullOrEmpty(name)) 53 | { 54 | return Response.Error("Name parameter is required."); 55 | } 56 | // Basic name validation (alphanumeric, underscores, cannot start with number) 57 | if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) 58 | { 59 | return Response.Error( 60 | $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." 61 | ); 62 | } 63 | 64 | // Ensure path is relative to Assets/, removing any leading "Assets/" 65 | // Set default directory to "Shaders" if path is not provided 66 | string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null 67 | if (!string.IsNullOrEmpty(relativeDir)) 68 | { 69 | relativeDir = relativeDir.Replace('\\', '/').Trim('/'); 70 | if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 71 | { 72 | relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); 73 | } 74 | } 75 | // Handle empty string case explicitly after processing 76 | if (string.IsNullOrEmpty(relativeDir)) 77 | { 78 | relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" 79 | } 80 | 81 | // Construct paths 82 | string shaderFileName = $"{name}.shader"; 83 | string fullPathDir = Path.Combine(Application.dataPath, relativeDir); 84 | string fullPath = Path.Combine(fullPathDir, shaderFileName); 85 | string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) 86 | .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes 87 | 88 | // Ensure the target directory exists for create/update 89 | if (action == "create" || action == "update") 90 | { 91 | try 92 | { 93 | if (!Directory.Exists(fullPathDir)) 94 | { 95 | Directory.CreateDirectory(fullPathDir); 96 | // Refresh AssetDatabase to recognize new folders 97 | AssetDatabase.Refresh(); 98 | } 99 | } 100 | catch (Exception e) 101 | { 102 | return Response.Error( 103 | $"Could not create directory '{fullPathDir}': {e.Message}" 104 | ); 105 | } 106 | } 107 | 108 | // Route to specific action handlers 109 | switch (action) 110 | { 111 | case "create": 112 | return CreateShader(fullPath, relativePath, name, contents); 113 | case "read": 114 | return ReadShader(fullPath, relativePath); 115 | case "update": 116 | return UpdateShader(fullPath, relativePath, name, contents); 117 | case "delete": 118 | return DeleteShader(fullPath, relativePath); 119 | default: 120 | return Response.Error( 121 | $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." 122 | ); 123 | } 124 | } 125 | 126 | /// <summary> 127 | /// Decode base64 string to normal text 128 | /// </summary> 129 | private static string DecodeBase64(string encoded) 130 | { 131 | byte[] data = Convert.FromBase64String(encoded); 132 | return System.Text.Encoding.UTF8.GetString(data); 133 | } 134 | 135 | /// <summary> 136 | /// Encode text to base64 string 137 | /// </summary> 138 | private static string EncodeBase64(string text) 139 | { 140 | byte[] data = System.Text.Encoding.UTF8.GetBytes(text); 141 | return Convert.ToBase64String(data); 142 | } 143 | 144 | private static object CreateShader( 145 | string fullPath, 146 | string relativePath, 147 | string name, 148 | string contents 149 | ) 150 | { 151 | // Check if shader already exists 152 | if (File.Exists(fullPath)) 153 | { 154 | return Response.Error( 155 | $"Shader already exists at '{relativePath}'. Use 'update' action to modify." 156 | ); 157 | } 158 | 159 | // Add validation for shader name conflicts in Unity 160 | if (Shader.Find(name) != null) 161 | { 162 | return Response.Error( 163 | $"A shader with name '{name}' already exists in the project. Choose a different name." 164 | ); 165 | } 166 | 167 | // Generate default content if none provided 168 | if (string.IsNullOrEmpty(contents)) 169 | { 170 | contents = GenerateDefaultShaderContent(name); 171 | } 172 | 173 | try 174 | { 175 | File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); 176 | AssetDatabase.ImportAsset(relativePath); 177 | AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader 178 | return Response.Success( 179 | $"Shader '{name}.shader' created successfully at '{relativePath}'.", 180 | new { path = relativePath } 181 | ); 182 | } 183 | catch (Exception e) 184 | { 185 | return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); 186 | } 187 | } 188 | 189 | private static object ReadShader(string fullPath, string relativePath) 190 | { 191 | if (!File.Exists(fullPath)) 192 | { 193 | return Response.Error($"Shader not found at '{relativePath}'."); 194 | } 195 | 196 | try 197 | { 198 | string contents = File.ReadAllText(fullPath); 199 | 200 | // Return both normal and encoded contents for larger files 201 | //TODO: Consider a threshold for large files 202 | bool isLarge = contents.Length > 10000; // If content is large, include encoded version 203 | var responseData = new 204 | { 205 | path = relativePath, 206 | contents = contents, 207 | // For large files, also include base64-encoded version 208 | encodedContents = isLarge ? EncodeBase64(contents) : null, 209 | contentsEncoded = isLarge, 210 | }; 211 | 212 | return Response.Success( 213 | $"Shader '{Path.GetFileName(relativePath)}' read successfully.", 214 | responseData 215 | ); 216 | } 217 | catch (Exception e) 218 | { 219 | return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); 220 | } 221 | } 222 | 223 | private static object UpdateShader( 224 | string fullPath, 225 | string relativePath, 226 | string name, 227 | string contents 228 | ) 229 | { 230 | if (!File.Exists(fullPath)) 231 | { 232 | return Response.Error( 233 | $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." 234 | ); 235 | } 236 | if (string.IsNullOrEmpty(contents)) 237 | { 238 | return Response.Error("Content is required for the 'update' action."); 239 | } 240 | 241 | try 242 | { 243 | File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); 244 | AssetDatabase.ImportAsset(relativePath); 245 | AssetDatabase.Refresh(); 246 | return Response.Success( 247 | $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", 248 | new { path = relativePath } 249 | ); 250 | } 251 | catch (Exception e) 252 | { 253 | return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); 254 | } 255 | } 256 | 257 | private static object DeleteShader(string fullPath, string relativePath) 258 | { 259 | if (!File.Exists(fullPath)) 260 | { 261 | return Response.Error($"Shader not found at '{relativePath}'."); 262 | } 263 | 264 | try 265 | { 266 | // Delete the asset through Unity's AssetDatabase first 267 | bool success = AssetDatabase.DeleteAsset(relativePath); 268 | if (!success) 269 | { 270 | return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); 271 | } 272 | 273 | // If the file still exists (rare case), try direct deletion 274 | if (File.Exists(fullPath)) 275 | { 276 | File.Delete(fullPath); 277 | } 278 | 279 | return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); 280 | } 281 | catch (Exception e) 282 | { 283 | return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); 284 | } 285 | } 286 | 287 | //This is a CGProgram template 288 | //TODO: making a HLSL template as well? 289 | private static string GenerateDefaultShaderContent(string name) 290 | { 291 | return @"Shader """ + name + @""" 292 | { 293 | Properties 294 | { 295 | _MainTex (""Texture"", 2D) = ""white"" {} 296 | } 297 | SubShader 298 | { 299 | Tags { ""RenderType""=""Opaque"" } 300 | LOD 100 301 | 302 | Pass 303 | { 304 | CGPROGRAM 305 | #pragma vertex vert 306 | #pragma fragment frag 307 | #include ""UnityCG.cginc"" 308 | 309 | struct appdata 310 | { 311 | float4 vertex : POSITION; 312 | float2 uv : TEXCOORD0; 313 | }; 314 | 315 | struct v2f 316 | { 317 | float2 uv : TEXCOORD0; 318 | float4 vertex : SV_POSITION; 319 | }; 320 | 321 | sampler2D _MainTex; 322 | float4 _MainTex_ST; 323 | 324 | v2f vert (appdata v) 325 | { 326 | v2f o; 327 | o.vertex = UnityObjectToClipPos(v.vertex); 328 | o.uv = TRANSFORM_TEX(v.uv, _MainTex); 329 | return o; 330 | } 331 | 332 | fixed4 frag (v2f i) : SV_Target 333 | { 334 | fixed4 col = tex2D(_MainTex, i.uv); 335 | return col; 336 | } 337 | ENDCG 338 | } 339 | } 340 | }"; 341 | } 342 | } 343 | } 344 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageShader.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEngine; 8 | using MCPForUnity.Editor.Helpers; 9 | 10 | namespace MCPForUnity.Editor.Tools 11 | { 12 | /// <summary> 13 | /// Handles CRUD operations for shader files within the Unity project. 14 | /// </summary> 15 | [McpForUnityTool("manage_shader")] 16 | public static class ManageShader 17 | { 18 | /// <summary> 19 | /// Main handler for shader management actions. 20 | /// </summary> 21 | public static object HandleCommand(JObject @params) 22 | { 23 | // Extract parameters 24 | string action = @params["action"]?.ToString().ToLower(); 25 | string name = @params["name"]?.ToString(); 26 | string path = @params["path"]?.ToString(); // Relative to Assets/ 27 | string contents = null; 28 | 29 | // Check if we have base64 encoded contents 30 | bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false; 31 | if (contentsEncoded && @params["encodedContents"] != null) 32 | { 33 | try 34 | { 35 | contents = DecodeBase64(@params["encodedContents"].ToString()); 36 | } 37 | catch (Exception e) 38 | { 39 | return Response.Error($"Failed to decode shader contents: {e.Message}"); 40 | } 41 | } 42 | else 43 | { 44 | contents = @params["contents"]?.ToString(); 45 | } 46 | 47 | // Validate required parameters 48 | if (string.IsNullOrEmpty(action)) 49 | { 50 | return Response.Error("Action parameter is required."); 51 | } 52 | if (string.IsNullOrEmpty(name)) 53 | { 54 | return Response.Error("Name parameter is required."); 55 | } 56 | // Basic name validation (alphanumeric, underscores, cannot start with number) 57 | if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) 58 | { 59 | return Response.Error( 60 | $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." 61 | ); 62 | } 63 | 64 | // Ensure path is relative to Assets/, removing any leading "Assets/" 65 | // Set default directory to "Shaders" if path is not provided 66 | string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null 67 | if (!string.IsNullOrEmpty(relativeDir)) 68 | { 69 | relativeDir = relativeDir.Replace('\\', '/').Trim('/'); 70 | if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 71 | { 72 | relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); 73 | } 74 | } 75 | // Handle empty string case explicitly after processing 76 | if (string.IsNullOrEmpty(relativeDir)) 77 | { 78 | relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" 79 | } 80 | 81 | // Construct paths 82 | string shaderFileName = $"{name}.shader"; 83 | string fullPathDir = Path.Combine(Application.dataPath, relativeDir); 84 | string fullPath = Path.Combine(fullPathDir, shaderFileName); 85 | string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) 86 | .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes 87 | 88 | // Ensure the target directory exists for create/update 89 | if (action == "create" || action == "update") 90 | { 91 | try 92 | { 93 | if (!Directory.Exists(fullPathDir)) 94 | { 95 | Directory.CreateDirectory(fullPathDir); 96 | // Refresh AssetDatabase to recognize new folders 97 | AssetDatabase.Refresh(); 98 | } 99 | } 100 | catch (Exception e) 101 | { 102 | return Response.Error( 103 | $"Could not create directory '{fullPathDir}': {e.Message}" 104 | ); 105 | } 106 | } 107 | 108 | // Route to specific action handlers 109 | switch (action) 110 | { 111 | case "create": 112 | return CreateShader(fullPath, relativePath, name, contents); 113 | case "read": 114 | return ReadShader(fullPath, relativePath); 115 | case "update": 116 | return UpdateShader(fullPath, relativePath, name, contents); 117 | case "delete": 118 | return DeleteShader(fullPath, relativePath); 119 | default: 120 | return Response.Error( 121 | $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." 122 | ); 123 | } 124 | } 125 | 126 | /// <summary> 127 | /// Decode base64 string to normal text 128 | /// </summary> 129 | private static string DecodeBase64(string encoded) 130 | { 131 | byte[] data = Convert.FromBase64String(encoded); 132 | return System.Text.Encoding.UTF8.GetString(data); 133 | } 134 | 135 | /// <summary> 136 | /// Encode text to base64 string 137 | /// </summary> 138 | private static string EncodeBase64(string text) 139 | { 140 | byte[] data = System.Text.Encoding.UTF8.GetBytes(text); 141 | return Convert.ToBase64String(data); 142 | } 143 | 144 | private static object CreateShader( 145 | string fullPath, 146 | string relativePath, 147 | string name, 148 | string contents 149 | ) 150 | { 151 | // Check if shader already exists 152 | if (File.Exists(fullPath)) 153 | { 154 | return Response.Error( 155 | $"Shader already exists at '{relativePath}'. Use 'update' action to modify." 156 | ); 157 | } 158 | 159 | // Add validation for shader name conflicts in Unity 160 | if (Shader.Find(name) != null) 161 | { 162 | return Response.Error( 163 | $"A shader with name '{name}' already exists in the project. Choose a different name." 164 | ); 165 | } 166 | 167 | // Generate default content if none provided 168 | if (string.IsNullOrEmpty(contents)) 169 | { 170 | contents = GenerateDefaultShaderContent(name); 171 | } 172 | 173 | try 174 | { 175 | File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); 176 | AssetDatabase.ImportAsset(relativePath); 177 | AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader 178 | return Response.Success( 179 | $"Shader '{name}.shader' created successfully at '{relativePath}'.", 180 | new { path = relativePath } 181 | ); 182 | } 183 | catch (Exception e) 184 | { 185 | return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); 186 | } 187 | } 188 | 189 | private static object ReadShader(string fullPath, string relativePath) 190 | { 191 | if (!File.Exists(fullPath)) 192 | { 193 | return Response.Error($"Shader not found at '{relativePath}'."); 194 | } 195 | 196 | try 197 | { 198 | string contents = File.ReadAllText(fullPath); 199 | 200 | // Return both normal and encoded contents for larger files 201 | //TODO: Consider a threshold for large files 202 | bool isLarge = contents.Length > 10000; // If content is large, include encoded version 203 | var responseData = new 204 | { 205 | path = relativePath, 206 | contents = contents, 207 | // For large files, also include base64-encoded version 208 | encodedContents = isLarge ? EncodeBase64(contents) : null, 209 | contentsEncoded = isLarge, 210 | }; 211 | 212 | return Response.Success( 213 | $"Shader '{Path.GetFileName(relativePath)}' read successfully.", 214 | responseData 215 | ); 216 | } 217 | catch (Exception e) 218 | { 219 | return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); 220 | } 221 | } 222 | 223 | private static object UpdateShader( 224 | string fullPath, 225 | string relativePath, 226 | string name, 227 | string contents 228 | ) 229 | { 230 | if (!File.Exists(fullPath)) 231 | { 232 | return Response.Error( 233 | $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." 234 | ); 235 | } 236 | if (string.IsNullOrEmpty(contents)) 237 | { 238 | return Response.Error("Content is required for the 'update' action."); 239 | } 240 | 241 | try 242 | { 243 | File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); 244 | AssetDatabase.ImportAsset(relativePath); 245 | AssetDatabase.Refresh(); 246 | return Response.Success( 247 | $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", 248 | new { path = relativePath } 249 | ); 250 | } 251 | catch (Exception e) 252 | { 253 | return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); 254 | } 255 | } 256 | 257 | private static object DeleteShader(string fullPath, string relativePath) 258 | { 259 | if (!File.Exists(fullPath)) 260 | { 261 | return Response.Error($"Shader not found at '{relativePath}'."); 262 | } 263 | 264 | try 265 | { 266 | // Delete the asset through Unity's AssetDatabase first 267 | bool success = AssetDatabase.DeleteAsset(relativePath); 268 | if (!success) 269 | { 270 | return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); 271 | } 272 | 273 | // If the file still exists (rare case), try direct deletion 274 | if (File.Exists(fullPath)) 275 | { 276 | File.Delete(fullPath); 277 | } 278 | 279 | return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); 280 | } 281 | catch (Exception e) 282 | { 283 | return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); 284 | } 285 | } 286 | 287 | //This is a CGProgram template 288 | //TODO: making a HLSL template as well? 289 | private static string GenerateDefaultShaderContent(string name) 290 | { 291 | return @"Shader """ + name + @""" 292 | { 293 | Properties 294 | { 295 | _MainTex (""Texture"", 2D) = ""white"" {} 296 | } 297 | SubShader 298 | { 299 | Tags { ""RenderType""=""Opaque"" } 300 | LOD 100 301 | 302 | Pass 303 | { 304 | CGPROGRAM 305 | #pragma vertex vert 306 | #pragma fragment frag 307 | #include ""UnityCG.cginc"" 308 | 309 | struct appdata 310 | { 311 | float4 vertex : POSITION; 312 | float2 uv : TEXCOORD0; 313 | }; 314 | 315 | struct v2f 316 | { 317 | float2 uv : TEXCOORD0; 318 | float4 vertex : SV_POSITION; 319 | }; 320 | 321 | sampler2D _MainTex; 322 | float4 _MainTex_ST; 323 | 324 | v2f vert (appdata v) 325 | { 326 | v2f o; 327 | o.vertex = UnityObjectToClipPos(v.vertex); 328 | o.uv = TRANSFORM_TEX(v.uv, _MainTex); 329 | return o; 330 | } 331 | 332 | fixed4 frag (v2f i) : SV_Target 333 | { 334 | fixed4 col = tex2D(_MainTex, i.uv); 335 | return col; 336 | } 337 | ENDCG 338 | } 339 | } 340 | }"; 341 | } 342 | } 343 | } 344 | ``` -------------------------------------------------------------------------------- /docs/CUSTOM_TOOLS.md: -------------------------------------------------------------------------------- ```markdown 1 | # Adding Custom Tools to MCP for Unity 2 | 3 | MCP for Unity supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools. 4 | 5 | Be sure to review the developer README first: 6 | 7 | | [English](README-DEV.md) | [简体中文](README-DEV-zh.md) | 8 | |---------------------------|------------------------------| 9 | 10 | --- 11 | 12 | # Part 1: How to Use (Quick Start Guide) 13 | 14 | This section shows you how to add custom tools to your Unity project. 15 | 16 | ## Step 1: Create a PythonToolsAsset 17 | 18 | First, create a ScriptableObject to manage your Python tools: 19 | 20 | 1. In Unity, right-click in the Project window 21 | 2. Select **Assets > Create > MCP For Unity > Python Tools** 22 | 3. Name it (e.g., `MyPythonTools`) 23 | 24 |  25 | 26 | ## Step 2: Create Your Python Tool File 27 | 28 | Create a Python file **anywhere in your Unity project**. For example, `Assets/Editor/MyTools/my_custom_tool.py`: 29 | 30 | ```python 31 | from typing import Annotated, Any 32 | from mcp.server.fastmcp import Context 33 | from registry import mcp_for_unity_tool 34 | from unity_connection import send_command_with_retry 35 | 36 | @mcp_for_unity_tool( 37 | description="My custom tool that does something amazing" 38 | ) 39 | async def my_custom_tool( 40 | ctx: Context, 41 | param1: Annotated[str, "Description of param1"], 42 | param2: Annotated[int, "Description of param2"] | None = None 43 | ) -> dict[str, Any]: 44 | await ctx.info(f"Processing my_custom_tool: {param1}") 45 | 46 | # Prepare parameters for Unity 47 | params = { 48 | "action": "do_something", 49 | "param1": param1, 50 | "param2": param2, 51 | } 52 | params = {k: v for k, v in params.items() if v is not None} 53 | 54 | # Send to Unity handler 55 | response = send_command_with_retry("my_custom_tool", params) 56 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 57 | ``` 58 | 59 | ## Step 3: Add Python File to Asset 60 | 61 | 1. Select your `PythonToolsAsset` in the Project window 62 | 2. In the Inspector, expand **Python Files** 63 | 3. Drag your `.py` file into the list (or click **+** and select it) 64 | 65 |  66 | 67 | **Note:** If you can't see `.py` files in the object picker, go to **Window > MCP For Unity > Tool Sync > Reimport Python Files** to force Unity to recognize them as text assets. 68 | 69 | ## Step 4: Create C# Handler 70 | 71 | Create a C# file anywhere in your Unity project (typically in `Editor/`): 72 | 73 | 74 | ```csharp 75 | using Newtonsoft.Json.Linq; 76 | using MCPForUnity.Editor.Helpers; 77 | 78 | namespace MyProject.Editor.CustomTools 79 | { 80 | [McpForUnityTool("my_custom_tool")] 81 | public static class MyCustomTool 82 | { 83 | public static object HandleCommand(JObject @params) 84 | { 85 | string action = @params["action"]?.ToString(); 86 | string param1 = @params["param1"]?.ToString(); 87 | int? param2 = @params["param2"]?.ToObject<int?>(); 88 | 89 | // Your custom logic here 90 | if (string.IsNullOrEmpty(param1)) 91 | { 92 | return Response.Error("param1 is required"); 93 | } 94 | 95 | // Do something amazing 96 | DoSomethingAmazing(param1, param2); 97 | 98 | return Response.Success("Custom tool executed successfully!"); 99 | } 100 | 101 | private static void DoSomethingAmazing(string param1, int? param2) 102 | { 103 | // Your implementation 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | ## Step 5: Rebuild the MCP Server 110 | 111 | 1. Open the MCP for Unity window in the Unity Editor 112 | 2. Click **Rebuild Server** to apply your changes 113 | 3. Your tool is now available to MCP clients! 114 | 115 | **What happens automatically:** 116 | - ✅ Python files are synced to the MCP server on Unity startup 117 | - ✅ Python files are synced when modified (you would need to rebuild the server) 118 | - ✅ C# handlers are discovered via reflection 119 | - ✅ Tools are registered with the MCP server 120 | 121 | ## Complete Example: Screenshot Tool 122 | 123 | Here's a complete example showing how to create a screenshot capture tool. 124 | 125 | ### Python File (`Assets/Editor/ScreenShots/Python/screenshot_tool.py`) 126 | 127 | ```python 128 | from typing import Annotated, Any 129 | 130 | from mcp.server.fastmcp import Context 131 | 132 | from registry import mcp_for_unity_tool 133 | from unity_connection import send_command_with_retry 134 | 135 | 136 | @mcp_for_unity_tool( 137 | description="Capture screenshots in Unity, saving them as PNGs" 138 | ) 139 | async def capture_screenshot( 140 | ctx: Context, 141 | filename: Annotated[str, "Screenshot filename without extension, e.g., screenshot_01"], 142 | ) -> dict[str, Any]: 143 | await ctx.info(f"Capturing screenshot: {filename}") 144 | 145 | params = { 146 | "action": "capture", 147 | "filename": filename, 148 | } 149 | params = {k: v for k, v in params.items() if v is not None} 150 | 151 | response = send_command_with_retry("capture_screenshot", params) 152 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 153 | ``` 154 | 155 | ### Add to PythonToolsAsset 156 | 157 | 1. Select your `PythonToolsAsset` 158 | 2. Add `screenshot_tool.py` to the **Python Files** list 159 | 3. The file will automatically sync to the MCP server 160 | 161 | ### C# Handler (`Assets/Editor/ScreenShots/CaptureScreenshotTool.cs`) 162 | 163 | ```csharp 164 | using System.IO; 165 | using Newtonsoft.Json.Linq; 166 | using UnityEngine; 167 | using MCPForUnity.Editor.Tools; 168 | 169 | namespace MyProject.Editor.Tools 170 | { 171 | [McpForUnityTool("capture_screenshot")] 172 | public static class CaptureScreenshotTool 173 | { 174 | public static object HandleCommand(JObject @params) 175 | { 176 | string filename = @params["filename"]?.ToString(); 177 | 178 | if (string.IsNullOrEmpty(filename)) 179 | { 180 | return MCPForUnity.Editor.Helpers.Response.Error("filename is required"); 181 | } 182 | 183 | try 184 | { 185 | string absolutePath = Path.Combine(Application.dataPath, "Screenshots", filename); 186 | Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)); 187 | 188 | // Find the main camera 189 | Camera camera = Camera.main; 190 | if (camera == null) 191 | { 192 | camera = Object.FindFirstObjectByType<Camera>(); 193 | } 194 | 195 | if (camera == null) 196 | { 197 | return MCPForUnity.Editor.Helpers.Response.Error("No camera found in the scene"); 198 | } 199 | 200 | // Create a RenderTexture 201 | RenderTexture rt = new RenderTexture(Screen.width, Screen.height, 24); 202 | camera.targetTexture = rt; 203 | 204 | // Render the camera's view 205 | camera.Render(); 206 | 207 | // Read pixels from the RenderTexture 208 | RenderTexture.active = rt; 209 | Texture2D screenshot = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false); 210 | screenshot.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); 211 | screenshot.Apply(); 212 | 213 | // Clean up 214 | camera.targetTexture = null; 215 | RenderTexture.active = null; 216 | Object.DestroyImmediate(rt); 217 | 218 | // Save to file 219 | byte[] bytes = screenshot.EncodeToPNG(); 220 | File.WriteAllBytes(absolutePath, bytes); 221 | Object.DestroyImmediate(screenshot); 222 | 223 | return MCPForUnity.Editor.Helpers.Response.Success($"Screenshot saved to {absolutePath}", new 224 | { 225 | path = absolutePath, 226 | }); 227 | } 228 | catch (System.Exception ex) 229 | { 230 | return MCPForUnity.Editor.Helpers.Response.Error($"Failed to capture screenshot: {ex.Message}"); 231 | } 232 | } 233 | } 234 | } 235 | ``` 236 | 237 | ### Rebuild and Test 238 | 239 | 1. Open the MCP for Unity window 240 | 2. Click **Rebuild Server** 241 | 3. Test your tool from your MCP client! 242 | 243 | --- 244 | 245 | # Part 2: How It Works (Technical Details) 246 | 247 | This section explains the technical implementation of the custom tools system. 248 | 249 | ## Python Side: Decorator System 250 | 251 | ### The `@mcp_for_unity_tool` Decorator 252 | 253 | The decorator automatically registers your function as an MCP tool: 254 | 255 | ```python 256 | @mcp_for_unity_tool( 257 | name="custom_name", # Optional: function name used by default 258 | description="Tool description", # Required: describe what the tool does 259 | ) 260 | ``` 261 | 262 | **How it works:** 263 | - Auto-generates the tool name from the function name (e.g., `my_custom_tool`) 264 | - Registers the tool with FastMCP during module import 265 | - Supports all FastMCP `mcp.tool` decorator options: <https://gofastmcp.com/servers/tools#tools> 266 | 267 | **Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289). 268 | 269 | ### Auto-Discovery 270 | 271 | Python tools are automatically discovered when: 272 | - The Python file is added to a `PythonToolsAsset` 273 | - The file is synced to `MCPForUnity/UnityMcpServer~/src/tools/custom/` 274 | - The file is imported during server startup 275 | - The decorator `@mcp_for_unity_tool` is used 276 | 277 | ### Sync System 278 | 279 | The `PythonToolsAsset` system automatically syncs your Python files: 280 | 281 | **When sync happens:** 282 | - ✅ Unity starts up 283 | - ✅ Python files are modified 284 | - ✅ Python files are added/removed from the asset 285 | 286 | **Manual controls:** 287 | - **Sync Now:** Window > MCP For Unity > Tool Sync > Sync Python Tools 288 | - **Toggle Auto-Sync:** Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools 289 | - **Reimport Python Files:** Window > MCP For Unity > Tool Sync > Reimport Python Files 290 | 291 | **How it works:** 292 | - Uses content hashing to detect changes (only syncs modified files) 293 | - Files are copied to `MCPForUnity/UnityMcpServer~/src/tools/custom/` 294 | - Stale files are automatically cleaned up 295 | 296 | ## C# Side: Attribute System 297 | 298 | ### The `[McpForUnityTool]` Attribute 299 | 300 | The attribute marks your class as a tool handler: 301 | 302 | ```csharp 303 | // Explicit command name 304 | [McpForUnityTool("my_custom_tool")] 305 | public static class MyCustomTool { } 306 | 307 | // Auto-generated from class name (MyCustomTool → my_custom_tool) 308 | [McpForUnityTool] 309 | public static class MyCustomTool { } 310 | ``` 311 | 312 | ### Auto-Discovery 313 | 314 | C# handlers are automatically discovered when: 315 | - The class has the `[McpForUnityTool]` attribute 316 | - The class has a `public static HandleCommand(JObject)` method 317 | - Unity loads the assembly containing the class 318 | 319 | **How it works:** 320 | - Unity scans all assemblies on startup 321 | - Finds classes with `[McpForUnityTool]` attribute 322 | - Registers them in the command registry 323 | - Routes MCP commands to the appropriate handler 324 | 325 | ## Best Practices 326 | 327 | ### Python 328 | - ✅ Use type hints with `Annotated` for parameter documentation 329 | - ✅ Return `dict[str, Any]` with `{"success": bool, "message": str, "data": Any}` 330 | - ✅ Use `ctx.info()` for logging 331 | - ✅ Handle errors gracefully and return structured error responses 332 | - ✅ Use `send_command_with_retry()` for Unity communication 333 | 334 | ### C# 335 | - ✅ Use the `Response.Success()` and `Response.Error()` helper methods 336 | - ✅ Validate input parameters before processing 337 | - ✅ Use `@params["key"]?.ToObject<Type>()` for safe type conversion 338 | - ✅ Return structured responses with meaningful data 339 | - ✅ Handle exceptions and return error responses 340 | 341 | ## Debugging 342 | 343 | ### Python 344 | - Check server logs: `~/Library/Application Support/UnityMCP/Logs/unity_mcp_server.log` 345 | - Look for: `"Registered X MCP tools"` message on startup 346 | - Use `ctx.info()` for debugging messages 347 | 348 | ### C# 349 | - Check Unity Console for: `"MCP-FOR-UNITY: Auto-discovered X tools"` message 350 | - Look for warnings about missing `HandleCommand` methods 351 | - Use `Debug.Log()` in your handler for debugging 352 | 353 | ## Troubleshooting 354 | 355 | **Tool not appearing:** 356 | - **Python:** 357 | - Ensure the `.py` file is added to a `PythonToolsAsset` 358 | - Check Unity Console for sync messages: "Python tools synced: X copied" 359 | - Verify file was synced to `UnityMcpServer~/src/tools/custom/` 360 | - Try manual sync: Window > MCP For Unity > Tool Sync > Sync Python Tools 361 | - Rebuild the server in the MCP for Unity window 362 | - **C#:** 363 | - Ensure the class has `[McpForUnityTool]` attribute 364 | - Ensure the class has a `public static HandleCommand(JObject)` method 365 | - Check Unity Console for: "MCP-FOR-UNITY: Auto-discovered X tools" 366 | 367 | **Python files not showing in Inspector:** 368 | - Go to **Window > MCP For Unity > Tool Sync > Reimport Python Files** 369 | - This forces Unity to recognize `.py` files as TextAssets 370 | - Check that `.py.meta` files show `ScriptedImporter` (not `DefaultImporter`) 371 | 372 | **Sync not working:** 373 | - Check if auto-sync is enabled: Window > MCP For Unity > Tool Sync > Auto-Sync Python Tools 374 | - Look for errors in Unity Console 375 | - Verify `PythonToolsAsset` has the correct files added 376 | 377 | **Name conflicts:** 378 | - Use explicit names in decorators/attributes to avoid conflicts 379 | - Check registered tools: `CommandRegistry.GetAllCommandNames()` in C# 380 | 381 | **Tool not being called:** 382 | - Verify the command name matches between Python and C# 383 | - Check that parameters are being passed correctly 384 | - Look for errors in logs 385 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.RegularExpressions; 7 | using MCPForUnity.External.Tommy; 8 | using Newtonsoft.Json; 9 | 10 | namespace MCPForUnity.Editor.Helpers 11 | { 12 | /// <summary> 13 | /// Codex CLI specific configuration helpers. Handles TOML snippet 14 | /// generation and lightweight parsing so Codex can join the auto-setup 15 | /// flow alongside JSON-based clients. 16 | /// </summary> 17 | public static class CodexConfigHelper 18 | { 19 | public static bool IsCodexConfigured(string pythonDir) 20 | { 21 | try 22 | { 23 | string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 24 | if (string.IsNullOrEmpty(basePath)) return false; 25 | 26 | string configPath = Path.Combine(basePath, ".codex", "config.toml"); 27 | if (!File.Exists(configPath)) return false; 28 | 29 | string toml = File.ReadAllText(configPath); 30 | if (!TryParseCodexServer(toml, out _, out var args)) return false; 31 | 32 | string dir = McpConfigFileHelper.ExtractDirectoryArg(args); 33 | if (string.IsNullOrEmpty(dir)) return false; 34 | 35 | return McpConfigFileHelper.PathsEqual(dir, pythonDir); 36 | } 37 | catch 38 | { 39 | return false; 40 | } 41 | } 42 | 43 | public static string BuildCodexServerBlock(string uvPath, string serverSrc) 44 | { 45 | string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" }); 46 | 47 | var sb = new StringBuilder(); 48 | sb.AppendLine("[mcp_servers.unityMCP]"); 49 | sb.AppendLine($"command = \"{EscapeTomlString(uvPath)}\""); 50 | sb.AppendLine($"args = {argsArray}"); 51 | sb.AppendLine($"startup_timeout_sec = 30"); 52 | 53 | // Windows-specific environment block to help Codex locate needed paths 54 | try 55 | { 56 | if (Environment.OSVersion.Platform == PlatformID.Win32NT) 57 | { 58 | string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 59 | string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; // Roaming 60 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; 61 | string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) ?? string.Empty; 62 | string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; 63 | string systemDrive = Environment.GetEnvironmentVariable("SystemDrive") ?? (Path.GetPathRoot(userProfile)?.TrimEnd('\\', '/') ?? "C:"); 64 | string systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? Path.Combine(systemDrive + "\\", "Windows"); 65 | string comspec = Environment.GetEnvironmentVariable("COMSPEC") ?? Path.Combine(Environment.SystemDirectory ?? (systemRoot + "\\System32"), "cmd.exe"); 66 | string homeDrive = Environment.GetEnvironmentVariable("HOMEDRIVE"); 67 | string homePath = Environment.GetEnvironmentVariable("HOMEPATH"); 68 | if (string.IsNullOrEmpty(homeDrive)) 69 | { 70 | homeDrive = systemDrive; 71 | } 72 | if (string.IsNullOrEmpty(homePath) && !string.IsNullOrEmpty(userProfile)) 73 | { 74 | // Derive HOMEPATH from USERPROFILE (e.g., C:\\Users\\name -> \\Users\\name) 75 | if (userProfile.StartsWith(homeDrive + "\\", StringComparison.OrdinalIgnoreCase)) 76 | { 77 | homePath = userProfile.Substring(homeDrive.Length); 78 | } 79 | else 80 | { 81 | try 82 | { 83 | var root = Path.GetPathRoot(userProfile) ?? string.Empty; // e.g., C:\\ 84 | homePath = userProfile.Substring(root.Length - 1); // keep leading backslash 85 | } 86 | catch { homePath = "\\"; } 87 | } 88 | } 89 | 90 | string powershell = Path.Combine(Environment.SystemDirectory ?? (systemRoot + "\\System32"), "WindowsPowerShell\\v1.0\\powershell.exe"); 91 | string pwsh = Path.Combine(programFiles, "PowerShell\\7\\pwsh.exe"); 92 | 93 | string tempDir = Path.Combine(localAppData, "Temp"); 94 | 95 | sb.AppendLine(); 96 | sb.AppendLine("[mcp_servers.unityMCP.env]"); 97 | sb.AppendLine($"SystemRoot = \"{EscapeTomlString(systemRoot)}\""); 98 | sb.AppendLine($"APPDATA = \"{EscapeTomlString(appData)}\""); 99 | sb.AppendLine($"COMSPEC = \"{EscapeTomlString(comspec)}\""); 100 | sb.AppendLine($"HOMEDRIVE = \"{EscapeTomlString(homeDrive?.TrimEnd('\\') ?? string.Empty)}\""); 101 | sb.AppendLine($"HOMEPATH = \"{EscapeTomlString(homePath ?? string.Empty)}\""); 102 | sb.AppendLine($"LOCALAPPDATA = \"{EscapeTomlString(localAppData)}\""); 103 | sb.AppendLine($"POWERSHELL = \"{EscapeTomlString(powershell)}\""); 104 | sb.AppendLine($"PROGRAMDATA = \"{EscapeTomlString(programData)}\""); 105 | sb.AppendLine($"PROGRAMFILES = \"{EscapeTomlString(programFiles)}\""); 106 | sb.AppendLine($"PWSH = \"{EscapeTomlString(pwsh)}\""); 107 | sb.AppendLine($"SYSTEMDRIVE = \"{EscapeTomlString(systemDrive)}\""); 108 | sb.AppendLine($"SYSTEMROOT = \"{EscapeTomlString(systemRoot)}\""); 109 | sb.AppendLine($"TEMP = \"{EscapeTomlString(tempDir)}\""); 110 | sb.AppendLine($"TMP = \"{EscapeTomlString(tempDir)}\""); 111 | sb.AppendLine($"USERPROFILE = \"{EscapeTomlString(userProfile)}\""); 112 | } 113 | } 114 | catch { /* best effort */ } 115 | 116 | return sb.ToString(); 117 | } 118 | 119 | public static string UpsertCodexServerBlock(string existingToml, string newBlock) 120 | { 121 | if (string.IsNullOrWhiteSpace(existingToml)) 122 | { 123 | // Default to snake_case section when creating new files 124 | return newBlock.TrimEnd() + Environment.NewLine; 125 | } 126 | 127 | StringBuilder sb = new StringBuilder(); 128 | using StringReader reader = new StringReader(existingToml); 129 | string line; 130 | bool inTarget = false; 131 | bool replaced = false; 132 | 133 | // Support both TOML section casings and nested subtables (e.g., env) 134 | // Prefer the casing already present in the user's file; fall back to snake_case 135 | bool hasCamelSection = existingToml.IndexOf("[mcpServers.unityMCP]", StringComparison.OrdinalIgnoreCase) >= 0 136 | || existingToml.IndexOf("[mcpServers.unityMCP.", StringComparison.OrdinalIgnoreCase) >= 0; 137 | bool hasSnakeSection = existingToml.IndexOf("[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase) >= 0 138 | || existingToml.IndexOf("[mcp_servers.unityMCP.", StringComparison.OrdinalIgnoreCase) >= 0; 139 | bool preferCamel = hasCamelSection || (!hasSnakeSection && existingToml.IndexOf("[mcpServers]", StringComparison.OrdinalIgnoreCase) >= 0); 140 | 141 | // Prepare block variants matching the chosen casing, including nested tables 142 | string newBlockCamel = newBlock 143 | .Replace("[mcp_servers.unityMCP.env]", "[mcpServers.unityMCP.env]") 144 | .Replace("[mcp_servers.unityMCP]", "[mcpServers.unityMCP]"); 145 | string newBlockEffective = preferCamel ? newBlockCamel : newBlock; 146 | 147 | static bool IsSection(string s) 148 | { 149 | string t = s.Trim(); 150 | return t.StartsWith("[") && t.EndsWith("]") && !t.StartsWith("[["); 151 | } 152 | 153 | static string SectionName(string header) 154 | { 155 | string t = header.Trim(); 156 | if (t.StartsWith("[") && t.EndsWith("]")) t = t.Substring(1, t.Length - 2); 157 | return t; 158 | } 159 | 160 | bool TargetOrChild(string section) 161 | { 162 | // Compare case-insensitively; accept both snake and camel as the same logical table 163 | string name = SectionName(section); 164 | return name.StartsWith("mcp_servers.unityMCP", StringComparison.OrdinalIgnoreCase) 165 | || name.StartsWith("mcpServers.unityMCP", StringComparison.OrdinalIgnoreCase); 166 | } 167 | 168 | while ((line = reader.ReadLine()) != null) 169 | { 170 | string trimmed = line.Trim(); 171 | bool isSection = IsSection(trimmed); 172 | if (isSection) 173 | { 174 | // If we encounter the target section or any of its nested tables, mark/keep in-target 175 | if (TargetOrChild(trimmed)) 176 | { 177 | if (!replaced) 178 | { 179 | if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine(); 180 | sb.AppendLine(newBlockEffective.TrimEnd()); 181 | replaced = true; 182 | } 183 | inTarget = true; 184 | continue; 185 | } 186 | 187 | // A new unrelated section ends the target region 188 | if (inTarget) 189 | { 190 | inTarget = false; 191 | } 192 | } 193 | 194 | if (inTarget) 195 | { 196 | continue; 197 | } 198 | 199 | sb.AppendLine(line); 200 | } 201 | 202 | if (!replaced) 203 | { 204 | if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine(); 205 | sb.AppendLine(newBlockEffective.TrimEnd()); 206 | } 207 | 208 | return sb.ToString().TrimEnd() + Environment.NewLine; 209 | } 210 | 211 | public static bool TryParseCodexServer(string toml, out string command, out string[] args) 212 | { 213 | command = null; 214 | args = null; 215 | if (string.IsNullOrWhiteSpace(toml)) return false; 216 | 217 | try 218 | { 219 | using var reader = new StringReader(toml); 220 | TomlTable root = TOML.Parse(reader); 221 | if (root == null) return false; 222 | 223 | if (!TryGetTable(root, "mcp_servers", out var servers) 224 | && !TryGetTable(root, "mcpServers", out servers)) 225 | { 226 | return false; 227 | } 228 | 229 | if (!TryGetTable(servers, "unityMCP", out var unity)) 230 | { 231 | return false; 232 | } 233 | 234 | command = GetTomlString(unity, "command"); 235 | args = GetTomlStringArray(unity, "args"); 236 | 237 | return !string.IsNullOrEmpty(command) && args != null; 238 | } 239 | catch (TomlParseException) 240 | { 241 | return false; 242 | } 243 | catch (TomlSyntaxException) 244 | { 245 | return false; 246 | } 247 | catch (FormatException) 248 | { 249 | return false; 250 | } 251 | } 252 | 253 | private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) 254 | { 255 | table = null; 256 | if (parent == null) return false; 257 | 258 | if (parent.TryGetNode(key, out var node)) 259 | { 260 | if (node is TomlTable tbl) 261 | { 262 | table = tbl; 263 | return true; 264 | } 265 | 266 | if (node is TomlArray array) 267 | { 268 | var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault(); 269 | if (firstTable != null) 270 | { 271 | table = firstTable; 272 | return true; 273 | } 274 | } 275 | } 276 | 277 | return false; 278 | } 279 | 280 | private static string GetTomlString(TomlTable table, string key) 281 | { 282 | if (table != null && table.TryGetNode(key, out var node)) 283 | { 284 | if (node is TomlString str) return str.Value; 285 | if (node.HasValue) return node.ToString(); 286 | } 287 | return null; 288 | } 289 | 290 | private static string[] GetTomlStringArray(TomlTable table, string key) 291 | { 292 | if (table == null) return null; 293 | if (!table.TryGetNode(key, out var node)) return null; 294 | 295 | if (node is TomlArray array) 296 | { 297 | List<string> values = new List<string>(); 298 | foreach (TomlNode element in array.Children) 299 | { 300 | if (element is TomlString str) 301 | { 302 | values.Add(str.Value); 303 | } 304 | else if (element.HasValue) 305 | { 306 | values.Add(element.ToString()); 307 | } 308 | } 309 | 310 | return values.Count > 0 ? values.ToArray() : Array.Empty<string>(); 311 | } 312 | 313 | if (node is TomlString single) 314 | { 315 | return new[] { single.Value }; 316 | } 317 | 318 | return null; 319 | } 320 | 321 | private static string FormatTomlStringArray(IEnumerable<string> values) 322 | { 323 | if (values == null) return "[]"; 324 | StringBuilder sb = new StringBuilder(); 325 | sb.Append('['); 326 | bool first = true; 327 | foreach (string value in values) 328 | { 329 | if (!first) 330 | { 331 | sb.Append(", "); 332 | } 333 | sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"'); 334 | first = false; 335 | } 336 | sb.Append(']'); 337 | return sb.ToString(); 338 | } 339 | 340 | private static string EscapeTomlString(string value) 341 | { 342 | if (string.IsNullOrEmpty(value)) return string.Empty; 343 | return value 344 | .Replace("\\", "\\\\") 345 | .Replace("\"", "\\\""); 346 | } 347 | 348 | } 349 | } 350 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/CommandRegistry.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | using MCPForUnity.Editor.Helpers; 8 | using MCPForUnity.Editor.Resources; 9 | using Newtonsoft.Json; 10 | using Newtonsoft.Json.Linq; 11 | 12 | namespace MCPForUnity.Editor.Tools 13 | { 14 | /// <summary> 15 | /// Holds information about a registered command handler. 16 | /// </summary> 17 | class HandlerInfo 18 | { 19 | public string CommandName { get; } 20 | public Func<JObject, object> SyncHandler { get; } 21 | public Func<JObject, Task<object>> AsyncHandler { get; } 22 | 23 | public bool IsAsync => AsyncHandler != null; 24 | 25 | public HandlerInfo(string commandName, Func<JObject, object> syncHandler, Func<JObject, Task<object>> asyncHandler) 26 | { 27 | CommandName = commandName; 28 | SyncHandler = syncHandler; 29 | AsyncHandler = asyncHandler; 30 | } 31 | } 32 | 33 | /// <summary> 34 | /// Registry for all MCP command handlers via reflection. 35 | /// Handles both MCP tools and resources. 36 | /// </summary> 37 | public static class CommandRegistry 38 | { 39 | private static readonly Dictionary<string, HandlerInfo> _handlers = new(); 40 | private static bool _initialized = false; 41 | 42 | /// <summary> 43 | /// Initialize and auto-discover all tools and resources marked with 44 | /// [McpForUnityTool] or [McpForUnityResource] 45 | /// </summary> 46 | public static void Initialize() 47 | { 48 | if (_initialized) return; 49 | 50 | AutoDiscoverCommands(); 51 | _initialized = true; 52 | } 53 | 54 | /// <summary> 55 | /// Convert PascalCase or camelCase to snake_case 56 | /// </summary> 57 | private static string ToSnakeCase(string name) 58 | { 59 | if (string.IsNullOrEmpty(name)) return name; 60 | 61 | // Insert underscore before uppercase letters (except first) 62 | var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); 63 | var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); 64 | return s2.ToLower(); 65 | } 66 | 67 | /// <summary> 68 | /// Auto-discover all types with [McpForUnityTool] or [McpForUnityResource] attributes 69 | /// </summary> 70 | private static void AutoDiscoverCommands() 71 | { 72 | try 73 | { 74 | var allTypes = AppDomain.CurrentDomain.GetAssemblies() 75 | .Where(a => !a.IsDynamic) 76 | .SelectMany(a => 77 | { 78 | try { return a.GetTypes(); } 79 | catch { return new Type[0]; } 80 | }) 81 | .ToList(); 82 | 83 | // Discover tools 84 | var toolTypes = allTypes.Where(t => t.GetCustomAttribute<McpForUnityToolAttribute>() != null); 85 | int toolCount = 0; 86 | foreach (var type in toolTypes) 87 | { 88 | if (RegisterCommandType(type, isResource: false)) 89 | toolCount++; 90 | } 91 | 92 | // Discover resources 93 | var resourceTypes = allTypes.Where(t => t.GetCustomAttribute<McpForUnityResourceAttribute>() != null); 94 | int resourceCount = 0; 95 | foreach (var type in resourceTypes) 96 | { 97 | if (RegisterCommandType(type, isResource: true)) 98 | resourceCount++; 99 | } 100 | 101 | McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)"); 102 | } 103 | catch (Exception ex) 104 | { 105 | McpLog.Error($"Failed to auto-discover MCP commands: {ex.Message}"); 106 | } 107 | } 108 | 109 | /// <summary> 110 | /// Register a command type (tool or resource) with the registry. 111 | /// Returns true if successfully registered, false otherwise. 112 | /// </summary> 113 | private static bool RegisterCommandType(Type type, bool isResource) 114 | { 115 | string commandName; 116 | string typeLabel = isResource ? "resource" : "tool"; 117 | 118 | // Get command name from appropriate attribute 119 | if (isResource) 120 | { 121 | var resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>(); 122 | commandName = resourceAttr.ResourceName; 123 | } 124 | else 125 | { 126 | var toolAttr = type.GetCustomAttribute<McpForUnityToolAttribute>(); 127 | commandName = toolAttr.CommandName; 128 | } 129 | 130 | // Auto-generate command name if not explicitly provided 131 | if (string.IsNullOrEmpty(commandName)) 132 | { 133 | commandName = ToSnakeCase(type.Name); 134 | } 135 | 136 | // Check for duplicate command names 137 | if (_handlers.ContainsKey(commandName)) 138 | { 139 | McpLog.Warn( 140 | $"Duplicate command name '{commandName}' detected. " + 141 | $"{typeLabel} {type.Name} will override previously registered handler." 142 | ); 143 | } 144 | 145 | // Find HandleCommand method 146 | var method = type.GetMethod( 147 | "HandleCommand", 148 | BindingFlags.Public | BindingFlags.Static, 149 | null, 150 | new[] { typeof(JObject) }, 151 | null 152 | ); 153 | 154 | if (method == null) 155 | { 156 | McpLog.Warn( 157 | $"MCP {typeLabel} {type.Name} is marked with [McpForUnity{(isResource ? "Resource" : "Tool")}] " + 158 | $"but has no public static HandleCommand(JObject) method" 159 | ); 160 | return false; 161 | } 162 | 163 | try 164 | { 165 | HandlerInfo handlerInfo; 166 | 167 | if (typeof(Task).IsAssignableFrom(method.ReturnType)) 168 | { 169 | var asyncHandler = CreateAsyncHandlerDelegate(method, commandName); 170 | handlerInfo = new HandlerInfo(commandName, null, asyncHandler); 171 | } 172 | else 173 | { 174 | var handler = (Func<JObject, object>)Delegate.CreateDelegate( 175 | typeof(Func<JObject, object>), 176 | method 177 | ); 178 | handlerInfo = new HandlerInfo(commandName, handler, null); 179 | } 180 | 181 | _handlers[commandName] = handlerInfo; 182 | return true; 183 | } 184 | catch (Exception ex) 185 | { 186 | McpLog.Error($"Failed to register {typeLabel} {type.Name}: {ex.Message}"); 187 | return false; 188 | } 189 | } 190 | 191 | /// <summary> 192 | /// Get a command handler by name 193 | /// </summary> 194 | private static HandlerInfo GetHandlerInfo(string commandName) 195 | { 196 | if (!_handlers.TryGetValue(commandName, out var handler)) 197 | { 198 | throw new InvalidOperationException( 199 | $"Unknown or unsupported command type: {commandName}" 200 | ); 201 | } 202 | return handler; 203 | } 204 | 205 | /// <summary> 206 | /// Get a synchronous command handler by name. 207 | /// Throws if the command is asynchronous. 208 | /// </summary> 209 | /// <param name="commandName"></param> 210 | /// <returns></returns> 211 | /// <exception cref="InvalidOperationException"></exception> 212 | public static Func<JObject, object> GetHandler(string commandName) 213 | { 214 | var handlerInfo = GetHandlerInfo(commandName); 215 | if (handlerInfo.IsAsync) 216 | { 217 | throw new InvalidOperationException( 218 | $"Command '{commandName}' is asynchronous and must be executed via ExecuteCommand" 219 | ); 220 | } 221 | 222 | return handlerInfo.SyncHandler; 223 | } 224 | 225 | /// <summary> 226 | /// Execute a command handler, supporting both synchronous and asynchronous (coroutine) handlers. 227 | /// If the handler returns an IEnumerator, it will be executed as a coroutine. 228 | /// </summary> 229 | /// <param name="commandName">The command name to execute</param> 230 | /// <param name="params">Command parameters</param> 231 | /// <param name="tcs">TaskCompletionSource to complete when async operation finishes</param> 232 | /// <returns>The result for synchronous commands, or null for async commands (TCS will be completed later)</returns> 233 | public static object ExecuteCommand(string commandName, JObject @params, TaskCompletionSource<string> tcs) 234 | { 235 | var handlerInfo = GetHandlerInfo(commandName); 236 | 237 | if (handlerInfo.IsAsync) 238 | { 239 | ExecuteAsyncHandler(handlerInfo, @params, commandName, tcs); 240 | return null; 241 | } 242 | 243 | if (handlerInfo.SyncHandler == null) 244 | { 245 | throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation"); 246 | } 247 | 248 | return handlerInfo.SyncHandler(@params); 249 | } 250 | 251 | /// <summary> 252 | /// Create a delegate for an async handler method that returns Task or Task<T>. 253 | /// The delegate will invoke the method and await its completion, returning the result. 254 | /// </summary> 255 | /// <param name="method"></param> 256 | /// <param name="commandName"></param> 257 | /// <returns></returns> 258 | /// <exception cref="InvalidOperationException"></exception> 259 | private static Func<JObject, Task<object>> CreateAsyncHandlerDelegate(MethodInfo method, string commandName) 260 | { 261 | return async (JObject parameters) => 262 | { 263 | object rawResult; 264 | 265 | try 266 | { 267 | rawResult = method.Invoke(null, new object[] { parameters }); 268 | } 269 | catch (TargetInvocationException ex) 270 | { 271 | throw ex.InnerException ?? ex; 272 | } 273 | 274 | if (rawResult == null) 275 | { 276 | return null; 277 | } 278 | 279 | if (rawResult is not Task task) 280 | { 281 | throw new InvalidOperationException( 282 | $"Async handler '{commandName}' returned an object that is not a Task" 283 | ); 284 | } 285 | 286 | await task.ConfigureAwait(true); 287 | 288 | var taskType = task.GetType(); 289 | if (taskType.IsGenericType) 290 | { 291 | var resultProperty = taskType.GetProperty("Result"); 292 | if (resultProperty != null) 293 | { 294 | return resultProperty.GetValue(task); 295 | } 296 | } 297 | 298 | return null; 299 | }; 300 | } 301 | 302 | private static void ExecuteAsyncHandler( 303 | HandlerInfo handlerInfo, 304 | JObject parameters, 305 | string commandName, 306 | TaskCompletionSource<string> tcs) 307 | { 308 | if (handlerInfo.AsyncHandler == null) 309 | { 310 | throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly"); 311 | } 312 | 313 | Task<object> handlerTask; 314 | 315 | try 316 | { 317 | handlerTask = handlerInfo.AsyncHandler(parameters); 318 | } 319 | catch (Exception ex) 320 | { 321 | ReportAsyncFailure(commandName, tcs, ex); 322 | return; 323 | } 324 | 325 | if (handlerTask == null) 326 | { 327 | CompleteAsyncCommand(commandName, tcs, null); 328 | return; 329 | } 330 | 331 | async void AwaitHandler() 332 | { 333 | try 334 | { 335 | var finalResult = await handlerTask.ConfigureAwait(true); 336 | CompleteAsyncCommand(commandName, tcs, finalResult); 337 | } 338 | catch (Exception ex) 339 | { 340 | ReportAsyncFailure(commandName, tcs, ex); 341 | } 342 | } 343 | 344 | AwaitHandler(); 345 | } 346 | 347 | /// <summary> 348 | /// Complete the TaskCompletionSource for an async command with a success result. 349 | /// </summary> 350 | /// <param name="commandName"></param> 351 | /// <param name="tcs"></param> 352 | /// <param name="result"></param> 353 | private static void CompleteAsyncCommand(string commandName, TaskCompletionSource<string> tcs, object result) 354 | { 355 | try 356 | { 357 | var response = new { status = "success", result }; 358 | string json = JsonConvert.SerializeObject(response); 359 | 360 | if (!tcs.TrySetResult(json)) 361 | { 362 | McpLog.Warn($"TCS for async command '{commandName}' was already completed"); 363 | } 364 | } 365 | catch (Exception ex) 366 | { 367 | McpLog.Error($"Error completing async command '{commandName}': {ex.Message}\n{ex.StackTrace}"); 368 | ReportAsyncFailure(commandName, tcs, ex); 369 | } 370 | } 371 | 372 | /// <summary> 373 | /// Report an error that occurred during async command execution. 374 | /// Completes the TaskCompletionSource with an error response. 375 | /// </summary> 376 | /// <param name="commandName"></param> 377 | /// <param name="tcs"></param> 378 | /// <param name="ex"></param> 379 | private static void ReportAsyncFailure(string commandName, TaskCompletionSource<string> tcs, Exception ex) 380 | { 381 | McpLog.Error($"Error in async command '{commandName}': {ex.Message}\n{ex.StackTrace}"); 382 | 383 | var errorResponse = new 384 | { 385 | status = "error", 386 | error = ex.Message, 387 | command = commandName, 388 | stackTrace = ex.StackTrace 389 | }; 390 | 391 | string json; 392 | try 393 | { 394 | json = JsonConvert.SerializeObject(errorResponse); 395 | } 396 | catch (Exception serializationEx) 397 | { 398 | McpLog.Error($"Failed to serialize error response for '{commandName}': {serializationEx.Message}"); 399 | json = "{\"status\":\"error\",\"error\":\"Failed to complete command\"}"; 400 | } 401 | 402 | if (!tcs.TrySetResult(json)) 403 | { 404 | McpLog.Warn($"TCS for async command '{commandName}' was already completed when trying to report error"); 405 | } 406 | } 407 | } 408 | } 409 | ``` -------------------------------------------------------------------------------- /.claude/prompts/nl-unity-suite-t.md: -------------------------------------------------------------------------------- ```markdown 1 | # Unity T Editing Suite — Additive Test Design 2 | You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. 3 | 4 | **Print this once, verbatim, early in the run:** 5 | AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha 6 | 7 | --- 8 | 9 | ## Mission 10 | 1) Pick target file (prefer): 11 | - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 12 | 2) Execute T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state. 13 | 3) Validate each edit with `mcp__unity__validate_script(level:"standard")`. 14 | 4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`. 15 | 16 | **CRITICAL XML FORMAT REQUIREMENTS:** 17 | - Each file must contain EXACTLY one `<testcase>` root element 18 | - NO prologue, epilogue, code fences, or extra characters 19 | - NO markdown formatting or explanations outside the XML 20 | - Use this exact format: 21 | 22 | ```xml 23 | <testcase name="T-D — End-of-Class Helper" classname="UnityMCP.NL-T"> 24 | <system-out><![CDATA[ 25 | (evidence of what was accomplished) 26 | ]]></system-out> 27 | </testcase> 28 | ``` 29 | 30 | - If test fails, include: `<failure message="reason"/>` 31 | - TESTID must be one of: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J 32 | 5) **NO RESTORATION** - tests build additively on previous state. 33 | 6) **STRICT FRAGMENT EMISSION** - After each test, immediately emit a clean XML file under `reports/<TESTID>_results.xml` with exactly one `<testcase>` whose `name` begins with the exact test id. No prologue/epilogue or fences. If the test fails, include a `<failure message="..."/>` and still emit. 34 | 35 | --- 36 | 37 | ## Environment & Paths (CI) 38 | - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. 39 | - **Canonical URIs only**: 40 | - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) 41 | - Relative (when supported): `Assets/...` 42 | 43 | CI provides: 44 | - `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created; leave alone) 45 | - `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit) 46 | 47 | --- 48 | 49 | ## Transcript Minimization Rules 50 | - Do not restate tool JSON; summarize in ≤ 2 short lines. 51 | - Never paste full file contents. For matches, include only the matched line and ±1 line. 52 | - Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`. 53 | - Per‑test `system-out` ≤ 400 chars: brief status only (no SHA). 54 | - Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment. 55 | - Avoid quoting multi‑line diffs; reference markers instead. 56 | — Console scans: perform two reads — last 10 `log/info` lines and up to 3 `error` entries (use `include_stacktrace:false`); include ≤ 3 lines total in the fragment; if no errors, state "no errors". 57 | — Final check is folded into T‑J: perform an errors‑only scan (with `include_stacktrace:false`) and include a single "no errors" line or up to 3 error lines within the T‑J fragment. 58 | 59 | --- 60 | 61 | ## Tool Mapping 62 | - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` 63 | - Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace` 64 | - For `anchor_insert`, always set `"position": "before"` or `"after"`. 65 | - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) 66 | STRICT OP GUARDRAILS 67 | - Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`. 68 | - For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`. 69 | 70 | - **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body 71 | - **Validation**: `mcp__unity__validate_script(level:"standard")` 72 | - **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers 73 | 74 | --- 75 | 76 | ## Additive Test Design Principles 77 | 78 | **Key Changes from Reset-Based:** 79 | 1. **Dynamic Targeting**: Use `find_in_file` to locate methods/content, never hardcode line numbers 80 | 2. **State Awareness**: Each test expects the file state left by the previous test 81 | 3. **Content-Based Operations**: Target methods by signature, classes by name, not coordinates 82 | 4. **Cumulative Validation**: Ensure the file remains structurally sound throughout the sequence 83 | 5. **Composability**: Tests demonstrate how operations work together in real workflows 84 | 85 | **State Tracking:** 86 | - Track file SHA after each test (`mcp__unity__get_sha`) and use it as a precondition 87 | for `apply_text_edits` in T‑F/T‑G/T‑I to exercise `stale_file` semantics. Do not include SHA values in report fragments. 88 | - Use content signatures (method names, comment markers) to verify expected state 89 | - Validate structural integrity after each major change 90 | 91 | --- 92 | 93 | ### T-A. Temporary Helper Lifecycle (Returns to State C) 94 | **Goal**: Test insert → verify → delete cycle for temporary code 95 | **Actions**: 96 | - Find current position of `GetCurrentTarget()` method (may have shifted from NL-2 comment) 97 | - Insert temporary helper: `private int __TempHelper(int a, int b) => a + b;` 98 | - Verify helper method exists and compiles 99 | - Delete helper method via structured delete operation 100 | - **Expected final state**: Return to State C (helper removed, other changes intact) 101 | 102 | ### Late-Test Editing Rule 103 | - When modifying a method body, use `mcp__unity__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__unity__validate_script` and rollback on error. Use `//` comments in inserted code. 104 | 105 | ### T-B. Method Body Interior Edit (Additive State D) 106 | **Goal**: Edit method interior without affecting structure, on modified file 107 | **Actions**: 108 | - Use `find_in_file` to locate current `HasTarget()` method (modified in NL-1) 109 | - Edit method body interior: change return statement to `return true; /* test modification */` 110 | - Validate with `mcp__unity__validate_script(level:"standard")` for consistency 111 | - Verify edit succeeded and file remains balanced 112 | - **Expected final state**: State C + modified HasTarget() body 113 | 114 | ### T-C. Different Method Interior Edit (Additive State E) 115 | **Goal**: Edit a different method to show operations don't interfere 116 | **Actions**: 117 | - Locate `ApplyBlend()` method using content search 118 | - Edit interior line to add null check: `if (animator == null) return; // safety check` 119 | - Preserve method signature and structure 120 | - **Expected final state**: State D + modified ApplyBlend() method 121 | 122 | ### T-D. End-of-Class Helper (Additive State F) 123 | **Goal**: Add permanent helper method at class end 124 | **Actions**: 125 | - Use smart anchor matching to find current class-ending brace (after NL-3 tail comments) 126 | - Insert permanent helper before class brace: `private void TestHelper() { /* placeholder */ }` 127 | - Validate with `mcp__unity__validate_script(level:"standard")` 128 | - **IMMEDIATELY** write clean XML fragment to `reports/T-D_results.xml` (no extra text). The `<testcase name>` must start with `T-D`. Include brief evidence in `system-out`. 129 | - **Expected final state**: State E + TestHelper() method before class end 130 | 131 | ### T-E. Method Evolution Lifecycle (Additive State G) 132 | **Goal**: Insert → modify → finalize a field + companion method 133 | **Actions**: 134 | - Insert field: `private int Counter = 0;` 135 | - Update it: find and replace with `private int Counter = 42; // initialized` 136 | - Add companion method: `private void IncrementCounter() { Counter++; }` 137 | - **Expected final state**: State F + Counter field + IncrementCounter() method 138 | 139 | ### T-F. Atomic Multi-Edit (Additive State H) 140 | **Goal**: Multiple coordinated edits in single atomic operation 141 | **Actions**: 142 | - Read current file state to compute precise ranges 143 | - Atomic edit combining: 144 | 1. Add comment in `HasTarget()`: `// validated access` 145 | 2. Add comment in `ApplyBlend()`: `// safe animation` 146 | 3. Add final class comment: `// end of test modifications` 147 | - All edits computed from same file snapshot, applied atomically 148 | - **Expected final state**: State G + three coordinated comments 149 | - After applying the atomic edits, run `validate_script(level:"standard")` and emit a clean fragment to `reports/T-F_results.xml` with a short summary. 150 | 151 | ### T-G. Path Normalization Test (No State Change) 152 | **Goal**: Verify URI forms work equivalently on modified file 153 | **Actions**: 154 | - Make identical edit using `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 155 | - Then using `Assets/Scripts/LongUnityScriptClaudeTest.cs` 156 | - Second should return `stale_file`, retry with updated SHA 157 | - Verify both URI forms target same file 158 | - **Expected final state**: State H (no content change, just path testing) 159 | - Emit `reports/T-G_results.xml` showing evidence of stale SHA handling. 160 | 161 | ### T-H. Validation on Modified File (No State Change) 162 | **Goal**: Ensure validation works correctly on heavily modified file 163 | **Actions**: 164 | - Run `validate_script(level:"standard")` on current state 165 | - Verify no structural errors despite extensive modifications 166 | - **Expected final state**: State H (validation only, no edits) 167 | - Emit `reports/T-H_results.xml` confirming validation OK. 168 | 169 | ### T-I. Failure Surface Testing (No State Change) 170 | **Goal**: Test error handling on real modified file 171 | **Actions**: 172 | - Attempt overlapping edits (should fail cleanly) 173 | - Attempt edit with stale SHA (should fail cleanly) 174 | - Verify error responses are informative 175 | - **Expected final state**: State H (failed operations don't modify file) 176 | - Emit `reports/T-I_results.xml` capturing error evidence; file must contain one `<testcase>`. 177 | 178 | ### T-J. Idempotency on Modified File (Additive State I) 179 | **Goal**: Verify operations behave predictably when repeated 180 | **Actions**: 181 | - **Insert (structured)**: `mcp__unity__script_apply_edits` with: 182 | `{"op":"anchor_insert","anchor":"// Tail test C","position":"after","text":"\n // idempotency test marker"}` 183 | - **Insert again** (same op) → expect `no_op: true`. 184 | - **Remove (structured)**: `{"op":"regex_replace","pattern":"(?m)^\\s*// idempotency test marker\\r?\\n?","text":""}` 185 | - **Remove again** (same `regex_replace`) → expect `no_op: true`. 186 | - `mcp__unity__validate_script(level:"standard")` 187 | - Perform a final console scan for errors/exceptions (errors only, up to 3); include "no errors" if none 188 | - **IMMEDIATELY** write clean XML fragment to `reports/T-J_results.xml` with evidence of both `no_op: true` outcomes and the console result. The `<testcase name>` must start with `T-J`. 189 | - **Expected final state**: State H + verified idempotent behavior 190 | 191 | --- 192 | 193 | ## Dynamic Targeting Examples 194 | 195 | **Instead of hardcoded coordinates:** 196 | ```json 197 | {"startLine": 31, "startCol": 26, "endLine": 31, "endCol": 58} 198 | ``` 199 | 200 | **Use content-aware targeting:** 201 | ```json 202 | # Find current method location 203 | find_in_file(pattern: "public bool HasTarget\\(\\)") 204 | # Then compute edit ranges from found position 205 | ``` 206 | 207 | **Method targeting by signature:** 208 | ```json 209 | {"op": "replace_method", "className": "LongUnityScriptClaudeTest", "methodName": "HasTarget"} 210 | ``` 211 | 212 | **Anchor-based insertions:** 213 | ```json 214 | {"op": "anchor_insert", "anchor": "private void Update\\(\\)", "position": "before", "text": "// comment"} 215 | ``` 216 | 217 | --- 218 | 219 | ## State Verification Patterns 220 | 221 | **After each test:** 222 | 1. Verify expected content exists: `find_in_file` for key markers 223 | 2. Check structural integrity: `validate_script(level:"standard")` 224 | 3. Update SHA tracking for next test's preconditions 225 | 4. Emit a per‑test fragment to `reports/<TESTID>_results.xml` immediately. If the test failed, still write a single `<testcase>` with a `<failure message="..."/>` and evidence in `system-out`. 226 | 5. Log cumulative changes in test evidence (keep concise per Transcript Minimization Rules; never paste raw tool JSON) 227 | 228 | **Error Recovery:** 229 | - If test fails, log current state but continue (don't restore) 230 | - Next test adapts to actual current state, not expected state 231 | - Demonstrates resilience of operations on varied file conditions 232 | 233 | --- 234 | 235 | ## Benefits of Additive Design 236 | 237 | 1. **Realistic Workflows**: Tests mirror actual development patterns 238 | 2. **Robust Operations**: Proves edits work on evolving files, not just pristine baselines 239 | 3. **Composability Validation**: Shows operations coordinate well together 240 | 4. **Simplified Infrastructure**: No restore scripts or snapshots needed 241 | 5. **Better Failure Analysis**: Failures don't cascade - each test adapts to current reality 242 | 6. **State Evolution Testing**: Validates SDK handles cumulative file modifications correctly 243 | 244 | This additive approach produces a more realistic and maintainable test suite that better represents actual SDK usage patterns. 245 | 246 | --- 247 | 248 | BAN ON EXTRA TOOLS AND DIRS 249 | - Do not use any tools outside `AllowedTools`. Do not create directories; assume `reports/` exists. 250 | 251 | --- 252 | 253 | ## XML Fragment Templates (T-F .. T-J) 254 | 255 | Use these skeletons verbatim as a starting point. Replace the bracketed placeholders with your evidence. Ensure each file contains exactly one `<testcase>` element and that the `name` begins with the exact test id. 256 | 257 | ```xml 258 | <testcase name="T-F — Atomic Multi-Edit" classname="UnityMCP.NL-T"> 259 | <system-out><![CDATA[ 260 | Applied 3 non-overlapping edits in one atomic call: 261 | - HasTarget(): added "// validated access" 262 | - ApplyBlend(): added "// safe animation" 263 | - End-of-class: added "// end of test modifications" 264 | validate_script: OK 265 | ]]></system-out> 266 | </testcase> 267 | ``` 268 | 269 | ```xml 270 | <testcase name="T-G — Path Normalization Test" classname="UnityMCP.NL-T"> 271 | <system-out><![CDATA[ 272 | Edit via unity://path/... succeeded. 273 | Same edit via Assets/... returned stale_file, retried with updated hash: OK. 274 | ]]></system-out> 275 | </testcase> 276 | ``` 277 | 278 | ```xml 279 | <testcase name="T-H — Validation on Modified File" classname="UnityMCP.NL-T"> 280 | <system-out><![CDATA[ 281 | validate_script(level:"standard"): OK on the modified file. 282 | ]]></system-out> 283 | </testcase> 284 | ``` 285 | 286 | ```xml 287 | <testcase name="T-I — Failure Surface Testing" classname="UnityMCP.NL-T"> 288 | <system-out><![CDATA[ 289 | Overlapping edit: failed cleanly (error captured). 290 | Stale hash edit: failed cleanly (error captured). 291 | File unchanged. 292 | ]]></system-out> 293 | </testcase> 294 | ``` 295 | 296 | ```xml 297 | <testcase name="T-J — Idempotency on Modified File" classname="UnityMCP.NL-T"> 298 | <system-out><![CDATA[ 299 | Insert marker after "// Tail test C": OK. 300 | Insert same marker again: no_op: true. 301 | regex_remove marker: OK. 302 | regex_remove again: no_op: true. 303 | validate_script: OK. 304 | ]]></system-out> 305 | </testcase> 306 | ``` -------------------------------------------------------------------------------- /tools/stress_mcp.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import argparse 4 | import json 5 | import os 6 | import struct 7 | import time 8 | from pathlib import Path 9 | import random 10 | import sys 11 | 12 | 13 | TIMEOUT = float(os.environ.get("MCP_STRESS_TIMEOUT", "2.0")) 14 | DEBUG = os.environ.get("MCP_STRESS_DEBUG", "").lower() in ("1", "true", "yes") 15 | 16 | 17 | def dlog(*args): 18 | if DEBUG: 19 | print(*args, file=sys.stderr) 20 | 21 | 22 | def find_status_files() -> list[Path]: 23 | home = Path.home() 24 | status_dir = Path(os.environ.get( 25 | "UNITY_MCP_STATUS_DIR", home / ".unity-mcp")) 26 | if not status_dir.exists(): 27 | return [] 28 | return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True) 29 | 30 | 31 | def discover_port(project_path: str | None) -> int: 32 | # Default bridge port if nothing found 33 | default_port = 6400 34 | files = find_status_files() 35 | for f in files: 36 | try: 37 | data = json.loads(f.read_text()) 38 | port = int(data.get("unity_port", 0) or 0) 39 | proj = data.get("project_path") or "" 40 | if project_path: 41 | # Match status for the given project if possible 42 | if proj and project_path in proj: 43 | if 0 < port < 65536: 44 | return port 45 | else: 46 | if 0 < port < 65536: 47 | return port 48 | except Exception: 49 | pass 50 | return default_port 51 | 52 | 53 | async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes: 54 | buf = b"" 55 | while len(buf) < n: 56 | chunk = await reader.read(n - len(buf)) 57 | if not chunk: 58 | raise ConnectionError("Connection closed while reading") 59 | buf += chunk 60 | return buf 61 | 62 | 63 | async def read_frame(reader: asyncio.StreamReader) -> bytes: 64 | header = await read_exact(reader, 8) 65 | (length,) = struct.unpack(">Q", header) 66 | if length <= 0 or length > (64 * 1024 * 1024): 67 | raise ValueError(f"Invalid frame length: {length}") 68 | return await read_exact(reader, length) 69 | 70 | 71 | async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None: 72 | header = struct.pack(">Q", len(payload)) 73 | writer.write(header) 74 | writer.write(payload) 75 | await asyncio.wait_for(writer.drain(), timeout=TIMEOUT) 76 | 77 | 78 | async def do_handshake(reader: asyncio.StreamReader) -> None: 79 | # Server sends a single line handshake: "WELCOME UNITY-MCP 1 FRAMING=1\n" 80 | line = await reader.readline() 81 | if not line or b"WELCOME UNITY-MCP" not in line: 82 | raise ConnectionError(f"Unexpected handshake from server: {line!r}") 83 | 84 | 85 | def make_ping_frame() -> bytes: 86 | return b"ping" 87 | 88 | 89 | def make_execute_menu_item(menu_path: str) -> bytes: 90 | # Retained for manual debugging; not used in normal stress runs 91 | payload = {"type": "execute_menu_item", "params": { 92 | "action": "execute", "menu_path": menu_path}} 93 | return json.dumps(payload).encode("utf-8") 94 | 95 | 96 | async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: dict): 97 | reconnect_delay = 0.2 98 | while time.time() < stop_time: 99 | writer = None 100 | try: 101 | # slight stagger to prevent burst synchronization across clients 102 | await asyncio.sleep(0.003 * (idx % 11)) 103 | reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT) 104 | await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT) 105 | # Send a quick ping first 106 | await write_frame(writer, make_ping_frame()) 107 | # ignore content 108 | _ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) 109 | 110 | # Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task. 111 | while time.time() < stop_time: 112 | # Ping-only; edits are sent via reload_churn_task to avoid console spam 113 | await write_frame(writer, make_ping_frame()) 114 | _ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) 115 | stats["pings"] += 1 116 | await asyncio.sleep(0.02 + random.uniform(-0.003, 0.003)) 117 | 118 | except (ConnectionError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError): 119 | stats["disconnects"] += 1 120 | dlog(f"[client {idx}] disconnect/backoff {reconnect_delay}s") 121 | await asyncio.sleep(reconnect_delay) 122 | reconnect_delay = min(reconnect_delay * 1.5, 2.0) 123 | continue 124 | except Exception: 125 | stats["errors"] += 1 126 | dlog(f"[client {idx}] unexpected error") 127 | await asyncio.sleep(0.2) 128 | continue 129 | finally: 130 | if writer is not None: 131 | try: 132 | writer.close() 133 | await writer.wait_closed() 134 | except Exception: 135 | pass 136 | 137 | 138 | async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int, stats: dict, storm_count: int = 1): 139 | # Use script edit tool to touch a C# file, which triggers compilation reliably 140 | path = Path(unity_file) if unity_file else None 141 | seq = 0 142 | proj_root = Path(project_path).resolve() if project_path else None 143 | # Build candidate list for storm mode 144 | candidates: list[Path] = [] 145 | if proj_root: 146 | try: 147 | for p in (proj_root / "Assets").rglob("*.cs"): 148 | candidates.append(p.resolve()) 149 | except Exception: 150 | candidates = [] 151 | if path and path.exists(): 152 | rp = path.resolve() 153 | if rp not in candidates: 154 | candidates.append(rp) 155 | while time.time() < stop_time: 156 | try: 157 | if path and path.exists(): 158 | # Determine files to touch this cycle 159 | targets: list[Path] 160 | if storm_count and storm_count > 1 and candidates: 161 | k = min(max(1, storm_count), len(candidates)) 162 | targets = random.sample(candidates, k) 163 | else: 164 | targets = [path] 165 | 166 | for tpath in targets: 167 | # Build a tiny ApplyTextEdits request that toggles a trailing comment 168 | relative = None 169 | try: 170 | # Derive Unity-relative path under Assets/ (cross-platform) 171 | resolved = tpath.resolve() 172 | parts = list(resolved.parts) 173 | if "Assets" in parts: 174 | i = parts.index("Assets") 175 | relative = Path(*parts[i:]).as_posix() 176 | elif proj_root and str(resolved).startswith(str(proj_root)): 177 | rel = resolved.relative_to(proj_root) 178 | parts2 = list(rel.parts) 179 | if "Assets" in parts2: 180 | i2 = parts2.index("Assets") 181 | relative = Path(*parts2[i2:]).as_posix() 182 | except Exception: 183 | relative = None 184 | 185 | if relative: 186 | # Derive name and directory for ManageScript and compute precondition SHA + EOF position 187 | name_base = Path(relative).stem 188 | dir_path = str( 189 | Path(relative).parent).replace('\\', '/') 190 | 191 | # 1) Read current contents via manage_script.read to compute SHA and true EOF location 192 | contents = None 193 | read_success = False 194 | for attempt in range(3): 195 | writer = None 196 | try: 197 | reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT) 198 | await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT) 199 | read_payload = { 200 | "type": "manage_script", 201 | "params": { 202 | "action": "read", 203 | "name": name_base, 204 | "path": dir_path 205 | } 206 | } 207 | await write_frame(writer, json.dumps(read_payload).encode("utf-8")) 208 | resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) 209 | 210 | read_obj = json.loads( 211 | resp.decode("utf-8", errors="ignore")) 212 | result = read_obj.get("result", read_obj) if isinstance( 213 | read_obj, dict) else {} 214 | if result.get("success"): 215 | data_obj = result.get("data", {}) 216 | contents = data_obj.get("contents") or "" 217 | read_success = True 218 | break 219 | except Exception: 220 | # retry with backoff 221 | await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1)) 222 | finally: 223 | if 'writer' in locals() and writer is not None: 224 | try: 225 | writer.close() 226 | await writer.wait_closed() 227 | except Exception: 228 | pass 229 | 230 | if not read_success or contents is None: 231 | stats["apply_errors"] = stats.get( 232 | "apply_errors", 0) + 1 233 | await asyncio.sleep(0.5) 234 | continue 235 | 236 | # Compute SHA and EOF insertion point 237 | import hashlib 238 | sha = hashlib.sha256( 239 | contents.encode("utf-8")).hexdigest() 240 | lines = contents.splitlines(keepends=True) 241 | # Insert at true EOF (safe against header guards) 242 | end_line = len(lines) + 1 # 1-based exclusive end 243 | end_col = 1 244 | 245 | # Build a unique marker append; ensure it begins with a newline if needed 246 | marker = f"// MCP_STRESS seq={seq} time={int(time.time())}" 247 | seq += 1 248 | insert_text = ("\n" if not contents.endswith( 249 | "\n") else "") + marker + "\n" 250 | 251 | # 2) Apply text edits with immediate refresh and precondition 252 | apply_payload = { 253 | "type": "manage_script", 254 | "params": { 255 | "action": "apply_text_edits", 256 | "name": name_base, 257 | "path": dir_path, 258 | "edits": [ 259 | { 260 | "startLine": end_line, 261 | "startCol": end_col, 262 | "endLine": end_line, 263 | "endCol": end_col, 264 | "newText": insert_text 265 | } 266 | ], 267 | "precondition_sha256": sha, 268 | "options": {"refresh": "immediate", "validate": "standard"} 269 | } 270 | } 271 | 272 | apply_success = False 273 | for attempt in range(3): 274 | writer = None 275 | try: 276 | reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT) 277 | await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT) 278 | await write_frame(writer, json.dumps(apply_payload).encode("utf-8")) 279 | resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) 280 | try: 281 | data = json.loads(resp.decode( 282 | "utf-8", errors="ignore")) 283 | result = data.get("result", data) if isinstance( 284 | data, dict) else {} 285 | ok = bool(result.get("success", False)) 286 | if ok: 287 | stats["applies"] = stats.get( 288 | "applies", 0) + 1 289 | apply_success = True 290 | break 291 | except Exception: 292 | # fall through to retry 293 | pass 294 | except Exception: 295 | # retry with backoff 296 | await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1)) 297 | finally: 298 | if 'writer' in locals() and writer is not None: 299 | try: 300 | writer.close() 301 | await writer.wait_closed() 302 | except Exception: 303 | pass 304 | if not apply_success: 305 | stats["apply_errors"] = stats.get( 306 | "apply_errors", 0) + 1 307 | 308 | except Exception: 309 | pass 310 | await asyncio.sleep(1.0) 311 | 312 | 313 | async def main(): 314 | ap = argparse.ArgumentParser( 315 | description="Stress test MCP for Unity with concurrent clients and reload churn") 316 | ap.add_argument("--host", default="127.0.0.1") 317 | ap.add_argument("--project", default=str( 318 | Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests")) 319 | ap.add_argument("--unity-file", default=str(Path(__file__).resolve( 320 | ).parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs")) 321 | ap.add_argument("--clients", type=int, default=10) 322 | ap.add_argument("--duration", type=int, default=60) 323 | ap.add_argument("--storm-count", type=int, default=1, 324 | help="Number of scripts to touch each cycle") 325 | args = ap.parse_args() 326 | 327 | port = discover_port(args.project) 328 | stop_time = time.time() + max(10, args.duration) 329 | 330 | stats = {"pings": 0, "menus": 0, "mods": 0, "disconnects": 0, "errors": 0} 331 | tasks = [] 332 | 333 | # Spawn clients 334 | for i in range(max(1, args.clients)): 335 | tasks.append(asyncio.create_task( 336 | client_loop(i, args.host, port, stop_time, stats))) 337 | 338 | # Spawn reload churn task 339 | tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, 340 | args.unity_file, args.host, port, stats, storm_count=args.storm_count))) 341 | 342 | await asyncio.gather(*tasks, return_exceptions=True) 343 | print(json.dumps({"port": port, "stats": stats}, indent=2)) 344 | 345 | 346 | if __name__ == "__main__": 347 | try: 348 | asyncio.run(main()) 349 | except KeyboardInterrupt: 350 | pass 351 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using NUnit.Framework; 4 | using UnityEngine; 5 | using UnityEditor; 6 | using UnityEngine.TestTools; 7 | using Newtonsoft.Json.Linq; 8 | using MCPForUnity.Editor.Tools; 9 | 10 | namespace MCPForUnityTests.Editor.Tools 11 | { 12 | public class ManageGameObjectTests 13 | { 14 | private GameObject testGameObject; 15 | 16 | [SetUp] 17 | public void SetUp() 18 | { 19 | // Create a test GameObject for each test 20 | testGameObject = new GameObject("TestObject"); 21 | } 22 | 23 | [TearDown] 24 | public void TearDown() 25 | { 26 | // Clean up test GameObject 27 | if (testGameObject != null) 28 | { 29 | UnityEngine.Object.DestroyImmediate(testGameObject); 30 | } 31 | } 32 | 33 | [Test] 34 | public void HandleCommand_ReturnsError_ForNullParams() 35 | { 36 | var result = ManageGameObject.HandleCommand(null); 37 | 38 | Assert.IsNotNull(result, "Should return a result object"); 39 | // Note: Actual error checking would need access to Response structure 40 | } 41 | 42 | [Test] 43 | public void HandleCommand_ReturnsError_ForEmptyParams() 44 | { 45 | var emptyParams = new JObject(); 46 | var result = ManageGameObject.HandleCommand(emptyParams); 47 | 48 | Assert.IsNotNull(result, "Should return a result object for empty params"); 49 | } 50 | 51 | [Test] 52 | public void HandleCommand_ProcessesValidCreateAction() 53 | { 54 | var createParams = new JObject 55 | { 56 | ["action"] = "create", 57 | ["name"] = "TestCreateObject" 58 | }; 59 | 60 | var result = ManageGameObject.HandleCommand(createParams); 61 | 62 | Assert.IsNotNull(result, "Should return a result for valid create action"); 63 | 64 | // Clean up - find and destroy the created object 65 | var createdObject = GameObject.Find("TestCreateObject"); 66 | if (createdObject != null) 67 | { 68 | UnityEngine.Object.DestroyImmediate(createdObject); 69 | } 70 | } 71 | 72 | [Test] 73 | public void ComponentResolver_Integration_WorksWithRealComponents() 74 | { 75 | // Test that our ComponentResolver works with actual Unity components 76 | var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error); 77 | 78 | Assert.IsTrue(transformResult, "Should resolve Transform component"); 79 | Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type"); 80 | Assert.IsEmpty(error, "Should have no error for valid component"); 81 | } 82 | 83 | [Test] 84 | public void ComponentResolver_Integration_WorksWithBuiltInComponents() 85 | { 86 | var components = new[] 87 | { 88 | ("Rigidbody", typeof(Rigidbody)), 89 | ("Collider", typeof(Collider)), 90 | ("Renderer", typeof(Renderer)), 91 | ("Camera", typeof(Camera)), 92 | ("Light", typeof(Light)) 93 | }; 94 | 95 | foreach (var (componentName, expectedType) in components) 96 | { 97 | var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error); 98 | 99 | // Some components might not resolve (abstract classes), but the method should handle gracefully 100 | if (result) 101 | { 102 | Assert.IsTrue(expectedType.IsAssignableFrom(actualType), 103 | $"{componentName} should resolve to assignable type"); 104 | } 105 | else 106 | { 107 | Assert.IsNotEmpty(error, $"Should have error message for {componentName}"); 108 | } 109 | } 110 | } 111 | 112 | [Test] 113 | public void PropertyMatching_Integration_WorksWithRealGameObject() 114 | { 115 | // Add a Rigidbody to test real property matching 116 | var rigidbody = testGameObject.AddComponent<Rigidbody>(); 117 | 118 | var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody)); 119 | 120 | Assert.IsNotEmpty(properties, "Rigidbody should have properties"); 121 | Assert.Contains("mass", properties, "Rigidbody should have mass property"); 122 | Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property"); 123 | 124 | // Test AI suggestions 125 | var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties); 126 | Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'"); 127 | } 128 | 129 | [Test] 130 | public void PropertyMatching_HandlesMonoBehaviourProperties() 131 | { 132 | var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour)); 133 | 134 | Assert.IsNotEmpty(properties, "MonoBehaviour should have properties"); 135 | Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property"); 136 | Assert.Contains("name", properties, "MonoBehaviour should have name property"); 137 | Assert.Contains("tag", properties, "MonoBehaviour should have tag property"); 138 | } 139 | 140 | [Test] 141 | public void PropertyMatching_HandlesCaseVariations() 142 | { 143 | var testProperties = new List<string> { "maxReachDistance", "playerHealth", "movementSpeed" }; 144 | 145 | var testCases = new[] 146 | { 147 | ("max reach distance", "maxReachDistance"), 148 | ("Max Reach Distance", "maxReachDistance"), 149 | ("MAX_REACH_DISTANCE", "maxReachDistance"), 150 | ("player health", "playerHealth"), 151 | ("movement speed", "movementSpeed") 152 | }; 153 | 154 | foreach (var (input, expected) in testCases) 155 | { 156 | var suggestions = ComponentResolver.GetAIPropertySuggestions(input, testProperties); 157 | Assert.Contains(expected, suggestions, $"Should suggest {expected} for input '{input}'"); 158 | } 159 | } 160 | 161 | [Test] 162 | public void ErrorHandling_ReturnsHelpfulMessages() 163 | { 164 | // This test verifies that error messages are helpful and contain suggestions 165 | var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" }; 166 | var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties); 167 | 168 | // Even if no perfect match, should return valid list 169 | Assert.IsNotNull(suggestions, "Should return valid suggestions list"); 170 | 171 | // Test with completely invalid input 172 | var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties); 173 | Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully"); 174 | } 175 | 176 | [Test] 177 | public void PerformanceTest_CachingWorks() 178 | { 179 | var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); 180 | var input = "Test Property Name"; 181 | 182 | // First call - populate cache 183 | var startTime = System.DateTime.UtcNow; 184 | var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties); 185 | var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; 186 | 187 | // Second call - should use cache 188 | startTime = System.DateTime.UtcNow; 189 | var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties); 190 | var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; 191 | 192 | Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical"); 193 | CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly"); 194 | 195 | // Second call should be faster (though this test might be flaky) 196 | Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower"); 197 | } 198 | 199 | [Test] 200 | public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() 201 | { 202 | // Arrange - add Transform and Rigidbody components to test with 203 | var transform = testGameObject.transform; 204 | var rigidbody = testGameObject.AddComponent<Rigidbody>(); 205 | 206 | // Create a params object with mixed valid and invalid properties 207 | var setPropertiesParams = new JObject 208 | { 209 | ["action"] = "modify", 210 | ["target"] = testGameObject.name, 211 | ["search_method"] = "by_name", 212 | ["componentProperties"] = new JObject 213 | { 214 | ["Transform"] = new JObject 215 | { 216 | ["localPosition"] = new JObject { ["x"] = 1.0f, ["y"] = 2.0f, ["z"] = 3.0f }, // Valid 217 | ["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation) 218 | ["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid 219 | }, 220 | ["Rigidbody"] = new JObject 221 | { 222 | ["mass"] = 5.0f, // Valid 223 | ["invalidProp"] = "test", // Invalid - doesn't exist 224 | ["useGravity"] = true // Valid 225 | } 226 | } 227 | }; 228 | 229 | // Store original values to verify changes 230 | var originalLocalPosition = transform.localPosition; 231 | var originalLocalScale = transform.localScale; 232 | var originalMass = rigidbody.mass; 233 | var originalUseGravity = rigidbody.useGravity; 234 | 235 | Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); 236 | 237 | // Expect the warning logs from the invalid properties 238 | LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'rotatoin' not found")); 239 | LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'invalidProp' not found")); 240 | 241 | // Act 242 | var result = ManageGameObject.HandleCommand(setPropertiesParams); 243 | 244 | Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); 245 | Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}"); 246 | Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}"); 247 | 248 | // Assert - verify that valid properties were set despite invalid ones 249 | Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition, 250 | "Valid localPosition should be set even with other invalid properties"); 251 | Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale, 252 | "Valid localScale should be set even with other invalid properties"); 253 | Assert.AreEqual(5.0f, rigidbody.mass, 0.001f, 254 | "Valid mass should be set even with other invalid properties"); 255 | Assert.AreEqual(true, rigidbody.useGravity, 256 | "Valid useGravity should be set even with other invalid properties"); 257 | 258 | // Verify the result indicates errors (since we had invalid properties) 259 | Assert.IsNotNull(result, "Should return a result object"); 260 | 261 | // The collect-and-continue behavior means we should get an error response 262 | // that contains info about the failed properties, but valid ones were still applied 263 | // This proves the collect-and-continue behavior is working 264 | 265 | // Harden: verify structured error response with failures list contains both invalid fields 266 | var successProp = result.GetType().GetProperty("success"); 267 | Assert.IsNotNull(successProp, "Result should expose 'success' property"); 268 | Assert.IsFalse((bool)successProp.GetValue(result), "Result.success should be false for partial failure"); 269 | 270 | var dataProp = result.GetType().GetProperty("data"); 271 | Assert.IsNotNull(dataProp, "Result should include 'data' with errors"); 272 | var dataVal = dataProp.GetValue(result); 273 | Assert.IsNotNull(dataVal, "Result.data should not be null"); 274 | var errorsProp = dataVal.GetType().GetProperty("errors"); 275 | Assert.IsNotNull(errorsProp, "Result.data should include 'errors' list"); 276 | var errorsEnum = errorsProp.GetValue(dataVal) as System.Collections.IEnumerable; 277 | Assert.IsNotNull(errorsEnum, "errors should be enumerable"); 278 | 279 | bool foundRotatoin = false; 280 | bool foundInvalidProp = false; 281 | foreach (var err in errorsEnum) 282 | { 283 | string s = err?.ToString() ?? string.Empty; 284 | if (s.Contains("rotatoin")) foundRotatoin = true; 285 | if (s.Contains("invalidProp")) foundInvalidProp = true; 286 | } 287 | Assert.IsTrue(foundRotatoin, "errors should mention the misspelled 'rotatoin' property"); 288 | Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property"); 289 | } 290 | 291 | [Test] 292 | public void SetComponentProperties_ContinuesAfterException() 293 | { 294 | // Arrange - create scenario that might cause exceptions 295 | var rigidbody = testGameObject.AddComponent<Rigidbody>(); 296 | 297 | // Set initial values that we'll change 298 | rigidbody.mass = 1.0f; 299 | rigidbody.useGravity = true; 300 | 301 | var setPropertiesParams = new JObject 302 | { 303 | ["action"] = "modify", 304 | ["target"] = testGameObject.name, 305 | ["search_method"] = "by_name", 306 | ["componentProperties"] = new JObject 307 | { 308 | ["Rigidbody"] = new JObject 309 | { 310 | ["mass"] = 2.5f, // Valid - should be set 311 | ["velocity"] = "invalid_type", // Invalid type - will cause exception 312 | ["useGravity"] = false // Valid - should still be set after exception 313 | } 314 | } 315 | }; 316 | 317 | // Expect the error logs from the invalid property 318 | LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Unexpected error converting token to UnityEngine.Vector3")); 319 | LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("SetProperty.*Failed to set 'velocity'")); 320 | LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found")); 321 | 322 | // Act 323 | var result = ManageGameObject.HandleCommand(setPropertiesParams); 324 | 325 | // Assert - verify that valid properties before AND after the exception were still set 326 | Assert.AreEqual(2.5f, rigidbody.mass, 0.001f, 327 | "Mass should be set even if later property causes exception"); 328 | Assert.AreEqual(false, rigidbody.useGravity, 329 | "UseGravity should be set even if previous property caused exception"); 330 | 331 | Assert.IsNotNull(result, "Should return a result even with exceptions"); 332 | 333 | // The key test: processing continued after the exception and set useGravity 334 | // This proves the collect-and-continue behavior works even with exceptions 335 | 336 | // Harden: verify structured error response contains velocity failure 337 | var successProp2 = result.GetType().GetProperty("success"); 338 | Assert.IsNotNull(successProp2, "Result should expose 'success' property"); 339 | Assert.IsFalse((bool)successProp2.GetValue(result), "Result.success should be false when an exception occurs for a property"); 340 | 341 | var dataProp2 = result.GetType().GetProperty("data"); 342 | Assert.IsNotNull(dataProp2, "Result should include 'data' with errors"); 343 | var dataVal2 = dataProp2.GetValue(result); 344 | Assert.IsNotNull(dataVal2, "Result.data should not be null"); 345 | var errorsProp2 = dataVal2.GetType().GetProperty("errors"); 346 | Assert.IsNotNull(errorsProp2, "Result.data should include 'errors' list"); 347 | var errorsEnum2 = errorsProp2.GetValue(dataVal2) as System.Collections.IEnumerable; 348 | Assert.IsNotNull(errorsEnum2, "errors should be enumerable"); 349 | 350 | bool foundVelocityError = false; 351 | foreach (var err in errorsEnum2) 352 | { 353 | string s = err?.ToString() ?? string.Empty; 354 | if (s.Contains("velocity")) { foundVelocityError = true; break; } 355 | } 356 | Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'"); 357 | } 358 | } 359 | } 360 | ```