#
tokens: 43451/50000 3/263 files (page 11/18)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 11 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .claude
│   ├── prompts
│   │   ├── nl-unity-suite-nl.md
│   │   └── nl-unity-suite-t.md
│   └── settings.json
├── .github
│   ├── scripts
│   │   └── mark_skipped.py
│   └── workflows
│       ├── bump-version.yml
│       ├── claude-nl-suite.yml
│       ├── github-repo-stats.yml
│       └── unity-tests.yml
├── .gitignore
├── deploy-dev.bat
├── docs
│   ├── CURSOR_HELP.md
│   ├── CUSTOM_TOOLS.md
│   ├── README-DEV-zh.md
│   ├── README-DEV.md
│   ├── screenshots
│   │   ├── v5_01_uninstall.png
│   │   ├── v5_02_install.png
│   │   ├── v5_03_open_mcp_window.png
│   │   ├── v5_04_rebuild_mcp_server.png
│   │   ├── v5_05_rebuild_success.png
│   │   ├── v6_2_create_python_tools_asset.png
│   │   ├── v6_2_python_tools_asset.png
│   │   ├── v6_new_ui_asset_store_version.png
│   │   ├── v6_new_ui_dark.png
│   │   └── v6_new_ui_light.png
│   ├── TELEMETRY.md
│   ├── v5_MIGRATION.md
│   └── v6_NEW_UI_CHANGES.md
├── LICENSE
├── logo.png
├── mcp_source.py
├── MCPForUnity
│   ├── Editor
│   │   ├── AssemblyInfo.cs
│   │   ├── AssemblyInfo.cs.meta
│   │   ├── Data
│   │   │   ├── DefaultServerConfig.cs
│   │   │   ├── DefaultServerConfig.cs.meta
│   │   │   ├── McpClients.cs
│   │   │   ├── McpClients.cs.meta
│   │   │   ├── PythonToolsAsset.cs
│   │   │   └── PythonToolsAsset.cs.meta
│   │   ├── Data.meta
│   │   ├── Dependencies
│   │   │   ├── DependencyManager.cs
│   │   │   ├── DependencyManager.cs.meta
│   │   │   ├── Models
│   │   │   │   ├── DependencyCheckResult.cs
│   │   │   │   ├── DependencyCheckResult.cs.meta
│   │   │   │   ├── DependencyStatus.cs
│   │   │   │   └── DependencyStatus.cs.meta
│   │   │   ├── Models.meta
│   │   │   ├── PlatformDetectors
│   │   │   │   ├── IPlatformDetector.cs
│   │   │   │   ├── IPlatformDetector.cs.meta
│   │   │   │   ├── LinuxPlatformDetector.cs
│   │   │   │   ├── LinuxPlatformDetector.cs.meta
│   │   │   │   ├── MacOSPlatformDetector.cs
│   │   │   │   ├── MacOSPlatformDetector.cs.meta
│   │   │   │   ├── PlatformDetectorBase.cs
│   │   │   │   ├── PlatformDetectorBase.cs.meta
│   │   │   │   ├── WindowsPlatformDetector.cs
│   │   │   │   └── WindowsPlatformDetector.cs.meta
│   │   │   └── PlatformDetectors.meta
│   │   ├── Dependencies.meta
│   │   ├── External
│   │   │   ├── Tommy.cs
│   │   │   └── Tommy.cs.meta
│   │   ├── External.meta
│   │   ├── Helpers
│   │   │   ├── AssetPathUtility.cs
│   │   │   ├── AssetPathUtility.cs.meta
│   │   │   ├── CodexConfigHelper.cs
│   │   │   ├── CodexConfigHelper.cs.meta
│   │   │   ├── ConfigJsonBuilder.cs
│   │   │   ├── ConfigJsonBuilder.cs.meta
│   │   │   ├── ExecPath.cs
│   │   │   ├── ExecPath.cs.meta
│   │   │   ├── GameObjectSerializer.cs
│   │   │   ├── GameObjectSerializer.cs.meta
│   │   │   ├── McpConfigFileHelper.cs
│   │   │   ├── McpConfigFileHelper.cs.meta
│   │   │   ├── McpConfigurationHelper.cs
│   │   │   ├── McpConfigurationHelper.cs.meta
│   │   │   ├── McpLog.cs
│   │   │   ├── McpLog.cs.meta
│   │   │   ├── McpPathResolver.cs
│   │   │   ├── McpPathResolver.cs.meta
│   │   │   ├── PackageDetector.cs
│   │   │   ├── PackageDetector.cs.meta
│   │   │   ├── PackageInstaller.cs
│   │   │   ├── PackageInstaller.cs.meta
│   │   │   ├── PortManager.cs
│   │   │   ├── PortManager.cs.meta
│   │   │   ├── PythonToolSyncProcessor.cs
│   │   │   ├── PythonToolSyncProcessor.cs.meta
│   │   │   ├── Response.cs
│   │   │   ├── Response.cs.meta
│   │   │   ├── ServerInstaller.cs
│   │   │   ├── ServerInstaller.cs.meta
│   │   │   ├── ServerPathResolver.cs
│   │   │   ├── ServerPathResolver.cs.meta
│   │   │   ├── TelemetryHelper.cs
│   │   │   ├── TelemetryHelper.cs.meta
│   │   │   ├── Vector3Helper.cs
│   │   │   └── Vector3Helper.cs.meta
│   │   ├── Helpers.meta
│   │   ├── Importers
│   │   │   ├── PythonFileImporter.cs
│   │   │   └── PythonFileImporter.cs.meta
│   │   ├── Importers.meta
│   │   ├── MCPForUnity.Editor.asmdef
│   │   ├── MCPForUnity.Editor.asmdef.meta
│   │   ├── MCPForUnityBridge.cs
│   │   ├── MCPForUnityBridge.cs.meta
│   │   ├── Models
│   │   │   ├── Command.cs
│   │   │   ├── Command.cs.meta
│   │   │   ├── McpClient.cs
│   │   │   ├── McpClient.cs.meta
│   │   │   ├── McpConfig.cs
│   │   │   ├── McpConfig.cs.meta
│   │   │   ├── MCPConfigServer.cs
│   │   │   ├── MCPConfigServer.cs.meta
│   │   │   ├── MCPConfigServers.cs
│   │   │   ├── MCPConfigServers.cs.meta
│   │   │   ├── McpStatus.cs
│   │   │   ├── McpStatus.cs.meta
│   │   │   ├── McpTypes.cs
│   │   │   ├── McpTypes.cs.meta
│   │   │   ├── ServerConfig.cs
│   │   │   └── ServerConfig.cs.meta
│   │   ├── Models.meta
│   │   ├── Resources
│   │   │   ├── McpForUnityResourceAttribute.cs
│   │   │   ├── McpForUnityResourceAttribute.cs.meta
│   │   │   ├── MenuItems
│   │   │   │   ├── GetMenuItems.cs
│   │   │   │   └── GetMenuItems.cs.meta
│   │   │   ├── MenuItems.meta
│   │   │   ├── Tests
│   │   │   │   ├── GetTests.cs
│   │   │   │   └── GetTests.cs.meta
│   │   │   └── Tests.meta
│   │   ├── Resources.meta
│   │   ├── Services
│   │   │   ├── BridgeControlService.cs
│   │   │   ├── BridgeControlService.cs.meta
│   │   │   ├── ClientConfigurationService.cs
│   │   │   ├── ClientConfigurationService.cs.meta
│   │   │   ├── IBridgeControlService.cs
│   │   │   ├── IBridgeControlService.cs.meta
│   │   │   ├── IClientConfigurationService.cs
│   │   │   ├── IClientConfigurationService.cs.meta
│   │   │   ├── IPackageUpdateService.cs
│   │   │   ├── IPackageUpdateService.cs.meta
│   │   │   ├── IPathResolverService.cs
│   │   │   ├── IPathResolverService.cs.meta
│   │   │   ├── IPythonToolRegistryService.cs
│   │   │   ├── IPythonToolRegistryService.cs.meta
│   │   │   ├── ITestRunnerService.cs
│   │   │   ├── ITestRunnerService.cs.meta
│   │   │   ├── IToolSyncService.cs
│   │   │   ├── IToolSyncService.cs.meta
│   │   │   ├── MCPServiceLocator.cs
│   │   │   ├── MCPServiceLocator.cs.meta
│   │   │   ├── PackageUpdateService.cs
│   │   │   ├── PackageUpdateService.cs.meta
│   │   │   ├── PathResolverService.cs
│   │   │   ├── PathResolverService.cs.meta
│   │   │   ├── PythonToolRegistryService.cs
│   │   │   ├── PythonToolRegistryService.cs.meta
│   │   │   ├── TestRunnerService.cs
│   │   │   ├── TestRunnerService.cs.meta
│   │   │   ├── ToolSyncService.cs
│   │   │   └── ToolSyncService.cs.meta
│   │   ├── Services.meta
│   │   ├── Setup
│   │   │   ├── SetupWizard.cs
│   │   │   ├── SetupWizard.cs.meta
│   │   │   ├── SetupWizardWindow.cs
│   │   │   └── SetupWizardWindow.cs.meta
│   │   ├── Setup.meta
│   │   ├── Tools
│   │   │   ├── CommandRegistry.cs
│   │   │   ├── CommandRegistry.cs.meta
│   │   │   ├── ExecuteMenuItem.cs
│   │   │   ├── ExecuteMenuItem.cs.meta
│   │   │   ├── ManageAsset.cs
│   │   │   ├── ManageAsset.cs.meta
│   │   │   ├── ManageEditor.cs
│   │   │   ├── ManageEditor.cs.meta
│   │   │   ├── ManageGameObject.cs
│   │   │   ├── ManageGameObject.cs.meta
│   │   │   ├── ManageScene.cs
│   │   │   ├── ManageScene.cs.meta
│   │   │   ├── ManageScript.cs
│   │   │   ├── ManageScript.cs.meta
│   │   │   ├── ManageShader.cs
│   │   │   ├── ManageShader.cs.meta
│   │   │   ├── McpForUnityToolAttribute.cs
│   │   │   ├── McpForUnityToolAttribute.cs.meta
│   │   │   ├── Prefabs
│   │   │   │   ├── ManagePrefabs.cs
│   │   │   │   └── ManagePrefabs.cs.meta
│   │   │   ├── Prefabs.meta
│   │   │   ├── ReadConsole.cs
│   │   │   ├── ReadConsole.cs.meta
│   │   │   ├── RunTests.cs
│   │   │   └── RunTests.cs.meta
│   │   ├── Tools.meta
│   │   ├── Windows
│   │   │   ├── ManualConfigEditorWindow.cs
│   │   │   ├── ManualConfigEditorWindow.cs.meta
│   │   │   ├── MCPForUnityEditorWindow.cs
│   │   │   ├── MCPForUnityEditorWindow.cs.meta
│   │   │   ├── MCPForUnityEditorWindowNew.cs
│   │   │   ├── MCPForUnityEditorWindowNew.cs.meta
│   │   │   ├── MCPForUnityEditorWindowNew.uss
│   │   │   ├── MCPForUnityEditorWindowNew.uss.meta
│   │   │   ├── MCPForUnityEditorWindowNew.uxml
│   │   │   ├── MCPForUnityEditorWindowNew.uxml.meta
│   │   │   ├── VSCodeManualSetupWindow.cs
│   │   │   └── VSCodeManualSetupWindow.cs.meta
│   │   └── Windows.meta
│   ├── Editor.meta
│   ├── package.json
│   ├── package.json.meta
│   ├── README.md
│   ├── README.md.meta
│   ├── Runtime
│   │   ├── MCPForUnity.Runtime.asmdef
│   │   ├── MCPForUnity.Runtime.asmdef.meta
│   │   ├── Serialization
│   │   │   ├── UnityTypeConverters.cs
│   │   │   └── UnityTypeConverters.cs.meta
│   │   └── Serialization.meta
│   ├── Runtime.meta
│   └── UnityMcpServer~
│       └── src
│           ├── __init__.py
│           ├── config.py
│           ├── Dockerfile
│           ├── models.py
│           ├── module_discovery.py
│           ├── port_discovery.py
│           ├── pyproject.toml
│           ├── pyrightconfig.json
│           ├── registry
│           │   ├── __init__.py
│           │   ├── resource_registry.py
│           │   └── tool_registry.py
│           ├── reload_sentinel.py
│           ├── resources
│           │   ├── __init__.py
│           │   ├── menu_items.py
│           │   └── tests.py
│           ├── server_version.txt
│           ├── server.py
│           ├── telemetry_decorator.py
│           ├── telemetry.py
│           ├── test_telemetry.py
│           ├── tools
│           │   ├── __init__.py
│           │   ├── execute_menu_item.py
│           │   ├── manage_asset.py
│           │   ├── manage_editor.py
│           │   ├── manage_gameobject.py
│           │   ├── manage_prefabs.py
│           │   ├── manage_scene.py
│           │   ├── manage_script.py
│           │   ├── manage_shader.py
│           │   ├── read_console.py
│           │   ├── resource_tools.py
│           │   ├── run_tests.py
│           │   └── script_apply_edits.py
│           ├── unity_connection.py
│           └── uv.lock
├── prune_tool_results.py
├── README-zh.md
├── README.md
├── restore-dev.bat
├── scripts
│   └── validate-nlt-coverage.sh
├── test_unity_socket_framing.py
├── TestProjects
│   └── UnityMCPTests
│       ├── .gitignore
│       ├── Assets
│       │   ├── Editor.meta
│       │   ├── Scenes
│       │   │   ├── SampleScene.unity
│       │   │   └── SampleScene.unity.meta
│       │   ├── Scenes.meta
│       │   ├── Scripts
│       │   │   ├── Hello.cs
│       │   │   ├── Hello.cs.meta
│       │   │   ├── LongUnityScriptClaudeTest.cs
│       │   │   ├── LongUnityScriptClaudeTest.cs.meta
│       │   │   ├── TestAsmdef
│       │   │   │   ├── CustomComponent.cs
│       │   │   │   ├── CustomComponent.cs.meta
│       │   │   │   ├── TestAsmdef.asmdef
│       │   │   │   └── TestAsmdef.asmdef.meta
│       │   │   └── TestAsmdef.meta
│       │   ├── Scripts.meta
│       │   ├── Tests
│       │   │   ├── EditMode
│       │   │   │   ├── Data
│       │   │   │   │   ├── PythonToolsAssetTests.cs
│       │   │   │   │   └── PythonToolsAssetTests.cs.meta
│       │   │   │   ├── Data.meta
│       │   │   │   ├── Helpers
│       │   │   │   │   ├── CodexConfigHelperTests.cs
│       │   │   │   │   ├── CodexConfigHelperTests.cs.meta
│       │   │   │   │   ├── WriteToConfigTests.cs
│       │   │   │   │   └── WriteToConfigTests.cs.meta
│       │   │   │   ├── Helpers.meta
│       │   │   │   ├── MCPForUnityTests.Editor.asmdef
│       │   │   │   ├── MCPForUnityTests.Editor.asmdef.meta
│       │   │   │   ├── Resources
│       │   │   │   │   ├── GetMenuItemsTests.cs
│       │   │   │   │   └── GetMenuItemsTests.cs.meta
│       │   │   │   ├── Resources.meta
│       │   │   │   ├── Services
│       │   │   │   │   ├── PackageUpdateServiceTests.cs
│       │   │   │   │   ├── PackageUpdateServiceTests.cs.meta
│       │   │   │   │   ├── PythonToolRegistryServiceTests.cs
│       │   │   │   │   ├── PythonToolRegistryServiceTests.cs.meta
│       │   │   │   │   ├── ToolSyncServiceTests.cs
│       │   │   │   │   └── ToolSyncServiceTests.cs.meta
│       │   │   │   ├── Services.meta
│       │   │   │   ├── Tools
│       │   │   │   │   ├── AIPropertyMatchingTests.cs
│       │   │   │   │   ├── AIPropertyMatchingTests.cs.meta
│       │   │   │   │   ├── CommandRegistryTests.cs
│       │   │   │   │   ├── CommandRegistryTests.cs.meta
│       │   │   │   │   ├── ComponentResolverTests.cs
│       │   │   │   │   ├── ComponentResolverTests.cs.meta
│       │   │   │   │   ├── ExecuteMenuItemTests.cs
│       │   │   │   │   ├── ExecuteMenuItemTests.cs.meta
│       │   │   │   │   ├── ManageGameObjectTests.cs
│       │   │   │   │   ├── ManageGameObjectTests.cs.meta
│       │   │   │   │   ├── ManagePrefabsTests.cs
│       │   │   │   │   ├── ManagePrefabsTests.cs.meta
│       │   │   │   │   ├── ManageScriptValidationTests.cs
│       │   │   │   │   └── ManageScriptValidationTests.cs.meta
│       │   │   │   ├── Tools.meta
│       │   │   │   ├── Windows
│       │   │   │   │   ├── ManualConfigJsonBuilderTests.cs
│       │   │   │   │   └── ManualConfigJsonBuilderTests.cs.meta
│       │   │   │   └── Windows.meta
│       │   │   └── EditMode.meta
│       │   └── Tests.meta
│       ├── Packages
│       │   └── manifest.json
│       └── ProjectSettings
│           ├── Packages
│           │   └── com.unity.testtools.codecoverage
│           │       └── Settings.json
│           └── ProjectVersion.txt
├── tests
│   ├── conftest.py
│   ├── test_edit_normalization_and_noop.py
│   ├── test_edit_strict_and_warnings.py
│   ├── test_find_in_file_minimal.py
│   ├── test_get_sha.py
│   ├── test_improved_anchor_matching.py
│   ├── test_logging_stdout.py
│   ├── test_manage_script_uri.py
│   ├── test_read_console_truncate.py
│   ├── test_read_resource_minimal.py
│   ├── test_resources_api.py
│   ├── test_script_editing.py
│   ├── test_script_tools.py
│   ├── test_telemetry_endpoint_validation.py
│   ├── test_telemetry_queue_worker.py
│   ├── test_telemetry_subaction.py
│   ├── test_transport_framing.py
│   └── test_validate_script_summary.py
├── tools
│   └── stress_mcp.py
└── UnityMcpBridge
    ├── Editor
    │   ├── AssemblyInfo.cs
    │   ├── AssemblyInfo.cs.meta
    │   ├── Data
    │   │   ├── DefaultServerConfig.cs
    │   │   ├── DefaultServerConfig.cs.meta
    │   │   ├── McpClients.cs
    │   │   └── McpClients.cs.meta
    │   ├── Data.meta
    │   ├── Dependencies
    │   │   ├── DependencyManager.cs
    │   │   ├── DependencyManager.cs.meta
    │   │   ├── Models
    │   │   │   ├── DependencyCheckResult.cs
    │   │   │   ├── DependencyCheckResult.cs.meta
    │   │   │   ├── DependencyStatus.cs
    │   │   │   └── DependencyStatus.cs.meta
    │   │   ├── Models.meta
    │   │   ├── PlatformDetectors
    │   │   │   ├── IPlatformDetector.cs
    │   │   │   ├── IPlatformDetector.cs.meta
    │   │   │   ├── LinuxPlatformDetector.cs
    │   │   │   ├── LinuxPlatformDetector.cs.meta
    │   │   │   ├── MacOSPlatformDetector.cs
    │   │   │   ├── MacOSPlatformDetector.cs.meta
    │   │   │   ├── PlatformDetectorBase.cs
    │   │   │   ├── PlatformDetectorBase.cs.meta
    │   │   │   ├── WindowsPlatformDetector.cs
    │   │   │   └── WindowsPlatformDetector.cs.meta
    │   │   └── PlatformDetectors.meta
    │   ├── Dependencies.meta
    │   ├── External
    │   │   ├── Tommy.cs
    │   │   └── Tommy.cs.meta
    │   ├── External.meta
    │   ├── Helpers
    │   │   ├── AssetPathUtility.cs
    │   │   ├── AssetPathUtility.cs.meta
    │   │   ├── CodexConfigHelper.cs
    │   │   ├── CodexConfigHelper.cs.meta
    │   │   ├── ConfigJsonBuilder.cs
    │   │   ├── ConfigJsonBuilder.cs.meta
    │   │   ├── ExecPath.cs
    │   │   ├── ExecPath.cs.meta
    │   │   ├── GameObjectSerializer.cs
    │   │   ├── GameObjectSerializer.cs.meta
    │   │   ├── McpConfigFileHelper.cs
    │   │   ├── McpConfigFileHelper.cs.meta
    │   │   ├── McpConfigurationHelper.cs
    │   │   ├── McpConfigurationHelper.cs.meta
    │   │   ├── McpLog.cs
    │   │   ├── McpLog.cs.meta
    │   │   ├── McpPathResolver.cs
    │   │   ├── McpPathResolver.cs.meta
    │   │   ├── PackageDetector.cs
    │   │   ├── PackageDetector.cs.meta
    │   │   ├── PackageInstaller.cs
    │   │   ├── PackageInstaller.cs.meta
    │   │   ├── PortManager.cs
    │   │   ├── PortManager.cs.meta
    │   │   ├── Response.cs
    │   │   ├── Response.cs.meta
    │   │   ├── ServerInstaller.cs
    │   │   ├── ServerInstaller.cs.meta
    │   │   ├── ServerPathResolver.cs
    │   │   ├── ServerPathResolver.cs.meta
    │   │   ├── TelemetryHelper.cs
    │   │   ├── TelemetryHelper.cs.meta
    │   │   ├── Vector3Helper.cs
    │   │   └── Vector3Helper.cs.meta
    │   ├── Helpers.meta
    │   ├── MCPForUnity.Editor.asmdef
    │   ├── MCPForUnity.Editor.asmdef.meta
    │   ├── MCPForUnityBridge.cs
    │   ├── MCPForUnityBridge.cs.meta
    │   ├── Models
    │   │   ├── Command.cs
    │   │   ├── Command.cs.meta
    │   │   ├── McpClient.cs
    │   │   ├── McpClient.cs.meta
    │   │   ├── McpConfig.cs
    │   │   ├── McpConfig.cs.meta
    │   │   ├── MCPConfigServer.cs
    │   │   ├── MCPConfigServer.cs.meta
    │   │   ├── MCPConfigServers.cs
    │   │   ├── MCPConfigServers.cs.meta
    │   │   ├── McpStatus.cs
    │   │   ├── McpStatus.cs.meta
    │   │   ├── McpTypes.cs
    │   │   ├── McpTypes.cs.meta
    │   │   ├── ServerConfig.cs
    │   │   └── ServerConfig.cs.meta
    │   ├── Models.meta
    │   ├── Setup
    │   │   ├── SetupWizard.cs
    │   │   ├── SetupWizard.cs.meta
    │   │   ├── SetupWizardWindow.cs
    │   │   └── SetupWizardWindow.cs.meta
    │   ├── Setup.meta
    │   ├── Tools
    │   │   ├── CommandRegistry.cs
    │   │   ├── CommandRegistry.cs.meta
    │   │   ├── ManageAsset.cs
    │   │   ├── ManageAsset.cs.meta
    │   │   ├── ManageEditor.cs
    │   │   ├── ManageEditor.cs.meta
    │   │   ├── ManageGameObject.cs
    │   │   ├── ManageGameObject.cs.meta
    │   │   ├── ManageScene.cs
    │   │   ├── ManageScene.cs.meta
    │   │   ├── ManageScript.cs
    │   │   ├── ManageScript.cs.meta
    │   │   ├── ManageShader.cs
    │   │   ├── ManageShader.cs.meta
    │   │   ├── McpForUnityToolAttribute.cs
    │   │   ├── McpForUnityToolAttribute.cs.meta
    │   │   ├── MenuItems
    │   │   │   ├── ManageMenuItem.cs
    │   │   │   ├── ManageMenuItem.cs.meta
    │   │   │   ├── MenuItemExecutor.cs
    │   │   │   ├── MenuItemExecutor.cs.meta
    │   │   │   ├── MenuItemsReader.cs
    │   │   │   └── MenuItemsReader.cs.meta
    │   │   ├── MenuItems.meta
    │   │   ├── Prefabs
    │   │   │   ├── ManagePrefabs.cs
    │   │   │   └── ManagePrefabs.cs.meta
    │   │   ├── Prefabs.meta
    │   │   ├── ReadConsole.cs
    │   │   └── ReadConsole.cs.meta
    │   ├── Tools.meta
    │   ├── Windows
    │   │   ├── ManualConfigEditorWindow.cs
    │   │   ├── ManualConfigEditorWindow.cs.meta
    │   │   ├── MCPForUnityEditorWindow.cs
    │   │   ├── MCPForUnityEditorWindow.cs.meta
    │   │   ├── VSCodeManualSetupWindow.cs
    │   │   └── VSCodeManualSetupWindow.cs.meta
    │   └── Windows.meta
    ├── Editor.meta
    ├── package.json
    ├── package.json.meta
    ├── README.md
    ├── README.md.meta
    ├── Runtime
    │   ├── MCPForUnity.Runtime.asmdef
    │   ├── MCPForUnity.Runtime.asmdef.meta
    │   ├── Serialization
    │   │   ├── UnityTypeConverters.cs
    │   │   └── UnityTypeConverters.cs.meta
    │   └── Serialization.meta
    ├── Runtime.meta
    └── UnityMcpServer~
        └── src
            ├── __init__.py
            ├── config.py
            ├── Dockerfile
            ├── port_discovery.py
            ├── pyproject.toml
            ├── pyrightconfig.json
            ├── registry
            │   ├── __init__.py
            │   └── tool_registry.py
            ├── reload_sentinel.py
            ├── server_version.txt
            ├── server.py
            ├── telemetry_decorator.py
            ├── telemetry.py
            ├── test_telemetry.py
            ├── tools
            │   ├── __init__.py
            │   ├── manage_asset.py
            │   ├── manage_editor.py
            │   ├── manage_gameobject.py
            │   ├── manage_menu_item.py
            │   ├── manage_prefabs.py
            │   ├── manage_scene.py
            │   ├── manage_script.py
            │   ├── manage_shader.py
            │   ├── read_console.py
            │   ├── resource_tools.py
            │   └── script_apply_edits.py
            ├── unity_connection.py
            └── uv.lock
