#
tokens: 47565/50000 12/263 files (page 6/18)
lines: on (toggle) GitHub
raw markdown copy reset
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 | ![Create Python Tools Asset](screenshots/v6_2_create_python_tools_asset.png)
 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 | ![Python Tools Asset Inspector](screenshots/v6_2_python_tools_asset.png)
 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 | 
```
Page 6/18FirstPrevNextLast