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