```

# Files

--------------------------------------------------------------------------------
/UnityMcpBridge/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 | 
```

--------------------------------------------------------------------------------
/UnityMcpBridge/Editor/MCPForUnityBridge.cs:
--------------------------------------------------------------------------------

```csharp
   1 | using System;
   2 | using System.Collections.Generic;
   3 | using System.Collections.Concurrent;
   4 | using System.IO;
   5 | using System.Linq;
   6 | using System.Net;
   7 | using System.Net.Sockets;
   8 | using System.Threading;
   9 | using System.Threading.Tasks;
  10 | using Newtonsoft.Json;
  11 | using Newtonsoft.Json.Linq;
  12 | using UnityEditor;
  13 | using UnityEngine;
  14 | using MCPForUnity.Editor.Helpers;
  15 | using MCPForUnity.Editor.Models;
  16 | using MCPForUnity.Editor.Tools;
  17 | using MCPForUnity.Editor.Tools.MenuItems;
  18 | using MCPForUnity.Editor.Tools.Prefabs;
  19 | 
  20 | namespace MCPForUnity.Editor
  21 | {
  22 |     [InitializeOnLoad]
  23 |     public static partial class MCPForUnityBridge
  24 |     {
  25 |         private static TcpListener listener;
  26 |         private static bool isRunning = false;
  27 |         private static readonly object lockObj = new();
  28 |         private static readonly object startStopLock = new();
  29 |         private static readonly object clientsLock = new();
  30 |         private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
  31 |         // Single-writer outbox for framed responses
  32 |         private class Outbound
  33 |         {
  34 |             public byte[] Payload;
  35 |             public string Tag;
  36 |             public int? ReqId;
  37 |         }
  38 |         private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
  39 |         private static CancellationTokenSource cts;
  40 |         private static Task listenerTask;
  41 |         private static int processingCommands = 0;
  42 |         private static bool initScheduled = false;
  43 |         private static bool ensureUpdateHooked = false;
  44 |         private static bool isStarting = false;
  45 |         private static double nextStartAt = 0.0f;
  46 |         private static double nextHeartbeatAt = 0.0f;
  47 |         private static int heartbeatSeq = 0;
  48 |         private static Dictionary<
  49 |             string,
  50 |             (string commandJson, TaskCompletionSource<string> tcs)
  51 |         > commandQueue = new();
  52 |         private static int mainThreadId;
  53 |         private static int currentUnityPort = 6400; // Dynamic port, starts with default
  54 |         private static bool isAutoConnectMode = false;
  55 |         private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
  56 |         private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
  57 | 
  58 |         // IO diagnostics
  59 |         private static long _ioSeq = 0;
  60 |         private static void IoInfo(string s) { McpLog.Info(s, always: false); }
  61 | 
  62 |         // Debug helpers
  63 |         private static bool IsDebugEnabled()
  64 |         {
  65 |             try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; }
  66 |         }
  67 | 
  68 |         private static void LogBreadcrumb(string stage)
  69 |         {
  70 |             if (IsDebugEnabled())
  71 |             {
  72 |                 McpLog.Info($"[{stage}]", always: false);
  73 |             }
  74 |         }
  75 | 
  76 |         public static bool IsRunning => isRunning;
  77 |         public static int GetCurrentPort() => currentUnityPort;
  78 |         public static bool IsAutoConnectMode() => isAutoConnectMode;
  79 | 
  80 |         /// <summary>
  81 |         /// Start with Auto-Connect mode - discovers new port and saves it
  82 |         /// </summary>
  83 |         public static void StartAutoConnect()
  84 |         {
  85 |             Stop(); // Stop current connection
  86 | 
  87 |             try
  88 |             {
  89 |                 // Prefer stored project port and start using the robust Start() path (with retries/options)
  90 |                 currentUnityPort = PortManager.GetPortWithFallback();
  91 |                 Start();
  92 |                 isAutoConnectMode = true;
  93 | 
  94 |                 // Record telemetry for bridge startup
  95 |                 TelemetryHelper.RecordBridgeStartup();
  96 |             }
  97 |             catch (Exception ex)
  98 |             {
  99 |                 Debug.LogError($"Auto-connect failed: {ex.Message}");
 100 | 
 101 |                 // Record telemetry for connection failure
 102 |                 TelemetryHelper.RecordBridgeConnection(false, ex.Message);
 103 |                 throw;
 104 |             }
 105 |         }
 106 | 
 107 |         public static bool FolderExists(string path)
 108 |         {
 109 |             if (string.IsNullOrEmpty(path))
 110 |             {
 111 |                 return false;
 112 |             }
 113 | 
 114 |             if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase))
 115 |             {
 116 |                 return true;
 117 |             }
 118 | 
 119 |             string fullPath = Path.Combine(
 120 |                 Application.dataPath,
 121 |                 path.StartsWith("Assets/") ? path[7..] : path
 122 |             );
 123 |             return Directory.Exists(fullPath);
 124 |         }
 125 | 
 126 |         static MCPForUnityBridge()
 127 |         {
 128 |             // Record the main thread ID for safe thread checks
 129 |             try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
 130 |             // Start single writer thread for framed responses
 131 |             try
 132 |             {
 133 |                 var writerThread = new Thread(() =>
 134 |                 {
 135 |                     foreach (var item in _outbox.GetConsumingEnumerable())
 136 |                     {
 137 |                         try
 138 |                         {
 139 |                             long seq = Interlocked.Increment(ref _ioSeq);
 140 |                             IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
 141 |                             var sw = System.Diagnostics.Stopwatch.StartNew();
 142 |                             // Note: We currently have a per-connection 'stream' in the client handler. For simplicity,
 143 |                             // writes are performed inline there. This outbox provides single-writer semantics; if a shared
 144 |                             // stream is introduced, redirect here accordingly.
 145 |                             // No-op: actual write happens in client loop using WriteFrameAsync
 146 |                             sw.Stop();
 147 |                             IoInfo($"[IO] ✓ write end   tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
 148 |                         }
 149 |                         catch (Exception ex)
 150 |                         {
 151 |                             IoInfo($"[IO] ✗ write FAIL  tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
 152 |                         }
 153 |                     }
 154 |                 })
 155 |                 { IsBackground = true, Name = "MCP-Writer" };
 156 |                 writerThread.Start();
 157 |             }
 158 |             catch { }
 159 | 
 160 |             // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
 161 |             // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
 162 |             if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
 163 |             {
 164 |                 return;
 165 |             }
 166 |             // Defer start until the editor is idle and not compiling
 167 |             ScheduleInitRetry();
 168 |             // Add a safety net update hook in case delayCall is missed during reload churn
 169 |             if (!ensureUpdateHooked)
 170 |             {
 171 |                 ensureUpdateHooked = true;
 172 |                 EditorApplication.update += EnsureStartedOnEditorIdle;
 173 |             }
 174 |             EditorApplication.quitting += Stop;
 175 |             AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
 176 |             AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
 177 |             // Also coalesce play mode transitions into a deferred init
 178 |             EditorApplication.playModeStateChanged += _ => ScheduleInitRetry();
 179 |         }
 180 | 
 181 |         /// <summary>
 182 |         /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete.
 183 |         /// This prevents repeated restarts during script compilation that cause port hopping.
 184 |         /// </summary>
 185 |         private static void InitializeAfterCompilation()
 186 |         {
 187 |             initScheduled = false;
 188 | 
 189 |             // Play-mode friendly: allow starting in play mode; only defer while compiling
 190 |             if (IsCompiling())
 191 |             {
 192 |                 ScheduleInitRetry();
 193 |                 return;
 194 |             }
 195 | 
 196 |             if (!isRunning)
 197 |             {
 198 |                 Start();
 199 |                 if (!isRunning)
 200 |                 {
 201 |                     // If a race prevented start, retry later
 202 |                     ScheduleInitRetry();
 203 |                 }
 204 |             }
 205 |         }
 206 | 
 207 |         private static void ScheduleInitRetry()
 208 |         {
 209 |             if (initScheduled)
 210 |             {
 211 |                 return;
 212 |             }
 213 |             initScheduled = true;
 214 |             // Debounce: start ~200ms after the last trigger
 215 |             nextStartAt = EditorApplication.timeSinceStartup + 0.20f;
 216 |             // Ensure the update pump is active
 217 |             if (!ensureUpdateHooked)
 218 |             {
 219 |                 ensureUpdateHooked = true;
 220 |                 EditorApplication.update += EnsureStartedOnEditorIdle;
 221 |             }
 222 |             // Keep the original delayCall as a secondary path
 223 |             EditorApplication.delayCall += InitializeAfterCompilation;
 224 |         }
 225 | 
 226 |         // Safety net: ensure the bridge starts shortly after domain reload when editor is idle
 227 |         private static void EnsureStartedOnEditorIdle()
 228 |         {
 229 |             // Do nothing while compiling
 230 |             if (IsCompiling())
 231 |             {
 232 |                 return;
 233 |             }
 234 | 
 235 |             // If already running, remove the hook
 236 |             if (isRunning)
 237 |             {
 238 |                 EditorApplication.update -= EnsureStartedOnEditorIdle;
 239 |                 ensureUpdateHooked = false;
 240 |                 return;
 241 |             }
 242 | 
 243 |             // Debounced start: wait until the scheduled time
 244 |             if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
 245 |             {
 246 |                 return;
 247 |             }
 248 | 
 249 |             if (isStarting)
 250 |             {
 251 |                 return;
 252 |             }
 253 | 
 254 |             isStarting = true;
 255 |             try
 256 |             {
 257 |                 // Attempt start; if it succeeds, remove the hook to avoid overhead
 258 |                 Start();
 259 |             }
 260 |             finally
 261 |             {
 262 |                 isStarting = false;
 263 |             }
 264 |             if (isRunning)
 265 |             {
 266 |                 EditorApplication.update -= EnsureStartedOnEditorIdle;
 267 |                 ensureUpdateHooked = false;
 268 |             }
 269 |         }
 270 | 
 271 |         // Helper to check compilation status across Unity versions
 272 |         private static bool IsCompiling()
 273 |         {
 274 |             if (EditorApplication.isCompiling)
 275 |             {
 276 |                 return true;
 277 |             }
 278 |             try
 279 |             {
 280 |                 System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
 281 |                 var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
 282 |                 if (prop != null)
 283 |                 {
 284 |                     return (bool)prop.GetValue(null);
 285 |                 }
 286 |             }
 287 |             catch { }
 288 |             return false;
 289 |         }
 290 | 
 291 |         public static void Start()
 292 |         {
 293 |             lock (startStopLock)
 294 |             {
 295 |                 // Don't restart if already running on a working port
 296 |                 if (isRunning && listener != null)
 297 |                 {
 298 |                     if (IsDebugEnabled())
 299 |                     {
 300 |                         Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
 301 |                     }
 302 |                     return;
 303 |                 }
 304 | 
 305 |                 Stop();
 306 | 
 307 |                 // Attempt fast bind with stored-port preference (sticky per-project)
 308 |                 try
 309 |                 {
 310 |                     // Always consult PortManager first so we prefer the persisted project port
 311 |                     currentUnityPort = PortManager.GetPortWithFallback();
 312 | 
 313 |                     // Breadcrumb: Start
 314 |                     LogBreadcrumb("Start");
 315 | 
 316 |                     const int maxImmediateRetries = 3;
 317 |                     const int retrySleepMs = 75;
 318 |                     int attempt = 0;
 319 |                     for (; ; )
 320 |                     {
 321 |                         try
 322 |                         {
 323 |                             listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
 324 |                             listener.Server.SetSocketOption(
 325 |                                 SocketOptionLevel.Socket,
 326 |                                 SocketOptionName.ReuseAddress,
 327 |                                 true
 328 |                             );
 329 | #if UNITY_EDITOR_WIN
 330 |                             try
 331 |                             {
 332 |                                 listener.ExclusiveAddressUse = false;
 333 |                             }
 334 |                             catch { }
 335 | #endif
 336 |                             // Minimize TIME_WAIT by sending RST on close
 337 |                             try
 338 |                             {
 339 |                                 listener.Server.LingerState = new LingerOption(true, 0);
 340 |                             }
 341 |                             catch (Exception)
 342 |                             {
 343 |                                 // Ignore if not supported on platform
 344 |                             }
 345 |                             listener.Start();
 346 |                             break;
 347 |                         }
 348 |                         catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries)
 349 |                         {
 350 |                             attempt++;
 351 |                             Thread.Sleep(retrySleepMs);
 352 |                             continue;
 353 |                         }
 354 |                         catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
 355 |                         {
 356 |                             currentUnityPort = PortManager.GetPortWithFallback();
 357 |                             listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
 358 |                             listener.Server.SetSocketOption(
 359 |                                 SocketOptionLevel.Socket,
 360 |                                 SocketOptionName.ReuseAddress,
 361 |                                 true
 362 |                             );
 363 | #if UNITY_EDITOR_WIN
 364 |                             try
 365 |                             {
 366 |                                 listener.ExclusiveAddressUse = false;
 367 |                             }
 368 |                             catch { }
 369 | #endif
 370 |                             try
 371 |                             {
 372 |                                 listener.Server.LingerState = new LingerOption(true, 0);
 373 |                             }
 374 |                             catch (Exception)
 375 |                             {
 376 |                             }
 377 |                             listener.Start();
 378 |                             break;
 379 |                         }
 380 |                     }
 381 | 
 382 |                     isRunning = true;
 383 |                     isAutoConnectMode = false;
 384 |                     string platform = Application.platform.ToString();
 385 |                     string serverVer = ReadInstalledServerVersionSafe();
 386 |                     Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
 387 |                     // Start background listener with cooperative cancellation
 388 |                     cts = new CancellationTokenSource();
 389 |                     listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
 390 |                     CommandRegistry.Initialize();
 391 |                     EditorApplication.update += ProcessCommands;
 392 |                     // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
 393 |                     try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
 394 |                     try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
 395 |                     try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
 396 |                     try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
 397 |                     try { EditorApplication.quitting -= Stop; } catch { }
 398 |                     try { EditorApplication.quitting += Stop; } catch { }
 399 |                     // Write initial heartbeat immediately
 400 |                     heartbeatSeq++;
 401 |                     WriteHeartbeat(false, "ready");
 402 |                     nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f;
 403 |                 }
 404 |                 catch (SocketException ex)
 405 |                 {
 406 |                     Debug.LogError($"Failed to start TCP listener: {ex.Message}");
 407 |                 }
 408 |             }
 409 |         }
 410 | 
 411 |         public static void Stop()
 412 |         {
 413 |             Task toWait = null;
 414 |             lock (startStopLock)
 415 |             {
 416 |                 if (!isRunning)
 417 |                 {
 418 |                     return;
 419 |                 }
 420 | 
 421 |                 try
 422 |                 {
 423 |                     // Mark as stopping early to avoid accept logging during disposal
 424 |                     isRunning = false;
 425 | 
 426 |                     // Quiesce background listener quickly
 427 |                     var cancel = cts;
 428 |                     cts = null;
 429 |                     try { cancel?.Cancel(); } catch { }
 430 | 
 431 |                     try { listener?.Stop(); } catch { }
 432 |                     listener = null;
 433 | 
 434 |                     // Capture background task to wait briefly outside the lock
 435 |                     toWait = listenerTask;
 436 |                     listenerTask = null;
 437 |                 }
 438 |                 catch (Exception ex)
 439 |                 {
 440 |                     Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
 441 |                 }
 442 |             }
 443 | 
 444 |             // Proactively close all active client sockets to unblock any pending reads
 445 |             TcpClient[] toClose;
 446 |             lock (clientsLock)
 447 |             {
 448 |                 toClose = activeClients.ToArray();
 449 |                 activeClients.Clear();
 450 |             }
 451 |             foreach (var c in toClose)
 452 |             {
 453 |                 try { c.Close(); } catch { }
 454 |             }
 455 | 
 456 |             // Give the background loop a short window to exit without blocking the editor
 457 |             if (toWait != null)
 458 |             {
 459 |                 try { toWait.Wait(100); } catch { }
 460 |             }
 461 | 
 462 |             // Now unhook editor events safely
 463 |             try { EditorApplication.update -= ProcessCommands; } catch { }
 464 |             try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
 465 |             try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
 466 |             try { EditorApplication.quitting -= Stop; } catch { }
 467 | 
 468 |             if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
 469 |         }
 470 | 
 471 |         private static async Task ListenerLoopAsync(CancellationToken token)
 472 |         {
 473 |             while (isRunning && !token.IsCancellationRequested)
 474 |             {
 475 |                 try
 476 |                 {
 477 |                     TcpClient client = await listener.AcceptTcpClientAsync();
 478 |                     // Enable basic socket keepalive
 479 |                     client.Client.SetSocketOption(
 480 |                         SocketOptionLevel.Socket,
 481 |                         SocketOptionName.KeepAlive,
 482 |                         true
 483 |                     );
 484 | 
 485 |                     // Set longer receive timeout to prevent quick disconnections
 486 |                     client.ReceiveTimeout = 60000; // 60 seconds
 487 | 
 488 |                     // Fire and forget each client connection
 489 |                     _ = Task.Run(() => HandleClientAsync(client, token), token);
 490 |                 }
 491 |                 catch (ObjectDisposedException)
 492 |                 {
 493 |                     // Listener was disposed during stop/reload; exit quietly
 494 |                     if (!isRunning || token.IsCancellationRequested)
 495 |                     {
 496 |                         break;
 497 |                     }
 498 |                 }
 499 |                 catch (OperationCanceledException)
 500 |                 {
 501 |                     break;
 502 |                 }
 503 |                 catch (Exception ex)
 504 |                 {
 505 |                     if (isRunning && !token.IsCancellationRequested)
 506 |                     {
 507 |                         if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
 508 |                     }
 509 |                 }
 510 |             }
 511 |         }
 512 | 
 513 |         private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
 514 |         {
 515 |             using (client)
 516 |             using (NetworkStream stream = client.GetStream())
 517 |             {
 518 |                 lock (clientsLock) { activeClients.Add(client); }
 519 |                 try
 520 |                 {
 521 |                     // Framed I/O only; legacy mode removed
 522 |                     try
 523 |                     {
 524 |                         if (IsDebugEnabled())
 525 |                         {
 526 |                             var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
 527 |                             Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
 528 |                         }
 529 |                     }
 530 |                     catch { }
 531 |                     // Strict framing: always require FRAMING=1 and frame all I/O
 532 |                     try
 533 |                     {
 534 |                         client.NoDelay = true;
 535 |                     }
 536 |                     catch { }
 537 |                     try
 538 |                     {
 539 |                         string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
 540 |                         byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
 541 |                         using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
 542 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 543 |                         await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false);
 544 | #else
 545 |                     await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
 546 | #endif
 547 |                         if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
 548 |                     }
 549 |                     catch (Exception ex)
 550 |                     {
 551 |                         if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
 552 |                         return; // abort this client
 553 |                     }
 554 | 
 555 |                     while (isRunning && !token.IsCancellationRequested)
 556 |                     {
 557 |                         try
 558 |                         {
 559 |                             // Strict framed mode only: enforced framed I/O for this connection
 560 |                             string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
 561 | 
 562 |                             try
 563 |                             {
 564 |                                 if (IsDebugEnabled())
 565 |                                 {
 566 |                                     var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
 567 |                                     MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
 568 |                                 }
 569 |                             }
 570 |                             catch { }
 571 |                             string commandId = Guid.NewGuid().ToString();
 572 |                             var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
 573 | 
 574 |                             // Special handling for ping command to avoid JSON parsing
 575 |                             if (commandText.Trim() == "ping")
 576 |                             {
 577 |                                 // Direct response to ping without going through JSON parsing
 578 |                                 byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
 579 |                                     /*lang=json,strict*/
 580 |                                     "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
 581 |                                 );
 582 |                                 await WriteFrameAsync(stream, pingResponseBytes);
 583 |                                 continue;
 584 |                             }
 585 | 
 586 |                             lock (lockObj)
 587 |                             {
 588 |                                 commandQueue[commandId] = (commandText, tcs);
 589 |                             }
 590 | 
 591 |                             // Wait for the handler to produce a response, but do not block indefinitely
 592 |                             string response;
 593 |                             try
 594 |                             {
 595 |                                 using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
 596 |                                 var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
 597 |                                 if (completed == tcs.Task)
 598 |                                 {
 599 |                                     // Got a result from the handler
 600 |                                     respCts.Cancel();
 601 |                                     response = tcs.Task.Result;
 602 |                                 }
 603 |                                 else
 604 |                                 {
 605 |                                     // Timeout: return a structured error so the client can recover
 606 |                                     var timeoutResponse = new
 607 |                                     {
 608 |                                         status = "error",
 609 |                                         error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
 610 |                                     };
 611 |                                     response = JsonConvert.SerializeObject(timeoutResponse);
 612 |                                 }
 613 |                             }
 614 |                             catch (Exception ex)
 615 |                             {
 616 |                                 var errorResponse = new
 617 |                                 {
 618 |                                     status = "error",
 619 |                                     error = ex.Message,
 620 |                                 };
 621 |                                 response = JsonConvert.SerializeObject(errorResponse);
 622 |                             }
 623 | 
 624 |                             if (IsDebugEnabled())
 625 |                             {
 626 |                                 try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
 627 |                             }
 628 |                             // Crash-proof and self-reporting writer logs (direct write to this client's stream)
 629 |                             long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
 630 |                             byte[] responseBytes;
 631 |                             try
 632 |                             {
 633 |                                 responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
 634 |                                 IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
 635 |                             }
 636 |                             catch (Exception ex)
 637 |                             {
 638 |                                 IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
 639 |                                 throw;
 640 |                             }
 641 | 
 642 |                             var swDirect = System.Diagnostics.Stopwatch.StartNew();
 643 |                             try
 644 |                             {
 645 |                                 await WriteFrameAsync(stream, responseBytes);
 646 |                                 swDirect.Stop();
 647 |                                 IoInfo($"[IO] ✓ write end   tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
 648 |                             }
 649 |                             catch (Exception ex)
 650 |                             {
 651 |                                 IoInfo($"[IO] ✗ write FAIL  tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
 652 |                                 throw;
 653 |                             }
 654 |                         }
 655 |                         catch (Exception ex)
 656 |                         {
 657 |                             // Treat common disconnects/timeouts as benign; only surface hard errors
 658 |                             string msg = ex.Message ?? string.Empty;
 659 |                             bool isBenign =
 660 |                                 msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
 661 |                                 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
 662 |                                 || ex is System.IO.IOException;
 663 |                             if (isBenign)
 664 |                             {
 665 |                                 if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false);
 666 |                             }
 667 |                             else
 668 |                             {
 669 |                                 MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}");
 670 |                             }
 671 |                             break;
 672 |                         }
 673 |                     }
 674 |                 }
 675 |                 finally
 676 |                 {
 677 |                     lock (clientsLock) { activeClients.Remove(client); }
 678 |                 }
 679 |             }
 680 |         }
 681 | 
 682 |         // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks
 683 |         private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
 684 |         {
 685 |             byte[] buffer = new byte[count];
 686 |             int offset = 0;
 687 |             var stopwatch = System.Diagnostics.Stopwatch.StartNew();
 688 | 
 689 |             while (offset < count)
 690 |             {
 691 |                 int remaining = count - offset;
 692 |                 int remainingTimeout = timeoutMs <= 0
 693 |                     ? Timeout.Infinite
 694 |                     : timeoutMs - (int)stopwatch.ElapsedMilliseconds;
 695 | 
 696 |                 // If a finite timeout is configured and already elapsed, fail immediately
 697 |                 if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0)
 698 |                 {
 699 |                     throw new System.IO.IOException("Read timed out");
 700 |                 }
 701 | 
 702 |                 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel);
 703 |                 if (remainingTimeout != Timeout.Infinite)
 704 |                 {
 705 |                     cts.CancelAfter(remainingTimeout);
 706 |                 }
 707 | 
 708 |                 try
 709 |                 {
 710 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 711 |                     int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false);
 712 | #else
 713 |                     int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false);
 714 | #endif
 715 |                     if (read == 0)
 716 |                     {
 717 |                         throw new System.IO.IOException("Connection closed before reading expected bytes");
 718 |                     }
 719 |                     offset += read;
 720 |                 }
 721 |                 catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
 722 |                 {
 723 |                     throw new System.IO.IOException("Read timed out");
 724 |                 }
 725 |             }
 726 | 
 727 |             return buffer;
 728 |         }
 729 | 
 730 |         private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload)
 731 |         {
 732 |             using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
 733 |             await WriteFrameAsync(stream, payload, cts.Token);
 734 |         }
 735 | 
 736 |         private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel)
 737 |         {
 738 |             if (payload == null)
 739 |             {
 740 |                 throw new System.ArgumentNullException(nameof(payload));
 741 |             }
 742 |             if ((ulong)payload.LongLength > MaxFrameBytes)
 743 |             {
 744 |                 throw new System.IO.IOException($"Frame too large: {payload.LongLength}");
 745 |             }
 746 |             byte[] header = new byte[8];
 747 |             WriteUInt64BigEndian(header, (ulong)payload.LongLength);
 748 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 749 |             await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false);
 750 |             await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false);
 751 | #else
 752 |             await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false);
 753 |             await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false);
 754 | #endif
 755 |         }
 756 | 
 757 |         private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
 758 |         {
 759 |             byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
 760 |             ulong payloadLen = ReadUInt64BigEndian(header);
 761 |             if (payloadLen > MaxFrameBytes)
 762 |             {
 763 |                 throw new System.IO.IOException($"Invalid framed length: {payloadLen}");
 764 |             }
 765 |             if (payloadLen == 0UL)
 766 |                 throw new System.IO.IOException("Zero-length frames are not allowed");
 767 |             if (payloadLen > int.MaxValue)
 768 |             {
 769 |                 throw new System.IO.IOException("Frame too large for buffer");
 770 |             }
 771 |             int count = (int)payloadLen;
 772 |             byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
 773 |             return System.Text.Encoding.UTF8.GetString(payload);
 774 |         }
 775 | 
 776 |         private static ulong ReadUInt64BigEndian(byte[] buffer)
 777 |         {
 778 |             if (buffer == null || buffer.Length < 8) return 0UL;
 779 |             return ((ulong)buffer[0] << 56)
 780 |                  | ((ulong)buffer[1] << 48)
 781 |                  | ((ulong)buffer[2] << 40)
 782 |                  | ((ulong)buffer[3] << 32)
 783 |                  | ((ulong)buffer[4] << 24)
 784 |                  | ((ulong)buffer[5] << 16)
 785 |                  | ((ulong)buffer[6] << 8)
 786 |                  | buffer[7];
 787 |         }
 788 | 
 789 |         private static void WriteUInt64BigEndian(byte[] dest, ulong value)
 790 |         {
 791 |             if (dest == null || dest.Length < 8)
 792 |             {
 793 |                 throw new System.ArgumentException("Destination buffer too small for UInt64");
 794 |             }
 795 |             dest[0] = (byte)(value >> 56);
 796 |             dest[1] = (byte)(value >> 48);
 797 |             dest[2] = (byte)(value >> 40);
 798 |             dest[3] = (byte)(value >> 32);
 799 |             dest[4] = (byte)(value >> 24);
 800 |             dest[5] = (byte)(value >> 16);
 801 |             dest[6] = (byte)(value >> 8);
 802 |             dest[7] = (byte)(value);
 803 |         }
 804 | 
 805 |         private static void ProcessCommands()
 806 |         {
 807 |             if (!isRunning) return;
 808 |             if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
 809 |             try
 810 |             {
 811 |                 // Heartbeat without holding the queue lock
 812 |                 double now = EditorApplication.timeSinceStartup;
 813 |                 if (now >= nextHeartbeatAt)
 814 |                 {
 815 |                     WriteHeartbeat(false);
 816 |                     nextHeartbeatAt = now + 0.5f;
 817 |                 }
 818 | 
 819 |                 // Snapshot under lock, then process outside to reduce contention
 820 |                 List<(string id, string text, TaskCompletionSource<string> tcs)> work;
 821 |                 lock (lockObj)
 822 |                 {
 823 |                     work = commandQueue
 824 |                         .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs))
 825 |                         .ToList();
 826 |                 }
 827 | 
 828 |                 foreach (var item in work)
 829 |                 {
 830 |                     string id = item.id;
 831 |                     string commandText = item.text;
 832 |                     TaskCompletionSource<string> tcs = item.tcs;
 833 | 
 834 |                     try
 835 |                     {
 836 |                         // Special case handling
 837 |                         if (string.IsNullOrEmpty(commandText))
 838 |                         {
 839 |                             var emptyResponse = new
 840 |                             {
 841 |                                 status = "error",
 842 |                                 error = "Empty command received",
 843 |                             };
 844 |                             tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
 845 |                             // Remove quickly under lock
 846 |                             lock (lockObj) { commandQueue.Remove(id); }
 847 |                             continue;
 848 |                         }
 849 | 
 850 |                         // Trim the command text to remove any whitespace
 851 |                         commandText = commandText.Trim();
 852 | 
 853 |                         // Non-JSON direct commands handling (like ping)
 854 |                         if (commandText == "ping")
 855 |                         {
 856 |                             var pingResponse = new
 857 |                             {
 858 |                                 status = "success",
 859 |                                 result = new { message = "pong" },
 860 |                             };
 861 |                             tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
 862 |                             lock (lockObj) { commandQueue.Remove(id); }
 863 |                             continue;
 864 |                         }
 865 | 
 866 |                         // Check if the command is valid JSON before attempting to deserialize
 867 |                         if (!IsValidJson(commandText))
 868 |                         {
 869 |                             var invalidJsonResponse = new
 870 |                             {
 871 |                                 status = "error",
 872 |                                 error = "Invalid JSON format",
 873 |                                 receivedText = commandText.Length > 50
 874 |                                     ? commandText[..50] + "..."
 875 |                                     : commandText,
 876 |                             };
 877 |                             tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
 878 |                             lock (lockObj) { commandQueue.Remove(id); }
 879 |                             continue;
 880 |                         }
 881 | 
 882 |                         // Normal JSON command processing
 883 |                         Command command = JsonConvert.DeserializeObject<Command>(commandText);
 884 | 
 885 |                         if (command == null)
 886 |                         {
 887 |                             var nullCommandResponse = new
 888 |                             {
 889 |                                 status = "error",
 890 |                                 error = "Command deserialized to null",
 891 |                                 details = "The command was valid JSON but could not be deserialized to a Command object",
 892 |                             };
 893 |                             tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
 894 |                         }
 895 |                         else
 896 |                         {
 897 |                             string responseJson = ExecuteCommand(command);
 898 |                             tcs.SetResult(responseJson);
 899 |                         }
 900 |                     }
 901 |                     catch (Exception ex)
 902 |                     {
 903 |                         Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
 904 | 
 905 |                         var response = new
 906 |                         {
 907 |                             status = "error",
 908 |                             error = ex.Message,
 909 |                             commandType = "Unknown (error during processing)",
 910 |                             receivedText = commandText?.Length > 50
 911 |                                 ? commandText[..50] + "..."
 912 |                                 : commandText,
 913 |                         };
 914 |                         string responseJson = JsonConvert.SerializeObject(response);
 915 |                         tcs.SetResult(responseJson);
 916 |                     }
 917 | 
 918 |                     // Remove quickly under lock
 919 |                     lock (lockObj) { commandQueue.Remove(id); }
 920 |                 }
 921 |             }
 922 |             finally
 923 |             {
 924 |                 Interlocked.Exchange(ref processingCommands, 0);
 925 |             }
 926 |         }
 927 | 
 928 |         // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result.
 929 |         // Returns null on timeout or error; caller should provide a fallback error response.
 930 |         private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
 931 |         {
 932 |             if (func == null) return null;
 933 |             try
 934 |             {
 935 |                 // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor.
 936 |                 if (mainThreadId == 0)
 937 |                 {
 938 |                     try { return func(); }
 939 |                     catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); }
 940 |                 }
 941 |                 // If we are already on the main thread, execute directly to avoid deadlocks
 942 |                 try
 943 |                 {
 944 |                     if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
 945 |                     {
 946 |                         return func();
 947 |                     }
 948 |                 }
 949 |                 catch { }
 950 | 
 951 |                 object result = null;
 952 |                 Exception captured = null;
 953 |                 var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
 954 |                 EditorApplication.delayCall += () =>
 955 |                 {
 956 |                     try
 957 |                     {
 958 |                         result = func();
 959 |                     }
 960 |                     catch (Exception ex)
 961 |                     {
 962 |                         captured = ex;
 963 |                     }
 964 |                     finally
 965 |                     {
 966 |                         try { tcs.TrySetResult(true); } catch { }
 967 |                     }
 968 |                 };
 969 | 
 970 |                 // Wait for completion with timeout (Editor thread will pump delayCall)
 971 |                 bool completed = tcs.Task.Wait(timeoutMs);
 972 |                 if (!completed)
 973 |                 {
 974 |                     return null; // timeout
 975 |                 }
 976 |                 if (captured != null)
 977 |                 {
 978 |                     throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured);
 979 |                 }
 980 |                 return result;
 981 |             }
 982 |             catch (Exception ex)
 983 |             {
 984 |                 throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex);
 985 |             }
 986 |         }
 987 | 
 988 |         // Helper method to check if a string is valid JSON
 989 |         private static bool IsValidJson(string text)
 990 |         {
 991 |             if (string.IsNullOrWhiteSpace(text))
 992 |             {
 993 |                 return false;
 994 |             }
 995 | 
 996 |             text = text.Trim();
 997 |             if (
 998 |                 (text.StartsWith("{") && text.EndsWith("}"))
 999 |                 || // Object
1000 |                 (text.StartsWith("[") && text.EndsWith("]"))
1001 |             ) // Array
1002 |             {
1003 |                 try
1004 |                 {
1005 |                     JToken.Parse(text);
1006 |                     return true;
1007 |                 }
1008 |                 catch
1009 |                 {
1010 |                     return false;
1011 |                 }
1012 |             }
1013 | 
1014 |             return false;
1015 |         }
1016 | 
1017 |         private static string ExecuteCommand(Command command)
1018 |         {
1019 |             try
1020 |             {
1021 |                 if (string.IsNullOrEmpty(command.type))
1022 |                 {
1023 |                     var errorResponse = new
1024 |                     {
1025 |                         status = "error",
1026 |                         error = "Command type cannot be empty",
1027 |                         details = "A valid command type is required for processing",
1028 |                     };
1029 |                     return JsonConvert.SerializeObject(errorResponse);
1030 |                 }
1031 | 
1032 |                 // Handle ping command for connection verification
1033 |                 if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase))
1034 |                 {
1035 |                     var pingResponse = new
1036 |                     {
1037 |                         status = "success",
1038 |                         result = new { message = "pong" },
1039 |                     };
1040 |                     return JsonConvert.SerializeObject(pingResponse);
1041 |                 }
1042 | 
1043 |                 // Use JObject for parameters as the new handlers likely expect this
1044 |                 JObject paramsObject = command.@params ?? new JObject();
1045 |                 object result = CommandRegistry.GetHandler(command.type)(paramsObject);
1046 | 
1047 |                 // Standard success response format
1048 |                 var response = new { status = "success", result };
1049 |                 return JsonConvert.SerializeObject(response);
1050 |             }
1051 |             catch (Exception ex)
1052 |             {
1053 |                 // Log the detailed error in Unity for debugging
1054 |                 Debug.LogError(
1055 |                     $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"
1056 |                 );
1057 | 
1058 |                 // Standard error response format
1059 |                 var response = new
1060 |                 {
1061 |                     status = "error",
1062 |                     error = ex.Message, // Provide the specific error message
1063 |                     command = command?.type ?? "Unknown", // Include the command type if available
1064 |                     stackTrace = ex.StackTrace, // Include stack trace for detailed debugging
1065 |                     paramsSummary = command?.@params != null
1066 |                         ? GetParamsSummary(command.@params)
1067 |                         : "No parameters", // Summarize parameters for context
1068 |                 };
1069 |                 return JsonConvert.SerializeObject(response);
1070 |             }
1071 |         }
1072 | 
1073 |         private static object HandleManageScene(JObject paramsObject)
1074 |         {
1075 |             try
1076 |             {
1077 |                 if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread");
1078 |                 var sw = System.Diagnostics.Stopwatch.StartNew();
1079 |                 var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs);
1080 |                 sw.Stop();
1081 |                 if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
1082 |                 return r ?? Response.Error("manage_scene returned null (timeout or error)");
1083 |             }
1084 |             catch (Exception ex)
1085 |             {
1086 |                 return Response.Error($"manage_scene dispatch error: {ex.Message}");
1087 |             }
1088 |         }
1089 | 
1090 |         // Helper method to get a summary of parameters for error reporting
1091 |         private static string GetParamsSummary(JObject @params)
1092 |         {
1093 |             try
1094 |             {
1095 |                 return @params == null || [email protected]
1096 |                     ? "No parameters"
1097 |                     : string.Join(
1098 |                         ", ",
1099 |                         @params
1100 |                             .Properties()
1101 |                             .Select(static p =>
1102 |                                 $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
1103 |                             )
1104 |                     );
1105 |             }
1106 |             catch
1107 |             {
1108 |                 return "Could not summarize parameters";
1109 |             }
1110 |         }
1111 | 
1112 |         // Heartbeat/status helpers
1113 |         private static void OnBeforeAssemblyReload()
1114 |         {
1115 |             // Stop cleanly before reload so sockets close and clients see 'reloading'
1116 |             try { Stop(); } catch { }
1117 |             // Avoid file I/O or heavy work here
1118 |         }
1119 | 
1120 |         private static void OnAfterAssemblyReload()
1121 |         {
1122 |             // Will be overwritten by Start(), but mark as alive quickly
1123 |             WriteHeartbeat(false, "idle");
1124 |             LogBreadcrumb("Idle");
1125 |             // Schedule a safe restart after reload to avoid races during compilation
1126 |             ScheduleInitRetry();
1127 |         }
1128 | 
1129 |         private static void WriteHeartbeat(bool reloading, string reason = null)
1130 |         {
1131 |             try
1132 |             {
1133 |                 // Allow override of status directory (useful in CI/containers)
1134 |                 string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR");
1135 |                 if (string.IsNullOrWhiteSpace(dir))
1136 |                 {
1137 |                     dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
1138 |                 }
1139 |                 Directory.CreateDirectory(dir);
1140 |                 string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
1141 |                 var payload = new
1142 |                 {
1143 |                     unity_port = currentUnityPort,
1144 |                     reloading,
1145 |                     reason = reason ?? (reloading ? "reloading" : "ready"),
1146 |                     seq = heartbeatSeq,
1147 |                     project_path = Application.dataPath,
1148 |                     last_heartbeat = DateTime.UtcNow.ToString("O")
1149 |                 };
1150 |                 File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false));
1151 |             }
1152 |             catch (Exception)
1153 |             {
1154 |                 // Best-effort only
1155 |             }
1156 |         }
1157 | 
1158 |         private static string ReadInstalledServerVersionSafe()
1159 |         {
1160 |             try
1161 |             {
1162 |                 string serverSrc = ServerInstaller.GetServerPath();
1163 |                 string verFile = Path.Combine(serverSrc, "server_version.txt");
1164 |                 if (File.Exists(verFile))
1165 |                 {
1166 |                     string v = File.ReadAllText(verFile)?.Trim();
1167 |                     if (!string.IsNullOrEmpty(v)) return v;
1168 |                 }
1169 |             }
1170 |             catch { }
1171 |             return "unknown";
1172 |         }
1173 | 
1174 |         private static string ComputeProjectHash(string input)
1175 |         {
1176 |             try
1177 |             {
1178 |                 using var sha1 = System.Security.Cryptography.SHA1.Create();
1179 |                 byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty);
1180 |                 byte[] hashBytes = sha1.ComputeHash(bytes);
1181 |                 var sb = new System.Text.StringBuilder();
1182 |                 foreach (byte b in hashBytes)
1183 |                 {
1184 |                     sb.Append(b.ToString("x2"));
1185 |                 }
1186 |                 return sb.ToString()[..8];
1187 |             }
1188 |             catch
1189 |             {
1190 |                 return "default";
1191 |             }
1192 |         }
1193 |     }
1194 | }
1195 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/Editor/MCPForUnityBridge.cs:
--------------------------------------------------------------------------------

```csharp
   1 | using System;
   2 | using System.Collections.Generic;
   3 | using System.Collections.Concurrent;
   4 | using System.IO;
   5 | using System.Linq;
   6 | using System.Net;
   7 | using System.Net.Sockets;
   8 | using System.Threading;
   9 | using System.Threading.Tasks;
  10 | using Newtonsoft.Json;
  11 | using Newtonsoft.Json.Linq;
  12 | using UnityEditor;
  13 | using UnityEngine;
  14 | using MCPForUnity.Editor.Helpers;
  15 | using MCPForUnity.Editor.Models;
  16 | using MCPForUnity.Editor.Tools;
  17 | using MCPForUnity.Editor.Tools.Prefabs;
  18 | 
  19 | namespace MCPForUnity.Editor
  20 | {
  21 | 
  22 |     /// <summary>
  23 |     /// Outbound message structure for the writer thread
  24 |     /// </summary>
  25 |     class Outbound
  26 |     {
  27 |         public byte[] Payload;
  28 |         public string Tag;
  29 |         public int? ReqId;
  30 |     }
  31 | 
  32 |     /// <summary>
  33 |     /// Queued command structure for main thread processing
  34 |     /// </summary>
  35 |     class QueuedCommand
  36 |     {
  37 |         public string CommandJson;
  38 |         public TaskCompletionSource<string> Tcs;
  39 |         public bool IsExecuting;
  40 |     }
  41 |     [InitializeOnLoad]
  42 |     public static partial class MCPForUnityBridge
  43 |     {
  44 |         private static TcpListener listener;
  45 |         private static bool isRunning = false;
  46 |         private static readonly object lockObj = new();
  47 |         private static readonly object startStopLock = new();
  48 |         private static readonly object clientsLock = new();
  49 |         private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
  50 |         private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
  51 |         private static CancellationTokenSource cts;
  52 |         private static Task listenerTask;
  53 |         private static int processingCommands = 0;
  54 |         private static bool initScheduled = false;
  55 |         private static bool ensureUpdateHooked = false;
  56 |         private static bool isStarting = false;
  57 |         private static double nextStartAt = 0.0f;
  58 |         private static double nextHeartbeatAt = 0.0f;
  59 |         private static int heartbeatSeq = 0;
  60 |         private static Dictionary<string, QueuedCommand> commandQueue = new();
  61 |         private static int mainThreadId;
  62 |         private static int currentUnityPort = 6400; // Dynamic port, starts with default
  63 |         private static bool isAutoConnectMode = false;
  64 |         private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
  65 |         private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
  66 | 
  67 |         // IO diagnostics
  68 |         private static long _ioSeq = 0;
  69 |         private static void IoInfo(string s) { McpLog.Info(s, always: false); }
  70 | 
  71 |         // Debug helpers
  72 |         private static bool IsDebugEnabled()
  73 |         {
  74 |             try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; }
  75 |         }
  76 | 
  77 |         private static void LogBreadcrumb(string stage)
  78 |         {
  79 |             if (IsDebugEnabled())
  80 |             {
  81 |                 McpLog.Info($"[{stage}]", always: false);
  82 |             }
  83 |         }
  84 | 
  85 |         public static bool IsRunning => isRunning;
  86 |         public static int GetCurrentPort() => currentUnityPort;
  87 |         public static bool IsAutoConnectMode() => isAutoConnectMode;
  88 | 
  89 |         /// <summary>
  90 |         /// Start with Auto-Connect mode - discovers new port and saves it
  91 |         /// </summary>
  92 |         public static void StartAutoConnect()
  93 |         {
  94 |             Stop(); // Stop current connection
  95 | 
  96 |             try
  97 |             {
  98 |                 // Prefer stored project port and start using the robust Start() path (with retries/options)
  99 |                 currentUnityPort = PortManager.GetPortWithFallback();
 100 |                 Start();
 101 |                 isAutoConnectMode = true;
 102 | 
 103 |                 // Record telemetry for bridge startup
 104 |                 TelemetryHelper.RecordBridgeStartup();
 105 |             }
 106 |             catch (Exception ex)
 107 |             {
 108 |                 McpLog.Error($"Auto-connect failed: {ex.Message}");
 109 | 
 110 |                 // Record telemetry for connection failure
 111 |                 TelemetryHelper.RecordBridgeConnection(false, ex.Message);
 112 |                 throw;
 113 |             }
 114 |         }
 115 | 
 116 |         public static bool FolderExists(string path)
 117 |         {
 118 |             if (string.IsNullOrEmpty(path))
 119 |             {
 120 |                 return false;
 121 |             }
 122 | 
 123 |             if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase))
 124 |             {
 125 |                 return true;
 126 |             }
 127 | 
 128 |             string fullPath = Path.Combine(
 129 |                 Application.dataPath,
 130 |                 path.StartsWith("Assets/") ? path[7..] : path
 131 |             );
 132 |             return Directory.Exists(fullPath);
 133 |         }
 134 | 
 135 |         static MCPForUnityBridge()
 136 |         {
 137 |             // Record the main thread ID for safe thread checks
 138 |             try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
 139 |             // Start single writer thread for framed responses
 140 |             try
 141 |             {
 142 |                 var writerThread = new Thread(() =>
 143 |                 {
 144 |                     foreach (var item in _outbox.GetConsumingEnumerable())
 145 |                     {
 146 |                         try
 147 |                         {
 148 |                             long seq = Interlocked.Increment(ref _ioSeq);
 149 |                             IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
 150 |                             var sw = System.Diagnostics.Stopwatch.StartNew();
 151 |                             // Note: We currently have a per-connection 'stream' in the client handler. For simplicity,
 152 |                             // writes are performed inline there. This outbox provides single-writer semantics; if a shared
 153 |                             // stream is introduced, redirect here accordingly.
 154 |                             // No-op: actual write happens in client loop using WriteFrameAsync
 155 |                             sw.Stop();
 156 |                             IoInfo($"[IO] ✓ write end   tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
 157 |                         }
 158 |                         catch (Exception ex)
 159 |                         {
 160 |                             IoInfo($"[IO] ✗ write FAIL  tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
 161 |                         }
 162 |                     }
 163 |                 })
 164 |                 { IsBackground = true, Name = "MCP-Writer" };
 165 |                 writerThread.Start();
 166 |             }
 167 |             catch { }
 168 | 
 169 |             // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
 170 |             // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
 171 |             if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
 172 |             {
 173 |                 return;
 174 |             }
 175 |             // Defer start until the editor is idle and not compiling
 176 |             ScheduleInitRetry();
 177 |             // Add a safety net update hook in case delayCall is missed during reload churn
 178 |             if (!ensureUpdateHooked)
 179 |             {
 180 |                 ensureUpdateHooked = true;
 181 |                 EditorApplication.update += EnsureStartedOnEditorIdle;
 182 |             }
 183 |             EditorApplication.quitting += Stop;
 184 |             AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
 185 |             AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
 186 |             // Also coalesce play mode transitions into a deferred init
 187 |             EditorApplication.playModeStateChanged += _ => ScheduleInitRetry();
 188 |         }
 189 | 
 190 |         /// <summary>
 191 |         /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete.
 192 |         /// This prevents repeated restarts during script compilation that cause port hopping.
 193 |         /// </summary>
 194 |         private static void InitializeAfterCompilation()
 195 |         {
 196 |             initScheduled = false;
 197 | 
 198 |             // Play-mode friendly: allow starting in play mode; only defer while compiling
 199 |             if (IsCompiling())
 200 |             {
 201 |                 ScheduleInitRetry();
 202 |                 return;
 203 |             }
 204 | 
 205 |             if (!isRunning)
 206 |             {
 207 |                 Start();
 208 |                 if (!isRunning)
 209 |                 {
 210 |                     // If a race prevented start, retry later
 211 |                     ScheduleInitRetry();
 212 |                 }
 213 |             }
 214 |         }
 215 | 
 216 |         private static void ScheduleInitRetry()
 217 |         {
 218 |             if (initScheduled)
 219 |             {
 220 |                 return;
 221 |             }
 222 |             initScheduled = true;
 223 |             // Debounce: start ~200ms after the last trigger
 224 |             nextStartAt = EditorApplication.timeSinceStartup + 0.20f;
 225 |             // Ensure the update pump is active
 226 |             if (!ensureUpdateHooked)
 227 |             {
 228 |                 ensureUpdateHooked = true;
 229 |                 EditorApplication.update += EnsureStartedOnEditorIdle;
 230 |             }
 231 |             // Keep the original delayCall as a secondary path
 232 |             EditorApplication.delayCall += InitializeAfterCompilation;
 233 |         }
 234 | 
 235 |         // Safety net: ensure the bridge starts shortly after domain reload when editor is idle
 236 |         private static void EnsureStartedOnEditorIdle()
 237 |         {
 238 |             // Do nothing while compiling
 239 |             if (IsCompiling())
 240 |             {
 241 |                 return;
 242 |             }
 243 | 
 244 |             // If already running, remove the hook
 245 |             if (isRunning)
 246 |             {
 247 |                 EditorApplication.update -= EnsureStartedOnEditorIdle;
 248 |                 ensureUpdateHooked = false;
 249 |                 return;
 250 |             }
 251 | 
 252 |             // Debounced start: wait until the scheduled time
 253 |             if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt)
 254 |             {
 255 |                 return;
 256 |             }
 257 | 
 258 |             if (isStarting)
 259 |             {
 260 |                 return;
 261 |             }
 262 | 
 263 |             isStarting = true;
 264 |             try
 265 |             {
 266 |                 // Attempt start; if it succeeds, remove the hook to avoid overhead
 267 |                 Start();
 268 |             }
 269 |             finally
 270 |             {
 271 |                 isStarting = false;
 272 |             }
 273 |             if (isRunning)
 274 |             {
 275 |                 EditorApplication.update -= EnsureStartedOnEditorIdle;
 276 |                 ensureUpdateHooked = false;
 277 |             }
 278 |         }
 279 | 
 280 |         // Helper to check compilation status across Unity versions
 281 |         private static bool IsCompiling()
 282 |         {
 283 |             if (EditorApplication.isCompiling)
 284 |             {
 285 |                 return true;
 286 |             }
 287 |             try
 288 |             {
 289 |                 System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
 290 |                 var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
 291 |                 if (prop != null)
 292 |                 {
 293 |                     return (bool)prop.GetValue(null);
 294 |                 }
 295 |             }
 296 |             catch { }
 297 |             return false;
 298 |         }
 299 | 
 300 |         public static void Start()
 301 |         {
 302 |             lock (startStopLock)
 303 |             {
 304 |                 // Don't restart if already running on a working port
 305 |                 if (isRunning && listener != null)
 306 |                 {
 307 |                     if (IsDebugEnabled())
 308 |                     {
 309 |                         McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}");
 310 |                     }
 311 |                     return;
 312 |                 }
 313 | 
 314 |                 Stop();
 315 | 
 316 |                 // Attempt fast bind with stored-port preference (sticky per-project)
 317 |                 try
 318 |                 {
 319 |                     // Always consult PortManager first so we prefer the persisted project port
 320 |                     currentUnityPort = PortManager.GetPortWithFallback();
 321 | 
 322 |                     // Breadcrumb: Start
 323 |                     LogBreadcrumb("Start");
 324 | 
 325 |                     const int maxImmediateRetries = 3;
 326 |                     const int retrySleepMs = 75;
 327 |                     int attempt = 0;
 328 |                     for (; ; )
 329 |                     {
 330 |                         try
 331 |                         {
 332 |                             listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
 333 |                             listener.Server.SetSocketOption(
 334 |                                 SocketOptionLevel.Socket,
 335 |                                 SocketOptionName.ReuseAddress,
 336 |                                 true
 337 |                             );
 338 | #if UNITY_EDITOR_WIN
 339 |                             try
 340 |                             {
 341 |                                 listener.ExclusiveAddressUse = false;
 342 |                             }
 343 |                             catch { }
 344 | #endif
 345 |                             // Minimize TIME_WAIT by sending RST on close
 346 |                             try
 347 |                             {
 348 |                                 listener.Server.LingerState = new LingerOption(true, 0);
 349 |                             }
 350 |                             catch (Exception)
 351 |                             {
 352 |                                 // Ignore if not supported on platform
 353 |                             }
 354 |                             listener.Start();
 355 |                             break;
 356 |                         }
 357 |                         catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries)
 358 |                         {
 359 |                             attempt++;
 360 |                             Thread.Sleep(retrySleepMs);
 361 |                             continue;
 362 |                         }
 363 |                         catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries)
 364 |                         {
 365 |                             currentUnityPort = PortManager.GetPortWithFallback();
 366 |                             listener = new TcpListener(IPAddress.Loopback, currentUnityPort);
 367 |                             listener.Server.SetSocketOption(
 368 |                                 SocketOptionLevel.Socket,
 369 |                                 SocketOptionName.ReuseAddress,
 370 |                                 true
 371 |                             );
 372 | #if UNITY_EDITOR_WIN
 373 |                             try
 374 |                             {
 375 |                                 listener.ExclusiveAddressUse = false;
 376 |                             }
 377 |                             catch { }
 378 | #endif
 379 |                             try
 380 |                             {
 381 |                                 listener.Server.LingerState = new LingerOption(true, 0);
 382 |                             }
 383 |                             catch (Exception)
 384 |                             {
 385 |                             }
 386 |                             listener.Start();
 387 |                             break;
 388 |                         }
 389 |                     }
 390 | 
 391 |                     isRunning = true;
 392 |                     isAutoConnectMode = false;
 393 |                     string platform = Application.platform.ToString();
 394 |                     string serverVer = ReadInstalledServerVersionSafe();
 395 |                     McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
 396 |                     // Start background listener with cooperative cancellation
 397 |                     cts = new CancellationTokenSource();
 398 |                     listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
 399 |                     CommandRegistry.Initialize();
 400 |                     EditorApplication.update += ProcessCommands;
 401 |                     // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
 402 |                     try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
 403 |                     try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
 404 |                     try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
 405 |                     try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
 406 |                     try { EditorApplication.quitting -= Stop; } catch { }
 407 |                     try { EditorApplication.quitting += Stop; } catch { }
 408 |                     // Write initial heartbeat immediately
 409 |                     heartbeatSeq++;
 410 |                     WriteHeartbeat(false, "ready");
 411 |                     nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f;
 412 |                 }
 413 |                 catch (SocketException ex)
 414 |                 {
 415 |                     McpLog.Error($"Failed to start TCP listener: {ex.Message}");
 416 |                 }
 417 |             }
 418 |         }
 419 | 
 420 |         public static void Stop()
 421 |         {
 422 |             Task toWait = null;
 423 |             lock (startStopLock)
 424 |             {
 425 |                 if (!isRunning)
 426 |                 {
 427 |                     return;
 428 |                 }
 429 | 
 430 |                 try
 431 |                 {
 432 |                     // Mark as stopping early to avoid accept logging during disposal
 433 |                     isRunning = false;
 434 | 
 435 |                     // Quiesce background listener quickly
 436 |                     var cancel = cts;
 437 |                     cts = null;
 438 |                     try { cancel?.Cancel(); } catch { }
 439 | 
 440 |                     try { listener?.Stop(); } catch { }
 441 |                     listener = null;
 442 | 
 443 |                     // Capture background task to wait briefly outside the lock
 444 |                     toWait = listenerTask;
 445 |                     listenerTask = null;
 446 |                 }
 447 |                 catch (Exception ex)
 448 |                 {
 449 |                     McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}");
 450 |                 }
 451 |             }
 452 | 
 453 |             // Proactively close all active client sockets to unblock any pending reads
 454 |             TcpClient[] toClose;
 455 |             lock (clientsLock)
 456 |             {
 457 |                 toClose = activeClients.ToArray();
 458 |                 activeClients.Clear();
 459 |             }
 460 |             foreach (var c in toClose)
 461 |             {
 462 |                 try { c.Close(); } catch { }
 463 |             }
 464 | 
 465 |             // Give the background loop a short window to exit without blocking the editor
 466 |             if (toWait != null)
 467 |             {
 468 |                 try { toWait.Wait(100); } catch { }
 469 |             }
 470 | 
 471 |             // Now unhook editor events safely
 472 |             try { EditorApplication.update -= ProcessCommands; } catch { }
 473 |             try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
 474 |             try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
 475 |             try { EditorApplication.quitting -= Stop; } catch { }
 476 | 
 477 |             if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped.");
 478 |         }
 479 | 
 480 |         private static async Task ListenerLoopAsync(CancellationToken token)
 481 |         {
 482 |             while (isRunning && !token.IsCancellationRequested)
 483 |             {
 484 |                 try
 485 |                 {
 486 |                     TcpClient client = await listener.AcceptTcpClientAsync();
 487 |                     // Enable basic socket keepalive
 488 |                     client.Client.SetSocketOption(
 489 |                         SocketOptionLevel.Socket,
 490 |                         SocketOptionName.KeepAlive,
 491 |                         true
 492 |                     );
 493 | 
 494 |                     // Set longer receive timeout to prevent quick disconnections
 495 |                     client.ReceiveTimeout = 60000; // 60 seconds
 496 | 
 497 |                     // Fire and forget each client connection
 498 |                     _ = Task.Run(() => HandleClientAsync(client, token), token);
 499 |                 }
 500 |                 catch (ObjectDisposedException)
 501 |                 {
 502 |                     // Listener was disposed during stop/reload; exit quietly
 503 |                     if (!isRunning || token.IsCancellationRequested)
 504 |                     {
 505 |                         break;
 506 |                     }
 507 |                 }
 508 |                 catch (OperationCanceledException)
 509 |                 {
 510 |                     break;
 511 |                 }
 512 |                 catch (Exception ex)
 513 |                 {
 514 |                     if (isRunning && !token.IsCancellationRequested)
 515 |                     {
 516 |                         if (IsDebugEnabled()) McpLog.Error($"Listener error: {ex.Message}");
 517 |                     }
 518 |                 }
 519 |             }
 520 |         }
 521 | 
 522 |         private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
 523 |         {
 524 |             using (client)
 525 |             using (NetworkStream stream = client.GetStream())
 526 |             {
 527 |                 lock (clientsLock) { activeClients.Add(client); }
 528 |                 try
 529 |                 {
 530 |                     // Framed I/O only; legacy mode removed
 531 |                     try
 532 |                     {
 533 |                         if (IsDebugEnabled())
 534 |                         {
 535 |                             var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
 536 |                             McpLog.Info($"Client connected {ep}");
 537 |                         }
 538 |                     }
 539 |                     catch { }
 540 |                     // Strict framing: always require FRAMING=1 and frame all I/O
 541 |                     try
 542 |                     {
 543 |                         client.NoDelay = true;
 544 |                     }
 545 |                     catch { }
 546 |                     try
 547 |                     {
 548 |                         string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
 549 |                         byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
 550 |                         using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
 551 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 552 |                         await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false);
 553 | #else
 554 |                     await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
 555 | #endif
 556 |                         if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
 557 |                     }
 558 |                     catch (Exception ex)
 559 |                     {
 560 |                         if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}");
 561 |                         return; // abort this client
 562 |                     }
 563 | 
 564 |                     while (isRunning && !token.IsCancellationRequested)
 565 |                     {
 566 |                         try
 567 |                         {
 568 |                             // Strict framed mode only: enforced framed I/O for this connection
 569 |                             string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
 570 | 
 571 |                             try
 572 |                             {
 573 |                                 if (IsDebugEnabled())
 574 |                                 {
 575 |                                     var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
 576 |                                     McpLog.Info($"recv framed: {preview}", always: false);
 577 |                                 }
 578 |                             }
 579 |                             catch { }
 580 |                             string commandId = Guid.NewGuid().ToString();
 581 |                             var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
 582 | 
 583 |                             // Special handling for ping command to avoid JSON parsing
 584 |                             if (commandText.Trim() == "ping")
 585 |                             {
 586 |                                 // Direct response to ping without going through JSON parsing
 587 |                                 byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes(
 588 |                                     /*lang=json,strict*/
 589 |                                     "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
 590 |                                 );
 591 |                                 await WriteFrameAsync(stream, pingResponseBytes);
 592 |                                 continue;
 593 |                             }
 594 | 
 595 |                             lock (lockObj)
 596 |                             {
 597 |                                 commandQueue[commandId] = new QueuedCommand
 598 |                                 {
 599 |                                     CommandJson = commandText,
 600 |                                     Tcs = tcs,
 601 |                                     IsExecuting = false
 602 |                                 };
 603 |                             }
 604 | 
 605 |                             // Wait for the handler to produce a response, but do not block indefinitely
 606 |                             string response;
 607 |                             try
 608 |                             {
 609 |                                 using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
 610 |                                 var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
 611 |                                 if (completed == tcs.Task)
 612 |                                 {
 613 |                                     // Got a result from the handler
 614 |                                     respCts.Cancel();
 615 |                                     response = tcs.Task.Result;
 616 |                                 }
 617 |                                 else
 618 |                                 {
 619 |                                     // Timeout: return a structured error so the client can recover
 620 |                                     var timeoutResponse = new
 621 |                                     {
 622 |                                         status = "error",
 623 |                                         error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
 624 |                                     };
 625 |                                     response = JsonConvert.SerializeObject(timeoutResponse);
 626 |                                 }
 627 |                             }
 628 |                             catch (Exception ex)
 629 |                             {
 630 |                                 var errorResponse = new
 631 |                                 {
 632 |                                     status = "error",
 633 |                                     error = ex.Message,
 634 |                                 };
 635 |                                 response = JsonConvert.SerializeObject(errorResponse);
 636 |                             }
 637 | 
 638 |                             if (IsDebugEnabled())
 639 |                             {
 640 |                                 try { McpLog.Info("[MCP] sending framed response", always: false); } catch { }
 641 |                             }
 642 |                             // Crash-proof and self-reporting writer logs (direct write to this client's stream)
 643 |                             long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
 644 |                             byte[] responseBytes;
 645 |                             try
 646 |                             {
 647 |                                 responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
 648 |                                 IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
 649 |                             }
 650 |                             catch (Exception ex)
 651 |                             {
 652 |                                 IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
 653 |                                 throw;
 654 |                             }
 655 | 
 656 |                             var swDirect = System.Diagnostics.Stopwatch.StartNew();
 657 |                             try
 658 |                             {
 659 |                                 await WriteFrameAsync(stream, responseBytes);
 660 |                                 swDirect.Stop();
 661 |                                 IoInfo($"[IO] ✓ write end   tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
 662 |                             }
 663 |                             catch (Exception ex)
 664 |                             {
 665 |                                 IoInfo($"[IO] ✗ write FAIL  tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
 666 |                                 throw;
 667 |                             }
 668 |                         }
 669 |                         catch (Exception ex)
 670 |                         {
 671 |                             // Treat common disconnects/timeouts as benign; only surface hard errors
 672 |                             string msg = ex.Message ?? string.Empty;
 673 |                             bool isBenign =
 674 |                                 msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
 675 |                                 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
 676 |                                 || ex is System.IO.IOException;
 677 |                             if (isBenign)
 678 |                             {
 679 |                                 if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false);
 680 |                             }
 681 |                             else
 682 |                             {
 683 |                                 McpLog.Error($"Client handler error: {msg}");
 684 |                             }
 685 |                             break;
 686 |                         }
 687 |                     }
 688 |                 }
 689 |                 finally
 690 |                 {
 691 |                     lock (clientsLock) { activeClients.Remove(client); }
 692 |                 }
 693 |             }
 694 |         }
 695 | 
 696 |         // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks
 697 |         private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
 698 |         {
 699 |             byte[] buffer = new byte[count];
 700 |             int offset = 0;
 701 |             var stopwatch = System.Diagnostics.Stopwatch.StartNew();
 702 | 
 703 |             while (offset < count)
 704 |             {
 705 |                 int remaining = count - offset;
 706 |                 int remainingTimeout = timeoutMs <= 0
 707 |                     ? Timeout.Infinite
 708 |                     : timeoutMs - (int)stopwatch.ElapsedMilliseconds;
 709 | 
 710 |                 // If a finite timeout is configured and already elapsed, fail immediately
 711 |                 if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0)
 712 |                 {
 713 |                     throw new System.IO.IOException("Read timed out");
 714 |                 }
 715 | 
 716 |                 using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel);
 717 |                 if (remainingTimeout != Timeout.Infinite)
 718 |                 {
 719 |                     cts.CancelAfter(remainingTimeout);
 720 |                 }
 721 | 
 722 |                 try
 723 |                 {
 724 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 725 |                     int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false);
 726 | #else
 727 |                     int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false);
 728 | #endif
 729 |                     if (read == 0)
 730 |                     {
 731 |                         throw new System.IO.IOException("Connection closed before reading expected bytes");
 732 |                     }
 733 |                     offset += read;
 734 |                 }
 735 |                 catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
 736 |                 {
 737 |                     throw new System.IO.IOException("Read timed out");
 738 |                 }
 739 |             }
 740 | 
 741 |             return buffer;
 742 |         }
 743 | 
 744 |         private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload)
 745 |         {
 746 |             using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
 747 |             await WriteFrameAsync(stream, payload, cts.Token);
 748 |         }
 749 | 
 750 |         private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel)
 751 |         {
 752 |             if (payload == null)
 753 |             {
 754 |                 throw new System.ArgumentNullException(nameof(payload));
 755 |             }
 756 |             if ((ulong)payload.LongLength > MaxFrameBytes)
 757 |             {
 758 |                 throw new System.IO.IOException($"Frame too large: {payload.LongLength}");
 759 |             }
 760 |             byte[] header = new byte[8];
 761 |             WriteUInt64BigEndian(header, (ulong)payload.LongLength);
 762 | #if NETSTANDARD2_1 || NET6_0_OR_GREATER
 763 |             await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false);
 764 |             await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false);
 765 | #else
 766 |             await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false);
 767 |             await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false);
 768 | #endif
 769 |         }
 770 | 
 771 |         private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
 772 |         {
 773 |             byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
 774 |             ulong payloadLen = ReadUInt64BigEndian(header);
 775 |             if (payloadLen > MaxFrameBytes)
 776 |             {
 777 |                 throw new System.IO.IOException($"Invalid framed length: {payloadLen}");
 778 |             }
 779 |             if (payloadLen == 0UL)
 780 |                 throw new System.IO.IOException("Zero-length frames are not allowed");
 781 |             if (payloadLen > int.MaxValue)
 782 |             {
 783 |                 throw new System.IO.IOException("Frame too large for buffer");
 784 |             }
 785 |             int count = (int)payloadLen;
 786 |             byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
 787 |             return System.Text.Encoding.UTF8.GetString(payload);
 788 |         }
 789 | 
 790 |         private static ulong ReadUInt64BigEndian(byte[] buffer)
 791 |         {
 792 |             if (buffer == null || buffer.Length < 8) return 0UL;
 793 |             return ((ulong)buffer[0] << 56)
 794 |                  | ((ulong)buffer[1] << 48)
 795 |                  | ((ulong)buffer[2] << 40)
 796 |                  | ((ulong)buffer[3] << 32)
 797 |                  | ((ulong)buffer[4] << 24)
 798 |                  | ((ulong)buffer[5] << 16)
 799 |                  | ((ulong)buffer[6] << 8)
 800 |                  | buffer[7];
 801 |         }
 802 | 
 803 |         private static void WriteUInt64BigEndian(byte[] dest, ulong value)
 804 |         {
 805 |             if (dest == null || dest.Length < 8)
 806 |             {
 807 |                 throw new System.ArgumentException("Destination buffer too small for UInt64");
 808 |             }
 809 |             dest[0] = (byte)(value >> 56);
 810 |             dest[1] = (byte)(value >> 48);
 811 |             dest[2] = (byte)(value >> 40);
 812 |             dest[3] = (byte)(value >> 32);
 813 |             dest[4] = (byte)(value >> 24);
 814 |             dest[5] = (byte)(value >> 16);
 815 |             dest[6] = (byte)(value >> 8);
 816 |             dest[7] = (byte)(value);
 817 |         }
 818 | 
 819 |         private static void ProcessCommands()
 820 |         {
 821 |             if (!isRunning) return;
 822 |             if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
 823 |             try
 824 |             {
 825 |                 // Heartbeat without holding the queue lock
 826 |                 double now = EditorApplication.timeSinceStartup;
 827 |                 if (now >= nextHeartbeatAt)
 828 |                 {
 829 |                     WriteHeartbeat(false);
 830 |                     nextHeartbeatAt = now + 0.5f;
 831 |                 }
 832 | 
 833 |                 // Snapshot under lock, then process outside to reduce contention
 834 |                 List<(string id, QueuedCommand command)> work;
 835 |                 lock (lockObj)
 836 |                 {
 837 |                     work = new List<(string, QueuedCommand)>(commandQueue.Count);
 838 |                     foreach (var kvp in commandQueue)
 839 |                     {
 840 |                         var queued = kvp.Value;
 841 |                         if (queued.IsExecuting) continue;
 842 |                         queued.IsExecuting = true;
 843 |                         work.Add((kvp.Key, queued));
 844 |                     }
 845 |                 }
 846 | 
 847 |                 foreach (var item in work)
 848 |                 {
 849 |                     string id = item.id;
 850 |                     QueuedCommand queuedCommand = item.command;
 851 |                     string commandText = queuedCommand.CommandJson;
 852 |                     TaskCompletionSource<string> tcs = queuedCommand.Tcs;
 853 | 
 854 |                     try
 855 |                     {
 856 |                         // Special case handling
 857 |                         if (string.IsNullOrEmpty(commandText))
 858 |                         {
 859 |                             var emptyResponse = new
 860 |                             {
 861 |                                 status = "error",
 862 |                                 error = "Empty command received",
 863 |                             };
 864 |                             tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
 865 |                             // Remove quickly under lock
 866 |                             lock (lockObj) { commandQueue.Remove(id); }
 867 |                             continue;
 868 |                         }
 869 | 
 870 |                         // Trim the command text to remove any whitespace
 871 |                         commandText = commandText.Trim();
 872 | 
 873 |                         // Non-JSON direct commands handling (like ping)
 874 |                         if (commandText == "ping")
 875 |                         {
 876 |                             var pingResponse = new
 877 |                             {
 878 |                                 status = "success",
 879 |                                 result = new { message = "pong" },
 880 |                             };
 881 |                             tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
 882 |                             lock (lockObj) { commandQueue.Remove(id); }
 883 |                             continue;
 884 |                         }
 885 | 
 886 |                         // Check if the command is valid JSON before attempting to deserialize
 887 |                         if (!IsValidJson(commandText))
 888 |                         {
 889 |                             var invalidJsonResponse = new
 890 |                             {
 891 |                                 status = "error",
 892 |                                 error = "Invalid JSON format",
 893 |                                 receivedText = commandText.Length > 50
 894 |                                     ? commandText[..50] + "..."
 895 |                                     : commandText,
 896 |                             };
 897 |                             tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
 898 |                             lock (lockObj) { commandQueue.Remove(id); }
 899 |                             continue;
 900 |                         }
 901 | 
 902 |                         // Normal JSON command processing
 903 |                         Command command = JsonConvert.DeserializeObject<Command>(commandText);
 904 | 
 905 |                         if (command == null)
 906 |                         {
 907 |                             var nullCommandResponse = new
 908 |                             {
 909 |                                 status = "error",
 910 |                                 error = "Command deserialized to null",
 911 |                                 details = "The command was valid JSON but could not be deserialized to a Command object",
 912 |                             };
 913 |                             tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
 914 |                         }
 915 |                         else
 916 |                         {
 917 |                             // Use JObject for parameters as handlers expect this
 918 |                             JObject paramsObject = command.@params ?? new JObject();
 919 | 
 920 |                             // Execute command (may be sync or async)
 921 |                             object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs);
 922 | 
 923 |                             // If result is null, it means async execution - TCS will be completed by the awaited task
 924 |                             // In this case, DON'T remove from queue yet, DON'T complete TCS
 925 |                             if (result == null)
 926 |                             {
 927 |                                 // Async command - the task continuation will complete the TCS
 928 |                                 // Setup cleanup when TCS completes - schedule on next frame to avoid race conditions
 929 |                                 string asyncCommandId = id;
 930 |                                 _ = tcs.Task.ContinueWith(_ =>
 931 |                                 {
 932 |                                     // Use EditorApplication.delayCall to schedule cleanup on main thread, next frame
 933 |                                     EditorApplication.delayCall += () =>
 934 |                                     {
 935 |                                         lock (lockObj)
 936 |                                         {
 937 |                                             commandQueue.Remove(asyncCommandId);
 938 |                                         }
 939 |                                     };
 940 |                                 });
 941 |                                 continue; // Skip the queue removal below
 942 |                             }
 943 | 
 944 |                             // Synchronous result - complete TCS now
 945 |                             var response = new { status = "success", result };
 946 |                             tcs.SetResult(JsonConvert.SerializeObject(response));
 947 |                         }
 948 |                     }
 949 |                     catch (Exception ex)
 950 |                     {
 951 |                         McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}");
 952 | 
 953 |                         var response = new
 954 |                         {
 955 |                             status = "error",
 956 |                             error = ex.Message,
 957 |                             commandType = "Unknown (error during processing)",
 958 |                             receivedText = commandText?.Length > 50
 959 |                                 ? commandText[..50] + "..."
 960 |                                 : commandText,
 961 |                         };
 962 |                         string responseJson = JsonConvert.SerializeObject(response);
 963 |                         tcs.SetResult(responseJson);
 964 |                     }
 965 | 
 966 |                     // Remove from queue (only for sync commands - async ones skip with 'continue' above)
 967 |                     lock (lockObj) { commandQueue.Remove(id); }
 968 |                 }
 969 |             }
 970 |             finally
 971 |             {
 972 |                 Interlocked.Exchange(ref processingCommands, 0);
 973 |             }
 974 |         }
 975 | 
 976 |         // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result.
 977 |         // Returns null on timeout or error; caller should provide a fallback error response.
 978 |         private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
 979 |         {
 980 |             if (func == null) return null;
 981 |             try
 982 |             {
 983 |                 // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor.
 984 |                 if (mainThreadId == 0)
 985 |                 {
 986 |                     try { return func(); }
 987 |                     catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); }
 988 |                 }
 989 |                 // If we are already on the main thread, execute directly to avoid deadlocks
 990 |                 try
 991 |                 {
 992 |                     if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
 993 |                     {
 994 |                         return func();
 995 |                     }
 996 |                 }
 997 |                 catch { }
 998 | 
 999 |                 object result = null;
