#
tokens: 47155/50000 4/263 files (page 10/18)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 10 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

--------------------------------------------------------------------------------
/MCPForUnity/Editor/Windows/MCPForUnityEditorWindowNew.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Diagnostics;
  3 | using System.IO;
  4 | using System.Linq;
  5 | using System.Runtime.InteropServices;
  6 | using UnityEditor;
  7 | using UnityEditor.UIElements; // For Unity 2021 compatibility
  8 | using UnityEngine;
  9 | using UnityEngine.UIElements;
 10 | using MCPForUnity.Editor.Data;
 11 | using MCPForUnity.Editor.Helpers;
 12 | using MCPForUnity.Editor.Models;
 13 | using MCPForUnity.Editor.Services;
 14 | 
 15 | namespace MCPForUnity.Editor.Windows
 16 | {
 17 |     public class MCPForUnityEditorWindowNew : EditorWindow
 18 |     {
 19 |         // Protocol enum for future HTTP support
 20 |         private enum ConnectionProtocol
 21 |         {
 22 |             Stdio,
 23 |             // HTTPStreaming // Future
 24 |         }
 25 | 
 26 |         // Settings UI Elements
 27 |         private Label versionLabel;
 28 |         private Toggle debugLogsToggle;
 29 |         private EnumField validationLevelField;
 30 |         private Label validationDescription;
 31 |         private Foldout advancedSettingsFoldout;
 32 |         private TextField mcpServerPathOverride;
 33 |         private TextField uvPathOverride;
 34 |         private Button browsePythonButton;
 35 |         private Button clearPythonButton;
 36 |         private Button browseUvButton;
 37 |         private Button clearUvButton;
 38 |         private VisualElement mcpServerPathStatus;
 39 |         private VisualElement uvPathStatus;
 40 | 
 41 |         // Connection UI Elements
 42 |         private EnumField protocolDropdown;
 43 |         private TextField unityPortField;
 44 |         private TextField serverPortField;
 45 |         private VisualElement statusIndicator;
 46 |         private Label connectionStatusLabel;
 47 |         private Button connectionToggleButton;
 48 |         private VisualElement healthIndicator;
 49 |         private Label healthStatusLabel;
 50 |         private Button testConnectionButton;
 51 |         private VisualElement serverStatusBanner;
 52 |         private Label serverStatusMessage;
 53 |         private Button downloadServerButton;
 54 |         private Button rebuildServerButton;
 55 | 
 56 |         // Client UI Elements
 57 |         private DropdownField clientDropdown;
 58 |         private Button configureAllButton;
 59 |         private VisualElement clientStatusIndicator;
 60 |         private Label clientStatusLabel;
 61 |         private Button configureButton;
 62 |         private VisualElement claudeCliPathRow;
 63 |         private TextField claudeCliPath;
 64 |         private Button browseClaudeButton;
 65 |         private Foldout manualConfigFoldout;
 66 |         private TextField configPathField;
 67 |         private Button copyPathButton;
 68 |         private Button openFileButton;
 69 |         private TextField configJsonField;
 70 |         private Button copyJsonButton;
 71 |         private Label installationStepsLabel;
 72 | 
 73 |         // Data
 74 |         private readonly McpClients mcpClients = new();
 75 |         private int selectedClientIndex = 0;
 76 |         private ValidationLevel currentValidationLevel = ValidationLevel.Standard;
 77 | 
 78 |         // Validation levels matching the existing enum
 79 |         private enum ValidationLevel
 80 |         {
 81 |             Basic,
 82 |             Standard,
 83 |             Comprehensive,
 84 |             Strict
 85 |         }
 86 | 
 87 |         public static void ShowWindow()
 88 |         {
 89 |             var window = GetWindow<MCPForUnityEditorWindowNew>("MCP For Unity");
 90 |             window.minSize = new Vector2(500, 600);
 91 |         }
 92 |         public void CreateGUI()
 93 |         {
 94 |             // Determine base path (Package Manager vs Asset Store install)
 95 |             string basePath = AssetPathUtility.GetMcpPackageRootPath();
 96 | 
 97 |             // Load UXML
 98 |             var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
 99 |                 $"{basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uxml"
100 |             );
101 | 
102 |             if (visualTree == null)
103 |             {
104 |                 McpLog.Error($"Failed to load UXML at: {basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uxml");
105 |                 return;
106 |             }
107 | 
108 |             visualTree.CloneTree(rootVisualElement);
109 | 
110 |             // Load USS
111 |             var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
112 |                 $"{basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uss"
113 |             );
114 | 
115 |             if (styleSheet != null)
116 |             {
117 |                 rootVisualElement.styleSheets.Add(styleSheet);
118 |             }
119 | 
120 |             // Cache UI elements
121 |             CacheUIElements();
122 | 
123 |             // Initialize UI
124 |             InitializeUI();
125 | 
126 |             // Register callbacks
127 |             RegisterCallbacks();
128 | 
129 |             // Initial update
130 |             UpdateConnectionStatus();
131 |             UpdateServerStatusBanner();
132 |             UpdateClientStatus();
133 |             UpdatePathOverrides();
134 |             // Technically not required to connect, but if we don't do this, the UI will be blank
135 |             UpdateManualConfiguration();
136 |             UpdateClaudeCliPathVisibility();
137 |         }
138 | 
139 |         private void OnEnable()
140 |         {
141 |             EditorApplication.update += OnEditorUpdate;
142 |         }
143 | 
144 |         private void OnDisable()
145 |         {
146 |             EditorApplication.update -= OnEditorUpdate;
147 |         }
148 | 
149 |         private void OnFocus()
150 |         {
151 |             // Only refresh data if UI is built
152 |             if (rootVisualElement == null || rootVisualElement.childCount == 0)
153 |                 return;
154 | 
155 |             RefreshAllData();
156 |         }
157 | 
158 |         private void OnEditorUpdate()
159 |         {
160 |             // Only update UI if it's built
161 |             if (rootVisualElement == null || rootVisualElement.childCount == 0)
162 |                 return;
163 | 
164 |             UpdateConnectionStatus();
165 |         }
166 | 
167 |         private void RefreshAllData()
168 |         {
169 |             // Update connection status
170 |             UpdateConnectionStatus();
171 | 
172 |             // Auto-verify bridge health if connected
173 |             if (MCPServiceLocator.Bridge.IsRunning)
174 |             {
175 |                 VerifyBridgeConnection();
176 |             }
177 | 
178 |             // Update path overrides
179 |             UpdatePathOverrides();
180 | 
181 |             // Refresh selected client (may have been configured externally)
182 |             if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count)
183 |             {
184 |                 var client = mcpClients.clients[selectedClientIndex];
185 |                 MCPServiceLocator.Client.CheckClientStatus(client);
186 |                 UpdateClientStatus();
187 |                 UpdateManualConfiguration();
188 |                 UpdateClaudeCliPathVisibility();
189 |             }
190 |         }
191 | 
192 |         private void CacheUIElements()
193 |         {
194 |             // Settings
195 |             versionLabel = rootVisualElement.Q<Label>("version-label");
196 |             debugLogsToggle = rootVisualElement.Q<Toggle>("debug-logs-toggle");
197 |             validationLevelField = rootVisualElement.Q<EnumField>("validation-level");
198 |             validationDescription = rootVisualElement.Q<Label>("validation-description");
199 |             advancedSettingsFoldout = rootVisualElement.Q<Foldout>("advanced-settings-foldout");
200 |             mcpServerPathOverride = rootVisualElement.Q<TextField>("python-path-override");
201 |             uvPathOverride = rootVisualElement.Q<TextField>("uv-path-override");
202 |             browsePythonButton = rootVisualElement.Q<Button>("browse-python-button");
203 |             clearPythonButton = rootVisualElement.Q<Button>("clear-python-button");
204 |             browseUvButton = rootVisualElement.Q<Button>("browse-uv-button");
205 |             clearUvButton = rootVisualElement.Q<Button>("clear-uv-button");
206 |             mcpServerPathStatus = rootVisualElement.Q<VisualElement>("mcp-server-path-status");
207 |             uvPathStatus = rootVisualElement.Q<VisualElement>("uv-path-status");
208 | 
209 |             // Connection
210 |             protocolDropdown = rootVisualElement.Q<EnumField>("protocol-dropdown");
211 |             unityPortField = rootVisualElement.Q<TextField>("unity-port");
212 |             serverPortField = rootVisualElement.Q<TextField>("server-port");
213 |             statusIndicator = rootVisualElement.Q<VisualElement>("status-indicator");
214 |             connectionStatusLabel = rootVisualElement.Q<Label>("connection-status");
215 |             connectionToggleButton = rootVisualElement.Q<Button>("connection-toggle");
216 |             healthIndicator = rootVisualElement.Q<VisualElement>("health-indicator");
217 |             healthStatusLabel = rootVisualElement.Q<Label>("health-status");
218 |             testConnectionButton = rootVisualElement.Q<Button>("test-connection-button");
219 |             serverStatusBanner = rootVisualElement.Q<VisualElement>("server-status-banner");
220 |             serverStatusMessage = rootVisualElement.Q<Label>("server-status-message");
221 |             downloadServerButton = rootVisualElement.Q<Button>("download-server-button");
222 |             rebuildServerButton = rootVisualElement.Q<Button>("rebuild-server-button");
223 | 
224 |             // Client
225 |             clientDropdown = rootVisualElement.Q<DropdownField>("client-dropdown");
226 |             configureAllButton = rootVisualElement.Q<Button>("configure-all-button");
227 |             clientStatusIndicator = rootVisualElement.Q<VisualElement>("client-status-indicator");
228 |             clientStatusLabel = rootVisualElement.Q<Label>("client-status");
229 |             configureButton = rootVisualElement.Q<Button>("configure-button");
230 |             claudeCliPathRow = rootVisualElement.Q<VisualElement>("claude-cli-path-row");
231 |             claudeCliPath = rootVisualElement.Q<TextField>("claude-cli-path");
232 |             browseClaudeButton = rootVisualElement.Q<Button>("browse-claude-button");
233 |             manualConfigFoldout = rootVisualElement.Q<Foldout>("manual-config-foldout");
234 |             configPathField = rootVisualElement.Q<TextField>("config-path");
235 |             copyPathButton = rootVisualElement.Q<Button>("copy-path-button");
236 |             openFileButton = rootVisualElement.Q<Button>("open-file-button");
237 |             configJsonField = rootVisualElement.Q<TextField>("config-json");
238 |             copyJsonButton = rootVisualElement.Q<Button>("copy-json-button");
239 |             installationStepsLabel = rootVisualElement.Q<Label>("installation-steps");
240 |         }
241 | 
242 |         private void InitializeUI()
243 |         {
244 |             // Settings Section
245 |             UpdateVersionLabel();
246 |             debugLogsToggle.value = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
247 | 
248 |             validationLevelField.Init(ValidationLevel.Standard);
249 |             int savedLevel = EditorPrefs.GetInt("MCPForUnity.ValidationLevel", 1);
250 |             currentValidationLevel = (ValidationLevel)Mathf.Clamp(savedLevel, 0, 3);
251 |             validationLevelField.value = currentValidationLevel;
252 |             UpdateValidationDescription();
253 | 
254 |             // Advanced settings starts collapsed
255 |             advancedSettingsFoldout.value = false;
256 | 
257 |             // Connection Section
258 |             protocolDropdown.Init(ConnectionProtocol.Stdio);
259 |             protocolDropdown.SetEnabled(false); // Disabled for now, only stdio supported
260 | 
261 |             unityPortField.value = MCPServiceLocator.Bridge.CurrentPort.ToString();
262 |             serverPortField.value = "6500";
263 | 
264 |             // Client Configuration
265 |             var clientNames = mcpClients.clients.Select(c => c.name).ToList();
266 |             clientDropdown.choices = clientNames;
267 |             if (clientNames.Count > 0)
268 |             {
269 |                 clientDropdown.index = 0;
270 |             }
271 | 
272 |             // Manual config starts collapsed
273 |             manualConfigFoldout.value = false;
274 | 
275 |             // Claude CLI path row hidden by default
276 |             claudeCliPathRow.style.display = DisplayStyle.None;
277 |         }
278 | 
279 |         private void RegisterCallbacks()
280 |         {
281 |             // Settings callbacks
282 |             debugLogsToggle.RegisterValueChangedCallback(evt =>
283 |             {
284 |                 EditorPrefs.SetBool("MCPForUnity.DebugLogs", evt.newValue);
285 |             });
286 | 
287 |             validationLevelField.RegisterValueChangedCallback(evt =>
288 |             {
289 |                 currentValidationLevel = (ValidationLevel)evt.newValue;
290 |                 EditorPrefs.SetInt("MCPForUnity.ValidationLevel", (int)currentValidationLevel);
291 |                 UpdateValidationDescription();
292 |             });
293 | 
294 |             // Advanced settings callbacks
295 |             browsePythonButton.clicked += OnBrowsePythonClicked;
296 |             clearPythonButton.clicked += OnClearPythonClicked;
297 |             browseUvButton.clicked += OnBrowseUvClicked;
298 |             clearUvButton.clicked += OnClearUvClicked;
299 | 
300 |             // Connection callbacks
301 |             connectionToggleButton.clicked += OnConnectionToggleClicked;
302 |             testConnectionButton.clicked += OnTestConnectionClicked;
303 |             downloadServerButton.clicked += OnDownloadServerClicked;
304 |             rebuildServerButton.clicked += OnRebuildServerClicked;
305 | 
306 |             // Client callbacks
307 |             clientDropdown.RegisterValueChangedCallback(evt =>
308 |             {
309 |                 selectedClientIndex = clientDropdown.index;
310 |                 UpdateClientStatus();
311 |                 UpdateManualConfiguration();
312 |                 UpdateClaudeCliPathVisibility();
313 |             });
314 | 
315 |             configureAllButton.clicked += OnConfigureAllClientsClicked;
316 |             configureButton.clicked += OnConfigureClicked;
317 |             browseClaudeButton.clicked += OnBrowseClaudeClicked;
318 |             copyPathButton.clicked += OnCopyPathClicked;
319 |             openFileButton.clicked += OnOpenFileClicked;
320 |             copyJsonButton.clicked += OnCopyJsonClicked;
321 |         }
322 | 
323 |         private void UpdateValidationDescription()
324 |         {
325 |             validationDescription.text = GetValidationLevelDescription((int)currentValidationLevel);
326 |         }
327 | 
328 |         private string GetValidationLevelDescription(int index)
329 |         {
330 |             return index switch
331 |             {
332 |                 0 => "Only basic syntax checks (braces, quotes, comments)",
333 |                 1 => "Syntax checks + Unity best practices and warnings",
334 |                 2 => "All checks + semantic analysis and performance warnings",
335 |                 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)",
336 |                 _ => "Standard validation"
337 |             };
338 |         }
339 | 
340 |         private void UpdateConnectionStatus()
341 |         {
342 |             var bridgeService = MCPServiceLocator.Bridge;
343 |             bool isRunning = bridgeService.IsRunning;
344 | 
345 |             if (isRunning)
346 |             {
347 |                 connectionStatusLabel.text = "Connected";
348 |                 statusIndicator.RemoveFromClassList("disconnected");
349 |                 statusIndicator.AddToClassList("connected");
350 |                 connectionToggleButton.text = "Stop";
351 |             }
352 |             else
353 |             {
354 |                 connectionStatusLabel.text = "Disconnected";
355 |                 statusIndicator.RemoveFromClassList("connected");
356 |                 statusIndicator.AddToClassList("disconnected");
357 |                 connectionToggleButton.text = "Start";
358 | 
359 |                 // Reset health status when disconnected
360 |                 healthStatusLabel.text = "Unknown";
361 |                 healthIndicator.RemoveFromClassList("healthy");
362 |                 healthIndicator.RemoveFromClassList("warning");
363 |                 healthIndicator.AddToClassList("unknown");
364 |             }
365 | 
366 |             // Update ports
367 |             unityPortField.value = bridgeService.CurrentPort.ToString();
368 |         }
369 | 
370 |         private void UpdateClientStatus()
371 |         {
372 |             if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count)
373 |                 return;
374 | 
375 |             var client = mcpClients.clients[selectedClientIndex];
376 |             MCPServiceLocator.Client.CheckClientStatus(client);
377 | 
378 |             clientStatusLabel.text = client.GetStatusDisplayString();
379 |             
380 |             // Reset inline color style (clear error state from OnConfigureClicked)
381 |             clientStatusLabel.style.color = StyleKeyword.Null;
382 | 
383 |             // Update status indicator color
384 |             clientStatusIndicator.RemoveFromClassList("configured");
385 |             clientStatusIndicator.RemoveFromClassList("not-configured");
386 |             clientStatusIndicator.RemoveFromClassList("warning");
387 | 
388 |             switch (client.status)
389 |             {
390 |                 case McpStatus.Configured:
391 |                 case McpStatus.Running:
392 |                 case McpStatus.Connected:
393 |                     clientStatusIndicator.AddToClassList("configured");
394 |                     break;
395 |                 case McpStatus.IncorrectPath:
396 |                 case McpStatus.CommunicationError:
397 |                 case McpStatus.NoResponse:
398 |                     clientStatusIndicator.AddToClassList("warning");
399 |                     break;
400 |                 default:
401 |                     clientStatusIndicator.AddToClassList("not-configured");
402 |                     break;
403 |             }
404 | 
405 |             // Update configure button text for Claude Code
406 |             if (client.mcpType == McpTypes.ClaudeCode)
407 |             {
408 |                 bool isConfigured = client.status == McpStatus.Configured;
409 |                 configureButton.text = isConfigured ? "Unregister" : "Register";
410 |             }
411 |             else
412 |             {
413 |                 configureButton.text = "Configure";
414 |             }
415 |         }
416 | 
417 |         private void UpdateManualConfiguration()
418 |         {
419 |             if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count)
420 |                 return;
421 | 
422 |             var client = mcpClients.clients[selectedClientIndex];
423 | 
424 |             // Get config path
425 |             string configPath = MCPServiceLocator.Client.GetConfigPath(client);
426 |             configPathField.value = configPath;
427 | 
428 |             // Get config JSON
429 |             string configJson = MCPServiceLocator.Client.GenerateConfigJson(client);
430 |             configJsonField.value = configJson;
431 | 
432 |             // Get installation steps
433 |             string steps = MCPServiceLocator.Client.GetInstallationSteps(client);
434 |             installationStepsLabel.text = steps;
435 |         }
436 | 
437 |         private void UpdateClaudeCliPathVisibility()
438 |         {
439 |             if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count)
440 |                 return;
441 | 
442 |             var client = mcpClients.clients[selectedClientIndex];
443 | 
444 |             // Show Claude CLI path only for Claude Code client
445 |             if (client.mcpType == McpTypes.ClaudeCode)
446 |             {
447 |                 string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
448 |                 if (string.IsNullOrEmpty(claudePath))
449 |                 {
450 |                     // Show path selector if not found
451 |                     claudeCliPathRow.style.display = DisplayStyle.Flex;
452 |                     claudeCliPath.value = "Not found - click Browse to select";
453 |                 }
454 |                 else
455 |                 {
456 |                     // Show detected path
457 |                     claudeCliPathRow.style.display = DisplayStyle.Flex;
458 |                     claudeCliPath.value = claudePath;
459 |                 }
460 |             }
461 |             else
462 |             {
463 |                 claudeCliPathRow.style.display = DisplayStyle.None;
464 |             }
465 |         }
466 | 
467 |         private void UpdatePathOverrides()
468 |         {
469 |             var pathService = MCPServiceLocator.Paths;
470 | 
471 |             // MCP Server Path
472 |             string mcpServerPath = pathService.GetMcpServerPath();
473 |             if (pathService.HasMcpServerOverride)
474 |             {
475 |                 mcpServerPathOverride.value = mcpServerPath ?? "(override set but invalid)";
476 |             }
477 |             else
478 |             {
479 |                 mcpServerPathOverride.value = mcpServerPath ?? "(auto-detected)";
480 |             }
481 | 
482 |             // Update status indicator
483 |             mcpServerPathStatus.RemoveFromClassList("valid");
484 |             mcpServerPathStatus.RemoveFromClassList("invalid");
485 |             if (!string.IsNullOrEmpty(mcpServerPath) && File.Exists(Path.Combine(mcpServerPath, "server.py")))
486 |             {
487 |                 mcpServerPathStatus.AddToClassList("valid");
488 |             }
489 |             else
490 |             {
491 |                 mcpServerPathStatus.AddToClassList("invalid");
492 |             }
493 | 
494 |             // UV Path
495 |             string uvPath = pathService.GetUvPath();
496 |             if (pathService.HasUvPathOverride)
497 |             {
498 |                 uvPathOverride.value = uvPath ?? "(override set but invalid)";
499 |             }
500 |             else
501 |             {
502 |                 uvPathOverride.value = uvPath ?? "(auto-detected)";
503 |             }
504 | 
505 |             // Update status indicator
506 |             uvPathStatus.RemoveFromClassList("valid");
507 |             uvPathStatus.RemoveFromClassList("invalid");
508 |             if (!string.IsNullOrEmpty(uvPath) && File.Exists(uvPath))
509 |             {
510 |                 uvPathStatus.AddToClassList("valid");
511 |             }
512 |             else
513 |             {
514 |                 uvPathStatus.AddToClassList("invalid");
515 |             }
516 |         }
517 | 
518 |         // Button callbacks
519 |         private void OnConnectionToggleClicked()
520 |         {
521 |             var bridgeService = MCPServiceLocator.Bridge;
522 | 
523 |             if (bridgeService.IsRunning)
524 |             {
525 |                 bridgeService.Stop();
526 |             }
527 |             else
528 |             {
529 |                 bridgeService.Start();
530 | 
531 |                 // Verify connection after starting (Option C: verify on connect)
532 |                 EditorApplication.delayCall += () =>
533 |                 {
534 |                     if (bridgeService.IsRunning)
535 |                     {
536 |                         VerifyBridgeConnection();
537 |                     }
538 |                 };
539 |             }
540 | 
541 |             UpdateConnectionStatus();
542 |         }
543 | 
544 |         private void OnTestConnectionClicked()
545 |         {
546 |             VerifyBridgeConnection();
547 |         }
548 | 
549 |         private void VerifyBridgeConnection()
550 |         {
551 |             var bridgeService = MCPServiceLocator.Bridge;
552 | 
553 |             if (!bridgeService.IsRunning)
554 |             {
555 |                 healthStatusLabel.text = "Disconnected";
556 |                 healthIndicator.RemoveFromClassList("healthy");
557 |                 healthIndicator.RemoveFromClassList("warning");
558 |                 healthIndicator.AddToClassList("unknown");
559 |                 McpLog.Warn("Cannot verify connection: Bridge is not running");
560 |                 return;
561 |             }
562 | 
563 |             var result = bridgeService.Verify(bridgeService.CurrentPort);
564 | 
565 |             healthIndicator.RemoveFromClassList("healthy");
566 |             healthIndicator.RemoveFromClassList("warning");
567 |             healthIndicator.RemoveFromClassList("unknown");
568 | 
569 |             if (result.Success && result.PingSucceeded)
570 |             {
571 |                 healthStatusLabel.text = "Healthy";
572 |                 healthIndicator.AddToClassList("healthy");
573 |                 McpLog.Info("Bridge verification successful");
574 |             }
575 |             else if (result.HandshakeValid)
576 |             {
577 |                 healthStatusLabel.text = "Ping Failed";
578 |                 healthIndicator.AddToClassList("warning");
579 |                 McpLog.Warn($"Bridge verification warning: {result.Message}");
580 |             }
581 |             else
582 |             {
583 |                 healthStatusLabel.text = "Unhealthy";
584 |                 healthIndicator.AddToClassList("warning");
585 |                 McpLog.Error($"Bridge verification failed: {result.Message}");
586 |             }
587 |         }
588 | 
589 |         private void OnDownloadServerClicked()
590 |         {
591 |             if (ServerInstaller.DownloadAndInstallServer())
592 |             {
593 |                 UpdateServerStatusBanner();
594 |                 UpdatePathOverrides();
595 |                 EditorUtility.DisplayDialog(
596 |                     "Download Complete",
597 |                     "Server installed successfully! Start your connection and configure your MCP clients to begin.",
598 |                     "OK"
599 |                 );
600 |             }
601 |         }
602 | 
603 |         private void OnRebuildServerClicked()
604 |         {
605 |             try
606 |             {
607 |                 bool success = ServerInstaller.RebuildMcpServer();
608 |                 if (success)
609 |                 {
610 |                     EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK");
611 |                     UpdateServerStatusBanner();
612 |                     UpdatePathOverrides();
613 |                 }
614 |                 else
615 |                 {
616 |                     EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK");
617 |                 }
618 |             }
619 |             catch (Exception ex)
620 |             {
621 |                 McpLog.Error($"Failed to rebuild server: {ex.Message}");
622 |                 EditorUtility.DisplayDialog("MCP For Unity", $"Rebuild failed: {ex.Message}", "OK");
623 |             }
624 |         }
625 | 
626 |         private void UpdateServerStatusBanner()
627 |         {
628 |             bool hasEmbedded = ServerInstaller.HasEmbeddedServer();
629 |             string installedVer = ServerInstaller.GetInstalledServerVersion();
630 |             string packageVer = AssetPathUtility.GetPackageVersion();
631 | 
632 |             // Show/hide download vs rebuild buttons
633 |             if (hasEmbedded)
634 |             {
635 |                 downloadServerButton.style.display = DisplayStyle.None;
636 |                 rebuildServerButton.style.display = DisplayStyle.Flex;
637 |             }
638 |             else
639 |             {
640 |                 downloadServerButton.style.display = DisplayStyle.Flex;
641 |                 rebuildServerButton.style.display = DisplayStyle.None;
642 |             }
643 | 
644 |             // Update banner
645 |             if (!hasEmbedded && string.IsNullOrEmpty(installedVer))
646 |             {
647 |                 serverStatusMessage.text = "\u26A0 Server not installed. Click 'Download & Install Server' to get started.";
648 |                 serverStatusBanner.style.display = DisplayStyle.Flex;
649 |             }
650 |             else if (!hasEmbedded && !string.IsNullOrEmpty(installedVer) && installedVer != packageVer)
651 |             {
652 |                 serverStatusMessage.text = $"\u26A0 Server update available (v{installedVer} \u2192 v{packageVer}). Update recommended.";
653 |                 serverStatusBanner.style.display = DisplayStyle.Flex;
654 |             }
655 |             else
656 |             {
657 |                 serverStatusBanner.style.display = DisplayStyle.None;
658 |             }
659 |         }
660 | 
661 |         private void OnConfigureAllClientsClicked()
662 |         {
663 |             try
664 |             {
665 |                 var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients();
666 | 
667 |                 // Build detailed message
668 |                 string message = summary.GetSummaryMessage() + "\n\n";
669 |                 foreach (var msg in summary.Messages)
670 |                 {
671 |                     message += msg + "\n";
672 |                 }
673 | 
674 |                 EditorUtility.DisplayDialog("Configure All Clients", message, "OK");
675 | 
676 |                 // Refresh current client status
677 |                 if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count)
678 |                 {
679 |                     UpdateClientStatus();
680 |                     UpdateManualConfiguration();
681 |                 }
682 |             }
683 |             catch (Exception ex)
684 |             {
685 |                 EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK");
686 |             }
687 |         }
688 | 
689 |         private void OnConfigureClicked()
690 |         {
691 |             if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count)
692 |                 return;
693 | 
694 |             var client = mcpClients.clients[selectedClientIndex];
695 | 
696 |             try
697 |             {
698 |                 if (client.mcpType == McpTypes.ClaudeCode)
699 |                 {
700 |                     bool isConfigured = client.status == McpStatus.Configured;
701 |                     if (isConfigured)
702 |                     {
703 |                         MCPServiceLocator.Client.UnregisterClaudeCode();
704 |                     }
705 |                     else
706 |                     {
707 |                         MCPServiceLocator.Client.RegisterClaudeCode();
708 |                     }
709 |                 }
710 |                 else
711 |                 {
712 |                     MCPServiceLocator.Client.ConfigureClient(client);
713 |                 }
714 | 
715 |                 UpdateClientStatus();
716 |                 UpdateManualConfiguration();
717 |             }
718 |             catch (Exception ex)
719 |             {
720 |                 clientStatusLabel.text = "Error";
721 |                 clientStatusLabel.style.color = Color.red;
722 |                 McpLog.Error($"Configuration failed: {ex.Message}");
723 |                 EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK");
724 |             }
725 |         }
726 | 
727 |         private void OnBrowsePythonClicked()
728 |         {
729 |             string picked = EditorUtility.OpenFolderPanel("Select MCP Server Directory", Application.dataPath, "");
730 |             if (!string.IsNullOrEmpty(picked))
731 |             {
732 |                 try
733 |                 {
734 |                     MCPServiceLocator.Paths.SetMcpServerOverride(picked);
735 |                     UpdatePathOverrides();
736 |                     McpLog.Info($"MCP server path override set to: {picked}");
737 |                 }
738 |                 catch (Exception ex)
739 |                 {
740 |                     EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK");
741 |                 }
742 |             }
743 |         }
744 | 
745 |         private void OnClearPythonClicked()
746 |         {
747 |             MCPServiceLocator.Paths.ClearMcpServerOverride();
748 |             UpdatePathOverrides();
749 |             McpLog.Info("MCP server path override cleared");
750 |         }
751 | 
752 |         private void OnBrowseUvClicked()
753 |         {
754 |             string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
755 |                 ? "/opt/homebrew/bin"
756 |                 : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
757 |             string picked = EditorUtility.OpenFilePanel("Select UV Executable", suggested, "");
758 |             if (!string.IsNullOrEmpty(picked))
759 |             {
760 |                 try
761 |                 {
762 |                     MCPServiceLocator.Paths.SetUvPathOverride(picked);
763 |                     UpdatePathOverrides();
764 |                     McpLog.Info($"UV path override set to: {picked}");
765 |                 }
766 |                 catch (Exception ex)
767 |                 {
768 |                     EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK");
769 |                 }
770 |             }
771 |         }
772 | 
773 |         private void OnClearUvClicked()
774 |         {
775 |             MCPServiceLocator.Paths.ClearUvPathOverride();
776 |             UpdatePathOverrides();
777 |             McpLog.Info("UV path override cleared");
778 |         }
779 | 
780 |         private void OnBrowseClaudeClicked()
781 |         {
782 |             string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
783 |                 ? "/opt/homebrew/bin"
784 |                 : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
785 |             string picked = EditorUtility.OpenFilePanel("Select Claude CLI", suggested, "");
786 |             if (!string.IsNullOrEmpty(picked))
787 |             {
788 |                 try
789 |                 {
790 |                     MCPServiceLocator.Paths.SetClaudeCliPathOverride(picked);
791 |                     UpdateClaudeCliPathVisibility();
792 |                     UpdateClientStatus();
793 |                     McpLog.Info($"Claude CLI path override set to: {picked}");
794 |                 }
795 |                 catch (Exception ex)
796 |                 {
797 |                     EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK");
798 |                 }
799 |             }
800 |         }
801 | 
802 |         private void OnCopyPathClicked()
803 |         {
804 |             EditorGUIUtility.systemCopyBuffer = configPathField.value;
805 |             McpLog.Info("Config path copied to clipboard");
806 |         }
807 | 
808 |         private void OnOpenFileClicked()
809 |         {
810 |             string path = configPathField.value;
811 |             try
812 |             {
813 |                 if (!File.Exists(path))
814 |                 {
815 |                     EditorUtility.DisplayDialog("Open File", "The configuration file path does not exist.", "OK");
816 |                     return;
817 |                 }
818 | 
819 |                 Process.Start(new ProcessStartInfo
820 |                 {
821 |                     FileName = path,
822 |                     UseShellExecute = true
823 |                 });
824 |             }
825 |             catch (Exception ex)
826 |             {
827 |                 McpLog.Error($"Failed to open file: {ex.Message}");
828 |             }
829 |         }
830 | 
831 |         private void OnCopyJsonClicked()
832 |         {
833 |             EditorGUIUtility.systemCopyBuffer = configJsonField.value;
834 |             McpLog.Info("Configuration copied to clipboard");
835 |         }
836 | 
837 |         private void UpdateVersionLabel()
838 |         {
839 |             string currentVersion = AssetPathUtility.GetPackageVersion();
840 |             versionLabel.text = $"v{currentVersion}";
841 | 
842 |             // Check for updates using the service
843 |             var updateCheck = MCPServiceLocator.Updates.CheckForUpdate(currentVersion);
844 | 
845 |             if (updateCheck.UpdateAvailable && !string.IsNullOrEmpty(updateCheck.LatestVersion))
846 |             {
847 |                 // Update available - enhance the label
848 |                 versionLabel.text = $"\u2191 v{currentVersion} (Update available: v{updateCheck.LatestVersion})";
849 |                 versionLabel.style.color = new Color(1f, 0.7f, 0f); // Orange
850 |                 versionLabel.tooltip = $"Version {updateCheck.LatestVersion} is available. Update via Package Manager.\n\nGit URL: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity";
851 |             }
852 |             else
853 |             {
854 |                 versionLabel.style.color = StyleKeyword.Null; // Default color
855 |                 versionLabel.tooltip = $"Current version: {currentVersion}";
856 |             }
857 |         }
858 | 
859 |     }
860 | }
861 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/Editor/Helpers/ServerInstaller.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Diagnostics;
  4 | using System.IO;
  5 | using System.IO.Compression;
  6 | using System.Linq;
  7 | using System.Net;
  8 | using System.Runtime.InteropServices;
  9 | using UnityEditor;
 10 | using UnityEngine;
 11 | 
 12 | namespace MCPForUnity.Editor.Helpers
 13 | {
 14 |     public static class ServerInstaller
 15 |     {
 16 |         private const string RootFolder = "UnityMCP";
 17 |         private const string ServerFolder = "UnityMcpServer";
 18 |         private const string VersionFileName = "server_version.txt";
 19 | 
 20 |         /// <summary>
 21 |         /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source.
 22 |         /// No network calls or Git operations are performed.
 23 |         /// </summary>
 24 |         public static void EnsureServerInstalled()
 25 |         {
 26 |             try
 27 |             {
 28 |                 string saveLocation = GetSaveLocation();
 29 |                 TryCreateMacSymlinkForAppSupport();
 30 |                 string destRoot = Path.Combine(saveLocation, ServerFolder);
 31 |                 string destSrc = Path.Combine(destRoot, "src");
 32 | 
 33 |                 // Detect legacy installs and version state (logs)
 34 |                 DetectAndLogLegacyInstallStates(destRoot);
 35 | 
 36 |                 // Resolve embedded source and versions
 37 |                 if (!TryGetEmbeddedServerSource(out string embeddedSrc))
 38 |                 {
 39 |                     // Asset Store install - no embedded server
 40 |                     // Check if server was already downloaded
 41 |                     if (File.Exists(Path.Combine(destSrc, "server.py")))
 42 |                     {
 43 |                         McpLog.Info("Using previously downloaded MCP server.", always: false);
 44 |                     }
 45 |                     else
 46 |                     {
 47 |                         McpLog.Info("MCP server not found. Download via Window > MCP For Unity > Open MCP Window.", always: false);
 48 |                     }
 49 |                     return; // Graceful exit - no exception
 50 |                 }
 51 | 
 52 |                 string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown";
 53 |                 string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName));
 54 | 
 55 |                 bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py"));
 56 |                 bool needOverwrite = !destHasServer
 57 |                                      || string.IsNullOrEmpty(installedVer)
 58 |                                      || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0);
 59 | 
 60 |                 // Ensure destination exists
 61 |                 Directory.CreateDirectory(destRoot);
 62 | 
 63 |                 if (needOverwrite)
 64 |                 {
 65 |                     // Copy the entire UnityMcpServer folder (parent of src)
 66 |                     string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer
 67 |                     CopyDirectoryRecursive(embeddedRoot, destRoot);
 68 | 
 69 |                     // Write/refresh version file
 70 |                     try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { }
 71 |                     McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer}).");
 72 |                 }
 73 | 
 74 |                 // Cleanup legacy installs that are missing version or older than embedded
 75 |                 foreach (var legacyRoot in GetLegacyRootsForDetection())
 76 |                 {
 77 |                     try
 78 |                     {
 79 |                         string legacySrc = Path.Combine(legacyRoot, "src");
 80 |                         if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue;
 81 |                         string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName));
 82 |                         bool legacyOlder = string.IsNullOrEmpty(legacyVer)
 83 |                                            || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0);
 84 |                         if (legacyOlder)
 85 |                         {
 86 |                             TryKillUvForPath(legacySrc);
 87 |                             try
 88 |                             {
 89 |                                 Directory.Delete(legacyRoot, recursive: true);
 90 |                                 McpLog.Info($"Removed legacy server at '{legacyRoot}'.");
 91 |                             }
 92 |                             catch (Exception ex)
 93 |                             {
 94 |                                 McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}");
 95 |                             }
 96 |                         }
 97 |                     }
 98 |                     catch { }
 99 |                 }