1000 |                 Exception captured = null;
1001 |                 var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
1002 |                 EditorApplication.delayCall += () =>
1003 |                 {
1004 |                     try
1005 |                     {
1006 |                         result = func();
1007 |                     }
1008 |                     catch (Exception ex)
1009 |                     {
1010 |                         captured = ex;
1011 |                     }
1012 |                     finally
1013 |                     {
1014 |                         try { tcs.TrySetResult(true); } catch { }
1015 |                     }
1016 |                 };
1017 | 
1018 |                 // Wait for completion with timeout (Editor thread will pump delayCall)
1019 |                 bool completed = tcs.Task.Wait(timeoutMs);
1020 |                 if (!completed)
1021 |                 {
1022 |                     return null; // timeout
1023 |                 }
1024 |                 if (captured != null)
1025 |                 {
1026 |                     throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured);
1027 |                 }
1028 |                 return result;
1029 |             }
1030 |             catch (Exception ex)
1031 |             {
1032 |                 throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex);
1033 |             }
1034 |         }
1035 | 
1036 |         // Helper method to check if a string is valid JSON
1037 |         private static bool IsValidJson(string text)
1038 |         {
1039 |             if (string.IsNullOrWhiteSpace(text))
1040 |             {
1041 |                 return false;
1042 |             }
1043 | 
1044 |             text = text.Trim();
1045 |             if (
1046 |                 (text.StartsWith("{") && text.EndsWith("}"))
1047 |                 || // Object
1048 |                 (text.StartsWith("[") && text.EndsWith("]"))
1049 |             ) // Array
1050 |             {
1051 |                 try
1052 |                 {
1053 |                     JToken.Parse(text);
1054 |                     return true;
1055 |                 }
1056 |                 catch
1057 |                 {
1058 |                     return false;
1059 |                 }
1060 |             }
1061 | 
1062 |             return false;
1063 |         }
1064 | 
1065 |         private static string ExecuteCommand(Command command)
1066 |         {
1067 |             try
1068 |             {
1069 |                 if (string.IsNullOrEmpty(command.type))
1070 |                 {
1071 |                     var errorResponse = new
1072 |                     {
1073 |                         status = "error",
1074 |                         error = "Command type cannot be empty",
1075 |                         details = "A valid command type is required for processing",
1076 |                     };
1077 |                     return JsonConvert.SerializeObject(errorResponse);
1078 |                 }
1079 | 
1080 |                 // Handle ping command for connection verification
1081 |                 if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase))
1082 |                 {
1083 |                     var pingResponse = new
1084 |                     {
1085 |                         status = "success",
1086 |                         result = new { message = "pong" },
1087 |                     };
1088 |                     return JsonConvert.SerializeObject(pingResponse);
1089 |                 }
1090 | 
1091 |                 // Use JObject for parameters as the new handlers likely expect this
1092 |                 JObject paramsObject = command.@params ?? new JObject();
1093 |                 object result = CommandRegistry.GetHandler(command.type)(paramsObject);
1094 | 
1095 |                 // Standard success response format
1096 |                 var response = new { status = "success", result };
1097 |                 return JsonConvert.SerializeObject(response);
1098 |             }
1099 |             catch (Exception ex)
1100 |             {
1101 |                 // Log the detailed error in Unity for debugging
1102 |                 McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}");
1103 | 
1104 |                 // Standard error response format
1105 |                 var response = new
1106 |                 {
1107 |                     status = "error",
1108 |                     error = ex.Message, // Provide the specific error message
1109 |                     command = command?.type ?? "Unknown", // Include the command type if available
1110 |                     stackTrace = ex.StackTrace, // Include stack trace for detailed debugging
1111 |                     paramsSummary = command?.@params != null
1112 |                         ? GetParamsSummary(command.@params)
1113 |                         : "No parameters", // Summarize parameters for context
1114 |                 };
1115 |                 return JsonConvert.SerializeObject(response);
1116 |             }
1117 |         }
1118 | 
1119 |         private static object HandleManageScene(JObject paramsObject)
1120 |         {
1121 |             try
1122 |             {
1123 |                 if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread");
1124 |                 var sw = System.Diagnostics.Stopwatch.StartNew();
1125 |                 var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs);
1126 |                 sw.Stop();
1127 |                 if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
1128 |                 return r ?? Response.Error("manage_scene returned null (timeout or error)");
1129 |             }
1130 |             catch (Exception ex)
1131 |             {
1132 |                 return Response.Error($"manage_scene dispatch error: {ex.Message}");
1133 |             }
1134 |         }
1135 | 
1136 |         // Helper method to get a summary of parameters for error reporting
1137 |         private static string GetParamsSummary(JObject @params)
1138 |         {
1139 |             try
1140 |             {
1141 |                 return @params == null || [email protected]
1142 |                     ? "No parameters"
1143 |                     : string.Join(
1144 |                         ", ",
1145 |                         @params
1146 |                             .Properties()
1147 |                             .Select(static p =>
1148 |                                 $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}"
1149 |                             )
1150 |                     );
1151 |             }
1152 |             catch
1153 |             {
1154 |                 return "Could not summarize parameters";
1155 |             }
1156 |         }
1157 | 
1158 |         // Heartbeat/status helpers
1159 |         private static void OnBeforeAssemblyReload()
1160 |         {
1161 |             // Stop cleanly before reload so sockets close and clients see 'reloading'
1162 |             try { Stop(); } catch { }
1163 |             // Avoid file I/O or heavy work here
1164 |         }
1165 | 
1166 |         private static void OnAfterAssemblyReload()
1167 |         {
1168 |             // Will be overwritten by Start(), but mark as alive quickly
1169 |             WriteHeartbeat(false, "idle");
1170 |             LogBreadcrumb("Idle");
1171 |             // Schedule a safe restart after reload to avoid races during compilation
1172 |             ScheduleInitRetry();
1173 |         }
1174 | 
1175 |         private static void WriteHeartbeat(bool reloading, string reason = null)
1176 |         {
1177 |             try
1178 |             {
1179 |                 // Allow override of status directory (useful in CI/containers)
1180 |                 string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR");
1181 |                 if (string.IsNullOrWhiteSpace(dir))
1182 |                 {
1183 |                     dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
1184 |                 }
1185 |                 Directory.CreateDirectory(dir);
1186 |                 string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
1187 |                 var payload = new
1188 |                 {
1189 |                     unity_port = currentUnityPort,
1190 |                     reloading,
1191 |                     reason = reason ?? (reloading ? "reloading" : "ready"),
1192 |                     seq = heartbeatSeq,
1193 |                     project_path = Application.dataPath,
1194 |                     last_heartbeat = DateTime.UtcNow.ToString("O")
1195 |                 };
1196 |                 File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false));
1197 |             }
1198 |             catch (Exception)
1199 |             {
1200 |                 // Best-effort only
1201 |             }
1202 |         }
1203 | 
1204 |         private static string ReadInstalledServerVersionSafe()
1205 |         {
1206 |             try
1207 |             {
1208 |                 string serverSrc = ServerInstaller.GetServerPath();
1209 |                 string verFile = Path.Combine(serverSrc, "server_version.txt");
1210 |                 if (File.Exists(verFile))
1211 |                 {
1212 |                     string v = File.ReadAllText(verFile)?.Trim();
1213 |                     if (!string.IsNullOrEmpty(v)) return v;
1214 |                 }
1215 |             }
1216 |             catch { }
1217 |             return "unknown";
1218 |         }
1219 | 
1220 |         private static string ComputeProjectHash(string input)
1221 |         {
1222 |             try
1223 |             {
1224 |                 using var sha1 = System.Security.Cryptography.SHA1.Create();
1225 |                 byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty);
1226 |                 byte[] hashBytes = sha1.ComputeHash(bytes);
1227 |                 var sb = new System.Text.StringBuilder();
1228 |                 foreach (byte b in hashBytes)
1229 |                 {
1230 |                     sb.Append(b.ToString("x2"));
1231 |                 }
1232 |                 return sb.ToString()[..8];
1233 |             }
1234 |             catch
1235 |             {
1236 |                 return "default";
1237 |             }
1238 |         }
1239 |     }
1240 | }
1241 | 
```
Page 11/18FirstPrevNextLast