100 | 
101 |                 // Clear overrides that might point at legacy locations
102 |                 try
103 |                 {
104 |                     EditorPrefs.DeleteKey("MCPForUnity.ServerSrc");
105 |                     EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride");
106 |                 }
107 |                 catch { }
108 |                 return;
109 |             }
110 |             catch (Exception ex)
111 |             {
112 |                 // If a usable server is already present (installed or embedded), don't fail hard—just warn.
113 |                 bool hasInstalled = false;
114 |                 try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { }
115 | 
116 |                 if (hasInstalled || TryGetEmbeddedServerSource(out _))
117 |                 {
118 |                     McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}");
119 |                     return;
120 |                 }
121 | 
122 |                 McpLog.Error($"Failed to ensure server installation: {ex.Message}");
123 |             }
124 |         }
125 | 
126 |         public static string GetServerPath()
127 |         {
128 |             return Path.Combine(GetSaveLocation(), ServerFolder, "src");
129 |         }
130 | 
131 |         /// <summary>
132 |         /// Gets the platform-specific save location for the server.
133 |         /// </summary>
134 |         private static string GetSaveLocation()
135 |         {
136 |             if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
137 |             {
138 |                 // Use per-user LocalApplicationData for canonical install location
139 |                 var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
140 |                                    ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local");
141 |                 return Path.Combine(localAppData, RootFolder);
142 |             }
143 |             else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
144 |             {
145 |                 var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
146 |                 if (string.IsNullOrEmpty(xdg))
147 |                 {
148 |                     xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty,
149 |                                        ".local", "share");
150 |                 }
151 |                 return Path.Combine(xdg, RootFolder);
152 |             }
153 |             else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
154 |             {
155 |                 // On macOS, use LocalApplicationData (~/Library/Application Support)
156 |                 var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
157 |                 // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support
158 |                 bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share");
159 |                 if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg)
160 |                 {
161 |                     // Fallback: construct from $HOME
162 |                     var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
163 |                     localAppSupport = Path.Combine(home, "Library", "Application Support");
164 |                 }
165 |                 TryCreateMacSymlinkForAppSupport();
166 |                 return Path.Combine(localAppSupport, RootFolder);
167 |             }
168 |             throw new Exception("Unsupported operating system");
169 |         }
170 | 
171 |         /// <summary>
172 |         /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support
173 |         /// to mitigate arg parsing and quoting issues in some MCP clients.
174 |         /// Safe to call repeatedly.
175 |         /// </summary>
176 |         private static void TryCreateMacSymlinkForAppSupport()
177 |         {
178 |             try
179 |             {
180 |                 if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return;
181 |                 string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
182 |                 if (string.IsNullOrEmpty(home)) return;
183 | 
184 |                 string canonical = Path.Combine(home, "Library", "Application Support");
185 |                 string symlink = Path.Combine(home, "Library", "AppSupport");
186 | 
187 |                 // If symlink exists already, nothing to do
188 |                 if (Directory.Exists(symlink) || File.Exists(symlink)) return;
189 | 
190 |                 // Create symlink only if canonical exists
191 |                 if (!Directory.Exists(canonical)) return;
192 | 
193 |                 // Use 'ln -s' to create a directory symlink (macOS)
194 |                 var psi = new ProcessStartInfo
195 |                 {
196 |                     FileName = "/bin/ln",
197 |                     Arguments = $"-s \"{canonical}\" \"{symlink}\"",
198 |                     UseShellExecute = false,
199 |                     RedirectStandardOutput = true,
200 |                     RedirectStandardError = true,
201 |                     CreateNoWindow = true
202 |                 };
203 |                 using var p = Process.Start(psi);
204 |                 p?.WaitForExit(2000);
205 |             }
206 |             catch { /* best-effort */ }
207 |         }
208 | 
209 |         private static bool IsDirectoryWritable(string path)
210 |         {
211 |             try
212 |             {
213 |                 File.Create(Path.Combine(path, "test.txt")).Dispose();
214 |                 File.Delete(Path.Combine(path, "test.txt"));
215 |                 return true;
216 |             }
217 |             catch
218 |             {
219 |                 return false;
220 |             }
221 |         }
222 | 
223 |         /// <summary>
224 |         /// Checks if the server is installed at the specified location.
225 |         /// </summary>
226 |         private static bool IsServerInstalled(string location)
227 |         {
228 |             return Directory.Exists(location)
229 |                 && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py"));
230 |         }
231 | 
232 |         /// <summary>
233 |         /// Detects legacy installs or older versions and logs findings (no deletion yet).
234 |         /// </summary>
235 |         private static void DetectAndLogLegacyInstallStates(string canonicalRoot)
236 |         {
237 |             try
238 |             {
239 |                 string canonicalSrc = Path.Combine(canonicalRoot, "src");
240 |                 // Normalize canonical root for comparisons
241 |                 string normCanonicalRoot = NormalizePathSafe(canonicalRoot);
242 |                 string embeddedSrc = null;
243 |                 TryGetEmbeddedServerSource(out embeddedSrc);
244 | 
245 |                 string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName));
246 |                 string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName));
247 | 
248 |                 // Legacy paths (macOS/Linux .config; Windows roaming as example)
249 |                 foreach (var legacyRoot in GetLegacyRootsForDetection())
250 |                 {
251 |                     // Skip logging for the canonical root itself
252 |                     if (PathsEqualSafe(legacyRoot, normCanonicalRoot))
253 |                         continue;
254 |                     string legacySrc = Path.Combine(legacyRoot, "src");
255 |                     bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py"));
256 |                     string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName));
257 | 
258 |                     if (hasServer)
259 |                     {
260 |                         // Case 1: No version file
261 |                         if (string.IsNullOrEmpty(legacyVer))
262 |                         {
263 |                             McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false);
264 |                         }
265 | 
266 |                         // Case 2: Lives in legacy path
267 |                         McpLog.Info("Detected legacy install path: " + legacyRoot, always: false);
268 | 
269 |                         // Case 3: Has version but appears older than embedded
270 |                         if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0)
271 |                         {
272 |                             McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false);
273 |                         }
274 |                     }
275 |                 }
276 | 
277 |                 // Also log if canonical is missing version (treated as older)
278 |                 if (Directory.Exists(canonicalRoot))
279 |                 {
280 |                     if (string.IsNullOrEmpty(installedVer))
281 |                     {
282 |                         McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false);
283 |                     }
284 |                     else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0)
285 |                     {
286 |                         McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false);
287 |                     }
288 |                 }
289 |             }
290 |             catch (Exception ex)
291 |             {
292 |                 McpLog.Warn("Detect legacy/version state failed: " + ex.Message);
293 |             }
294 |         }
295 | 
296 |         private static string NormalizePathSafe(string path)
297 |         {
298 |             try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); }
299 |             catch { return path; }
300 |         }
301 | 
302 |         private static bool PathsEqualSafe(string a, string b)
303 |         {
304 |             if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
305 |             string na = NormalizePathSafe(a);
306 |             string nb = NormalizePathSafe(b);
307 |             try
308 |             {
309 |                 if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
310 |                 {
311 |                     return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
312 |                 }
313 |                 return string.Equals(na, nb, StringComparison.Ordinal);
314 |             }
315 |             catch { return false; }
316 |         }
317 | 
318 |         private static IEnumerable<string> GetLegacyRootsForDetection()
319 |         {
320 |             var roots = new List<string>();
321 |             string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
322 |             // macOS/Linux legacy
323 |             roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer"));
324 |             roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer"));
325 |             // Windows roaming example
326 |             try
327 |             {
328 |                 string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
329 |                 if (!string.IsNullOrEmpty(roaming))
330 |                     roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer"));
331 |                 // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer
332 |                 // Detect this location so we can clean up older copies during install/update.
333 |                 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
334 |                 if (!string.IsNullOrEmpty(localAppData))
335 |                     roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer"));
336 |             }
337 |             catch { }
338 |             return roots;
339 |         }
340 | 
341 |         private static void TryKillUvForPath(string serverSrcPath)
342 |         {
343 |             try
344 |             {
345 |                 if (string.IsNullOrEmpty(serverSrcPath)) return;
346 |                 if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
347 | 
348 |                 var psi = new ProcessStartInfo
349 |                 {
350 |                     FileName = "/usr/bin/pgrep",
351 |                     Arguments = $"-f \"uv .*--directory {serverSrcPath}\"",
352 |                     UseShellExecute = false,
353 |                     RedirectStandardOutput = true,
354 |                     RedirectStandardError = true,
355 |                     CreateNoWindow = true
356 |                 };
357 |                 using var p = Process.Start(psi);
358 |                 if (p == null) return;
359 |                 string outp = p.StandardOutput.ReadToEnd();
360 |                 p.WaitForExit(1500);
361 |                 if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp))
362 |                 {
363 |                     foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries))
364 |                     {
365 |                         if (int.TryParse(line.Trim(), out int pid))
366 |                         {
367 |                             try { Process.GetProcessById(pid).Kill(); } catch { }
368 |                         }
369 |                     }
370 |                 }
371 |             }
372 |             catch { }
373 |         }
374 | 
375 |         private static string ReadVersionFile(string path)
376 |         {
377 |             try
378 |             {
379 |                 if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
380 |                 string v = File.ReadAllText(path).Trim();
381 |                 return string.IsNullOrEmpty(v) ? null : v;
382 |             }
383 |             catch { return null; }
384 |         }
385 | 
386 |         private static int CompareSemverSafe(string a, string b)
387 |         {
388 |             try
389 |             {
390 |                 if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0;
391 |                 var ap = a.Split('.');
392 |                 var bp = b.Split('.');
393 |                 for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++)
394 |                 {
395 |                     int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0;
396 |                     int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0;
397 |                     if (ai != bi) return ai.CompareTo(bi);
398 |                 }
399 |                 return 0;
400 |             }
401 |             catch { return 0; }
402 |         }
403 | 
404 |         /// <summary>
405 |         /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package
406 |         /// or common development locations.
407 |         /// </summary>
408 |         private static bool TryGetEmbeddedServerSource(out string srcPath)
409 |         {
410 |             return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath);
411 |         }
412 | 
413 |         private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" };
414 | 
415 |         private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
416 |         {
417 |             Directory.CreateDirectory(destinationDir);
418 | 
419 |             foreach (string filePath in Directory.GetFiles(sourceDir))
420 |             {
421 |                 string fileName = Path.GetFileName(filePath);
422 |                 string destFile = Path.Combine(destinationDir, fileName);
423 |                 File.Copy(filePath, destFile, overwrite: true);
424 |             }
425 | 
426 |             foreach (string dirPath in Directory.GetDirectories(sourceDir))
427 |             {
428 |                 string dirName = Path.GetFileName(dirPath);
429 |                 foreach (var skip in _skipDirs)
430 |                 {
431 |                     if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase))
432 |                         goto NextDir;
433 |                 }
434 |                 try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { }
435 |                 string destSubDir = Path.Combine(destinationDir, dirName);
436 |                 CopyDirectoryRecursive(dirPath, destSubDir);
437 |             NextDir:;
438 |             }
439 |         }
440 | 
441 |         public static bool RebuildMcpServer()
442 |         {
443 |             try
444 |             {
445 |                 // Find embedded source
446 |                 if (!TryGetEmbeddedServerSource(out string embeddedSrc))
447 |                 {
448 |                     McpLog.Error("RebuildMcpServer: Could not find embedded server source.");
449 |                     return false;
450 |                 }
451 | 
452 |                 string saveLocation = GetSaveLocation();
453 |                 string destRoot = Path.Combine(saveLocation, ServerFolder);
454 |                 string destSrc = Path.Combine(destRoot, "src");
455 | 
456 |                 // Kill any running uv processes for this server
457 |                 TryKillUvForPath(destSrc);
458 | 
459 |                 // Delete the entire installed server directory
460 |                 if (Directory.Exists(destRoot))
461 |                 {
462 |                     try
463 |                     {
464 |                         Directory.Delete(destRoot, recursive: true);
465 |                         McpLog.Info($"Deleted existing server at {destRoot}");
466 |                     }
467 |                     catch (Exception ex)
468 |                     {
469 |                         McpLog.Error($"Failed to delete existing server: {ex.Message}");
470 |                         return false;
471 |                     }
472 |                 }
473 | 
474 |                 // Re-copy from embedded source
475 |                 string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc;
476 |                 Directory.CreateDirectory(destRoot);
477 |                 CopyDirectoryRecursive(embeddedRoot, destRoot);
478 | 
479 |                 // Write version file
480 |                 string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown";
481 |                 try
482 |                 {
483 |                     File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer);
484 |                 }
485 |                 catch (Exception ex)
486 |                 {
487 |                     McpLog.Warn($"Failed to write version file: {ex.Message}");
488 |                 }
489 | 
490 |                 McpLog.Info($"Server rebuilt successfully at {destRoot} (version {embeddedVer})");
491 |                 return true;
492 |             }
493 |             catch (Exception ex)
494 |             {
495 |                 McpLog.Error($"RebuildMcpServer failed: {ex.Message}");
496 |                 return false;
497 |             }
498 |         }
499 | 
500 |         internal static string FindUvPath()
501 |         {
502 |             // Allow user override via EditorPrefs
503 |             try
504 |             {
505 |                 string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty);
506 |                 if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath))
507 |                 {
508 |                     if (ValidateUvBinary(overridePath)) return overridePath;
509 |                 }
510 |             }
511 |             catch { }
512 | 
513 |             string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
514 | 
515 |             // Platform-specific candidate lists
516 |             string[] candidates;
517 |             if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
518 |             {
519 |                 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty;
520 |                 string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty;
521 |                 string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty;
522 | 
523 |                 // Fast path: resolve from PATH first
524 |                 try
525 |                 {
526 |                     var wherePsi = new ProcessStartInfo
527 |                     {
528 |                         FileName = "where",
529 |                         Arguments = "uv.exe",
530 |                         UseShellExecute = false,
531 |                         RedirectStandardOutput = true,
532 |                         RedirectStandardError = true,
533 |                         CreateNoWindow = true
534 |                     };
535 |                     using var wp = Process.Start(wherePsi);
536 |                     string output = wp.StandardOutput.ReadToEnd().Trim();
537 |                     wp.WaitForExit(1500);
538 |                     if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output))
539 |                     {
540 |                         foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
541 |                         {
542 |                             string path = line.Trim();
543 |                             if (File.Exists(path) && ValidateUvBinary(path)) return path;
544 |                         }
545 |                     }
546 |                 }
547 |                 catch { }
548 | 
549 |                 // Windows Store (PythonSoftwareFoundation) install location probe
550 |                 // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe
551 |                 try
552 |                 {
553 |                     string pkgsRoot = Path.Combine(localAppData, "Packages");
554 |                     if (Directory.Exists(pkgsRoot))
555 |                     {
556 |                         var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly)
557 |                                                  .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase);
558 |                         foreach (var pkg in pythonPkgs)
559 |                         {
560 |                             string localCache = Path.Combine(pkg, "LocalCache", "local-packages");
561 |                             if (!Directory.Exists(localCache)) continue;
562 |                             var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly)
563 |                                                    .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase);
564 |                             foreach (var pyRoot in pyRoots)
565 |                             {
566 |                                 string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe");
567 |                                 if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe;
568 |                             }
569 |                         }
570 |                     }
571 |                 }
572 |                 catch { }
573 | 
574 |                 candidates = new[]
575 |                 {
576 |                     // Preferred: WinGet Links shims (stable entrypoints)
577 |                     // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links)
578 |                     Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"),
579 |                     Path.Combine(programFiles, "WinGet", "Links", "uv.exe"),
580 | 
581 |                     // Common per-user installs
582 |                     Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"),
583 |                     Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"),
584 |                     Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"),
585 |                     Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"),
586 |                     Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"),
587 |                     Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"),
588 |                     Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"),
589 |                     Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"),
590 | 
591 |                     // Program Files style installs (if a native installer was used)
592 |                     Path.Combine(programFiles, @"uv\uv.exe"),
593 | 
594 |                     // Try simple name resolution later via PATH
595 |                     "uv.exe",
596 |                     "uv"
597 |                 };
598 |             }
599 |             else
600 |             {
601 |                 candidates = new[]
602 |                 {
603 |                     "/opt/homebrew/bin/uv",
604 |                     "/usr/local/bin/uv",
605 |                     "/usr/bin/uv",
606 |                     "/opt/local/bin/uv",
607 |                     Path.Combine(home, ".local", "bin", "uv"),
608 |                     "/opt/homebrew/opt/uv/bin/uv",
609 |                     // Framework Python installs
610 |                     "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv",
611 |                     "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv",
612 |                     // Fallback to PATH resolution by name
613 |                     "uv"
614 |                 };
615 |             }
616 | 
617 |             foreach (string c in candidates)
618 |             {
619 |                 try
620 |                 {
621 |                     if (File.Exists(c) && ValidateUvBinary(c)) return c;
622 |                 }
623 |                 catch { /* ignore */ }
624 |             }
625 | 
626 |             // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier)
627 |             try
628 |             {
629 |                 if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
630 |                 {
631 |                     var whichPsi = new ProcessStartInfo
632 |                     {
633 |                         FileName = "/usr/bin/which",
634 |                         Arguments = "uv",
635 |                         UseShellExecute = false,
636 |                         RedirectStandardOutput = true,
637 |                         RedirectStandardError = true,
638 |                         CreateNoWindow = true
639 |                     };
640 |                     try
641 |                     {
642 |                         // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env
643 |                         string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty;
644 |                         string prepend = string.Join(":", new[]
645 |                         {
646 |                             Path.Combine(homeDir, ".local", "bin"),
647 |                             "/opt/homebrew/bin",
648 |                             "/usr/local/bin",
649 |                             "/usr/bin",
650 |                             "/bin"
651 |                         });
652 |                         string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
653 |                         whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath);
654 |                     }
655 |                     catch { }
656 |                     using var wp = Process.Start(whichPsi);
657 |                     string output = wp.StandardOutput.ReadToEnd().Trim();
658 |                     wp.WaitForExit(3000);
659 |                     if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output))
660 |                     {
661 |                         if (ValidateUvBinary(output)) return output;
662 |                     }
663 |                 }
664 |             }
665 |             catch { }
666 | 
667 |             // Manual PATH scan
668 |             try
669 |             {
670 |                 string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
671 |                 string[] parts = pathEnv.Split(Path.PathSeparator);
672 |                 foreach (string part in parts)
673 |                 {
674 |                     try
675 |                     {
676 |                         // Check both uv and uv.exe
677 |                         string candidateUv = Path.Combine(part, "uv");
678 |                         string candidateUvExe = Path.Combine(part, "uv.exe");
679 |                         if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv;
680 |                         if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe;
681 |                     }
682 |                     catch { }
683 |                 }
684 |             }
685 |             catch { }
686 | 
687 |             return null;
688 |         }
689 | 
690 |         private static bool ValidateUvBinary(string uvPath)
691 |         {
692 |             try
693 |             {
694 |                 var psi = new ProcessStartInfo
695 |                 {
696 |                     FileName = uvPath,
697 |                     Arguments = "--version",
698 |                     UseShellExecute = false,
699 |                     RedirectStandardOutput = true,
700 |                     RedirectStandardError = true,
701 |                     CreateNoWindow = true
702 |                 };
703 |                 using var p = Process.Start(psi);
704 |                 if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; }
705 |                 if (p.ExitCode == 0)
706 |                 {
707 |                     string output = p.StandardOutput.ReadToEnd().Trim();
708 |                     return output.StartsWith("uv ");
709 |                 }
710 |             }
711 |             catch { }
712 |             return false;
713 |         }
714 | 
715 |         /// <summary>
716 |         /// Download and install server from GitHub release (Asset Store workflow)
717 |         /// </summary>
718 |         public static bool DownloadAndInstallServer()
719 |         {
720 |             string packageVersion = AssetPathUtility.GetPackageVersion();
721 |             if (packageVersion == "unknown")
722 |             {
723 |                 McpLog.Error("Cannot determine package version for download.");
724 |                 return false;
725 |             }
726 | 
727 |             string downloadUrl = $"https://github.com/CoplayDev/unity-mcp/releases/download/v{packageVersion}/mcp-for-unity-server-v{packageVersion}.zip";
728 |             string tempZip = Path.Combine(Path.GetTempPath(), $"mcp-server-v{packageVersion}.zip");
729 |             string destRoot = Path.Combine(GetSaveLocation(), ServerFolder);
730 | 
731 |             try
732 |             {
733 |                 EditorUtility.DisplayProgressBar("MCP for Unity", "Downloading server...", 0.3f);
734 | 
735 |                 // Download
736 |                 using (var client = new WebClient())
737 |                 {
738 |                     client.DownloadFile(downloadUrl, tempZip);
739 |                 }
740 | 
741 |                 EditorUtility.DisplayProgressBar("MCP for Unity", "Extracting server...", 0.7f);
742 | 
743 |                 // Kill any running UV processes
744 |                 string destSrc = Path.Combine(destRoot, "src");
745 |                 TryKillUvForPath(destSrc);
746 | 
747 |                 // Delete old installation
748 |                 if (Directory.Exists(destRoot))
749 |                 {
750 |                     try
751 |                     {
752 |                         Directory.Delete(destRoot, recursive: true);
753 |                     }
754 |                     catch (Exception ex)
755 |                     {
756 |                         McpLog.Warn($"Could not fully delete old server: {ex.Message}");
757 |                     }
758 |                 }
759 | 
760 |                 // Extract to temp location first
761 |                 string tempExtractDir = Path.Combine(Path.GetTempPath(), $"mcp-server-extract-{Guid.NewGuid()}");
762 |                 Directory.CreateDirectory(tempExtractDir);
763 | 
764 |                 try
765 |                 {
766 |                     ZipFile.ExtractToDirectory(tempZip, tempExtractDir);
767 | 
768 |                     // The ZIP contains UnityMcpServer~ folder, find it and move its contents
769 |                     string extractedServerFolder = Path.Combine(tempExtractDir, "UnityMcpServer~");
770 |                     Directory.CreateDirectory(destRoot);
771 |                     CopyDirectoryRecursive(extractedServerFolder, destRoot);
772 |                 }
773 |                 finally
774 |                 {
775 |                     // Cleanup temp extraction directory
776 |                     try
777 |                     {
778 |                         if (Directory.Exists(tempExtractDir))
779 |                         {
780 |                             Directory.Delete(tempExtractDir, recursive: true);
781 |                         }
782 |                     }
783 |                     catch (Exception ex)
784 |                     {
785 |                         McpLog.Warn($"Could not fully delete temp extraction directory: {ex.Message}");
786 |                     }
787 |                 }
788 | 
789 |                 EditorUtility.ClearProgressBar();
790 |                 McpLog.Info($"Server v{packageVersion} downloaded and installed successfully!");
791 |                 return true;
792 |             }
793 |             catch (Exception ex)
794 |             {
795 |                 EditorUtility.ClearProgressBar();
796 |                 McpLog.Error($"Failed to download server: {ex.Message}");
797 |                 EditorUtility.DisplayDialog(
798 |                     "Download Failed",
799 |                     $"Could not download server from GitHub.\n\n{ex.Message}\n\nPlease check your internet connection or try again later.",
800 |                     "OK"
801 |                 );
802 |                 return false;
803 |             }
804 |             finally
805 |             {
806 |                 try {
807 |                     if (File.Exists(tempZip)) File.Delete(tempZip); 
808 |                 } catch (Exception ex) {
809 |                     McpLog.Warn($"Could not delete temp zip file: {ex.Message}");
810 |                 }
811 |             }
812 |         }
813 | 
814 |         /// <summary>
815 |         /// Check if the package has an embedded server (Git install vs Asset Store)
816 |         /// </summary>
817 |         public static bool HasEmbeddedServer()
818 |         {
819 |             return TryGetEmbeddedServerSource(out _);
820 |         }
821 | 
822 |         /// <summary>
823 |         /// Get the installed server version from the local installation
824 |         /// </summary>
825 |         public static string GetInstalledServerVersion()
826 |         {
827 |             try
828 |             {
829 |                 string destRoot = Path.Combine(GetSaveLocation(), ServerFolder);
830 |                 string versionPath = Path.Combine(destRoot, "src", VersionFileName);
831 |                 if (File.Exists(versionPath))
832 |                 {
833 |                     return File.ReadAllText(versionPath)?.Trim() ?? string.Empty;
834 |                 }
835 |             }
836 |             catch (Exception ex)
837 |             {
838 |                 McpLog.Warn($"Could not read version file: {ex.Message}");
839 |             }
840 |             return string.Empty;
841 |         }
842 |     }
843 | }
844 | 
```

--------------------------------------------------------------------------------
/.github/workflows/claude-nl-suite.yml:
--------------------------------------------------------------------------------

```yaml
  1 | name: Claude NL/T Full Suite (Unity live)
  2 | 
  3 | on: [workflow_dispatch]
  4 | 
  5 | permissions:
  6 |   contents: read
  7 |   checks: write
  8 | 
  9 | concurrency:
 10 |   group: ${{ github.workflow }}-${{ github.ref }}
 11 |   cancel-in-progress: true
 12 | 
 13 | env:
 14 |   UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3
 15 | 
 16 | jobs:
 17 |   nl-suite:
 18 |     runs-on: ubuntu-latest
 19 |     timeout-minutes: 60
 20 |     env:
 21 |       JUNIT_OUT: reports/junit-nl-suite.xml
 22 |       MD_OUT: reports/junit-nl-suite.md
 23 | 
 24 |     steps:
 25 |       # ---------- Secrets check ----------
 26 |       - name: Detect secrets (outputs)
 27 |         id: detect
 28 |         env:
 29 |           UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
 30 |           UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
 31 |           UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
 32 |           UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
 33 |           ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
 34 |         run: |
 35 |           set -e
 36 |           if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi
 37 |           if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then
 38 |             echo "unity_ok=true" >> "$GITHUB_OUTPUT"
 39 |           else
 40 |             echo "unity_ok=false" >> "$GITHUB_OUTPUT"
 41 |           fi
 42 | 
 43 |       - uses: actions/checkout@v4
 44 |         with:
 45 |           fetch-depth: 0
 46 | 
 47 |       # ---------- Python env for MCP server (uv) ----------
 48 |       - uses: astral-sh/setup-uv@v4
 49 |         with:
 50 |           python-version: "3.11"
 51 | 
 52 |       - name: Install MCP server
 53 |         run: |
 54 |           set -eux
 55 |           uv venv
 56 |           echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
 57 |           echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
 58 |           if [ -f MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then
 59 |             uv pip install -e MCPForUnity/UnityMcpServer~/src
 60 |           elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then
 61 |             uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt
 62 |           elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then
 63 |             uv pip install -e MCPForUnity/UnityMcpServer~/
 64 |           elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then
 65 |             uv pip install -r MCPForUnity/UnityMcpServer~/requirements.txt
 66 |           else
 67 |             echo "No MCP Python deps found (skipping)"
 68 |           fi
 69 | 
 70 |       # --- Licensing: allow both ULF and EBL when available ---
 71 |       - name: Decide license sources
 72 |         id: lic
 73 |         shell: bash
 74 |         env:
 75 |           UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
 76 |           UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
 77 |           UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
 78 |           UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
 79 |         run: |
 80 |           set -eu
 81 |           use_ulf=false; use_ebl=false
 82 |           [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
 83 |           [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true
 84 |           echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
 85 |           echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
 86 |           echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
 87 | 
 88 |       - name: Stage Unity .ulf license (from secret)
 89 |         if: steps.lic.outputs.use_ulf == 'true'
 90 |         id: ulf
 91 |         env:
 92 |           UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
 93 |         shell: bash
 94 |         run: |
 95 |           set -eu
 96 |           mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
 97 |           f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
 98 |           if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
 99 |             printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
100 |           else
101 |             printf "%s" "$UNITY_LICENSE" > "$f"
102 |           fi
103 |           chmod 600 "$f" || true
104 |           # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it:
105 |           if head -c 100 "$f" | grep -qi '<\?xml'; then
106 |             mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses"
107 |             mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml"
108 |             echo "ok=false" >> "$GITHUB_OUTPUT"
109 |           elif grep -qi '<Signature>' "$f"; then
110 |             # provide it in the standard local-share path too
111 |             cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
112 |             echo "ok=true" >> "$GITHUB_OUTPUT"
113 |           else
114 |             echo "ok=false" >> "$GITHUB_OUTPUT"
115 |           fi
116 | 
117 |       # --- Activate via EBL inside the same Unity image (writes host-side entitlement) ---
118 |       - name: Activate Unity (EBL via container - host-mount)
119 |         if: steps.lic.outputs.use_ebl == 'true'
120 |         shell: bash
121 |         env:
122 |           UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
123 |           UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
124 |           UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
125 |           UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
126 |         run: |
127 |           set -euxo pipefail
128 |           # host dirs to receive the full Unity config and local-share
129 |           mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
130 | 
131 |           # Try Pro first if serial is present, otherwise named-user EBL.
132 |           docker run --rm --network host \
133 |             -e HOME=/root \
134 |             -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
135 |             -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
136 |             -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
137 |             "$UNITY_IMAGE" bash -lc '
138 |               set -euxo pipefail
139 |               if [[ -n "${UNITY_SERIAL:-}" ]]; then
140 |                 /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
141 |                   -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
142 |               else
143 |                 /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
144 |                   -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true
145 |               fi
146 |               ls -la /root/.config/unity3d/Unity/licenses || true
147 |             '
148 | 
149 |           # Verify entitlement written to host mount; allow ULF-only runs to proceed
150 |           if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then
151 |             if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then
152 |               echo "EBL entitlement not found; proceeding with ULF-only (ok=true)."
153 |             else
154 |               echo "No entitlement produced and no valid ULF; cannot continue." >&2
155 |               exit 1
156 |             fi
157 |           fi
158 | 
159 |       # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step
160 | 
161 |       # ---------- Warm up project (import Library once) ----------
162 |       - name: Warm up project (import Library once)
163 |         if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true'
164 |         shell: bash
165 |         env:
166 |           UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
167 |           ULF_OK: ${{ steps.ulf.outputs.ok }}
168 |         run: |
169 |           set -euxo pipefail
170 |           manual_args=()
171 |           if [[ "${ULF_OK:-false}" == "true" ]]; then
172 |             manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
173 |           fi
174 |           docker run --rm --network host \
175 |             -e HOME=/root \
176 |             -v "${{ github.workspace }}:/workspace" -w /workspace \
177 |             -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
178 |             -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
179 |             "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
180 |               -projectPath /workspace/TestProjects/UnityMCPTests \
181 |               "${manual_args[@]}" \
182 |               -quit
183 | 
184 |       # ---------- Clean old MCP status ----------
185 |       - name: Clean old MCP status
186 |         run: |
187 |           set -eux
188 |           mkdir -p "$HOME/.unity-mcp"
189 |           rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true
190 | 
191 |       # ---------- Start headless Unity (persistent bridge) ----------
192 |       - name: Start Unity (persistent bridge)
193 |         if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true'
194 |         shell: bash
195 |         env:
196 |           UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
197 |           ULF_OK: ${{ steps.ulf.outputs.ok }}
198 |         run: |
199 |           set -euxo pipefail
200 |           manual_args=()
201 |           if [[ "${ULF_OK:-false}" == "true" ]]; then
202 |             manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
203 |           fi
204 | 
205 |           mkdir -p "$RUNNER_TEMP/unity-status"
206 |           docker rm -f unity-mcp >/dev/null 2>&1 || true
207 |           docker run -d --name unity-mcp --network host \
208 |             -e HOME=/root \
209 |             -e UNITY_MCP_ALLOW_BATCH=1 \
210 |             -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \
211 |             -e UNITY_MCP_BIND_HOST=127.0.0.1 \
212 |             -v "${{ github.workspace }}:/workspace" -w /workspace \
213 |             -v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \
214 |             -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \
215 |             -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \
216 |             "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
217 |               -stackTraceLogType Full \
218 |               -projectPath /workspace/TestProjects/UnityMCPTests \
219 |               "${manual_args[@]}" \
220 |               -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect
221 | 
222 |       # ---------- Wait for Unity bridge ----------
223 |       - name: Wait for Unity bridge (robust)
224 |         shell: bash
225 |         run: |
226 |           set -euo pipefail
227 |           deadline=$((SECONDS+900))          # 15 min max
228 |           fatal_after=$((SECONDS+120))       # give licensing 2 min to settle
229 | 
230 |           # Fail fast only if container actually died
231 |           st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)"
232 |           case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac
233 | 
234 |           # Patterns
235 |           ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)'
236 |           # Only truly fatal signals; allow transient "Licensing::..." chatter
237 |           license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)'
238 | 
239 |           while [ $SECONDS -lt $deadline ]; do
240 |             logs="$(docker logs unity-mcp 2>&1 || true)"
241 | 
242 |             # 1) Primary: status JSON exposes TCP port
243 |             port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)"
244 |             if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then
245 |               echo "Bridge ready on port $port"
246 |               exit 0
247 |             fi
248 | 
249 |             # 2) Secondary: log markers
250 |             if echo "$logs" | grep -qiE "$ok_pat"; then
251 |               echo "Bridge ready (log markers)"
252 |               exit 0
253 |             fi
254 | 
255 |             # Only treat license failures as fatal *after* warm-up
256 |             if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then
257 |               echo "::error::Fatal licensing signal detected after warm-up"
258 |               echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
259 |               exit 1
260 |             fi
261 | 
262 |             # If the container dies mid-wait, bail
263 |             st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)"
264 |             if [[ "$st" != "running" ]]; then
265 |               echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
266 |               exit 1
267 |             fi
268 | 
269 |             sleep 2
270 |           done
271 | 
272 |           echo "::error::Bridge not ready before deadline"
273 |           docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
274 |           exit 1
275 | 
276 |       # (moved) — return license after Unity is stopped
277 | 
278 |       # ---------- MCP client config ----------
279 |       - name: Write MCP config (.claude/mcp.json)
280 |         run: |
281 |           set -eux
282 |           mkdir -p .claude
283 |           cat > .claude/mcp.json <<JSON
284 |           {
285 |             "mcpServers": {
286 |               "unity": {
287 |                 "command": "uv",
288 |                 "args": ["run","--active","--directory","MCPForUnity/UnityMcpServer~/src","python","server.py"],
289 |                 "transport": { "type": "stdio" },
290 |                 "env": {
291 |                   "PYTHONUNBUFFERED": "1",
292 |                   "MCP_LOG_LEVEL": "debug",
293 |                   "UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests",
294 |                   "UNITY_MCP_STATUS_DIR": "$RUNNER_TEMP/unity-status",
295 |                   "UNITY_MCP_HOST": "127.0.0.1"
296 |                 }
297 |               }
298 |             }
299 |           }
300 |           JSON
301 | 
302 |       - name: Pin Claude tool permissions (.claude/settings.json)
303 |         run: |
304 |           set -eux
305 |           mkdir -p .claude
306 |           cat > .claude/settings.json <<'JSON'
307 |           {
308 |             "permissions": {
309 |               "allow": [
310 |                 "mcp__unity",
311 |                 "Edit(reports/**)"
312 |               ],
313 |               "deny": [
314 |                 "Bash",
315 |                 "MultiEdit",
316 |                 "WebFetch",
317 |                 "WebSearch",
318 |                 "Task",
319 |                 "TodoWrite",
320 |                 "NotebookEdit",
321 |                 "NotebookRead"
322 |               ]
323 |             }
324 |           }
325 |           JSON
326 | 
327 |       # ---------- Reports & helper ----------
328 |       - name: Prepare reports and dirs
329 |         run: |
330 |           set -eux
331 |           rm -f reports/*.xml reports/*.md || true
332 |           mkdir -p reports reports/_snapshots reports/_staging
333 | 
334 |       - name: Create report skeletons
335 |         run: |
336 |           set -eu
337 |           cat > "$JUNIT_OUT" <<'XML'
338 |           <?xml version="1.0" encoding="UTF-8"?>
339 |           <testsuites><testsuite name="UnityMCP.NL-T" tests="1" failures="1" errors="0" skipped="0" time="0">
340 |             <testcase name="NL-Suite.Bootstrap" classname="UnityMCP.NL-T">
341 |               <failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure>
342 |             </testcase>
343 |           </testsuite></testsuites>
344 |           XML
345 |           printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT"
346 | 
347 |       - name: Verify Unity bridge status/port
348 |         run: |
349 |           set -euxo pipefail
350 |           ls -la "$RUNNER_TEMP/unity-status" || true
351 |           jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true
352 | 
353 |           shopt -s nullglob
354 |           status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json)
355 |           if ((${#status_files[@]})); then
356 |             port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \
357 |               | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)"
358 |           else
359 |             port=""
360 |           fi
361 | 
362 |           echo "unity_port=$port"
363 |           if [[ -n "$port" ]]; then
364 |             timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK"
365 |           fi
366 | 
367 |       # (removed) Revert helper and baseline snapshot are no longer used
368 | 
369 |       # ---------- Run suite in two passes ----------
370 |       - name: Run Claude NL pass
371 |         uses: anthropics/claude-code-base-action@beta
372 |         if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
373 |         continue-on-error: true
374 |         with:
375 |           use_node_cache: false
376 |           prompt_file: .claude/prompts/nl-unity-suite-nl.md
377 |           mcp_config: .claude/mcp.json
378 |           settings: .claude/settings.json
379 |           allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
380 |           disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
381 |           model: claude-3-7-sonnet-20250219
382 |           append_system_prompt: |
383 |             You are running the NL pass only.
384 |             - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4.
385 |             - Write each to reports/${ID}_results.xml.
386 |             - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests.
387 |             - Stop after NL-4_results.xml is written.
388 |           timeout_minutes: "30"
389 |           anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
390 | 
391 |       - name: Run Claude T pass A-J
392 |         uses: anthropics/claude-code-base-action@beta
393 |         if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
394 |         continue-on-error: true
395 |         with:
396 |           use_node_cache: false
397 |           prompt_file: .claude/prompts/nl-unity-suite-t.md
398 |           mcp_config: .claude/mcp.json
399 |           settings: .claude/settings.json
400 |           allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
401 |           disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
402 |           model: claude-3-5-haiku-20241022
403 |           append_system_prompt: |
404 |             You are running the T pass (A–J) only.
405 |             Output requirements:
406 |             - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J.
407 |             - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml).
408 |             - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch.
409 |             - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist.
410 |             - Do not emit any NL-* fragments.
411 |             Stop condition:
412 |             - After T-J_results.xml is written, stop.
413 |           timeout_minutes: "30"
414 |           anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
415 | 
416 |       # (moved) Assert T coverage after staged fragments are promoted
417 | 
418 |       - name: Check T coverage incomplete (pre-retry)
419 |         id: t_cov
420 |         if: always()
421 |         shell: bash
422 |         run: |
423 |           set -euo pipefail
424 |           missing=()
425 |           for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
426 |             if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then
427 |               missing+=("$id")
428 |             fi
429 |           done
430 |           echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT"
431 |           if (( ${#missing[@]} )); then
432 |             echo "list=${missing[*]}" >> "$GITHUB_OUTPUT"
433 |           fi
434 | 
435 |       - name: Retry T pass (Sonnet) if incomplete
436 |         if: steps.t_cov.outputs.missing != '0'
437 |         uses: anthropics/claude-code-base-action@beta
438 |         with:
439 |           use_node_cache: false
440 |           prompt_file: .claude/prompts/nl-unity-suite-t.md
441 |           mcp_config: .claude/mcp.json
442 |           settings: .claude/settings.json
443 |           allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
444 |           disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
445 |           model: claude-3-7-sonnet-20250219
446 |           fallback_model: claude-3-5-haiku-20241022
447 |           append_system_prompt: |
448 |             You are running the T pass only.
449 |             Output requirements:
450 |             - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J.
451 |             - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml).
452 |             - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch.
453 |             - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist.
454 |             - Do not emit any NL-* fragments.
455 |             Stop condition:
456 |             - After T-J_results.xml is written, stop.
457 |           timeout_minutes: "30"
458 |           anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
459 | 
460 |       - name: Re-assert T coverage (post-retry)
461 |         if: always()
462 |         shell: bash
463 |         run: |
464 |           set -euo pipefail
465 |           missing=()
466 |           for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
467 |             [[ -s "reports/${id}_results.xml" ]] || missing+=("$id")
468 |           done
469 |           if (( ${#missing[@]} )); then
470 |             echo "::error::Still missing T fragments: ${missing[*]}"
471 |             exit 1
472 |           fi
473 | 
474 |       # (kept) Finalize staged report fragments (promote to reports/)
475 | 
476 |       # (removed duplicate) Finalize staged report fragments
477 | 
478 |       - name: Assert T coverage (after promotion)
479 |         if: always()
480 |         shell: bash
481 |         run: |
482 |           set -euo pipefail
483 |           missing=()
484 |           for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
485 |             if [[ ! -s "reports/${id}_results.xml" ]]; then
486 |               # Accept staged fragment as present
487 |               [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id")
488 |             fi
489 |           done
490 |           if (( ${#missing[@]} )); then
491 |             echo "::error::Missing T fragments: ${missing[*]}"
492 |             exit 1
493 |           fi
494 | 
495 |       - name: Canonicalize testcase names (NL/T prefixes)
496 |         if: always()
497 |         shell: bash
498 |         run: |
499 |           python3 - <<'PY'
500 |           from pathlib import Path
501 |           import xml.etree.ElementTree as ET, re, os
502 | 
503 |           RULES = [
504 |             ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"),
505 |             ("NL-1", r"\b(NL-1|Core\s*Method)\b"),
506 |             ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"),
507 |             ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"),
508 |             ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"),
509 |             ("T-A",  r"\b(T-?A|Temporary\s*Helper)\b"),
510 |             ("T-B",  r"\b(T-?B|Method\s*Body\s*Interior)\b"),
511 |             ("T-C",  r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"),
512 |             ("T-D",  r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"),
513 |             ("T-E",  r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"),
514 |             ("T-F",  r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"),
515 |             ("T-G",  r"\b(T-?G|Path\s*Normalization)\b"),
516 |             ("T-H",  r"\b(T-?H|Validation\s*on\s*Modified)\b"),
517 |             ("T-I",  r"\b(T-?I|Failure\s*Surface)\b"),
518 |             ("T-J",  r"\b(T-?J|Idempotenc(y|e))\b"),
519 |           ]
520 | 
521 |           def canon_name(name: str) -> str:
522 |             n = name or ""
523 |             for tid, pat in RULES:
524 |               if re.search(pat, n, flags=re.I):
525 |                 # If it already starts with the correct format, leave it alone
526 |                 if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I):
527 |                   return n.strip()
528 |                 # If it has a different separator, extract title and reformat
529 |                 title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I)
530 |                 if title_match:
531 |                   title = title_match.group(1).strip()
532 |                   return f"{tid} — {title}"
533 |                 # Otherwise, just return the canonical ID
534 |                 return tid
535 |             return n
536 | 
537 |           def id_from_filename(p: Path):
538 |             n = p.name
539 |             m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
540 |             if m:
541 |               return f"NL-{int(m.group(1))}"
542 |             m = re.match(r'T([A-J])_results\.xml$', n, re.I)
543 |             if m:
544 |               return f"T-{m.group(1).upper()}"
545 |             return None
546 | 
547 |           frags = list(sorted(Path("reports").glob("*_results.xml")))
548 |           for frag in frags:
549 |             try:
550 |               tree = ET.parse(frag); root = tree.getroot()
551 |             except Exception:
552 |               continue
553 |             if root.tag != "testcase":
554 |               continue
555 |             file_id = id_from_filename(frag)
556 |             old = root.get("name") or ""
557 |             # Prefer filename-derived ID; if name doesn't start with it, override
558 |             if file_id:
559 |               # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns)
560 |               title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip()
561 |               new = f"{file_id} — {title}" if title else file_id
562 |             else:
563 |               new = canon_name(old)
564 |             if new != old and new:
565 |               root.set("name", new)
566 |               tree.write(frag, encoding="utf-8", xml_declaration=False)
567 |               print(f'canon: {frag.name}: "{old}" -> "{new}"')
568 | 
569 |           # Note: Do not auto-relable fragments. We rely on per-test strict emission
570 |           # and the backfill step to surface missing tests explicitly.
571 |           PY
572 | 
573 |       - name: Backfill missing NL/T tests (fail placeholders)
574 |         if: always()
575 |         shell: bash
576 |         run: |
577 |           python3 - <<'PY'
578 |           from pathlib import Path
579 |           import xml.etree.ElementTree as ET
580 |           import re
581 | 
582 |           DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"]
583 |           seen = set()
584 |           def id_from_filename(p: Path):
585 |             n = p.name
586 |             m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
587 |             if m:
588 |               return f"NL-{int(m.group(1))}"
589 |             m = re.match(r'T([A-J])_results\.xml$', n, re.I)
590 |             if m:
591 |               return f"T-{m.group(1).upper()}"
592 |             return None
593 | 
594 |           for p in Path("reports").glob("*_results.xml"):
595 |             try:
596 |               r = ET.parse(p).getroot()
597 |             except Exception:
598 |               continue
599 |             # Count by filename id primarily; fall back to testcase name if needed
600 |             fid = id_from_filename(p)
601 |             if fid in DESIRED:
602 |               seen.add(fid)
603 |               continue
604 |             if r.tag == "testcase":
605 |               name = (r.get("name") or "").strip()
606 |               for d in DESIRED:
607 |                 if name.startswith(d):
608 |                   seen.add(d)
609 |                   break
610 | 
611 |           Path("reports").mkdir(parents=True, exist_ok=True)
612 |           for d in DESIRED:
613 |             if d in seen:
614 |               continue
615 |             frag = Path(f"reports/{d}_results.xml")
616 |             tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d})
617 |             fail = ET.SubElement(tc, "failure", {"message":"not produced"})
618 |             fail.text = "The agent did not emit a fragment for this test."
619 |             ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False)
620 |             print(f"backfill: {d}")
621 |           PY
622 | 
623 |       - name: "Debug: list testcase names"
624 |         if: always()
625 |         run: |
626 |           python3 - <<'PY'
627 |           from pathlib import Path
628 |           import xml.etree.ElementTree as ET
629 |           for p in sorted(Path('reports').glob('*_results.xml')):
630 |               try:
631 |                   r = ET.parse(p).getroot()
632 |                   if r.tag == 'testcase':
633 |                       print(f"{p.name}: {(r.get('name') or '').strip()}")
634 |               except Exception:
635 |                   pass
636 |           PY
637 | 
638 |       # ---------- Merge testcase fragments into JUnit ----------
639 |       - name: Normalize/assemble JUnit in-place (single file)
640 |         if: always()
641 |         shell: bash
642 |         run: |
643 |           python3 - <<'PY'
644 |           from pathlib import Path
645 |           import xml.etree.ElementTree as ET
646 |           import re, os
647 | 
648 |           def localname(tag: str) -> str:
649 |               return tag.rsplit('}', 1)[-1] if '}' in tag else tag
650 | 
651 |           src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml'))
652 |           if not src.exists():
653 |               raise SystemExit(0)
654 | 
655 |           tree = ET.parse(src)
656 |           root = tree.getroot()
657 |           suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
658 |           if suite is None:
659 |               raise SystemExit(0)
660 | 
661 |           def id_from_filename(p: Path):
662 |               n = p.name
663 |               m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
664 |               if m:
665 |                   return f"NL-{int(m.group(1))}"
666 |               m = re.match(r'T([A-J])_results\.xml$', n, re.I)
667 |               if m:
668 |                   return f"T-{m.group(1).upper()}"
669 |               return None
670 | 
671 |           def id_from_system_out(tc):
672 |               so = tc.find('system-out')
673 |               if so is not None and so.text:
674 |                   m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
675 |                   if m:
676 |                       return m.group(1)
677 |               return None
678 | 
679 |           fragments = sorted(Path('reports').glob('*_results.xml'))
680 |           added = 0
681 |           renamed = 0
682 | 
683 |           for frag in fragments:
684 |               tcs = []
685 |               try:
686 |                   froot = ET.parse(frag).getroot()
687 |                   if localname(froot.tag) == 'testcase':
688 |                       tcs = [froot]
689 |                   else:
690 |                       tcs = list(froot.findall('.//testcase'))
691 |               except Exception:
692 |                   txt = Path(frag).read_text(encoding='utf-8', errors='replace')
693 |                   # Extract all testcase nodes from raw text
694 |                   nodes = re.findall(r'<testcase[\s\S]*?</testcase>', txt, flags=re.DOTALL)
695 |                   for m in nodes:
696 |                       try:
697 |                           tcs.append(ET.fromstring(m))
698 |                       except Exception:
699 |                           pass
700 | 
701 |               # Guard: keep only the first testcase from each fragment
702 |               if len(tcs) > 1:
703 |                   tcs = tcs[:1]
704 | 
705 |               test_id = id_from_filename(frag)
706 | 
707 |               for tc in tcs:
708 |                   current_name = tc.get('name') or ''
709 |                   tid = test_id or id_from_system_out(tc)
710 |                   # Enforce filename-derived ID as prefix; repair names if needed
711 |                   if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name):
712 |                       title = current_name.strip()
713 |                       new_name = f'{tid} — {title}' if title else tid
714 |                       tc.set('name', new_name)
715 |                   elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name):
716 |                       # Replace any wrong leading ID with the correct one
717 |                       title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip()
718 |                       new_name = f'{tid} — {title}' if title else tid
719 |                       tc.set('name', new_name)
720 |                       renamed += 1
721 |                   suite.append(tc)
722 |                   added += 1
723 | 
724 |           if added:
725 |               # Drop bootstrap placeholder and recompute counts
726 |               for tc in list(suite.findall('.//testcase')):
727 |                   if (tc.get('name') or '') == 'NL-Suite.Bootstrap':
728 |                       suite.remove(tc)
729 |               testcases = suite.findall('.//testcase')
730 |               failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None))
731 |               suite.set('tests', str(len(testcases)))
732 |               suite.set('failures', str(failures_cnt))
733 |               suite.set('errors', '0')
734 |               suite.set('skipped', '0')
735 |               tree.write(src, encoding='utf-8', xml_declaration=True)
736 |               print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.")
737 |           PY
738 | 
739 |       # ---------- Markdown summary from JUnit ----------
740 |       - name: Build markdown summary from JUnit
741 |         if: always()
742 |         shell: bash
743 |         run: |
744 |           python3 - <<'PY'
745 |           import xml.etree.ElementTree as ET
746 |           from pathlib import Path
747 |           import os, html, re
748 | 
749 |           def localname(tag: str) -> str:
750 |               return tag.rsplit('}', 1)[-1] if '}' in tag else tag
751 | 
752 |           src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml'))
753 |           md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md'))
754 |           md_out.parent.mkdir(parents=True, exist_ok=True)
755 | 
756 |           if not src.exists():
757 |               md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8')
758 |               raise SystemExit(0)
759 | 
760 |           tree = ET.parse(src)
761 |           root = tree.getroot()
762 |           suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
763 |           cases = [] if suite is None else list(suite.findall('.//testcase'))
764 | 
765 |           def id_from_case(tc):
766 |               n = (tc.get('name') or '')
767 |               m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n)
768 |               if m:
769 |                   return m.group(1)
770 |               so = tc.find('system-out')
771 |               if so is not None and so.text:
772 |                   m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
773 |                   if m:
774 |                       return m.group(1)
775 |               return None
776 | 
777 |           id_status = {}
778 |           name_map = {}
779 |           for tc in cases:
780 |               tid = id_from_case(tc)
781 |               ok = (tc.find('failure') is None and tc.find('error') is None)
782 |               if tid and tid not in id_status:
783 |                   id_status[tid] = ok
784 |                   name_map[tid] = (tc.get('name') or tid)
785 | 
786 |           desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
787 | 
788 |           total = len(cases)
789 |           failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None))
790 |           passed = total - failures
791 | 
792 |           lines = []
793 |           lines += [
794 |               '# Unity NL/T Editing Suite Test Results',
795 |               '',
796 |               f'Totals: {passed} passed, {failures} failed, {total} total',
797 |               '',
798 |               '## Test Checklist'
799 |           ]
800 |           for p in desired:
801 |               st = id_status.get(p, None)
802 |               lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)"))
803 |           lines.append('')
804 | 
805 |           lines.append('## Test Details')
806 | 
807 |           def order_key(n: str):
808 |               if n.startswith('NL-'):
809 |                   try:
810 |                       return (0, int(n.split('-')[1]))
811 |                   except:
812 |                       return (0, 999)
813 |               if n.startswith('T-') and len(n) > 2:
814 |                   return (1, ord(n[2]))
815 |               return (2, n)
816 | 
817 |           MAX_CHARS = 2000
818 |           seen = set()
819 |           for tid in sorted(id_status.keys(), key=order_key):
820 |               seen.add(tid)
821 |               tc = next((c for c in cases if (id_from_case(c) == tid)), None)
822 |               if not tc:
823 |                   continue
824 |               title = name_map.get(tid, tid)
825 |               status_badge = "PASS" if id_status[tid] else "FAIL"
826 |               lines.append(f"### {title} — {status_badge}")
827 |               so = tc.find('system-out')
828 |               text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n'))
829 |               if text.strip():
830 |                   t = text.strip()
831 |                   if len(t) > MAX_CHARS:
832 |                       t = t[:MAX_CHARS] + "\n…(truncated)"
833 |                   fence = '```' if '```' not in t else '````'
834 |                   lines += [fence, t, fence]
835 |               else:
836 |                   lines.append('(no system-out)')
837 |               node = tc.find('failure') or tc.find('error')
838 |               if node is not None:
839 |                   msg = (node.get('message') or '').strip()
840 |                   body = (node.text or '').strip()
841 |                   if msg:
842 |                       lines.append(f"- Message: {msg}")
843 |                   if body:
844 |                       lines.append(f"- Detail: {body.splitlines()[0][:500]}")
845 |               lines.append('')
846 | 
847 |           for tc in cases:
848 |               if id_from_case(tc) in seen:
849 |                   continue
850 |               title = tc.get('name') or '(unnamed)'
851 |               status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL"
852 |               lines.append(f"### {title} — {status_badge}")
853 |               lines.append('(unmapped test id)')
854 |               lines.append('')
855 | 
856 |           md_out.write_text('\n'.join(lines), encoding='utf-8')
857 |           PY
858 | 
859 |       - name: "Debug: list report files"
860 |         if: always()
861 |         shell: bash
862 |         run: |
863 |           set -eux
864 |           ls -la reports || true
865 |           shopt -s nullglob
866 |           for f in reports/*.xml; do
867 |             echo "===== $f ====="
868 |             head -n 40 "$f" || true
869 |           done
870 | 
871 |       # ---------- Collect execution transcript (if present) ----------
872 |       - name: Collect action execution transcript
873 |         if: always()
874 |         shell: bash
875 |         run: |
876 |           set -eux
877 |           if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then
878 |             cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json
879 |           elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then
880 |             cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json
881 |           fi
882 | 
883 |       - name: Sanitize markdown (normalize newlines)
884 |         if: always()
885 |         run: |
886 |           set -eu
887 |           python3 - <<'PY'
888 |           from pathlib import Path
889 |           rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True)
890 |           for p in rp.glob('*.md'):
891 |               b=p.read_bytes().replace(b'\x00', b'')
892 |               s=b.decode('utf-8','replace').replace('\r\n','\n')
893 |               p.write_text(s, encoding='utf-8', newline='\n')
894 |           PY
895 | 
896 |       - name: NL/T details -> Job Summary
897 |         if: always()
898 |         run: |
899 |           echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY
900 |           python3 - <<'PY' >> $GITHUB_STEP_SUMMARY
901 |           from pathlib import Path
902 |           p = Path('reports/junit-nl-suite.md')
903 |           if p.exists():
904 |               text = p.read_bytes().decode('utf-8', 'replace')
905 |               MAX = 65000
906 |               print(text[:MAX])
907 |               if len(text) > MAX:
908 |                   print("\n\n_…truncated; full report in artifacts._")
909 |           else:
910 |               print("_No markdown report found._")
911 |           PY
912 | 
913 |       - name: Fallback JUnit if missing
914 |         if: always()
915 |         run: |
916 |           set -eu
917 |           mkdir -p reports
918 |           if [ ! -f "$JUNIT_OUT" ]; then
919 |             printf '%s\n' \
920 |               '<?xml version="1.0" encoding="UTF-8"?>' \
921 |               '<testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0">' \
922 |               '  <testcase classname="UnityMCP.NL-T" name="NL-Suite.Execution" time="0.0">' \
923 |               '    <failure><![CDATA[No JUnit was produced by the NL suite step. See the step logs.]]></failure>' \
924 |               '  </testcase>' \
925 |               '</testsuite>' \
926 |               > "$JUNIT_OUT"
927 |           fi
928 | 
929 |       - name: Publish JUnit report
930 |         if: always()
931 |         uses: mikepenz/action-junit-report@v5
932 |         with:
933 |           report_paths: "${{ env.JUNIT_OUT }}"
934 |           include_passed: true
935 |           detailed_summary: true
936 |           annotate_notice: true
937 |           require_tests: false
938 |           fail_on_parse_error: true
939 | 
940 |       - name: Upload artifacts (reports + fragments + transcript)
941 |         if: always()
942 |         uses: actions/upload-artifact@v4
943 |         with:
944 |           name: claude-nl-suite-artifacts
945 |           path: |
946 |             ${{ env.JUNIT_OUT }}
947 |             ${{ env.MD_OUT }}
948 |             reports/*_results.xml
949 |             reports/claude-execution-output.json
950 |           retention-days: 7
951 | 
952 |       # ---------- Always stop Unity ----------
953 |       - name: Stop Unity
954 |         if: always()
955 |         run: |
956 |           docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
957 |           docker rm -f unity-mcp || true
958 | 
959 |       - name: Return Pro license (if used)
960 |         if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true'
961 |         uses: game-ci/unity-return-license@v2
962 |         continue-on-error: true
963 |         env:
964 |           UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
965 |           UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
966 |           UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
967 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py:
--------------------------------------------------------------------------------

```python
  1 | import base64
  2 | import hashlib
  3 | import re
  4 | from typing import Annotated, Any
  5 | 
  6 | from mcp.server.fastmcp import Context
  7 | 
  8 | from registry import mcp_for_unity_tool
  9 | from unity_connection import send_command_with_retry
 10 | 
 11 | 
 12 | def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
 13 |     text = original_text
 14 |     for edit in edits or []:
 15 |         op = (
 16 |             (edit.get("op")
 17 |              or edit.get("operation")
 18 |              or edit.get("type")
 19 |              or edit.get("mode")
 20 |              or "")
 21 |             .strip()
 22 |             .lower()
 23 |         )
 24 | 
 25 |         if not op:
 26 |             allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
 27 |             raise RuntimeError(
 28 |                 f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)."
 29 |             )
 30 | 
 31 |         if op == "prepend":
 32 |             prepend_text = edit.get("text", "")
 33 |             text = (prepend_text if prepend_text.endswith(
 34 |                 "\n") else prepend_text + "\n") + text
 35 |         elif op == "append":
 36 |             append_text = edit.get("text", "")
 37 |             if not text.endswith("\n"):
 38 |                 text += "\n"
 39 |             text += append_text
 40 |             if not text.endswith("\n"):
 41 |                 text += "\n"
 42 |         elif op == "anchor_insert":
 43 |             anchor = edit.get("anchor", "")
 44 |             position = (edit.get("position") or "before").lower()
 45 |             insert_text = edit.get("text", "")
 46 |             flags = re.MULTILINE | (
 47 |                 re.IGNORECASE if edit.get("ignore_case") else 0)
 48 | 
 49 |             # Find the best match using improved heuristics
 50 |             match = _find_best_anchor_match(
 51 |                 anchor, text, flags, bool(edit.get("prefer_last", True)))
 52 |             if not match:
 53 |                 if edit.get("allow_noop", True):
 54 |                     continue
 55 |                 raise RuntimeError(f"anchor not found: {anchor}")
 56 |             idx = match.start() if position == "before" else match.end()
 57 |             text = text[:idx] + insert_text + text[idx:]
 58 |         elif op == "replace_range":
 59 |             start_line = int(edit.get("startLine", 1))
 60 |             start_col = int(edit.get("startCol", 1))
 61 |             end_line = int(edit.get("endLine", start_line))
 62 |             end_col = int(edit.get("endCol", 1))
 63 |             replacement = edit.get("text", "")
 64 |             lines = text.splitlines(keepends=True)
 65 |             max_line = len(lines) + 1  # 1-based, exclusive end
 66 |             if (start_line < 1 or end_line < start_line or end_line > max_line
 67 |                     or start_col < 1 or end_col < 1):
 68 |                 raise RuntimeError("replace_range out of bounds")
 69 | 
 70 |             def index_of(line: int, col: int) -> int:
 71 |                 if line <= len(lines):
 72 |                     return sum(len(l) for l in lines[: line - 1]) + (col - 1)
 73 |                 return sum(len(l) for l in lines)
 74 |             a = index_of(start_line, start_col)
 75 |             b = index_of(end_line, end_col)
 76 |             text = text[:a] + replacement + text[b:]
 77 |         elif op == "regex_replace":
 78 |             pattern = edit.get("pattern", "")
 79 |             repl = edit.get("replacement", "")
 80 |             # Translate $n backrefs (our input) to Python \g<n>
 81 |             repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
 82 |             count = int(edit.get("count", 0))  # 0 = replace all
 83 |             flags = re.MULTILINE
 84 |             if edit.get("ignore_case"):
 85 |                 flags |= re.IGNORECASE
 86 |             text = re.sub(pattern, repl_py, text, count=count, flags=flags)
 87 |         else:
 88 |             allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
 89 |             raise RuntimeError(
 90 |                 f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
 91 |     return text
 92 | 
 93 | 
 94 | def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
 95 |     """
 96 |     Find the best anchor match using improved heuristics.
 97 | 
 98 |     For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
 99 |     this function uses heuristics to choose the most semantically appropriate match:
100 | 
101 |     1. If prefer_last=True, prefer the last match (common for class-end insertions)
102 |     2. Use indentation levels to distinguish class vs method braces
103 |     3. Consider context to avoid matches inside strings/comments
104 | 
105 |     Args:
106 |         pattern: Regex pattern to search for
107 |         text: Text to search in  
108 |         flags: Regex flags
109 |         prefer_last: If True, prefer the last match over the first
110 | 
111 |     Returns:
112 |         Match object of the best match, or None if no match found
113 |     """
114 | 
115 |     # Find all matches
116 |     matches = list(re.finditer(pattern, text, flags))
117 |     if not matches:
118 |         return None
119 | 
120 |     # If only one match, return it
121 |     if len(matches) == 1:
122 |         return matches[0]
123 | 
124 |     # For patterns that look like they're trying to match closing braces at end of lines
125 |     is_closing_brace_pattern = '}' in pattern and (
126 |         '$' in pattern or pattern.endswith(r'\s*'))
127 | 
128 |     if is_closing_brace_pattern and prefer_last:
129 |         # Use heuristics to find the best closing brace match
130 |         return _find_best_closing_brace_match(matches, text)
131 | 
132 |     # Default behavior: use last match if prefer_last, otherwise first match
133 |     return matches[-1] if prefer_last else matches[0]
134 | 
135 | 
136 | def _find_best_closing_brace_match(matches, text: str):
137 |     """
138 |     Find the best closing brace match using C# structure heuristics.
139 | 
140 |     Enhanced heuristics for scope-aware matching:
141 |     1. Prefer matches with lower indentation (likely class-level)
142 |     2. Prefer matches closer to end of file  
143 |     3. Avoid matches that seem to be inside method bodies
144 |     4. For #endregion patterns, ensure class-level context
145 |     5. Validate insertion point is at appropriate scope
146 | 
147 |     Args:
148 |         matches: List of regex match objects
149 |         text: The full text being searched
150 | 
151 |     Returns:
152 |         The best match object
153 |     """
154 |     if not matches:
155 |         return None
156 | 
157 |     scored_matches = []
158 |     lines = text.splitlines()
159 | 
160 |     for match in matches:
161 |         score = 0
162 |         start_pos = match.start()
163 | 
164 |         # Find which line this match is on
165 |         lines_before = text[:start_pos].count('\n')
166 |         line_num = lines_before
167 | 
168 |         if line_num < len(lines):
169 |             line_content = lines[line_num]
170 | 
171 |             # Calculate indentation level (lower is better for class braces)
172 |             indentation = len(line_content) - len(line_content.lstrip())
173 | 
174 |             # Prefer lower indentation (class braces are typically less indented than method braces)
175 |             # Max 20 points for indentation=0
176 |             score += max(0, 20 - indentation)
177 | 
178 |             # Prefer matches closer to end of file (class closing braces are typically at the end)
179 |             distance_from_end = len(lines) - line_num
180 |             # More points for being closer to end
181 |             score += max(0, 10 - distance_from_end)
182 | 
183 |             # Look at surrounding context to avoid method braces
184 |             context_start = max(0, line_num - 3)
185 |             context_end = min(len(lines), line_num + 2)
186 |             context_lines = lines[context_start:context_end]
187 | 
188 |             # Penalize if this looks like it's inside a method (has method-like patterns above)
189 |             for context_line in context_lines:
190 |                 if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
191 |                     score -= 5  # Penalty for being near method signatures
192 | 
193 |             # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
194 |             if indentation <= 4 and distance_from_end <= 3:
195 |                 score += 15  # Bonus for likely class-ending brace
196 | 
197 |         scored_matches.append((score, match))
198 | 
199 |     # Return the match with the highest score
200 |     scored_matches.sort(key=lambda x: x[0], reverse=True)
201 |     best_match = scored_matches[0][1]
202 | 
203 |     return best_match
204 | 
205 | 
206 | def _infer_class_name(script_name: str) -> str:
207 |     # Default to script name as class name (common Unity pattern)
208 |     return (script_name or "").strip()
209 | 
210 | 
211 | def _extract_code_after(keyword: str, request: str) -> str:
212 |     # Deprecated with NL removal; retained as no-op for compatibility
213 |     idx = request.lower().find(keyword)
214 |     if idx >= 0:
215 |         return request[idx + len(keyword):].strip()
216 |     return ""
217 | # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
218 | 
219 | 
220 | def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
221 |     """Best-effort normalization of script "name" and "path".
222 | 
223 |     Accepts any of:
224 |     - name = "SmartReach", path = "Assets/Scripts/Interaction"
225 |     - name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
226 |     - name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
227 |     - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
228 |     - name or path using uri prefixes: unity://path/..., file://...
229 |     - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
230 | 
231 |     Returns (name_without_extension, directory_path_under_Assets).
232 |     """
233 |     n = (name or "").strip()
234 |     p = (path or "").strip()
235 | 
236 |     def strip_prefix(s: str) -> str:
237 |         if s.startswith("unity://path/"):
238 |             return s[len("unity://path/"):]
239 |         if s.startswith("file://"):
240 |             return s[len("file://"):]
241 |         return s
242 | 
243 |     def collapse_duplicate_tail(s: str) -> str:
244 |         # Collapse trailing "/X.cs/X.cs" to "/X.cs"
245 |         parts = s.split("/")
246 |         if len(parts) >= 2 and parts[-1] == parts[-2]:
247 |             parts = parts[:-1]
248 |         return "/".join(parts)
249 | 
250 |     # Prefer a full path if provided in either field
251 |     candidate = ""
252 |     for v in (n, p):
253 |         v2 = strip_prefix(v)
254 |         if v2.endswith(".cs") or v2.startswith("Assets/"):
255 |             candidate = v2
256 |             break
257 | 
258 |     if candidate:
259 |         candidate = collapse_duplicate_tail(candidate)
260 |         # If a directory was passed in path and file in name, join them
261 |         if not candidate.endswith(".cs") and n.endswith(".cs"):
262 |             v2 = strip_prefix(n)
263 |             candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1])
264 |         if candidate.endswith(".cs"):
265 |             parts = candidate.split("/")
266 |             file_name = parts[-1]
267 |             dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
268 |             base = file_name[:-
269 |                              3] if file_name.lower().endswith(".cs") else file_name
270 |             return base, dir_path
271 | 
272 |     # Fall back: remove extension from name if present and return given path
273 |     base_name = n[:-3] if n.lower().endswith(".cs") else n
274 |     return base_name, (p or "Assets")
275 | 
276 | 
277 | def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any:
278 |     if not isinstance(resp, dict):
279 |         return resp
280 |     data = resp.setdefault("data", {})
281 |     data.setdefault("normalizedEdits", edits)
282 |     if routing:
283 |         data["routing"] = routing
284 |     return resp
285 | 
286 | 
287 | def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None,
288 |          normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
289 |     payload: dict[str, Any] = {"success": False,
290 |                                "code": code, "message": message}
291 |     data: dict[str, Any] = {}
292 |     if expected:
293 |         data["expected"] = expected
294 |     if rewrite:
295 |         data["rewrite_suggestion"] = rewrite
296 |     if normalized is not None:
297 |         data["normalizedEdits"] = normalized
298 |     if routing:
299 |         data["routing"] = routing
300 |     if extra:
301 |         data.update(extra)
302 |     if data:
303 |         payload["data"] = data
304 |     return payload
305 | 
306 | # Natural-language parsing removed; clients should send structured edits.
307 | 
308 | 
309 | @mcp_for_unity_tool(name="script_apply_edits", description=(
310 |     """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
311 |     Best practices:
312 |     - Prefer anchor_* ops for pattern-based insert/replace near stable markers
313 |     - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
314 |     - Avoid whole-file regex deletes; validators will guard unbalanced braces
315 |     - For tail insertions, prefer anchor/regex_replace on final brace (class closing)
316 |     - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
317 |     Canonical fields (use these exact keys):
318 |     - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
319 |     - className: string (defaults to 'name' if omitted on method/class ops)
320 |     - methodName: string (required for replace_method, delete_method)
321 |     - replacement: string (required for replace_method, insert_method)
322 |     - position: start | end | after | before (insert_method only)
323 |     - afterMethodName / beforeMethodName: string (required when position='after'/'before')
324 |     - anchor: regex string (for anchor_* ops)
325 |     - text: string (for anchor_insert/anchor_replace)
326 |     Examples:
327 |     1) Replace a method:
328 |     {
329 |         "name": "SmartReach",
330 |         "path": "Assets/Scripts/Interaction",
331 |         "edits": [
332 |         {
333 |         "op": "replace_method",
334 |         "className": "SmartReach",
335 |         "methodName": "HasTarget",
336 |         "replacement": "public bool HasTarget(){ return currentTarget!=null; }"
337 |         }
338 |     ],
339 |     "options": {"validate": "standard", "refresh": "immediate"}
340 |     }
341 |     "2) Insert a method after another:
342 |     {
343 |         "name": "SmartReach",
344 |         "path": "Assets/Scripts/Interaction",
345 |         "edits": [
346 |         {
347 |         "op": "insert_method",
348 |         "className": "SmartReach",
349 |         "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
350 |         "position": "after",
351 |         "afterMethodName": "GetCurrentTarget"
352 |         }
353 |     ],
354 |     }
355 |     ]"""
356 | ))
357 | def script_apply_edits(
358 |     ctx: Context,
359 |     name: Annotated[str, "Name of the script to edit"],
360 |     path: Annotated[str, "Path to the script to edit under Assets/ directory"],
361 |     edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"],
362 |     options: Annotated[dict[str, Any],
363 |                        "Options for the script edit"] | None = None,
364 |     script_type: Annotated[str,
365 |                            "Type of the script to edit"] = "MonoBehaviour",
366 |     namespace: Annotated[str,
367 |                          "Namespace of the script to edit"] | None = None,
368 | ) -> dict[str, Any]:
369 |     ctx.info(f"Processing script_apply_edits: {name}")
370 |     # Normalize locator first so downstream calls target the correct script file.
371 |     name, path = _normalize_script_locator(name, path)
372 |     # Normalize unsupported or aliased ops to known structured/text paths
373 | 
374 |     def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]:
375 |         # Unwrap single-key wrappers like {"replace_method": {...}}
376 |         for wrapper_key in (
377 |             "replace_method", "insert_method", "delete_method",
378 |             "replace_class", "delete_class",
379 |             "anchor_insert", "anchor_replace", "anchor_delete",
380 |         ):
381 |             if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
382 |                 inner = dict(edit[wrapper_key])
383 |                 inner["op"] = wrapper_key
384 |                 edit = inner
385 |                 break
386 | 
387 |         e = dict(edit)
388 |         op = (e.get("op") or e.get("operation") or e.get(
389 |             "type") or e.get("mode") or "").strip().lower()
390 |         if op:
391 |             e["op"] = op
392 | 
393 |         # Common field aliases
394 |         if "class_name" in e and "className" not in e:
395 |             e["className"] = e.pop("class_name")
396 |         if "class" in e and "className" not in e:
397 |             e["className"] = e.pop("class")
398 |         if "method_name" in e and "methodName" not in e:
399 |             e["methodName"] = e.pop("method_name")
400 |         # Some clients use a generic 'target' for method name
401 |         if "target" in e and "methodName" not in e:
402 |             e["methodName"] = e.pop("target")
403 |         if "method" in e and "methodName" not in e:
404 |             e["methodName"] = e.pop("method")
405 |         if "new_content" in e and "replacement" not in e:
406 |             e["replacement"] = e.pop("new_content")
407 |         if "newMethod" in e and "replacement" not in e:
408 |             e["replacement"] = e.pop("newMethod")
409 |         if "new_method" in e and "replacement" not in e:
410 |             e["replacement"] = e.pop("new_method")
411 |         if "content" in e and "replacement" not in e:
412 |             e["replacement"] = e.pop("content")
413 |         if "after" in e and "afterMethodName" not in e:
414 |             e["afterMethodName"] = e.pop("after")
415 |         if "after_method" in e and "afterMethodName" not in e:
416 |             e["afterMethodName"] = e.pop("after_method")
417 |         if "before" in e and "beforeMethodName" not in e:
418 |             e["beforeMethodName"] = e.pop("before")
419 |         if "before_method" in e and "beforeMethodName" not in e:
420 |             e["beforeMethodName"] = e.pop("before_method")
421 |         # anchor_method → before/after based on position (default after)
422 |         if "anchor_method" in e:
423 |             anchor = e.pop("anchor_method")
424 |             pos = (e.get("position") or "after").strip().lower()
425 |             if pos == "before" and "beforeMethodName" not in e:
426 |                 e["beforeMethodName"] = anchor
427 |             elif "afterMethodName" not in e:
428 |                 e["afterMethodName"] = anchor
429 |         if "anchorText" in e and "anchor" not in e:
430 |             e["anchor"] = e.pop("anchorText")
431 |         if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"):
432 |             e["anchor"] = e.pop("pattern")
433 |         if "newText" in e and "text" not in e:
434 |             e["text"] = e.pop("newText")
435 | 
436 |         # CI compatibility (T‑A/T‑E):
437 |         # Accept method-anchored anchor_insert and upgrade to insert_method
438 |         # Example incoming shape:
439 |         #   {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."}
440 |         if (
441 |             e.get("op") == "anchor_insert"
442 |             and not e.get("anchor")
443 |             and (e.get("afterMethodName") or e.get("beforeMethodName"))
444 |         ):
445 |             e["op"] = "insert_method"
446 |             if "replacement" not in e:
447 |                 e["replacement"] = e.get("text", "")
448 | 
449 |         # LSP-like range edit -> replace_range
450 |         if "range" in e and isinstance(e["range"], dict):
451 |             rng = e.pop("range")
452 |             start = rng.get("start", {})
453 |             end = rng.get("end", {})
454 |             # Convert 0-based to 1-based line/col
455 |             e["op"] = "replace_range"
456 |             e["startLine"] = int(start.get("line", 0)) + 1
457 |             e["startCol"] = int(start.get("character", 0)) + 1
458 |             e["endLine"] = int(end.get("line", 0)) + 1
459 |             e["endCol"] = int(end.get("character", 0)) + 1
460 |             if "newText" in edit and "text" not in e:
461 |                 e["text"] = edit.get("newText", "")
462 |         return e
463 | 
464 |     normalized_edits: list[dict[str, Any]] = []
465 |     for raw in edits or []:
466 |         e = _unwrap_and_alias(raw)
467 |         op = (e.get("op") or e.get("operation") or e.get(
468 |             "type") or e.get("mode") or "").strip().lower()
469 | 
470 |         # Default className to script name if missing on structured method/class ops
471 |         if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
472 |             e["className"] = name
473 | 
474 |         # Map common aliases for text ops
475 |         if op in ("text_replace",):
476 |             e["op"] = "replace_range"
477 |             normalized_edits.append(e)
478 |             continue
479 |         if op in ("regex_delete",):
480 |             e["op"] = "regex_replace"
481 |             e.setdefault("text", "")
482 |             normalized_edits.append(e)
483 |             continue
484 |         if op == "regex_replace" and ("replacement" not in e):
485 |             if "text" in e:
486 |                 e["replacement"] = e.get("text", "")
487 |             elif "insert" in e or "content" in e:
488 |                 e["replacement"] = e.get(
489 |                     "insert") or e.get("content") or ""
490 |         if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
491 |             e["op"] = "anchor_delete"
492 |             normalized_edits.append(e)
493 |             continue
494 |         normalized_edits.append(e)
495 | 
496 |     edits = normalized_edits
497 |     normalized_for_echo = edits
498 | 
499 |     # Validate required fields and produce machine-parsable hints
500 |     def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]:
501 |         return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
502 | 
503 |     for e in edits or []:
504 |         op = e.get("op", "")
505 |         if op == "replace_method":
506 |             if not e.get("methodName"):
507 |                 return error_with_hint(
508 |                     "replace_method requires 'methodName'.",
509 |                     {"op": "replace_method", "required": [
510 |                         "className", "methodName", "replacement"]},
511 |                     {"edits[0].methodName": "HasTarget"}
512 |                 )
513 |             if not (e.get("replacement") or e.get("text")):
514 |                 return error_with_hint(
515 |                     "replace_method requires 'replacement' (inline or base64).",
516 |                     {"op": "replace_method", "required": [
517 |                         "className", "methodName", "replacement"]},
518 |                     {"edits[0].replacement": "public bool X(){ return true; }"}
519 |                 )
520 |         elif op == "insert_method":
521 |             if not (e.get("replacement") or e.get("text")):
522 |                 return error_with_hint(
523 |                     "insert_method requires a non-empty 'replacement'.",
524 |                     {"op": "insert_method", "required": ["className", "replacement"], "position": {
525 |                         "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
526 |                     {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
527 |                 )
528 |             pos = (e.get("position") or "").lower()
529 |             if pos == "after" and not e.get("afterMethodName"):
530 |                 return error_with_hint(
531 |                     "insert_method with position='after' requires 'afterMethodName'.",
532 |                     {"op": "insert_method", "position": {
533 |                         "after_requires": "afterMethodName"}},
534 |                     {"edits[0].afterMethodName": "GetCurrentTarget"}
535 |                 )
536 |             if pos == "before" and not e.get("beforeMethodName"):
537 |                 return error_with_hint(
538 |                     "insert_method with position='before' requires 'beforeMethodName'.",
539 |                     {"op": "insert_method", "position": {
540 |                         "before_requires": "beforeMethodName"}},
541 |                     {"edits[0].beforeMethodName": "GetCurrentTarget"}
542 |                 )
543 |         elif op == "delete_method":
544 |             if not e.get("methodName"):
545 |                 return error_with_hint(
546 |                     "delete_method requires 'methodName'.",
547 |                     {"op": "delete_method", "required": [
548 |                         "className", "methodName"]},
549 |                     {"edits[0].methodName": "PrintSeries"}
550 |                 )
551 |         elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
552 |             if not e.get("anchor"):
553 |                 return error_with_hint(
554 |                     f"{op} requires 'anchor' (regex).",
555 |                     {"op": op, "required": ["anchor"]},
556 |                     {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("}
557 |                 )
558 |             if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")):
559 |                 return error_with_hint(
560 |                     f"{op} requires 'text'.",
561 |                     {"op": op, "required": ["anchor", "text"]},
562 |                     {"edits[0].text": "/* comment */\n"}
563 |                 )
564 | 
565 |     # Decide routing: structured vs text vs mixed
566 |     STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method",
567 |               "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
568 |     TEXT = {"prepend", "append", "replace_range", "regex_replace"}
569 |     ops_set = {(e.get("op") or "").lower() for e in edits or []}
570 |     all_struct = ops_set.issubset(STRUCT)
571 |     all_text = ops_set.issubset(TEXT)
572 |     mixed = not (all_struct or all_text)
573 | 
574 |     # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
575 |     if all_struct:
576 |         opts2 = dict(options or {})
577 |         # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
578 |         opts2.setdefault("refresh", "immediate")
579 |         params_struct: dict[str, Any] = {
580 |             "action": "edit",
581 |             "name": name,
582 |             "path": path,
583 |             "namespace": namespace,
584 |             "scriptType": script_type,
585 |             "edits": edits,
586 |             "options": opts2,
587 |         }
588 |         resp_struct = send_command_with_retry(
589 |             "manage_script", params_struct)
590 |         if isinstance(resp_struct, dict) and resp_struct.get("success"):
591 |             pass  # Optional sentinel reload removed (deprecated)
592 |         return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
593 | 
594 |     # 1) read from Unity
595 |     read_resp = send_command_with_retry("manage_script", {
596 |         "action": "read",
597 |         "name": name,
598 |         "path": path,
599 |         "namespace": namespace,
600 |         "scriptType": script_type,
601 |     })
602 |     if not isinstance(read_resp, dict) or not read_resp.get("success"):
603 |         return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
604 | 
605 |     data = read_resp.get("data") or read_resp.get(
606 |         "result", {}).get("data") or {}
607 |     contents = data.get("contents")
608 |     if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
609 |         contents = base64.b64decode(
610 |             data["encodedContents"]).decode("utf-8")
611 |     if contents is None:
612 |         return {"success": False, "message": "No contents returned from Unity read."}
613 | 
614 |     # Optional preview/dry-run: apply locally and return diff without writing
615 |     preview = bool((options or {}).get("preview"))
616 | 
617 |     # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
618 |     if mixed:
619 |         text_edits = [e for e in edits or [] if (
620 |             e.get("op") or "").lower() in TEXT]
621 |         struct_edits = [e for e in edits or [] if (
622 |             e.get("op") or "").lower() in STRUCT]
623 |         try:
624 |             base_text = contents
625 | 
626 |             def line_col_from_index(idx: int) -> tuple[int, int]:
627 |                 line = base_text.count("\n", 0, idx) + 1
628 |                 last_nl = base_text.rfind("\n", 0, idx)
629 |                 col = (idx - (last_nl + 1)) + \
630 |                     1 if last_nl >= 0 else idx + 1
631 |                 return line, col
632 | 
633 |             at_edits: list[dict[str, Any]] = []
634 |             for e in text_edits:
635 |                 opx = (e.get("op") or e.get("operation") or e.get(
636 |                     "type") or e.get("mode") or "").strip().lower()
637 |                 text_field = e.get("text") or e.get("insert") or e.get(
638 |                     "content") or e.get("replacement") or ""
639 |                 if opx == "anchor_insert":
640 |                     anchor = e.get("anchor") or ""
641 |                     position = (e.get("position") or "after").lower()
642 |                     flags = re.MULTILINE | (
643 |                         re.IGNORECASE if e.get("ignore_case") else 0)
644 |                     try:
645 |                         # Use improved anchor matching logic
646 |                         m = _find_best_anchor_match(
647 |                             anchor, base_text, flags, prefer_last=True)
648 |                     except Exception as ex:
649 |                         return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
650 |                     if not m:
651 |                         return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
652 |                     idx = m.start() if position == "before" else m.end()
653 |                     # Normalize insertion to avoid jammed methods
654 |                     text_field_norm = text_field
655 |                     if not text_field_norm.startswith("\n"):
656 |                         text_field_norm = "\n" + text_field_norm
657 |                     if not text_field_norm.endswith("\n"):
658 |                         text_field_norm = text_field_norm + "\n"
659 |                     sl, sc = line_col_from_index(idx)
660 |                     at_edits.append(
661 |                         {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
662 |                     # do not mutate base_text when building atomic spans
663 |                 elif opx == "replace_range":
664 |                     if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
665 |                         at_edits.append({
666 |                             "startLine": int(e.get("startLine", 1)),
667 |                             "startCol": int(e.get("startCol", 1)),
668 |                             "endLine": int(e.get("endLine", 1)),
669 |                             "endCol": int(e.get("endCol", 1)),
670 |                             "newText": text_field
671 |                         })
672 |                     else:
673 |                         return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
674 |                 elif opx == "regex_replace":
675 |                     pattern = e.get("pattern") or ""
676 |                     try:
677 |                         regex_obj = re.compile(pattern, re.MULTILINE | (
678 |                             re.IGNORECASE if e.get("ignore_case") else 0))
679 |                     except Exception as ex:
680 |                         return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
681 |                     m = regex_obj.search(base_text)
682 |                     if not m:
683 |                         continue
684 |                     # Expand $1, $2... in replacement using this match
685 | 
686 |                     def _expand_dollars(rep: str, _m=m) -> str:
687 |                         return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
688 |                     repl = _expand_dollars(text_field)
689 |                     sl, sc = line_col_from_index(m.start())
690 |                     el, ec = line_col_from_index(m.end())
691 |                     at_edits.append(
692 |                         {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
693 |                     # do not mutate base_text when building atomic spans
694 |                 elif opx in ("prepend", "append"):
695 |                     if opx == "prepend":
696 |                         sl, sc = 1, 1
697 |                         at_edits.append(
698 |                             {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
699 |                         # prepend can be applied atomically without local mutation
700 |                     else:
701 |                         # Insert at true EOF position (handles both \n and \r\n correctly)
702 |                         eof_idx = len(base_text)
703 |                         sl, sc = line_col_from_index(eof_idx)
704 |                         new_text = ("\n" if not base_text.endswith(
705 |                             "\n") else "") + text_field
706 |                         at_edits.append(
707 |                             {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
708 |                         # do not mutate base_text when building atomic spans
709 |                 else:
710 |                     return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
711 | 
712 |             sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
713 |             if at_edits:
714 |                 params_text: dict[str, Any] = {
715 |                     "action": "apply_text_edits",
716 |                     "name": name,
717 |                     "path": path,
718 |                     "namespace": namespace,
719 |                     "scriptType": script_type,
720 |                     "edits": at_edits,
721 |                     "precondition_sha256": sha,
722 |                     "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
723 |                 }
724 |                 resp_text = send_command_with_retry(
725 |                     "manage_script", params_text)
726 |                 if not (isinstance(resp_text, dict) and resp_text.get("success")):
727 |                     return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
728 |                 # Optional sentinel reload removed (deprecated)
729 |         except Exception as e:
730 |             return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
731 | 
732 |         if struct_edits:
733 |             opts2 = dict(options or {})
734 |             # Prefer debounced background refresh unless explicitly overridden
735 |             opts2.setdefault("refresh", "debounced")
736 |             params_struct: dict[str, Any] = {
737 |                 "action": "edit",
738 |                 "name": name,
739 |                 "path": path,
740 |                 "namespace": namespace,
741 |                 "scriptType": script_type,
742 |                 "edits": struct_edits,
743 |                 "options": opts2
744 |             }
745 |             resp_struct = send_command_with_retry(
746 |                 "manage_script", params_struct)
747 |             if isinstance(resp_struct, dict) and resp_struct.get("success"):
748 |                 pass  # Optional sentinel reload removed (deprecated)
749 |             return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
750 | 
751 |         return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
752 | 
753 |     # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
754 |     # so header guards and validation run on the C# side.
755 |     # Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
756 |     text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
757 |         "mode") or "").strip().lower() for e in (edits or [])}
758 |     structured_kinds = {"replace_class", "delete_class",
759 |                         "replace_method", "delete_method", "insert_method", "anchor_insert"}
760 |     if not text_ops.issubset(structured_kinds):
761 |         # Convert to apply_text_edits payload
762 |         try:
763 |             base_text = contents
764 | 
765 |             def line_col_from_index(idx: int) -> tuple[int, int]:
766 |                 # 1-based line/col against base buffer
767 |                 line = base_text.count("\n", 0, idx) + 1
768 |                 last_nl = base_text.rfind("\n", 0, idx)
769 |                 col = (idx - (last_nl + 1)) + \
770 |                     1 if last_nl >= 0 else idx + 1
771 |                 return line, col
772 | 
773 |             at_edits: list[dict[str, Any]] = []
774 |             import re as _re
775 |             for e in edits or []:
776 |                 op = (e.get("op") or e.get("operation") or e.get(
777 |                     "type") or e.get("mode") or "").strip().lower()
778 |                 # aliasing for text field
779 |                 text_field = e.get("text") or e.get(
780 |                     "insert") or e.get("content") or ""
781 |                 if op == "anchor_insert":
782 |                     anchor = e.get("anchor") or ""
783 |                     position = (e.get("position") or "after").lower()
784 |                     # Use improved anchor matching logic with helpful errors, honoring ignore_case
785 |                     try:
786 |                         flags = re.MULTILINE | (
787 |                             re.IGNORECASE if e.get("ignore_case") else 0)
788 |                         m = _find_best_anchor_match(
789 |                             anchor, base_text, flags, prefer_last=True)
790 |                     except Exception as ex:
791 |                         return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
792 |                     if not m:
793 |                         return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
794 |                     idx = m.start() if position == "before" else m.end()
795 |                     # Normalize insertion newlines
796 |                     if text_field and not text_field.startswith("\n"):
797 |                         text_field = "\n" + text_field
798 |                     if text_field and not text_field.endswith("\n"):
799 |                         text_field = text_field + "\n"
800 |                     sl, sc = line_col_from_index(idx)
801 |                     at_edits.append({
802 |                         "startLine": sl,
803 |                         "startCol": sc,
804 |                         "endLine": sl,
805 |                         "endCol": sc,
806 |                         "newText": text_field or ""
807 |                     })
808 |                     # Do not mutate base buffer when building an atomic batch
809 |                 elif op == "replace_range":
810 |                     # Directly forward if already in line/col form
811 |                     if "startLine" in e:
812 |                         at_edits.append({
813 |                             "startLine": int(e.get("startLine", 1)),
814 |                             "startCol": int(e.get("startCol", 1)),
815 |                             "endLine": int(e.get("endLine", 1)),
816 |                             "endCol": int(e.get("endCol", 1)),
817 |                             "newText": text_field
818 |                         })
819 |                     else:
820 |                         # If only indices provided, skip (we don't support index-based here)
821 |                         return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
822 |                 elif op == "regex_replace":
823 |                     pattern = e.get("pattern") or ""
824 |                     repl = text_field
825 |                     flags = re.MULTILINE | (
826 |                         re.IGNORECASE if e.get("ignore_case") else 0)
827 |                     # Early compile for clearer error messages
828 |                     try:
829 |                         regex_obj = re.compile(pattern, flags)
830 |                     except Exception as ex:
831 |                         return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
832 |                     # Use smart anchor matching for consistent behavior with anchor_insert
833 |                     m = _find_best_anchor_match(
834 |                         pattern, base_text, flags, prefer_last=True)
835 |                     if not m:
836 |                         continue
837 |                     # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
838 | 
839 |                     def _expand_dollars(rep: str, _m=m) -> str:
840 |                         return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
841 |                     repl_expanded = _expand_dollars(repl)
842 |                     # Let C# side handle validation using Unity's built-in compiler services
843 |                     sl, sc = line_col_from_index(m.start())
844 |                     el, ec = line_col_from_index(m.end())
845 |                     at_edits.append({
846 |                         "startLine": sl,
847 |                         "startCol": sc,
848 |                         "endLine": el,
849 |                         "endCol": ec,
850 |                         "newText": repl_expanded
851 |                     })
852 |                     # Do not mutate base buffer when building an atomic batch
853 |                 else:
854 |                     return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
855 | 
856 |             if not at_edits:
857 |                 return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
858 | 
859 |             sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
860 |             params: dict[str, Any] = {
861 |                 "action": "apply_text_edits",
862 |                 "name": name,
863 |                 "path": path,
864 |                 "namespace": namespace,
865 |                 "scriptType": script_type,
866 |                 "edits": at_edits,
867 |                 "precondition_sha256": sha,
868 |                 "options": {
869 |                     "refresh": (options or {}).get("refresh", "debounced"),
870 |                     "validate": (options or {}).get("validate", "standard"),
871 |                     "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
872 |                 }
873 |             }
874 |             resp = send_command_with_retry("manage_script", params)
875 |             if isinstance(resp, dict) and resp.get("success"):
876 |                 pass  # Optional sentinel reload removed (deprecated)
877 |             return _with_norm(
878 |                 resp if isinstance(resp, dict) else {
879 |                     "success": False, "message": str(resp)},
880 |                 normalized_for_echo,
881 |                 routing="text"
882 |             )
883 |         except Exception as e:
884 |             return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
885 | 
886 |     # For regex_replace, honor preview consistently: if preview=true, always return diff without writing.
887 |     # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply.
888 |     if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")):
889 |         try:
890 |             preview_text = _apply_edits_locally(contents, edits)
891 |             import difflib
892 |             diff = list(difflib.unified_diff(contents.splitlines(
893 |             ), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
894 |             if len(diff) > 800:
895 |                 diff = diff[:800] + ["... (diff truncated) ..."]
896 |             if preview:
897 |                 return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
898 |             return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text")
899 |         except Exception as e:
900 |             return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text")
901 |     # 2) apply edits locally (only if not text-ops)
902 |     try:
903 |         new_contents = _apply_edits_locally(contents, edits)
904 |     except Exception as e:
905 |         return {"success": False, "message": f"Edit application failed: {e}"}
906 | 
907 |     # Short-circuit no-op edits to avoid false "applied" reports downstream
908 |     if new_contents == contents:
909 |         return _with_norm({
910 |             "success": True,
911 |             "message": "No-op: contents unchanged",
912 |             "data": {"no_op": True, "evidence": {"reason": "identical_content"}}
913 |         }, normalized_for_echo, routing="text")
914 | 
915 |     if preview:
916 |         # Produce a compact unified diff limited to small context
917 |         import difflib
918 |         a = contents.splitlines()
919 |         b = new_contents.splitlines()
920 |         diff = list(difflib.unified_diff(
921 |             a, b, fromfile="before", tofile="after", n=3))
922 |         # Limit diff size to keep responses small
923 |         if len(diff) > 2000:
924 |             diff = diff[:2000] + ["... (diff truncated) ..."]
925 |         return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
926 | 
927 |     # 3) update to Unity
928 |     # Default refresh/validate for natural usage on text path as well
929 |     options = dict(options or {})
930 |     options.setdefault("validate", "standard")
931 |     options.setdefault("refresh", "debounced")
932 | 
933 |     # Compute the SHA of the current file contents for the precondition
934 |     old_lines = contents.splitlines(keepends=True)
935 |     end_line = len(old_lines) + 1  # 1-based exclusive end
936 |     sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
937 | 
938 |     # Apply a whole-file text edit rather than the deprecated 'update' action
939 |     params = {
940 |         "action": "apply_text_edits",
941 |         "name": name,
942 |         "path": path,
943 |         "namespace": namespace,
944 |         "scriptType": script_type,
945 |         "edits": [
946 |             {
947 |                 "startLine": 1,
948 |                 "startCol": 1,
949 |                 "endLine": end_line,
950 |                 "endCol": 1,
951 |                 "newText": new_contents,
952 |             }
953 |         ],
954 |         "precondition_sha256": sha,
955 |         "options": options or {"validate": "standard", "refresh": "debounced"},
956 |     }
957 | 
958 |     write_resp = send_command_with_retry("manage_script", params)
959 |     if isinstance(write_resp, dict) and write_resp.get("success"):
960 |         pass  # Optional sentinel reload removed (deprecated)
961 |     return _with_norm(
962 |         write_resp if isinstance(write_resp, dict)
963 |         else {"success": False, "message": str(write_resp)},
964 |         normalized_for_echo,
965 |         routing="text",
966 |     )
967 | 
```
Page 10/18FirstPrevNextLast