This is page 10 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /MCPForUnity/Editor/Windows/MCPForUnityEditorWindowNew.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using UnityEditor; 7 | using UnityEditor.UIElements; // For Unity 2021 compatibility 8 | using UnityEngine; 9 | using UnityEngine.UIElements; 10 | using MCPForUnity.Editor.Data; 11 | using MCPForUnity.Editor.Helpers; 12 | using MCPForUnity.Editor.Models; 13 | using MCPForUnity.Editor.Services; 14 | 15 | namespace MCPForUnity.Editor.Windows 16 | { 17 | public class MCPForUnityEditorWindowNew : EditorWindow 18 | { 19 | // Protocol enum for future HTTP support 20 | private enum ConnectionProtocol 21 | { 22 | Stdio, 23 | // HTTPStreaming // Future 24 | } 25 | 26 | // Settings UI Elements 27 | private Label versionLabel; 28 | private Toggle debugLogsToggle; 29 | private EnumField validationLevelField; 30 | private Label validationDescription; 31 | private Foldout advancedSettingsFoldout; 32 | private TextField mcpServerPathOverride; 33 | private TextField uvPathOverride; 34 | private Button browsePythonButton; 35 | private Button clearPythonButton; 36 | private Button browseUvButton; 37 | private Button clearUvButton; 38 | private VisualElement mcpServerPathStatus; 39 | private VisualElement uvPathStatus; 40 | 41 | // Connection UI Elements 42 | private EnumField protocolDropdown; 43 | private TextField unityPortField; 44 | private TextField serverPortField; 45 | private VisualElement statusIndicator; 46 | private Label connectionStatusLabel; 47 | private Button connectionToggleButton; 48 | private VisualElement healthIndicator; 49 | private Label healthStatusLabel; 50 | private Button testConnectionButton; 51 | private VisualElement serverStatusBanner; 52 | private Label serverStatusMessage; 53 | private Button downloadServerButton; 54 | private Button rebuildServerButton; 55 | 56 | // Client UI Elements 57 | private DropdownField clientDropdown; 58 | private Button configureAllButton; 59 | private VisualElement clientStatusIndicator; 60 | private Label clientStatusLabel; 61 | private Button configureButton; 62 | private VisualElement claudeCliPathRow; 63 | private TextField claudeCliPath; 64 | private Button browseClaudeButton; 65 | private Foldout manualConfigFoldout; 66 | private TextField configPathField; 67 | private Button copyPathButton; 68 | private Button openFileButton; 69 | private TextField configJsonField; 70 | private Button copyJsonButton; 71 | private Label installationStepsLabel; 72 | 73 | // Data 74 | private readonly McpClients mcpClients = new(); 75 | private int selectedClientIndex = 0; 76 | private ValidationLevel currentValidationLevel = ValidationLevel.Standard; 77 | 78 | // Validation levels matching the existing enum 79 | private enum ValidationLevel 80 | { 81 | Basic, 82 | Standard, 83 | Comprehensive, 84 | Strict 85 | } 86 | 87 | public static void ShowWindow() 88 | { 89 | var window = GetWindow<MCPForUnityEditorWindowNew>("MCP For Unity"); 90 | window.minSize = new Vector2(500, 600); 91 | } 92 | public void CreateGUI() 93 | { 94 | // Determine base path (Package Manager vs Asset Store install) 95 | string basePath = AssetPathUtility.GetMcpPackageRootPath(); 96 | 97 | // Load UXML 98 | var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>( 99 | $"{basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uxml" 100 | ); 101 | 102 | if (visualTree == null) 103 | { 104 | McpLog.Error($"Failed to load UXML at: {basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uxml"); 105 | return; 106 | } 107 | 108 | visualTree.CloneTree(rootVisualElement); 109 | 110 | // Load USS 111 | var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>( 112 | $"{basePath}/Editor/Windows/MCPForUnityEditorWindowNew.uss" 113 | ); 114 | 115 | if (styleSheet != null) 116 | { 117 | rootVisualElement.styleSheets.Add(styleSheet); 118 | } 119 | 120 | // Cache UI elements 121 | CacheUIElements(); 122 | 123 | // Initialize UI 124 | InitializeUI(); 125 | 126 | // Register callbacks 127 | RegisterCallbacks(); 128 | 129 | // Initial update 130 | UpdateConnectionStatus(); 131 | UpdateServerStatusBanner(); 132 | UpdateClientStatus(); 133 | UpdatePathOverrides(); 134 | // Technically not required to connect, but if we don't do this, the UI will be blank 135 | UpdateManualConfiguration(); 136 | UpdateClaudeCliPathVisibility(); 137 | } 138 | 139 | private void OnEnable() 140 | { 141 | EditorApplication.update += OnEditorUpdate; 142 | } 143 | 144 | private void OnDisable() 145 | { 146 | EditorApplication.update -= OnEditorUpdate; 147 | } 148 | 149 | private void OnFocus() 150 | { 151 | // Only refresh data if UI is built 152 | if (rootVisualElement == null || rootVisualElement.childCount == 0) 153 | return; 154 | 155 | RefreshAllData(); 156 | } 157 | 158 | private void OnEditorUpdate() 159 | { 160 | // Only update UI if it's built 161 | if (rootVisualElement == null || rootVisualElement.childCount == 0) 162 | return; 163 | 164 | UpdateConnectionStatus(); 165 | } 166 | 167 | private void RefreshAllData() 168 | { 169 | // Update connection status 170 | UpdateConnectionStatus(); 171 | 172 | // Auto-verify bridge health if connected 173 | if (MCPServiceLocator.Bridge.IsRunning) 174 | { 175 | VerifyBridgeConnection(); 176 | } 177 | 178 | // Update path overrides 179 | UpdatePathOverrides(); 180 | 181 | // Refresh selected client (may have been configured externally) 182 | if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count) 183 | { 184 | var client = mcpClients.clients[selectedClientIndex]; 185 | MCPServiceLocator.Client.CheckClientStatus(client); 186 | UpdateClientStatus(); 187 | UpdateManualConfiguration(); 188 | UpdateClaudeCliPathVisibility(); 189 | } 190 | } 191 | 192 | private void CacheUIElements() 193 | { 194 | // Settings 195 | versionLabel = rootVisualElement.Q<Label>("version-label"); 196 | debugLogsToggle = rootVisualElement.Q<Toggle>("debug-logs-toggle"); 197 | validationLevelField = rootVisualElement.Q<EnumField>("validation-level"); 198 | validationDescription = rootVisualElement.Q<Label>("validation-description"); 199 | advancedSettingsFoldout = rootVisualElement.Q<Foldout>("advanced-settings-foldout"); 200 | mcpServerPathOverride = rootVisualElement.Q<TextField>("python-path-override"); 201 | uvPathOverride = rootVisualElement.Q<TextField>("uv-path-override"); 202 | browsePythonButton = rootVisualElement.Q<Button>("browse-python-button"); 203 | clearPythonButton = rootVisualElement.Q<Button>("clear-python-button"); 204 | browseUvButton = rootVisualElement.Q<Button>("browse-uv-button"); 205 | clearUvButton = rootVisualElement.Q<Button>("clear-uv-button"); 206 | mcpServerPathStatus = rootVisualElement.Q<VisualElement>("mcp-server-path-status"); 207 | uvPathStatus = rootVisualElement.Q<VisualElement>("uv-path-status"); 208 | 209 | // Connection 210 | protocolDropdown = rootVisualElement.Q<EnumField>("protocol-dropdown"); 211 | unityPortField = rootVisualElement.Q<TextField>("unity-port"); 212 | serverPortField = rootVisualElement.Q<TextField>("server-port"); 213 | statusIndicator = rootVisualElement.Q<VisualElement>("status-indicator"); 214 | connectionStatusLabel = rootVisualElement.Q<Label>("connection-status"); 215 | connectionToggleButton = rootVisualElement.Q<Button>("connection-toggle"); 216 | healthIndicator = rootVisualElement.Q<VisualElement>("health-indicator"); 217 | healthStatusLabel = rootVisualElement.Q<Label>("health-status"); 218 | testConnectionButton = rootVisualElement.Q<Button>("test-connection-button"); 219 | serverStatusBanner = rootVisualElement.Q<VisualElement>("server-status-banner"); 220 | serverStatusMessage = rootVisualElement.Q<Label>("server-status-message"); 221 | downloadServerButton = rootVisualElement.Q<Button>("download-server-button"); 222 | rebuildServerButton = rootVisualElement.Q<Button>("rebuild-server-button"); 223 | 224 | // Client 225 | clientDropdown = rootVisualElement.Q<DropdownField>("client-dropdown"); 226 | configureAllButton = rootVisualElement.Q<Button>("configure-all-button"); 227 | clientStatusIndicator = rootVisualElement.Q<VisualElement>("client-status-indicator"); 228 | clientStatusLabel = rootVisualElement.Q<Label>("client-status"); 229 | configureButton = rootVisualElement.Q<Button>("configure-button"); 230 | claudeCliPathRow = rootVisualElement.Q<VisualElement>("claude-cli-path-row"); 231 | claudeCliPath = rootVisualElement.Q<TextField>("claude-cli-path"); 232 | browseClaudeButton = rootVisualElement.Q<Button>("browse-claude-button"); 233 | manualConfigFoldout = rootVisualElement.Q<Foldout>("manual-config-foldout"); 234 | configPathField = rootVisualElement.Q<TextField>("config-path"); 235 | copyPathButton = rootVisualElement.Q<Button>("copy-path-button"); 236 | openFileButton = rootVisualElement.Q<Button>("open-file-button"); 237 | configJsonField = rootVisualElement.Q<TextField>("config-json"); 238 | copyJsonButton = rootVisualElement.Q<Button>("copy-json-button"); 239 | installationStepsLabel = rootVisualElement.Q<Label>("installation-steps"); 240 | } 241 | 242 | private void InitializeUI() 243 | { 244 | // Settings Section 245 | UpdateVersionLabel(); 246 | debugLogsToggle.value = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); 247 | 248 | validationLevelField.Init(ValidationLevel.Standard); 249 | int savedLevel = EditorPrefs.GetInt("MCPForUnity.ValidationLevel", 1); 250 | currentValidationLevel = (ValidationLevel)Mathf.Clamp(savedLevel, 0, 3); 251 | validationLevelField.value = currentValidationLevel; 252 | UpdateValidationDescription(); 253 | 254 | // Advanced settings starts collapsed 255 | advancedSettingsFoldout.value = false; 256 | 257 | // Connection Section 258 | protocolDropdown.Init(ConnectionProtocol.Stdio); 259 | protocolDropdown.SetEnabled(false); // Disabled for now, only stdio supported 260 | 261 | unityPortField.value = MCPServiceLocator.Bridge.CurrentPort.ToString(); 262 | serverPortField.value = "6500"; 263 | 264 | // Client Configuration 265 | var clientNames = mcpClients.clients.Select(c => c.name).ToList(); 266 | clientDropdown.choices = clientNames; 267 | if (clientNames.Count > 0) 268 | { 269 | clientDropdown.index = 0; 270 | } 271 | 272 | // Manual config starts collapsed 273 | manualConfigFoldout.value = false; 274 | 275 | // Claude CLI path row hidden by default 276 | claudeCliPathRow.style.display = DisplayStyle.None; 277 | } 278 | 279 | private void RegisterCallbacks() 280 | { 281 | // Settings callbacks 282 | debugLogsToggle.RegisterValueChangedCallback(evt => 283 | { 284 | EditorPrefs.SetBool("MCPForUnity.DebugLogs", evt.newValue); 285 | }); 286 | 287 | validationLevelField.RegisterValueChangedCallback(evt => 288 | { 289 | currentValidationLevel = (ValidationLevel)evt.newValue; 290 | EditorPrefs.SetInt("MCPForUnity.ValidationLevel", (int)currentValidationLevel); 291 | UpdateValidationDescription(); 292 | }); 293 | 294 | // Advanced settings callbacks 295 | browsePythonButton.clicked += OnBrowsePythonClicked; 296 | clearPythonButton.clicked += OnClearPythonClicked; 297 | browseUvButton.clicked += OnBrowseUvClicked; 298 | clearUvButton.clicked += OnClearUvClicked; 299 | 300 | // Connection callbacks 301 | connectionToggleButton.clicked += OnConnectionToggleClicked; 302 | testConnectionButton.clicked += OnTestConnectionClicked; 303 | downloadServerButton.clicked += OnDownloadServerClicked; 304 | rebuildServerButton.clicked += OnRebuildServerClicked; 305 | 306 | // Client callbacks 307 | clientDropdown.RegisterValueChangedCallback(evt => 308 | { 309 | selectedClientIndex = clientDropdown.index; 310 | UpdateClientStatus(); 311 | UpdateManualConfiguration(); 312 | UpdateClaudeCliPathVisibility(); 313 | }); 314 | 315 | configureAllButton.clicked += OnConfigureAllClientsClicked; 316 | configureButton.clicked += OnConfigureClicked; 317 | browseClaudeButton.clicked += OnBrowseClaudeClicked; 318 | copyPathButton.clicked += OnCopyPathClicked; 319 | openFileButton.clicked += OnOpenFileClicked; 320 | copyJsonButton.clicked += OnCopyJsonClicked; 321 | } 322 | 323 | private void UpdateValidationDescription() 324 | { 325 | validationDescription.text = GetValidationLevelDescription((int)currentValidationLevel); 326 | } 327 | 328 | private string GetValidationLevelDescription(int index) 329 | { 330 | return index switch 331 | { 332 | 0 => "Only basic syntax checks (braces, quotes, comments)", 333 | 1 => "Syntax checks + Unity best practices and warnings", 334 | 2 => "All checks + semantic analysis and performance warnings", 335 | 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", 336 | _ => "Standard validation" 337 | }; 338 | } 339 | 340 | private void UpdateConnectionStatus() 341 | { 342 | var bridgeService = MCPServiceLocator.Bridge; 343 | bool isRunning = bridgeService.IsRunning; 344 | 345 | if (isRunning) 346 | { 347 | connectionStatusLabel.text = "Connected"; 348 | statusIndicator.RemoveFromClassList("disconnected"); 349 | statusIndicator.AddToClassList("connected"); 350 | connectionToggleButton.text = "Stop"; 351 | } 352 | else 353 | { 354 | connectionStatusLabel.text = "Disconnected"; 355 | statusIndicator.RemoveFromClassList("connected"); 356 | statusIndicator.AddToClassList("disconnected"); 357 | connectionToggleButton.text = "Start"; 358 | 359 | // Reset health status when disconnected 360 | healthStatusLabel.text = "Unknown"; 361 | healthIndicator.RemoveFromClassList("healthy"); 362 | healthIndicator.RemoveFromClassList("warning"); 363 | healthIndicator.AddToClassList("unknown"); 364 | } 365 | 366 | // Update ports 367 | unityPortField.value = bridgeService.CurrentPort.ToString(); 368 | } 369 | 370 | private void UpdateClientStatus() 371 | { 372 | if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) 373 | return; 374 | 375 | var client = mcpClients.clients[selectedClientIndex]; 376 | MCPServiceLocator.Client.CheckClientStatus(client); 377 | 378 | clientStatusLabel.text = client.GetStatusDisplayString(); 379 | 380 | // Reset inline color style (clear error state from OnConfigureClicked) 381 | clientStatusLabel.style.color = StyleKeyword.Null; 382 | 383 | // Update status indicator color 384 | clientStatusIndicator.RemoveFromClassList("configured"); 385 | clientStatusIndicator.RemoveFromClassList("not-configured"); 386 | clientStatusIndicator.RemoveFromClassList("warning"); 387 | 388 | switch (client.status) 389 | { 390 | case McpStatus.Configured: 391 | case McpStatus.Running: 392 | case McpStatus.Connected: 393 | clientStatusIndicator.AddToClassList("configured"); 394 | break; 395 | case McpStatus.IncorrectPath: 396 | case McpStatus.CommunicationError: 397 | case McpStatus.NoResponse: 398 | clientStatusIndicator.AddToClassList("warning"); 399 | break; 400 | default: 401 | clientStatusIndicator.AddToClassList("not-configured"); 402 | break; 403 | } 404 | 405 | // Update configure button text for Claude Code 406 | if (client.mcpType == McpTypes.ClaudeCode) 407 | { 408 | bool isConfigured = client.status == McpStatus.Configured; 409 | configureButton.text = isConfigured ? "Unregister" : "Register"; 410 | } 411 | else 412 | { 413 | configureButton.text = "Configure"; 414 | } 415 | } 416 | 417 | private void UpdateManualConfiguration() 418 | { 419 | if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) 420 | return; 421 | 422 | var client = mcpClients.clients[selectedClientIndex]; 423 | 424 | // Get config path 425 | string configPath = MCPServiceLocator.Client.GetConfigPath(client); 426 | configPathField.value = configPath; 427 | 428 | // Get config JSON 429 | string configJson = MCPServiceLocator.Client.GenerateConfigJson(client); 430 | configJsonField.value = configJson; 431 | 432 | // Get installation steps 433 | string steps = MCPServiceLocator.Client.GetInstallationSteps(client); 434 | installationStepsLabel.text = steps; 435 | } 436 | 437 | private void UpdateClaudeCliPathVisibility() 438 | { 439 | if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) 440 | return; 441 | 442 | var client = mcpClients.clients[selectedClientIndex]; 443 | 444 | // Show Claude CLI path only for Claude Code client 445 | if (client.mcpType == McpTypes.ClaudeCode) 446 | { 447 | string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); 448 | if (string.IsNullOrEmpty(claudePath)) 449 | { 450 | // Show path selector if not found 451 | claudeCliPathRow.style.display = DisplayStyle.Flex; 452 | claudeCliPath.value = "Not found - click Browse to select"; 453 | } 454 | else 455 | { 456 | // Show detected path 457 | claudeCliPathRow.style.display = DisplayStyle.Flex; 458 | claudeCliPath.value = claudePath; 459 | } 460 | } 461 | else 462 | { 463 | claudeCliPathRow.style.display = DisplayStyle.None; 464 | } 465 | } 466 | 467 | private void UpdatePathOverrides() 468 | { 469 | var pathService = MCPServiceLocator.Paths; 470 | 471 | // MCP Server Path 472 | string mcpServerPath = pathService.GetMcpServerPath(); 473 | if (pathService.HasMcpServerOverride) 474 | { 475 | mcpServerPathOverride.value = mcpServerPath ?? "(override set but invalid)"; 476 | } 477 | else 478 | { 479 | mcpServerPathOverride.value = mcpServerPath ?? "(auto-detected)"; 480 | } 481 | 482 | // Update status indicator 483 | mcpServerPathStatus.RemoveFromClassList("valid"); 484 | mcpServerPathStatus.RemoveFromClassList("invalid"); 485 | if (!string.IsNullOrEmpty(mcpServerPath) && File.Exists(Path.Combine(mcpServerPath, "server.py"))) 486 | { 487 | mcpServerPathStatus.AddToClassList("valid"); 488 | } 489 | else 490 | { 491 | mcpServerPathStatus.AddToClassList("invalid"); 492 | } 493 | 494 | // UV Path 495 | string uvPath = pathService.GetUvPath(); 496 | if (pathService.HasUvPathOverride) 497 | { 498 | uvPathOverride.value = uvPath ?? "(override set but invalid)"; 499 | } 500 | else 501 | { 502 | uvPathOverride.value = uvPath ?? "(auto-detected)"; 503 | } 504 | 505 | // Update status indicator 506 | uvPathStatus.RemoveFromClassList("valid"); 507 | uvPathStatus.RemoveFromClassList("invalid"); 508 | if (!string.IsNullOrEmpty(uvPath) && File.Exists(uvPath)) 509 | { 510 | uvPathStatus.AddToClassList("valid"); 511 | } 512 | else 513 | { 514 | uvPathStatus.AddToClassList("invalid"); 515 | } 516 | } 517 | 518 | // Button callbacks 519 | private void OnConnectionToggleClicked() 520 | { 521 | var bridgeService = MCPServiceLocator.Bridge; 522 | 523 | if (bridgeService.IsRunning) 524 | { 525 | bridgeService.Stop(); 526 | } 527 | else 528 | { 529 | bridgeService.Start(); 530 | 531 | // Verify connection after starting (Option C: verify on connect) 532 | EditorApplication.delayCall += () => 533 | { 534 | if (bridgeService.IsRunning) 535 | { 536 | VerifyBridgeConnection(); 537 | } 538 | }; 539 | } 540 | 541 | UpdateConnectionStatus(); 542 | } 543 | 544 | private void OnTestConnectionClicked() 545 | { 546 | VerifyBridgeConnection(); 547 | } 548 | 549 | private void VerifyBridgeConnection() 550 | { 551 | var bridgeService = MCPServiceLocator.Bridge; 552 | 553 | if (!bridgeService.IsRunning) 554 | { 555 | healthStatusLabel.text = "Disconnected"; 556 | healthIndicator.RemoveFromClassList("healthy"); 557 | healthIndicator.RemoveFromClassList("warning"); 558 | healthIndicator.AddToClassList("unknown"); 559 | McpLog.Warn("Cannot verify connection: Bridge is not running"); 560 | return; 561 | } 562 | 563 | var result = bridgeService.Verify(bridgeService.CurrentPort); 564 | 565 | healthIndicator.RemoveFromClassList("healthy"); 566 | healthIndicator.RemoveFromClassList("warning"); 567 | healthIndicator.RemoveFromClassList("unknown"); 568 | 569 | if (result.Success && result.PingSucceeded) 570 | { 571 | healthStatusLabel.text = "Healthy"; 572 | healthIndicator.AddToClassList("healthy"); 573 | McpLog.Info("Bridge verification successful"); 574 | } 575 | else if (result.HandshakeValid) 576 | { 577 | healthStatusLabel.text = "Ping Failed"; 578 | healthIndicator.AddToClassList("warning"); 579 | McpLog.Warn($"Bridge verification warning: {result.Message}"); 580 | } 581 | else 582 | { 583 | healthStatusLabel.text = "Unhealthy"; 584 | healthIndicator.AddToClassList("warning"); 585 | McpLog.Error($"Bridge verification failed: {result.Message}"); 586 | } 587 | } 588 | 589 | private void OnDownloadServerClicked() 590 | { 591 | if (ServerInstaller.DownloadAndInstallServer()) 592 | { 593 | UpdateServerStatusBanner(); 594 | UpdatePathOverrides(); 595 | EditorUtility.DisplayDialog( 596 | "Download Complete", 597 | "Server installed successfully! Start your connection and configure your MCP clients to begin.", 598 | "OK" 599 | ); 600 | } 601 | } 602 | 603 | private void OnRebuildServerClicked() 604 | { 605 | try 606 | { 607 | bool success = ServerInstaller.RebuildMcpServer(); 608 | if (success) 609 | { 610 | EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); 611 | UpdateServerStatusBanner(); 612 | UpdatePathOverrides(); 613 | } 614 | else 615 | { 616 | EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); 617 | } 618 | } 619 | catch (Exception ex) 620 | { 621 | McpLog.Error($"Failed to rebuild server: {ex.Message}"); 622 | EditorUtility.DisplayDialog("MCP For Unity", $"Rebuild failed: {ex.Message}", "OK"); 623 | } 624 | } 625 | 626 | private void UpdateServerStatusBanner() 627 | { 628 | bool hasEmbedded = ServerInstaller.HasEmbeddedServer(); 629 | string installedVer = ServerInstaller.GetInstalledServerVersion(); 630 | string packageVer = AssetPathUtility.GetPackageVersion(); 631 | 632 | // Show/hide download vs rebuild buttons 633 | if (hasEmbedded) 634 | { 635 | downloadServerButton.style.display = DisplayStyle.None; 636 | rebuildServerButton.style.display = DisplayStyle.Flex; 637 | } 638 | else 639 | { 640 | downloadServerButton.style.display = DisplayStyle.Flex; 641 | rebuildServerButton.style.display = DisplayStyle.None; 642 | } 643 | 644 | // Update banner 645 | if (!hasEmbedded && string.IsNullOrEmpty(installedVer)) 646 | { 647 | serverStatusMessage.text = "\u26A0 Server not installed. Click 'Download & Install Server' to get started."; 648 | serverStatusBanner.style.display = DisplayStyle.Flex; 649 | } 650 | else if (!hasEmbedded && !string.IsNullOrEmpty(installedVer) && installedVer != packageVer) 651 | { 652 | serverStatusMessage.text = $"\u26A0 Server update available (v{installedVer} \u2192 v{packageVer}). Update recommended."; 653 | serverStatusBanner.style.display = DisplayStyle.Flex; 654 | } 655 | else 656 | { 657 | serverStatusBanner.style.display = DisplayStyle.None; 658 | } 659 | } 660 | 661 | private void OnConfigureAllClientsClicked() 662 | { 663 | try 664 | { 665 | var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); 666 | 667 | // Build detailed message 668 | string message = summary.GetSummaryMessage() + "\n\n"; 669 | foreach (var msg in summary.Messages) 670 | { 671 | message += msg + "\n"; 672 | } 673 | 674 | EditorUtility.DisplayDialog("Configure All Clients", message, "OK"); 675 | 676 | // Refresh current client status 677 | if (selectedClientIndex >= 0 && selectedClientIndex < mcpClients.clients.Count) 678 | { 679 | UpdateClientStatus(); 680 | UpdateManualConfiguration(); 681 | } 682 | } 683 | catch (Exception ex) 684 | { 685 | EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK"); 686 | } 687 | } 688 | 689 | private void OnConfigureClicked() 690 | { 691 | if (selectedClientIndex < 0 || selectedClientIndex >= mcpClients.clients.Count) 692 | return; 693 | 694 | var client = mcpClients.clients[selectedClientIndex]; 695 | 696 | try 697 | { 698 | if (client.mcpType == McpTypes.ClaudeCode) 699 | { 700 | bool isConfigured = client.status == McpStatus.Configured; 701 | if (isConfigured) 702 | { 703 | MCPServiceLocator.Client.UnregisterClaudeCode(); 704 | } 705 | else 706 | { 707 | MCPServiceLocator.Client.RegisterClaudeCode(); 708 | } 709 | } 710 | else 711 | { 712 | MCPServiceLocator.Client.ConfigureClient(client); 713 | } 714 | 715 | UpdateClientStatus(); 716 | UpdateManualConfiguration(); 717 | } 718 | catch (Exception ex) 719 | { 720 | clientStatusLabel.text = "Error"; 721 | clientStatusLabel.style.color = Color.red; 722 | McpLog.Error($"Configuration failed: {ex.Message}"); 723 | EditorUtility.DisplayDialog("Configuration Failed", ex.Message, "OK"); 724 | } 725 | } 726 | 727 | private void OnBrowsePythonClicked() 728 | { 729 | string picked = EditorUtility.OpenFolderPanel("Select MCP Server Directory", Application.dataPath, ""); 730 | if (!string.IsNullOrEmpty(picked)) 731 | { 732 | try 733 | { 734 | MCPServiceLocator.Paths.SetMcpServerOverride(picked); 735 | UpdatePathOverrides(); 736 | McpLog.Info($"MCP server path override set to: {picked}"); 737 | } 738 | catch (Exception ex) 739 | { 740 | EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK"); 741 | } 742 | } 743 | } 744 | 745 | private void OnClearPythonClicked() 746 | { 747 | MCPServiceLocator.Paths.ClearMcpServerOverride(); 748 | UpdatePathOverrides(); 749 | McpLog.Info("MCP server path override cleared"); 750 | } 751 | 752 | private void OnBrowseUvClicked() 753 | { 754 | string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 755 | ? "/opt/homebrew/bin" 756 | : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); 757 | string picked = EditorUtility.OpenFilePanel("Select UV Executable", suggested, ""); 758 | if (!string.IsNullOrEmpty(picked)) 759 | { 760 | try 761 | { 762 | MCPServiceLocator.Paths.SetUvPathOverride(picked); 763 | UpdatePathOverrides(); 764 | McpLog.Info($"UV path override set to: {picked}"); 765 | } 766 | catch (Exception ex) 767 | { 768 | EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK"); 769 | } 770 | } 771 | } 772 | 773 | private void OnClearUvClicked() 774 | { 775 | MCPServiceLocator.Paths.ClearUvPathOverride(); 776 | UpdatePathOverrides(); 777 | McpLog.Info("UV path override cleared"); 778 | } 779 | 780 | private void OnBrowseClaudeClicked() 781 | { 782 | string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 783 | ? "/opt/homebrew/bin" 784 | : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); 785 | string picked = EditorUtility.OpenFilePanel("Select Claude CLI", suggested, ""); 786 | if (!string.IsNullOrEmpty(picked)) 787 | { 788 | try 789 | { 790 | MCPServiceLocator.Paths.SetClaudeCliPathOverride(picked); 791 | UpdateClaudeCliPathVisibility(); 792 | UpdateClientStatus(); 793 | McpLog.Info($"Claude CLI path override set to: {picked}"); 794 | } 795 | catch (Exception ex) 796 | { 797 | EditorUtility.DisplayDialog("Invalid Path", ex.Message, "OK"); 798 | } 799 | } 800 | } 801 | 802 | private void OnCopyPathClicked() 803 | { 804 | EditorGUIUtility.systemCopyBuffer = configPathField.value; 805 | McpLog.Info("Config path copied to clipboard"); 806 | } 807 | 808 | private void OnOpenFileClicked() 809 | { 810 | string path = configPathField.value; 811 | try 812 | { 813 | if (!File.Exists(path)) 814 | { 815 | EditorUtility.DisplayDialog("Open File", "The configuration file path does not exist.", "OK"); 816 | return; 817 | } 818 | 819 | Process.Start(new ProcessStartInfo 820 | { 821 | FileName = path, 822 | UseShellExecute = true 823 | }); 824 | } 825 | catch (Exception ex) 826 | { 827 | McpLog.Error($"Failed to open file: {ex.Message}"); 828 | } 829 | } 830 | 831 | private void OnCopyJsonClicked() 832 | { 833 | EditorGUIUtility.systemCopyBuffer = configJsonField.value; 834 | McpLog.Info("Configuration copied to clipboard"); 835 | } 836 | 837 | private void UpdateVersionLabel() 838 | { 839 | string currentVersion = AssetPathUtility.GetPackageVersion(); 840 | versionLabel.text = $"v{currentVersion}"; 841 | 842 | // Check for updates using the service 843 | var updateCheck = MCPServiceLocator.Updates.CheckForUpdate(currentVersion); 844 | 845 | if (updateCheck.UpdateAvailable && !string.IsNullOrEmpty(updateCheck.LatestVersion)) 846 | { 847 | // Update available - enhance the label 848 | versionLabel.text = $"\u2191 v{currentVersion} (Update available: v{updateCheck.LatestVersion})"; 849 | versionLabel.style.color = new Color(1f, 0.7f, 0f); // Orange 850 | versionLabel.tooltip = $"Version {updateCheck.LatestVersion} is available. Update via Package Manager.\n\nGit URL: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity"; 851 | } 852 | else 853 | { 854 | versionLabel.style.color = StyleKeyword.Null; // Default color 855 | versionLabel.tooltip = $"Current version: {currentVersion}"; 856 | } 857 | } 858 | 859 | } 860 | } 861 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ServerInstaller.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Linq; 7 | using System.Net; 8 | using System.Runtime.InteropServices; 9 | using UnityEditor; 10 | using UnityEngine; 11 | 12 | namespace MCPForUnity.Editor.Helpers 13 | { 14 | public static class ServerInstaller 15 | { 16 | private const string RootFolder = "UnityMCP"; 17 | private const string ServerFolder = "UnityMcpServer"; 18 | private const string VersionFileName = "server_version.txt"; 19 | 20 | /// <summary> 21 | /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. 22 | /// No network calls or Git operations are performed. 23 | /// </summary> 24 | public static void EnsureServerInstalled() 25 | { 26 | try 27 | { 28 | string saveLocation = GetSaveLocation(); 29 | TryCreateMacSymlinkForAppSupport(); 30 | string destRoot = Path.Combine(saveLocation, ServerFolder); 31 | string destSrc = Path.Combine(destRoot, "src"); 32 | 33 | // Detect legacy installs and version state (logs) 34 | DetectAndLogLegacyInstallStates(destRoot); 35 | 36 | // Resolve embedded source and versions 37 | if (!TryGetEmbeddedServerSource(out string embeddedSrc)) 38 | { 39 | // Asset Store install - no embedded server 40 | // Check if server was already downloaded 41 | if (File.Exists(Path.Combine(destSrc, "server.py"))) 42 | { 43 | McpLog.Info("Using previously downloaded MCP server.", always: false); 44 | } 45 | else 46 | { 47 | McpLog.Info("MCP server not found. Download via Window > MCP For Unity > Open MCP Window.", always: false); 48 | } 49 | return; // Graceful exit - no exception 50 | } 51 | 52 | string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; 53 | string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); 54 | 55 | bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); 56 | bool needOverwrite = !destHasServer 57 | || string.IsNullOrEmpty(installedVer) 58 | || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); 59 | 60 | // Ensure destination exists 61 | Directory.CreateDirectory(destRoot); 62 | 63 | if (needOverwrite) 64 | { 65 | // Copy the entire UnityMcpServer folder (parent of src) 66 | string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer 67 | CopyDirectoryRecursive(embeddedRoot, destRoot); 68 | 69 | // Write/refresh version file 70 | try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } 71 | McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); 72 | } 73 | 74 | // Cleanup legacy installs that are missing version or older than embedded 75 | foreach (var legacyRoot in GetLegacyRootsForDetection()) 76 | { 77 | try 78 | { 79 | string legacySrc = Path.Combine(legacyRoot, "src"); 80 | if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; 81 | string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); 82 | bool legacyOlder = string.IsNullOrEmpty(legacyVer) 83 | || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); 84 | if (legacyOlder) 85 | { 86 | TryKillUvForPath(legacySrc); 87 | try 88 | { 89 | Directory.Delete(legacyRoot, recursive: true); 90 | McpLog.Info($"Removed legacy server at '{legacyRoot}'."); 91 | } 92 | catch (Exception ex) 93 | { 94 | McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); 95 | } 96 | } 97 | } 98 | catch { } 99 | } 100 | 101 | // Clear overrides that might point at legacy locations 102 | try 103 | { 104 | EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); 105 | EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); 106 | } 107 | catch { } 108 | return; 109 | } 110 | catch (Exception ex) 111 | { 112 | // If a usable server is already present (installed or embedded), don't fail hard—just warn. 113 | bool hasInstalled = false; 114 | try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } 115 | 116 | if (hasInstalled || TryGetEmbeddedServerSource(out _)) 117 | { 118 | McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); 119 | return; 120 | } 121 | 122 | McpLog.Error($"Failed to ensure server installation: {ex.Message}"); 123 | } 124 | } 125 | 126 | public static string GetServerPath() 127 | { 128 | return Path.Combine(GetSaveLocation(), ServerFolder, "src"); 129 | } 130 | 131 | /// <summary> 132 | /// Gets the platform-specific save location for the server. 133 | /// </summary> 134 | private static string GetSaveLocation() 135 | { 136 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 137 | { 138 | // Use per-user LocalApplicationData for canonical install location 139 | var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) 140 | ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); 141 | return Path.Combine(localAppData, RootFolder); 142 | } 143 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 144 | { 145 | var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); 146 | if (string.IsNullOrEmpty(xdg)) 147 | { 148 | xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, 149 | ".local", "share"); 150 | } 151 | return Path.Combine(xdg, RootFolder); 152 | } 153 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 154 | { 155 | // On macOS, use LocalApplicationData (~/Library/Application Support) 156 | var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); 157 | // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support 158 | bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); 159 | if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) 160 | { 161 | // Fallback: construct from $HOME 162 | var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; 163 | localAppSupport = Path.Combine(home, "Library", "Application Support"); 164 | } 165 | TryCreateMacSymlinkForAppSupport(); 166 | return Path.Combine(localAppSupport, RootFolder); 167 | } 168 | throw new Exception("Unsupported operating system"); 169 | } 170 | 171 | /// <summary> 172 | /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support 173 | /// to mitigate arg parsing and quoting issues in some MCP clients. 174 | /// Safe to call repeatedly. 175 | /// </summary> 176 | private static void TryCreateMacSymlinkForAppSupport() 177 | { 178 | try 179 | { 180 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; 181 | string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; 182 | if (string.IsNullOrEmpty(home)) return; 183 | 184 | string canonical = Path.Combine(home, "Library", "Application Support"); 185 | string symlink = Path.Combine(home, "Library", "AppSupport"); 186 | 187 | // If symlink exists already, nothing to do 188 | if (Directory.Exists(symlink) || File.Exists(symlink)) return; 189 | 190 | // Create symlink only if canonical exists 191 | if (!Directory.Exists(canonical)) return; 192 | 193 | // Use 'ln -s' to create a directory symlink (macOS) 194 | var psi = new ProcessStartInfo 195 | { 196 | FileName = "/bin/ln", 197 | Arguments = $"-s \"{canonical}\" \"{symlink}\"", 198 | UseShellExecute = false, 199 | RedirectStandardOutput = true, 200 | RedirectStandardError = true, 201 | CreateNoWindow = true 202 | }; 203 | using var p = Process.Start(psi); 204 | p?.WaitForExit(2000); 205 | } 206 | catch { /* best-effort */ } 207 | } 208 | 209 | private static bool IsDirectoryWritable(string path) 210 | { 211 | try 212 | { 213 | File.Create(Path.Combine(path, "test.txt")).Dispose(); 214 | File.Delete(Path.Combine(path, "test.txt")); 215 | return true; 216 | } 217 | catch 218 | { 219 | return false; 220 | } 221 | } 222 | 223 | /// <summary> 224 | /// Checks if the server is installed at the specified location. 225 | /// </summary> 226 | private static bool IsServerInstalled(string location) 227 | { 228 | return Directory.Exists(location) 229 | && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); 230 | } 231 | 232 | /// <summary> 233 | /// Detects legacy installs or older versions and logs findings (no deletion yet). 234 | /// </summary> 235 | private static void DetectAndLogLegacyInstallStates(string canonicalRoot) 236 | { 237 | try 238 | { 239 | string canonicalSrc = Path.Combine(canonicalRoot, "src"); 240 | // Normalize canonical root for comparisons 241 | string normCanonicalRoot = NormalizePathSafe(canonicalRoot); 242 | string embeddedSrc = null; 243 | TryGetEmbeddedServerSource(out embeddedSrc); 244 | 245 | string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); 246 | string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); 247 | 248 | // Legacy paths (macOS/Linux .config; Windows roaming as example) 249 | foreach (var legacyRoot in GetLegacyRootsForDetection()) 250 | { 251 | // Skip logging for the canonical root itself 252 | if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) 253 | continue; 254 | string legacySrc = Path.Combine(legacyRoot, "src"); 255 | bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); 256 | string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); 257 | 258 | if (hasServer) 259 | { 260 | // Case 1: No version file 261 | if (string.IsNullOrEmpty(legacyVer)) 262 | { 263 | McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); 264 | } 265 | 266 | // Case 2: Lives in legacy path 267 | McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); 268 | 269 | // Case 3: Has version but appears older than embedded 270 | if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) 271 | { 272 | McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); 273 | } 274 | } 275 | } 276 | 277 | // Also log if canonical is missing version (treated as older) 278 | if (Directory.Exists(canonicalRoot)) 279 | { 280 | if (string.IsNullOrEmpty(installedVer)) 281 | { 282 | McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); 283 | } 284 | else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) 285 | { 286 | McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); 287 | } 288 | } 289 | } 290 | catch (Exception ex) 291 | { 292 | McpLog.Warn("Detect legacy/version state failed: " + ex.Message); 293 | } 294 | } 295 | 296 | private static string NormalizePathSafe(string path) 297 | { 298 | try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } 299 | catch { return path; } 300 | } 301 | 302 | private static bool PathsEqualSafe(string a, string b) 303 | { 304 | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; 305 | string na = NormalizePathSafe(a); 306 | string nb = NormalizePathSafe(b); 307 | try 308 | { 309 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 310 | { 311 | return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); 312 | } 313 | return string.Equals(na, nb, StringComparison.Ordinal); 314 | } 315 | catch { return false; } 316 | } 317 | 318 | private static IEnumerable<string> GetLegacyRootsForDetection() 319 | { 320 | var roots = new List<string>(); 321 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 322 | // macOS/Linux legacy 323 | roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); 324 | roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); 325 | // Windows roaming example 326 | try 327 | { 328 | string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; 329 | if (!string.IsNullOrEmpty(roaming)) 330 | roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); 331 | // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer 332 | // Detect this location so we can clean up older copies during install/update. 333 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; 334 | if (!string.IsNullOrEmpty(localAppData)) 335 | roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer")); 336 | } 337 | catch { } 338 | return roots; 339 | } 340 | 341 | private static void TryKillUvForPath(string serverSrcPath) 342 | { 343 | try 344 | { 345 | if (string.IsNullOrEmpty(serverSrcPath)) return; 346 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; 347 | 348 | var psi = new ProcessStartInfo 349 | { 350 | FileName = "/usr/bin/pgrep", 351 | Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", 352 | UseShellExecute = false, 353 | RedirectStandardOutput = true, 354 | RedirectStandardError = true, 355 | CreateNoWindow = true 356 | }; 357 | using var p = Process.Start(psi); 358 | if (p == null) return; 359 | string outp = p.StandardOutput.ReadToEnd(); 360 | p.WaitForExit(1500); 361 | if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) 362 | { 363 | foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) 364 | { 365 | if (int.TryParse(line.Trim(), out int pid)) 366 | { 367 | try { Process.GetProcessById(pid).Kill(); } catch { } 368 | } 369 | } 370 | } 371 | } 372 | catch { } 373 | } 374 | 375 | private static string ReadVersionFile(string path) 376 | { 377 | try 378 | { 379 | if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; 380 | string v = File.ReadAllText(path).Trim(); 381 | return string.IsNullOrEmpty(v) ? null : v; 382 | } 383 | catch { return null; } 384 | } 385 | 386 | private static int CompareSemverSafe(string a, string b) 387 | { 388 | try 389 | { 390 | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; 391 | var ap = a.Split('.'); 392 | var bp = b.Split('.'); 393 | for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) 394 | { 395 | int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; 396 | int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; 397 | if (ai != bi) return ai.CompareTo(bi); 398 | } 399 | return 0; 400 | } 401 | catch { return 0; } 402 | } 403 | 404 | /// <summary> 405 | /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package 406 | /// or common development locations. 407 | /// </summary> 408 | private static bool TryGetEmbeddedServerSource(out string srcPath) 409 | { 410 | return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); 411 | } 412 | 413 | private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; 414 | 415 | private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) 416 | { 417 | Directory.CreateDirectory(destinationDir); 418 | 419 | foreach (string filePath in Directory.GetFiles(sourceDir)) 420 | { 421 | string fileName = Path.GetFileName(filePath); 422 | string destFile = Path.Combine(destinationDir, fileName); 423 | File.Copy(filePath, destFile, overwrite: true); 424 | } 425 | 426 | foreach (string dirPath in Directory.GetDirectories(sourceDir)) 427 | { 428 | string dirName = Path.GetFileName(dirPath); 429 | foreach (var skip in _skipDirs) 430 | { 431 | if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) 432 | goto NextDir; 433 | } 434 | try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } 435 | string destSubDir = Path.Combine(destinationDir, dirName); 436 | CopyDirectoryRecursive(dirPath, destSubDir); 437 | NextDir:; 438 | } 439 | } 440 | 441 | public static bool RebuildMcpServer() 442 | { 443 | try 444 | { 445 | // Find embedded source 446 | if (!TryGetEmbeddedServerSource(out string embeddedSrc)) 447 | { 448 | McpLog.Error("RebuildMcpServer: Could not find embedded server source."); 449 | return false; 450 | } 451 | 452 | string saveLocation = GetSaveLocation(); 453 | string destRoot = Path.Combine(saveLocation, ServerFolder); 454 | string destSrc = Path.Combine(destRoot, "src"); 455 | 456 | // Kill any running uv processes for this server 457 | TryKillUvForPath(destSrc); 458 | 459 | // Delete the entire installed server directory 460 | if (Directory.Exists(destRoot)) 461 | { 462 | try 463 | { 464 | Directory.Delete(destRoot, recursive: true); 465 | McpLog.Info($"Deleted existing server at {destRoot}"); 466 | } 467 | catch (Exception ex) 468 | { 469 | McpLog.Error($"Failed to delete existing server: {ex.Message}"); 470 | return false; 471 | } 472 | } 473 | 474 | // Re-copy from embedded source 475 | string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; 476 | Directory.CreateDirectory(destRoot); 477 | CopyDirectoryRecursive(embeddedRoot, destRoot); 478 | 479 | // Write version file 480 | string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; 481 | try 482 | { 483 | File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer); 484 | } 485 | catch (Exception ex) 486 | { 487 | McpLog.Warn($"Failed to write version file: {ex.Message}"); 488 | } 489 | 490 | McpLog.Info($"Server rebuilt successfully at {destRoot} (version {embeddedVer})"); 491 | return true; 492 | } 493 | catch (Exception ex) 494 | { 495 | McpLog.Error($"RebuildMcpServer failed: {ex.Message}"); 496 | return false; 497 | } 498 | } 499 | 500 | internal static string FindUvPath() 501 | { 502 | // Allow user override via EditorPrefs 503 | try 504 | { 505 | string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty); 506 | if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) 507 | { 508 | if (ValidateUvBinary(overridePath)) return overridePath; 509 | } 510 | } 511 | catch { } 512 | 513 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 514 | 515 | // Platform-specific candidate lists 516 | string[] candidates; 517 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 518 | { 519 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; 520 | string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; 521 | string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; 522 | 523 | // Fast path: resolve from PATH first 524 | try 525 | { 526 | var wherePsi = new ProcessStartInfo 527 | { 528 | FileName = "where", 529 | Arguments = "uv.exe", 530 | UseShellExecute = false, 531 | RedirectStandardOutput = true, 532 | RedirectStandardError = true, 533 | CreateNoWindow = true 534 | }; 535 | using var wp = Process.Start(wherePsi); 536 | string output = wp.StandardOutput.ReadToEnd().Trim(); 537 | wp.WaitForExit(1500); 538 | if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) 539 | { 540 | foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) 541 | { 542 | string path = line.Trim(); 543 | if (File.Exists(path) && ValidateUvBinary(path)) return path; 544 | } 545 | } 546 | } 547 | catch { } 548 | 549 | // Windows Store (PythonSoftwareFoundation) install location probe 550 | // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe 551 | try 552 | { 553 | string pkgsRoot = Path.Combine(localAppData, "Packages"); 554 | if (Directory.Exists(pkgsRoot)) 555 | { 556 | var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly) 557 | .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase); 558 | foreach (var pkg in pythonPkgs) 559 | { 560 | string localCache = Path.Combine(pkg, "LocalCache", "local-packages"); 561 | if (!Directory.Exists(localCache)) continue; 562 | var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly) 563 | .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase); 564 | foreach (var pyRoot in pyRoots) 565 | { 566 | string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe"); 567 | if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe; 568 | } 569 | } 570 | } 571 | } 572 | catch { } 573 | 574 | candidates = new[] 575 | { 576 | // Preferred: WinGet Links shims (stable entrypoints) 577 | // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) 578 | Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), 579 | Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), 580 | 581 | // Common per-user installs 582 | Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), 583 | Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), 584 | Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), 585 | Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), 586 | Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), 587 | Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), 588 | Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), 589 | Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), 590 | 591 | // Program Files style installs (if a native installer was used) 592 | Path.Combine(programFiles, @"uv\uv.exe"), 593 | 594 | // Try simple name resolution later via PATH 595 | "uv.exe", 596 | "uv" 597 | }; 598 | } 599 | else 600 | { 601 | candidates = new[] 602 | { 603 | "/opt/homebrew/bin/uv", 604 | "/usr/local/bin/uv", 605 | "/usr/bin/uv", 606 | "/opt/local/bin/uv", 607 | Path.Combine(home, ".local", "bin", "uv"), 608 | "/opt/homebrew/opt/uv/bin/uv", 609 | // Framework Python installs 610 | "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", 611 | "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", 612 | // Fallback to PATH resolution by name 613 | "uv" 614 | }; 615 | } 616 | 617 | foreach (string c in candidates) 618 | { 619 | try 620 | { 621 | if (File.Exists(c) && ValidateUvBinary(c)) return c; 622 | } 623 | catch { /* ignore */ } 624 | } 625 | 626 | // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) 627 | try 628 | { 629 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 630 | { 631 | var whichPsi = new ProcessStartInfo 632 | { 633 | FileName = "/usr/bin/which", 634 | Arguments = "uv", 635 | UseShellExecute = false, 636 | RedirectStandardOutput = true, 637 | RedirectStandardError = true, 638 | CreateNoWindow = true 639 | }; 640 | try 641 | { 642 | // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env 643 | string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 644 | string prepend = string.Join(":", new[] 645 | { 646 | Path.Combine(homeDir, ".local", "bin"), 647 | "/opt/homebrew/bin", 648 | "/usr/local/bin", 649 | "/usr/bin", 650 | "/bin" 651 | }); 652 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 653 | whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); 654 | } 655 | catch { } 656 | using var wp = Process.Start(whichPsi); 657 | string output = wp.StandardOutput.ReadToEnd().Trim(); 658 | wp.WaitForExit(3000); 659 | if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) 660 | { 661 | if (ValidateUvBinary(output)) return output; 662 | } 663 | } 664 | } 665 | catch { } 666 | 667 | // Manual PATH scan 668 | try 669 | { 670 | string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 671 | string[] parts = pathEnv.Split(Path.PathSeparator); 672 | foreach (string part in parts) 673 | { 674 | try 675 | { 676 | // Check both uv and uv.exe 677 | string candidateUv = Path.Combine(part, "uv"); 678 | string candidateUvExe = Path.Combine(part, "uv.exe"); 679 | if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; 680 | if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; 681 | } 682 | catch { } 683 | } 684 | } 685 | catch { } 686 | 687 | return null; 688 | } 689 | 690 | private static bool ValidateUvBinary(string uvPath) 691 | { 692 | try 693 | { 694 | var psi = new ProcessStartInfo 695 | { 696 | FileName = uvPath, 697 | Arguments = "--version", 698 | UseShellExecute = false, 699 | RedirectStandardOutput = true, 700 | RedirectStandardError = true, 701 | CreateNoWindow = true 702 | }; 703 | using var p = Process.Start(psi); 704 | if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } 705 | if (p.ExitCode == 0) 706 | { 707 | string output = p.StandardOutput.ReadToEnd().Trim(); 708 | return output.StartsWith("uv "); 709 | } 710 | } 711 | catch { } 712 | return false; 713 | } 714 | 715 | /// <summary> 716 | /// Download and install server from GitHub release (Asset Store workflow) 717 | /// </summary> 718 | public static bool DownloadAndInstallServer() 719 | { 720 | string packageVersion = AssetPathUtility.GetPackageVersion(); 721 | if (packageVersion == "unknown") 722 | { 723 | McpLog.Error("Cannot determine package version for download."); 724 | return false; 725 | } 726 | 727 | string downloadUrl = $"https://github.com/CoplayDev/unity-mcp/releases/download/v{packageVersion}/mcp-for-unity-server-v{packageVersion}.zip"; 728 | string tempZip = Path.Combine(Path.GetTempPath(), $"mcp-server-v{packageVersion}.zip"); 729 | string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); 730 | 731 | try 732 | { 733 | EditorUtility.DisplayProgressBar("MCP for Unity", "Downloading server...", 0.3f); 734 | 735 | // Download 736 | using (var client = new WebClient()) 737 | { 738 | client.DownloadFile(downloadUrl, tempZip); 739 | } 740 | 741 | EditorUtility.DisplayProgressBar("MCP for Unity", "Extracting server...", 0.7f); 742 | 743 | // Kill any running UV processes 744 | string destSrc = Path.Combine(destRoot, "src"); 745 | TryKillUvForPath(destSrc); 746 | 747 | // Delete old installation 748 | if (Directory.Exists(destRoot)) 749 | { 750 | try 751 | { 752 | Directory.Delete(destRoot, recursive: true); 753 | } 754 | catch (Exception ex) 755 | { 756 | McpLog.Warn($"Could not fully delete old server: {ex.Message}"); 757 | } 758 | } 759 | 760 | // Extract to temp location first 761 | string tempExtractDir = Path.Combine(Path.GetTempPath(), $"mcp-server-extract-{Guid.NewGuid()}"); 762 | Directory.CreateDirectory(tempExtractDir); 763 | 764 | try 765 | { 766 | ZipFile.ExtractToDirectory(tempZip, tempExtractDir); 767 | 768 | // The ZIP contains UnityMcpServer~ folder, find it and move its contents 769 | string extractedServerFolder = Path.Combine(tempExtractDir, "UnityMcpServer~"); 770 | Directory.CreateDirectory(destRoot); 771 | CopyDirectoryRecursive(extractedServerFolder, destRoot); 772 | } 773 | finally 774 | { 775 | // Cleanup temp extraction directory 776 | try 777 | { 778 | if (Directory.Exists(tempExtractDir)) 779 | { 780 | Directory.Delete(tempExtractDir, recursive: true); 781 | } 782 | } 783 | catch (Exception ex) 784 | { 785 | McpLog.Warn($"Could not fully delete temp extraction directory: {ex.Message}"); 786 | } 787 | } 788 | 789 | EditorUtility.ClearProgressBar(); 790 | McpLog.Info($"Server v{packageVersion} downloaded and installed successfully!"); 791 | return true; 792 | } 793 | catch (Exception ex) 794 | { 795 | EditorUtility.ClearProgressBar(); 796 | McpLog.Error($"Failed to download server: {ex.Message}"); 797 | EditorUtility.DisplayDialog( 798 | "Download Failed", 799 | $"Could not download server from GitHub.\n\n{ex.Message}\n\nPlease check your internet connection or try again later.", 800 | "OK" 801 | ); 802 | return false; 803 | } 804 | finally 805 | { 806 | try { 807 | if (File.Exists(tempZip)) File.Delete(tempZip); 808 | } catch (Exception ex) { 809 | McpLog.Warn($"Could not delete temp zip file: {ex.Message}"); 810 | } 811 | } 812 | } 813 | 814 | /// <summary> 815 | /// Check if the package has an embedded server (Git install vs Asset Store) 816 | /// </summary> 817 | public static bool HasEmbeddedServer() 818 | { 819 | return TryGetEmbeddedServerSource(out _); 820 | } 821 | 822 | /// <summary> 823 | /// Get the installed server version from the local installation 824 | /// </summary> 825 | public static string GetInstalledServerVersion() 826 | { 827 | try 828 | { 829 | string destRoot = Path.Combine(GetSaveLocation(), ServerFolder); 830 | string versionPath = Path.Combine(destRoot, "src", VersionFileName); 831 | if (File.Exists(versionPath)) 832 | { 833 | return File.ReadAllText(versionPath)?.Trim() ?? string.Empty; 834 | } 835 | } 836 | catch (Exception ex) 837 | { 838 | McpLog.Warn($"Could not read version file: {ex.Message}"); 839 | } 840 | return string.Empty; 841 | } 842 | } 843 | } 844 | ``` -------------------------------------------------------------------------------- /.github/workflows/claude-nl-suite.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Claude NL/T Full Suite (Unity live) 2 | 3 | on: [workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | checks: write 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 15 | 16 | jobs: 17 | nl-suite: 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 60 20 | env: 21 | JUNIT_OUT: reports/junit-nl-suite.xml 22 | MD_OUT: reports/junit-nl-suite.md 23 | 24 | steps: 25 | # ---------- Secrets check ---------- 26 | - name: Detect secrets (outputs) 27 | id: detect 28 | env: 29 | UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} 30 | UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} 31 | UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} 32 | UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} 33 | ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 34 | run: | 35 | set -e 36 | if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi 37 | if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then 38 | echo "unity_ok=true" >> "$GITHUB_OUTPUT" 39 | else 40 | echo "unity_ok=false" >> "$GITHUB_OUTPUT" 41 | fi 42 | 43 | - uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 0 46 | 47 | # ---------- Python env for MCP server (uv) ---------- 48 | - uses: astral-sh/setup-uv@v4 49 | with: 50 | python-version: "3.11" 51 | 52 | - name: Install MCP server 53 | run: | 54 | set -eux 55 | uv venv 56 | echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" 57 | echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" 58 | if [ -f MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then 59 | uv pip install -e MCPForUnity/UnityMcpServer~/src 60 | elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then 61 | uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt 62 | elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then 63 | uv pip install -e MCPForUnity/UnityMcpServer~/ 64 | elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then 65 | uv pip install -r MCPForUnity/UnityMcpServer~/requirements.txt 66 | else 67 | echo "No MCP Python deps found (skipping)" 68 | fi 69 | 70 | # --- Licensing: allow both ULF and EBL when available --- 71 | - name: Decide license sources 72 | id: lic 73 | shell: bash 74 | env: 75 | UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} 76 | UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} 77 | UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} 78 | UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} 79 | run: | 80 | set -eu 81 | use_ulf=false; use_ebl=false 82 | [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true 83 | [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true 84 | echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" 85 | echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" 86 | echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" 87 | 88 | - name: Stage Unity .ulf license (from secret) 89 | if: steps.lic.outputs.use_ulf == 'true' 90 | id: ulf 91 | env: 92 | UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} 93 | shell: bash 94 | run: | 95 | set -eu 96 | mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" 97 | f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" 98 | if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then 99 | printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" 100 | else 101 | printf "%s" "$UNITY_LICENSE" > "$f" 102 | fi 103 | chmod 600 "$f" || true 104 | # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: 105 | if head -c 100 "$f" | grep -qi '<\?xml'; then 106 | mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" 107 | mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" 108 | echo "ok=false" >> "$GITHUB_OUTPUT" 109 | elif grep -qi '<Signature>' "$f"; then 110 | # provide it in the standard local-share path too 111 | cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" 112 | echo "ok=true" >> "$GITHUB_OUTPUT" 113 | else 114 | echo "ok=false" >> "$GITHUB_OUTPUT" 115 | fi 116 | 117 | # --- Activate via EBL inside the same Unity image (writes host-side entitlement) --- 118 | - name: Activate Unity (EBL via container - host-mount) 119 | if: steps.lic.outputs.use_ebl == 'true' 120 | shell: bash 121 | env: 122 | UNITY_IMAGE: ${{ env.UNITY_IMAGE }} 123 | UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} 124 | UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} 125 | UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} 126 | run: | 127 | set -euxo pipefail 128 | # host dirs to receive the full Unity config and local-share 129 | mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" 130 | 131 | # Try Pro first if serial is present, otherwise named-user EBL. 132 | docker run --rm --network host \ 133 | -e HOME=/root \ 134 | -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ 135 | -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ 136 | -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ 137 | "$UNITY_IMAGE" bash -lc ' 138 | set -euxo pipefail 139 | if [[ -n "${UNITY_SERIAL:-}" ]]; then 140 | /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ 141 | -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true 142 | else 143 | /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ 144 | -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true 145 | fi 146 | ls -la /root/.config/unity3d/Unity/licenses || true 147 | ' 148 | 149 | # Verify entitlement written to host mount; allow ULF-only runs to proceed 150 | if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then 151 | if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then 152 | echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." 153 | else 154 | echo "No entitlement produced and no valid ULF; cannot continue." >&2 155 | exit 1 156 | fi 157 | fi 158 | 159 | # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step 160 | 161 | # ---------- Warm up project (import Library once) ---------- 162 | - name: Warm up project (import Library once) 163 | if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' 164 | shell: bash 165 | env: 166 | UNITY_IMAGE: ${{ env.UNITY_IMAGE }} 167 | ULF_OK: ${{ steps.ulf.outputs.ok }} 168 | run: | 169 | set -euxo pipefail 170 | manual_args=() 171 | if [[ "${ULF_OK:-false}" == "true" ]]; then 172 | manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") 173 | fi 174 | docker run --rm --network host \ 175 | -e HOME=/root \ 176 | -v "${{ github.workspace }}:/workspace" -w /workspace \ 177 | -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ 178 | -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ 179 | "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ 180 | -projectPath /workspace/TestProjects/UnityMCPTests \ 181 | "${manual_args[@]}" \ 182 | -quit 183 | 184 | # ---------- Clean old MCP status ---------- 185 | - name: Clean old MCP status 186 | run: | 187 | set -eux 188 | mkdir -p "$HOME/.unity-mcp" 189 | rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true 190 | 191 | # ---------- Start headless Unity (persistent bridge) ---------- 192 | - name: Start Unity (persistent bridge) 193 | if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' 194 | shell: bash 195 | env: 196 | UNITY_IMAGE: ${{ env.UNITY_IMAGE }} 197 | ULF_OK: ${{ steps.ulf.outputs.ok }} 198 | run: | 199 | set -euxo pipefail 200 | manual_args=() 201 | if [[ "${ULF_OK:-false}" == "true" ]]; then 202 | manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") 203 | fi 204 | 205 | mkdir -p "$RUNNER_TEMP/unity-status" 206 | docker rm -f unity-mcp >/dev/null 2>&1 || true 207 | docker run -d --name unity-mcp --network host \ 208 | -e HOME=/root \ 209 | -e UNITY_MCP_ALLOW_BATCH=1 \ 210 | -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ 211 | -e UNITY_MCP_BIND_HOST=127.0.0.1 \ 212 | -v "${{ github.workspace }}:/workspace" -w /workspace \ 213 | -v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \ 214 | -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ 215 | -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \ 216 | "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ 217 | -stackTraceLogType Full \ 218 | -projectPath /workspace/TestProjects/UnityMCPTests \ 219 | "${manual_args[@]}" \ 220 | -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect 221 | 222 | # ---------- Wait for Unity bridge ---------- 223 | - name: Wait for Unity bridge (robust) 224 | shell: bash 225 | run: | 226 | set -euo pipefail 227 | deadline=$((SECONDS+900)) # 15 min max 228 | fatal_after=$((SECONDS+120)) # give licensing 2 min to settle 229 | 230 | # Fail fast only if container actually died 231 | st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)" 232 | case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac 233 | 234 | # Patterns 235 | ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' 236 | # Only truly fatal signals; allow transient "Licensing::..." chatter 237 | license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' 238 | 239 | while [ $SECONDS -lt $deadline ]; do 240 | logs="$(docker logs unity-mcp 2>&1 || true)" 241 | 242 | # 1) Primary: status JSON exposes TCP port 243 | port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" 244 | if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then 245 | echo "Bridge ready on port $port" 246 | exit 0 247 | fi 248 | 249 | # 2) Secondary: log markers 250 | if echo "$logs" | grep -qiE "$ok_pat"; then 251 | echo "Bridge ready (log markers)" 252 | exit 0 253 | fi 254 | 255 | # Only treat license failures as fatal *after* warm-up 256 | if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then 257 | echo "::error::Fatal licensing signal detected after warm-up" 258 | echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' 259 | exit 1 260 | fi 261 | 262 | # If the container dies mid-wait, bail 263 | st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" 264 | if [[ "$st" != "running" ]]; then 265 | echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' 266 | exit 1 267 | fi 268 | 269 | sleep 2 270 | done 271 | 272 | echo "::error::Bridge not ready before deadline" 273 | docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' 274 | exit 1 275 | 276 | # (moved) — return license after Unity is stopped 277 | 278 | # ---------- MCP client config ---------- 279 | - name: Write MCP config (.claude/mcp.json) 280 | run: | 281 | set -eux 282 | mkdir -p .claude 283 | cat > .claude/mcp.json <<JSON 284 | { 285 | "mcpServers": { 286 | "unity": { 287 | "command": "uv", 288 | "args": ["run","--active","--directory","MCPForUnity/UnityMcpServer~/src","python","server.py"], 289 | "transport": { "type": "stdio" }, 290 | "env": { 291 | "PYTHONUNBUFFERED": "1", 292 | "MCP_LOG_LEVEL": "debug", 293 | "UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests", 294 | "UNITY_MCP_STATUS_DIR": "$RUNNER_TEMP/unity-status", 295 | "UNITY_MCP_HOST": "127.0.0.1" 296 | } 297 | } 298 | } 299 | } 300 | JSON 301 | 302 | - name: Pin Claude tool permissions (.claude/settings.json) 303 | run: | 304 | set -eux 305 | mkdir -p .claude 306 | cat > .claude/settings.json <<'JSON' 307 | { 308 | "permissions": { 309 | "allow": [ 310 | "mcp__unity", 311 | "Edit(reports/**)" 312 | ], 313 | "deny": [ 314 | "Bash", 315 | "MultiEdit", 316 | "WebFetch", 317 | "WebSearch", 318 | "Task", 319 | "TodoWrite", 320 | "NotebookEdit", 321 | "NotebookRead" 322 | ] 323 | } 324 | } 325 | JSON 326 | 327 | # ---------- Reports & helper ---------- 328 | - name: Prepare reports and dirs 329 | run: | 330 | set -eux 331 | rm -f reports/*.xml reports/*.md || true 332 | mkdir -p reports reports/_snapshots reports/_staging 333 | 334 | - name: Create report skeletons 335 | run: | 336 | set -eu 337 | cat > "$JUNIT_OUT" <<'XML' 338 | <?xml version="1.0" encoding="UTF-8"?> 339 | <testsuites><testsuite name="UnityMCP.NL-T" tests="1" failures="1" errors="0" skipped="0" time="0"> 340 | <testcase name="NL-Suite.Bootstrap" classname="UnityMCP.NL-T"> 341 | <failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure> 342 | </testcase> 343 | </testsuite></testsuites> 344 | XML 345 | printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" 346 | 347 | - name: Verify Unity bridge status/port 348 | run: | 349 | set -euxo pipefail 350 | ls -la "$RUNNER_TEMP/unity-status" || true 351 | jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true 352 | 353 | shopt -s nullglob 354 | status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json) 355 | if ((${#status_files[@]})); then 356 | port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \ 357 | | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)" 358 | else 359 | port="" 360 | fi 361 | 362 | echo "unity_port=$port" 363 | if [[ -n "$port" ]]; then 364 | timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" 365 | fi 366 | 367 | # (removed) Revert helper and baseline snapshot are no longer used 368 | 369 | # ---------- Run suite in two passes ---------- 370 | - name: Run Claude NL pass 371 | uses: anthropics/claude-code-base-action@beta 372 | if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' 373 | continue-on-error: true 374 | with: 375 | use_node_cache: false 376 | prompt_file: .claude/prompts/nl-unity-suite-nl.md 377 | mcp_config: .claude/mcp.json 378 | settings: .claude/settings.json 379 | allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" 380 | disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" 381 | model: claude-3-7-sonnet-20250219 382 | append_system_prompt: | 383 | You are running the NL pass only. 384 | - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. 385 | - Write each to reports/${ID}_results.xml. 386 | - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests. 387 | - Stop after NL-4_results.xml is written. 388 | timeout_minutes: "30" 389 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 390 | 391 | - name: Run Claude T pass A-J 392 | uses: anthropics/claude-code-base-action@beta 393 | if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' 394 | continue-on-error: true 395 | with: 396 | use_node_cache: false 397 | prompt_file: .claude/prompts/nl-unity-suite-t.md 398 | mcp_config: .claude/mcp.json 399 | settings: .claude/settings.json 400 | allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" 401 | disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" 402 | model: claude-3-5-haiku-20241022 403 | append_system_prompt: | 404 | You are running the T pass (A–J) only. 405 | Output requirements: 406 | - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. 407 | - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). 408 | - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. 409 | - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. 410 | - Do not emit any NL-* fragments. 411 | Stop condition: 412 | - After T-J_results.xml is written, stop. 413 | timeout_minutes: "30" 414 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 415 | 416 | # (moved) Assert T coverage after staged fragments are promoted 417 | 418 | - name: Check T coverage incomplete (pre-retry) 419 | id: t_cov 420 | if: always() 421 | shell: bash 422 | run: | 423 | set -euo pipefail 424 | missing=() 425 | for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do 426 | if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then 427 | missing+=("$id") 428 | fi 429 | done 430 | echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" 431 | if (( ${#missing[@]} )); then 432 | echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" 433 | fi 434 | 435 | - name: Retry T pass (Sonnet) if incomplete 436 | if: steps.t_cov.outputs.missing != '0' 437 | uses: anthropics/claude-code-base-action@beta 438 | with: 439 | use_node_cache: false 440 | prompt_file: .claude/prompts/nl-unity-suite-t.md 441 | mcp_config: .claude/mcp.json 442 | settings: .claude/settings.json 443 | allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" 444 | disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" 445 | model: claude-3-7-sonnet-20250219 446 | fallback_model: claude-3-5-haiku-20241022 447 | append_system_prompt: | 448 | You are running the T pass only. 449 | Output requirements: 450 | - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. 451 | - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). 452 | - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. 453 | - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. 454 | - Do not emit any NL-* fragments. 455 | Stop condition: 456 | - After T-J_results.xml is written, stop. 457 | timeout_minutes: "30" 458 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 459 | 460 | - name: Re-assert T coverage (post-retry) 461 | if: always() 462 | shell: bash 463 | run: | 464 | set -euo pipefail 465 | missing=() 466 | for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do 467 | [[ -s "reports/${id}_results.xml" ]] || missing+=("$id") 468 | done 469 | if (( ${#missing[@]} )); then 470 | echo "::error::Still missing T fragments: ${missing[*]}" 471 | exit 1 472 | fi 473 | 474 | # (kept) Finalize staged report fragments (promote to reports/) 475 | 476 | # (removed duplicate) Finalize staged report fragments 477 | 478 | - name: Assert T coverage (after promotion) 479 | if: always() 480 | shell: bash 481 | run: | 482 | set -euo pipefail 483 | missing=() 484 | for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do 485 | if [[ ! -s "reports/${id}_results.xml" ]]; then 486 | # Accept staged fragment as present 487 | [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id") 488 | fi 489 | done 490 | if (( ${#missing[@]} )); then 491 | echo "::error::Missing T fragments: ${missing[*]}" 492 | exit 1 493 | fi 494 | 495 | - name: Canonicalize testcase names (NL/T prefixes) 496 | if: always() 497 | shell: bash 498 | run: | 499 | python3 - <<'PY' 500 | from pathlib import Path 501 | import xml.etree.ElementTree as ET, re, os 502 | 503 | RULES = [ 504 | ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"), 505 | ("NL-1", r"\b(NL-1|Core\s*Method)\b"), 506 | ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"), 507 | ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"), 508 | ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"), 509 | ("T-A", r"\b(T-?A|Temporary\s*Helper)\b"), 510 | ("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"), 511 | ("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"), 512 | ("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"), 513 | ("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"), 514 | ("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"), 515 | ("T-G", r"\b(T-?G|Path\s*Normalization)\b"), 516 | ("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"), 517 | ("T-I", r"\b(T-?I|Failure\s*Surface)\b"), 518 | ("T-J", r"\b(T-?J|Idempotenc(y|e))\b"), 519 | ] 520 | 521 | def canon_name(name: str) -> str: 522 | n = name or "" 523 | for tid, pat in RULES: 524 | if re.search(pat, n, flags=re.I): 525 | # If it already starts with the correct format, leave it alone 526 | if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I): 527 | return n.strip() 528 | # If it has a different separator, extract title and reformat 529 | title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I) 530 | if title_match: 531 | title = title_match.group(1).strip() 532 | return f"{tid} — {title}" 533 | # Otherwise, just return the canonical ID 534 | return tid 535 | return n 536 | 537 | def id_from_filename(p: Path): 538 | n = p.name 539 | m = re.match(r'NL(\d+)_results\.xml$', n, re.I) 540 | if m: 541 | return f"NL-{int(m.group(1))}" 542 | m = re.match(r'T([A-J])_results\.xml$', n, re.I) 543 | if m: 544 | return f"T-{m.group(1).upper()}" 545 | return None 546 | 547 | frags = list(sorted(Path("reports").glob("*_results.xml"))) 548 | for frag in frags: 549 | try: 550 | tree = ET.parse(frag); root = tree.getroot() 551 | except Exception: 552 | continue 553 | if root.tag != "testcase": 554 | continue 555 | file_id = id_from_filename(frag) 556 | old = root.get("name") or "" 557 | # Prefer filename-derived ID; if name doesn't start with it, override 558 | if file_id: 559 | # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns) 560 | title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip() 561 | new = f"{file_id} — {title}" if title else file_id 562 | else: 563 | new = canon_name(old) 564 | if new != old and new: 565 | root.set("name", new) 566 | tree.write(frag, encoding="utf-8", xml_declaration=False) 567 | print(f'canon: {frag.name}: "{old}" -> "{new}"') 568 | 569 | # Note: Do not auto-relable fragments. We rely on per-test strict emission 570 | # and the backfill step to surface missing tests explicitly. 571 | PY 572 | 573 | - name: Backfill missing NL/T tests (fail placeholders) 574 | if: always() 575 | shell: bash 576 | run: | 577 | python3 - <<'PY' 578 | from pathlib import Path 579 | import xml.etree.ElementTree as ET 580 | import re 581 | 582 | DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"] 583 | seen = set() 584 | def id_from_filename(p: Path): 585 | n = p.name 586 | m = re.match(r'NL(\d+)_results\.xml$', n, re.I) 587 | if m: 588 | return f"NL-{int(m.group(1))}" 589 | m = re.match(r'T([A-J])_results\.xml$', n, re.I) 590 | if m: 591 | return f"T-{m.group(1).upper()}" 592 | return None 593 | 594 | for p in Path("reports").glob("*_results.xml"): 595 | try: 596 | r = ET.parse(p).getroot() 597 | except Exception: 598 | continue 599 | # Count by filename id primarily; fall back to testcase name if needed 600 | fid = id_from_filename(p) 601 | if fid in DESIRED: 602 | seen.add(fid) 603 | continue 604 | if r.tag == "testcase": 605 | name = (r.get("name") or "").strip() 606 | for d in DESIRED: 607 | if name.startswith(d): 608 | seen.add(d) 609 | break 610 | 611 | Path("reports").mkdir(parents=True, exist_ok=True) 612 | for d in DESIRED: 613 | if d in seen: 614 | continue 615 | frag = Path(f"reports/{d}_results.xml") 616 | tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d}) 617 | fail = ET.SubElement(tc, "failure", {"message":"not produced"}) 618 | fail.text = "The agent did not emit a fragment for this test." 619 | ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) 620 | print(f"backfill: {d}") 621 | PY 622 | 623 | - name: "Debug: list testcase names" 624 | if: always() 625 | run: | 626 | python3 - <<'PY' 627 | from pathlib import Path 628 | import xml.etree.ElementTree as ET 629 | for p in sorted(Path('reports').glob('*_results.xml')): 630 | try: 631 | r = ET.parse(p).getroot() 632 | if r.tag == 'testcase': 633 | print(f"{p.name}: {(r.get('name') or '').strip()}") 634 | except Exception: 635 | pass 636 | PY 637 | 638 | # ---------- Merge testcase fragments into JUnit ---------- 639 | - name: Normalize/assemble JUnit in-place (single file) 640 | if: always() 641 | shell: bash 642 | run: | 643 | python3 - <<'PY' 644 | from pathlib import Path 645 | import xml.etree.ElementTree as ET 646 | import re, os 647 | 648 | def localname(tag: str) -> str: 649 | return tag.rsplit('}', 1)[-1] if '}' in tag else tag 650 | 651 | src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) 652 | if not src.exists(): 653 | raise SystemExit(0) 654 | 655 | tree = ET.parse(src) 656 | root = tree.getroot() 657 | suite = root.find('./*') if localname(root.tag) == 'testsuites' else root 658 | if suite is None: 659 | raise SystemExit(0) 660 | 661 | def id_from_filename(p: Path): 662 | n = p.name 663 | m = re.match(r'NL(\d+)_results\.xml$', n, re.I) 664 | if m: 665 | return f"NL-{int(m.group(1))}" 666 | m = re.match(r'T([A-J])_results\.xml$', n, re.I) 667 | if m: 668 | return f"T-{m.group(1).upper()}" 669 | return None 670 | 671 | def id_from_system_out(tc): 672 | so = tc.find('system-out') 673 | if so is not None and so.text: 674 | m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) 675 | if m: 676 | return m.group(1) 677 | return None 678 | 679 | fragments = sorted(Path('reports').glob('*_results.xml')) 680 | added = 0 681 | renamed = 0 682 | 683 | for frag in fragments: 684 | tcs = [] 685 | try: 686 | froot = ET.parse(frag).getroot() 687 | if localname(froot.tag) == 'testcase': 688 | tcs = [froot] 689 | else: 690 | tcs = list(froot.findall('.//testcase')) 691 | except Exception: 692 | txt = Path(frag).read_text(encoding='utf-8', errors='replace') 693 | # Extract all testcase nodes from raw text 694 | nodes = re.findall(r'<testcase[\s\S]*?</testcase>', txt, flags=re.DOTALL) 695 | for m in nodes: 696 | try: 697 | tcs.append(ET.fromstring(m)) 698 | except Exception: 699 | pass 700 | 701 | # Guard: keep only the first testcase from each fragment 702 | if len(tcs) > 1: 703 | tcs = tcs[:1] 704 | 705 | test_id = id_from_filename(frag) 706 | 707 | for tc in tcs: 708 | current_name = tc.get('name') or '' 709 | tid = test_id or id_from_system_out(tc) 710 | # Enforce filename-derived ID as prefix; repair names if needed 711 | if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name): 712 | title = current_name.strip() 713 | new_name = f'{tid} — {title}' if title else tid 714 | tc.set('name', new_name) 715 | elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name): 716 | # Replace any wrong leading ID with the correct one 717 | title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip() 718 | new_name = f'{tid} — {title}' if title else tid 719 | tc.set('name', new_name) 720 | renamed += 1 721 | suite.append(tc) 722 | added += 1 723 | 724 | if added: 725 | # Drop bootstrap placeholder and recompute counts 726 | for tc in list(suite.findall('.//testcase')): 727 | if (tc.get('name') or '') == 'NL-Suite.Bootstrap': 728 | suite.remove(tc) 729 | testcases = suite.findall('.//testcase') 730 | failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) 731 | suite.set('tests', str(len(testcases))) 732 | suite.set('failures', str(failures_cnt)) 733 | suite.set('errors', '0') 734 | suite.set('skipped', '0') 735 | tree.write(src, encoding='utf-8', xml_declaration=True) 736 | print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") 737 | PY 738 | 739 | # ---------- Markdown summary from JUnit ---------- 740 | - name: Build markdown summary from JUnit 741 | if: always() 742 | shell: bash 743 | run: | 744 | python3 - <<'PY' 745 | import xml.etree.ElementTree as ET 746 | from pathlib import Path 747 | import os, html, re 748 | 749 | def localname(tag: str) -> str: 750 | return tag.rsplit('}', 1)[-1] if '}' in tag else tag 751 | 752 | src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) 753 | md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) 754 | md_out.parent.mkdir(parents=True, exist_ok=True) 755 | 756 | if not src.exists(): 757 | md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') 758 | raise SystemExit(0) 759 | 760 | tree = ET.parse(src) 761 | root = tree.getroot() 762 | suite = root.find('./*') if localname(root.tag) == 'testsuites' else root 763 | cases = [] if suite is None else list(suite.findall('.//testcase')) 764 | 765 | def id_from_case(tc): 766 | n = (tc.get('name') or '') 767 | m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n) 768 | if m: 769 | return m.group(1) 770 | so = tc.find('system-out') 771 | if so is not None and so.text: 772 | m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) 773 | if m: 774 | return m.group(1) 775 | return None 776 | 777 | id_status = {} 778 | name_map = {} 779 | for tc in cases: 780 | tid = id_from_case(tc) 781 | ok = (tc.find('failure') is None and tc.find('error') is None) 782 | if tid and tid not in id_status: 783 | id_status[tid] = ok 784 | name_map[tid] = (tc.get('name') or tid) 785 | 786 | desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] 787 | 788 | total = len(cases) 789 | failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) 790 | passed = total - failures 791 | 792 | lines = [] 793 | lines += [ 794 | '# Unity NL/T Editing Suite Test Results', 795 | '', 796 | f'Totals: {passed} passed, {failures} failed, {total} total', 797 | '', 798 | '## Test Checklist' 799 | ] 800 | for p in desired: 801 | st = id_status.get(p, None) 802 | lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) 803 | lines.append('') 804 | 805 | lines.append('## Test Details') 806 | 807 | def order_key(n: str): 808 | if n.startswith('NL-'): 809 | try: 810 | return (0, int(n.split('-')[1])) 811 | except: 812 | return (0, 999) 813 | if n.startswith('T-') and len(n) > 2: 814 | return (1, ord(n[2])) 815 | return (2, n) 816 | 817 | MAX_CHARS = 2000 818 | seen = set() 819 | for tid in sorted(id_status.keys(), key=order_key): 820 | seen.add(tid) 821 | tc = next((c for c in cases if (id_from_case(c) == tid)), None) 822 | if not tc: 823 | continue 824 | title = name_map.get(tid, tid) 825 | status_badge = "PASS" if id_status[tid] else "FAIL" 826 | lines.append(f"### {title} — {status_badge}") 827 | so = tc.find('system-out') 828 | text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n')) 829 | if text.strip(): 830 | t = text.strip() 831 | if len(t) > MAX_CHARS: 832 | t = t[:MAX_CHARS] + "\n…(truncated)" 833 | fence = '```' if '```' not in t else '````' 834 | lines += [fence, t, fence] 835 | else: 836 | lines.append('(no system-out)') 837 | node = tc.find('failure') or tc.find('error') 838 | if node is not None: 839 | msg = (node.get('message') or '').strip() 840 | body = (node.text or '').strip() 841 | if msg: 842 | lines.append(f"- Message: {msg}") 843 | if body: 844 | lines.append(f"- Detail: {body.splitlines()[0][:500]}") 845 | lines.append('') 846 | 847 | for tc in cases: 848 | if id_from_case(tc) in seen: 849 | continue 850 | title = tc.get('name') or '(unnamed)' 851 | status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" 852 | lines.append(f"### {title} — {status_badge}") 853 | lines.append('(unmapped test id)') 854 | lines.append('') 855 | 856 | md_out.write_text('\n'.join(lines), encoding='utf-8') 857 | PY 858 | 859 | - name: "Debug: list report files" 860 | if: always() 861 | shell: bash 862 | run: | 863 | set -eux 864 | ls -la reports || true 865 | shopt -s nullglob 866 | for f in reports/*.xml; do 867 | echo "===== $f =====" 868 | head -n 40 "$f" || true 869 | done 870 | 871 | # ---------- Collect execution transcript (if present) ---------- 872 | - name: Collect action execution transcript 873 | if: always() 874 | shell: bash 875 | run: | 876 | set -eux 877 | if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then 878 | cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json 879 | elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then 880 | cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json 881 | fi 882 | 883 | - name: Sanitize markdown (normalize newlines) 884 | if: always() 885 | run: | 886 | set -eu 887 | python3 - <<'PY' 888 | from pathlib import Path 889 | rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) 890 | for p in rp.glob('*.md'): 891 | b=p.read_bytes().replace(b'\x00', b'') 892 | s=b.decode('utf-8','replace').replace('\r\n','\n') 893 | p.write_text(s, encoding='utf-8', newline='\n') 894 | PY 895 | 896 | - name: NL/T details -> Job Summary 897 | if: always() 898 | run: | 899 | echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY 900 | python3 - <<'PY' >> $GITHUB_STEP_SUMMARY 901 | from pathlib import Path 902 | p = Path('reports/junit-nl-suite.md') 903 | if p.exists(): 904 | text = p.read_bytes().decode('utf-8', 'replace') 905 | MAX = 65000 906 | print(text[:MAX]) 907 | if len(text) > MAX: 908 | print("\n\n_…truncated; full report in artifacts._") 909 | else: 910 | print("_No markdown report found._") 911 | PY 912 | 913 | - name: Fallback JUnit if missing 914 | if: always() 915 | run: | 916 | set -eu 917 | mkdir -p reports 918 | if [ ! -f "$JUNIT_OUT" ]; then 919 | printf '%s\n' \ 920 | '<?xml version="1.0" encoding="UTF-8"?>' \ 921 | '<testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0">' \ 922 | ' <testcase classname="UnityMCP.NL-T" name="NL-Suite.Execution" time="0.0">' \ 923 | ' <failure><![CDATA[No JUnit was produced by the NL suite step. See the step logs.]]></failure>' \ 924 | ' </testcase>' \ 925 | '</testsuite>' \ 926 | > "$JUNIT_OUT" 927 | fi 928 | 929 | - name: Publish JUnit report 930 | if: always() 931 | uses: mikepenz/action-junit-report@v5 932 | with: 933 | report_paths: "${{ env.JUNIT_OUT }}" 934 | include_passed: true 935 | detailed_summary: true 936 | annotate_notice: true 937 | require_tests: false 938 | fail_on_parse_error: true 939 | 940 | - name: Upload artifacts (reports + fragments + transcript) 941 | if: always() 942 | uses: actions/upload-artifact@v4 943 | with: 944 | name: claude-nl-suite-artifacts 945 | path: | 946 | ${{ env.JUNIT_OUT }} 947 | ${{ env.MD_OUT }} 948 | reports/*_results.xml 949 | reports/claude-execution-output.json 950 | retention-days: 7 951 | 952 | # ---------- Always stop Unity ---------- 953 | - name: Stop Unity 954 | if: always() 955 | run: | 956 | docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true 957 | docker rm -f unity-mcp || true 958 | 959 | - name: Return Pro license (if used) 960 | if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' 961 | uses: game-ci/unity-return-license@v2 962 | continue-on-error: true 963 | env: 964 | UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} 965 | UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} 966 | UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} 967 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py: -------------------------------------------------------------------------------- ```python 1 | import base64 2 | import hashlib 3 | import re 4 | from typing import Annotated, Any 5 | 6 | from mcp.server.fastmcp import Context 7 | 8 | from registry import mcp_for_unity_tool 9 | from unity_connection import send_command_with_retry 10 | 11 | 12 | def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: 13 | text = original_text 14 | for edit in edits or []: 15 | op = ( 16 | (edit.get("op") 17 | or edit.get("operation") 18 | or edit.get("type") 19 | or edit.get("mode") 20 | or "") 21 | .strip() 22 | .lower() 23 | ) 24 | 25 | if not op: 26 | allowed = "anchor_insert, prepend, append, replace_range, regex_replace" 27 | raise RuntimeError( 28 | f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." 29 | ) 30 | 31 | if op == "prepend": 32 | prepend_text = edit.get("text", "") 33 | text = (prepend_text if prepend_text.endswith( 34 | "\n") else prepend_text + "\n") + text 35 | elif op == "append": 36 | append_text = edit.get("text", "") 37 | if not text.endswith("\n"): 38 | text += "\n" 39 | text += append_text 40 | if not text.endswith("\n"): 41 | text += "\n" 42 | elif op == "anchor_insert": 43 | anchor = edit.get("anchor", "") 44 | position = (edit.get("position") or "before").lower() 45 | insert_text = edit.get("text", "") 46 | flags = re.MULTILINE | ( 47 | re.IGNORECASE if edit.get("ignore_case") else 0) 48 | 49 | # Find the best match using improved heuristics 50 | match = _find_best_anchor_match( 51 | anchor, text, flags, bool(edit.get("prefer_last", True))) 52 | if not match: 53 | if edit.get("allow_noop", True): 54 | continue 55 | raise RuntimeError(f"anchor not found: {anchor}") 56 | idx = match.start() if position == "before" else match.end() 57 | text = text[:idx] + insert_text + text[idx:] 58 | elif op == "replace_range": 59 | start_line = int(edit.get("startLine", 1)) 60 | start_col = int(edit.get("startCol", 1)) 61 | end_line = int(edit.get("endLine", start_line)) 62 | end_col = int(edit.get("endCol", 1)) 63 | replacement = edit.get("text", "") 64 | lines = text.splitlines(keepends=True) 65 | max_line = len(lines) + 1 # 1-based, exclusive end 66 | if (start_line < 1 or end_line < start_line or end_line > max_line 67 | or start_col < 1 or end_col < 1): 68 | raise RuntimeError("replace_range out of bounds") 69 | 70 | def index_of(line: int, col: int) -> int: 71 | if line <= len(lines): 72 | return sum(len(l) for l in lines[: line - 1]) + (col - 1) 73 | return sum(len(l) for l in lines) 74 | a = index_of(start_line, start_col) 75 | b = index_of(end_line, end_col) 76 | text = text[:a] + replacement + text[b:] 77 | elif op == "regex_replace": 78 | pattern = edit.get("pattern", "") 79 | repl = edit.get("replacement", "") 80 | # Translate $n backrefs (our input) to Python \g<n> 81 | repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) 82 | count = int(edit.get("count", 0)) # 0 = replace all 83 | flags = re.MULTILINE 84 | if edit.get("ignore_case"): 85 | flags |= re.IGNORECASE 86 | text = re.sub(pattern, repl_py, text, count=count, flags=flags) 87 | else: 88 | allowed = "anchor_insert, prepend, append, replace_range, regex_replace" 89 | raise RuntimeError( 90 | f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") 91 | return text 92 | 93 | 94 | def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): 95 | """ 96 | Find the best anchor match using improved heuristics. 97 | 98 | For patterns like \\s*}\\s*$ that are meant to find class-ending braces, 99 | this function uses heuristics to choose the most semantically appropriate match: 100 | 101 | 1. If prefer_last=True, prefer the last match (common for class-end insertions) 102 | 2. Use indentation levels to distinguish class vs method braces 103 | 3. Consider context to avoid matches inside strings/comments 104 | 105 | Args: 106 | pattern: Regex pattern to search for 107 | text: Text to search in 108 | flags: Regex flags 109 | prefer_last: If True, prefer the last match over the first 110 | 111 | Returns: 112 | Match object of the best match, or None if no match found 113 | """ 114 | 115 | # Find all matches 116 | matches = list(re.finditer(pattern, text, flags)) 117 | if not matches: 118 | return None 119 | 120 | # If only one match, return it 121 | if len(matches) == 1: 122 | return matches[0] 123 | 124 | # For patterns that look like they're trying to match closing braces at end of lines 125 | is_closing_brace_pattern = '}' in pattern and ( 126 | '$' in pattern or pattern.endswith(r'\s*')) 127 | 128 | if is_closing_brace_pattern and prefer_last: 129 | # Use heuristics to find the best closing brace match 130 | return _find_best_closing_brace_match(matches, text) 131 | 132 | # Default behavior: use last match if prefer_last, otherwise first match 133 | return matches[-1] if prefer_last else matches[0] 134 | 135 | 136 | def _find_best_closing_brace_match(matches, text: str): 137 | """ 138 | Find the best closing brace match using C# structure heuristics. 139 | 140 | Enhanced heuristics for scope-aware matching: 141 | 1. Prefer matches with lower indentation (likely class-level) 142 | 2. Prefer matches closer to end of file 143 | 3. Avoid matches that seem to be inside method bodies 144 | 4. For #endregion patterns, ensure class-level context 145 | 5. Validate insertion point is at appropriate scope 146 | 147 | Args: 148 | matches: List of regex match objects 149 | text: The full text being searched 150 | 151 | Returns: 152 | The best match object 153 | """ 154 | if not matches: 155 | return None 156 | 157 | scored_matches = [] 158 | lines = text.splitlines() 159 | 160 | for match in matches: 161 | score = 0 162 | start_pos = match.start() 163 | 164 | # Find which line this match is on 165 | lines_before = text[:start_pos].count('\n') 166 | line_num = lines_before 167 | 168 | if line_num < len(lines): 169 | line_content = lines[line_num] 170 | 171 | # Calculate indentation level (lower is better for class braces) 172 | indentation = len(line_content) - len(line_content.lstrip()) 173 | 174 | # Prefer lower indentation (class braces are typically less indented than method braces) 175 | # Max 20 points for indentation=0 176 | score += max(0, 20 - indentation) 177 | 178 | # Prefer matches closer to end of file (class closing braces are typically at the end) 179 | distance_from_end = len(lines) - line_num 180 | # More points for being closer to end 181 | score += max(0, 10 - distance_from_end) 182 | 183 | # Look at surrounding context to avoid method braces 184 | context_start = max(0, line_num - 3) 185 | context_end = min(len(lines), line_num + 2) 186 | context_lines = lines[context_start:context_end] 187 | 188 | # Penalize if this looks like it's inside a method (has method-like patterns above) 189 | for context_line in context_lines: 190 | if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): 191 | score -= 5 # Penalty for being near method signatures 192 | 193 | # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) 194 | if indentation <= 4 and distance_from_end <= 3: 195 | score += 15 # Bonus for likely class-ending brace 196 | 197 | scored_matches.append((score, match)) 198 | 199 | # Return the match with the highest score 200 | scored_matches.sort(key=lambda x: x[0], reverse=True) 201 | best_match = scored_matches[0][1] 202 | 203 | return best_match 204 | 205 | 206 | def _infer_class_name(script_name: str) -> str: 207 | # Default to script name as class name (common Unity pattern) 208 | return (script_name or "").strip() 209 | 210 | 211 | def _extract_code_after(keyword: str, request: str) -> str: 212 | # Deprecated with NL removal; retained as no-op for compatibility 213 | idx = request.lower().find(keyword) 214 | if idx >= 0: 215 | return request[idx + len(keyword):].strip() 216 | return "" 217 | # Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services 218 | 219 | 220 | def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: 221 | """Best-effort normalization of script "name" and "path". 222 | 223 | Accepts any of: 224 | - name = "SmartReach", path = "Assets/Scripts/Interaction" 225 | - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" 226 | - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" 227 | - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) 228 | - name or path using uri prefixes: unity://path/..., file://... 229 | - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" 230 | 231 | Returns (name_without_extension, directory_path_under_Assets). 232 | """ 233 | n = (name or "").strip() 234 | p = (path or "").strip() 235 | 236 | def strip_prefix(s: str) -> str: 237 | if s.startswith("unity://path/"): 238 | return s[len("unity://path/"):] 239 | if s.startswith("file://"): 240 | return s[len("file://"):] 241 | return s 242 | 243 | def collapse_duplicate_tail(s: str) -> str: 244 | # Collapse trailing "/X.cs/X.cs" to "/X.cs" 245 | parts = s.split("/") 246 | if len(parts) >= 2 and parts[-1] == parts[-2]: 247 | parts = parts[:-1] 248 | return "/".join(parts) 249 | 250 | # Prefer a full path if provided in either field 251 | candidate = "" 252 | for v in (n, p): 253 | v2 = strip_prefix(v) 254 | if v2.endswith(".cs") or v2.startswith("Assets/"): 255 | candidate = v2 256 | break 257 | 258 | if candidate: 259 | candidate = collapse_duplicate_tail(candidate) 260 | # If a directory was passed in path and file in name, join them 261 | if not candidate.endswith(".cs") and n.endswith(".cs"): 262 | v2 = strip_prefix(n) 263 | candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) 264 | if candidate.endswith(".cs"): 265 | parts = candidate.split("/") 266 | file_name = parts[-1] 267 | dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" 268 | base = file_name[:- 269 | 3] if file_name.lower().endswith(".cs") else file_name 270 | return base, dir_path 271 | 272 | # Fall back: remove extension from name if present and return given path 273 | base_name = n[:-3] if n.lower().endswith(".cs") else n 274 | return base_name, (p or "Assets") 275 | 276 | 277 | def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: 278 | if not isinstance(resp, dict): 279 | return resp 280 | data = resp.setdefault("data", {}) 281 | data.setdefault("normalizedEdits", edits) 282 | if routing: 283 | data["routing"] = routing 284 | return resp 285 | 286 | 287 | def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, 288 | normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: 289 | payload: dict[str, Any] = {"success": False, 290 | "code": code, "message": message} 291 | data: dict[str, Any] = {} 292 | if expected: 293 | data["expected"] = expected 294 | if rewrite: 295 | data["rewrite_suggestion"] = rewrite 296 | if normalized is not None: 297 | data["normalizedEdits"] = normalized 298 | if routing: 299 | data["routing"] = routing 300 | if extra: 301 | data.update(extra) 302 | if data: 303 | payload["data"] = data 304 | return payload 305 | 306 | # Natural-language parsing removed; clients should send structured edits. 307 | 308 | 309 | @mcp_for_unity_tool(name="script_apply_edits", description=( 310 | """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. 311 | Best practices: 312 | - Prefer anchor_* ops for pattern-based insert/replace near stable markers 313 | - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) 314 | - Avoid whole-file regex deletes; validators will guard unbalanced braces 315 | - For tail insertions, prefer anchor/regex_replace on final brace (class closing) 316 | - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits 317 | Canonical fields (use these exact keys): 318 | - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace 319 | - className: string (defaults to 'name' if omitted on method/class ops) 320 | - methodName: string (required for replace_method, delete_method) 321 | - replacement: string (required for replace_method, insert_method) 322 | - position: start | end | after | before (insert_method only) 323 | - afterMethodName / beforeMethodName: string (required when position='after'/'before') 324 | - anchor: regex string (for anchor_* ops) 325 | - text: string (for anchor_insert/anchor_replace) 326 | Examples: 327 | 1) Replace a method: 328 | { 329 | "name": "SmartReach", 330 | "path": "Assets/Scripts/Interaction", 331 | "edits": [ 332 | { 333 | "op": "replace_method", 334 | "className": "SmartReach", 335 | "methodName": "HasTarget", 336 | "replacement": "public bool HasTarget(){ return currentTarget!=null; }" 337 | } 338 | ], 339 | "options": {"validate": "standard", "refresh": "immediate"} 340 | } 341 | "2) Insert a method after another: 342 | { 343 | "name": "SmartReach", 344 | "path": "Assets/Scripts/Interaction", 345 | "edits": [ 346 | { 347 | "op": "insert_method", 348 | "className": "SmartReach", 349 | "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", 350 | "position": "after", 351 | "afterMethodName": "GetCurrentTarget" 352 | } 353 | ], 354 | } 355 | ]""" 356 | )) 357 | def script_apply_edits( 358 | ctx: Context, 359 | name: Annotated[str, "Name of the script to edit"], 360 | path: Annotated[str, "Path to the script to edit under Assets/ directory"], 361 | edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], 362 | options: Annotated[dict[str, Any], 363 | "Options for the script edit"] | None = None, 364 | script_type: Annotated[str, 365 | "Type of the script to edit"] = "MonoBehaviour", 366 | namespace: Annotated[str, 367 | "Namespace of the script to edit"] | None = None, 368 | ) -> dict[str, Any]: 369 | ctx.info(f"Processing script_apply_edits: {name}") 370 | # Normalize locator first so downstream calls target the correct script file. 371 | name, path = _normalize_script_locator(name, path) 372 | # Normalize unsupported or aliased ops to known structured/text paths 373 | 374 | def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: 375 | # Unwrap single-key wrappers like {"replace_method": {...}} 376 | for wrapper_key in ( 377 | "replace_method", "insert_method", "delete_method", 378 | "replace_class", "delete_class", 379 | "anchor_insert", "anchor_replace", "anchor_delete", 380 | ): 381 | if wrapper_key in edit and isinstance(edit[wrapper_key], dict): 382 | inner = dict(edit[wrapper_key]) 383 | inner["op"] = wrapper_key 384 | edit = inner 385 | break 386 | 387 | e = dict(edit) 388 | op = (e.get("op") or e.get("operation") or e.get( 389 | "type") or e.get("mode") or "").strip().lower() 390 | if op: 391 | e["op"] = op 392 | 393 | # Common field aliases 394 | if "class_name" in e and "className" not in e: 395 | e["className"] = e.pop("class_name") 396 | if "class" in e and "className" not in e: 397 | e["className"] = e.pop("class") 398 | if "method_name" in e and "methodName" not in e: 399 | e["methodName"] = e.pop("method_name") 400 | # Some clients use a generic 'target' for method name 401 | if "target" in e and "methodName" not in e: 402 | e["methodName"] = e.pop("target") 403 | if "method" in e and "methodName" not in e: 404 | e["methodName"] = e.pop("method") 405 | if "new_content" in e and "replacement" not in e: 406 | e["replacement"] = e.pop("new_content") 407 | if "newMethod" in e and "replacement" not in e: 408 | e["replacement"] = e.pop("newMethod") 409 | if "new_method" in e and "replacement" not in e: 410 | e["replacement"] = e.pop("new_method") 411 | if "content" in e and "replacement" not in e: 412 | e["replacement"] = e.pop("content") 413 | if "after" in e and "afterMethodName" not in e: 414 | e["afterMethodName"] = e.pop("after") 415 | if "after_method" in e and "afterMethodName" not in e: 416 | e["afterMethodName"] = e.pop("after_method") 417 | if "before" in e and "beforeMethodName" not in e: 418 | e["beforeMethodName"] = e.pop("before") 419 | if "before_method" in e and "beforeMethodName" not in e: 420 | e["beforeMethodName"] = e.pop("before_method") 421 | # anchor_method → before/after based on position (default after) 422 | if "anchor_method" in e: 423 | anchor = e.pop("anchor_method") 424 | pos = (e.get("position") or "after").strip().lower() 425 | if pos == "before" and "beforeMethodName" not in e: 426 | e["beforeMethodName"] = anchor 427 | elif "afterMethodName" not in e: 428 | e["afterMethodName"] = anchor 429 | if "anchorText" in e and "anchor" not in e: 430 | e["anchor"] = e.pop("anchorText") 431 | if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): 432 | e["anchor"] = e.pop("pattern") 433 | if "newText" in e and "text" not in e: 434 | e["text"] = e.pop("newText") 435 | 436 | # CI compatibility (T‑A/T‑E): 437 | # Accept method-anchored anchor_insert and upgrade to insert_method 438 | # Example incoming shape: 439 | # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} 440 | if ( 441 | e.get("op") == "anchor_insert" 442 | and not e.get("anchor") 443 | and (e.get("afterMethodName") or e.get("beforeMethodName")) 444 | ): 445 | e["op"] = "insert_method" 446 | if "replacement" not in e: 447 | e["replacement"] = e.get("text", "") 448 | 449 | # LSP-like range edit -> replace_range 450 | if "range" in e and isinstance(e["range"], dict): 451 | rng = e.pop("range") 452 | start = rng.get("start", {}) 453 | end = rng.get("end", {}) 454 | # Convert 0-based to 1-based line/col 455 | e["op"] = "replace_range" 456 | e["startLine"] = int(start.get("line", 0)) + 1 457 | e["startCol"] = int(start.get("character", 0)) + 1 458 | e["endLine"] = int(end.get("line", 0)) + 1 459 | e["endCol"] = int(end.get("character", 0)) + 1 460 | if "newText" in edit and "text" not in e: 461 | e["text"] = edit.get("newText", "") 462 | return e 463 | 464 | normalized_edits: list[dict[str, Any]] = [] 465 | for raw in edits or []: 466 | e = _unwrap_and_alias(raw) 467 | op = (e.get("op") or e.get("operation") or e.get( 468 | "type") or e.get("mode") or "").strip().lower() 469 | 470 | # Default className to script name if missing on structured method/class ops 471 | if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): 472 | e["className"] = name 473 | 474 | # Map common aliases for text ops 475 | if op in ("text_replace",): 476 | e["op"] = "replace_range" 477 | normalized_edits.append(e) 478 | continue 479 | if op in ("regex_delete",): 480 | e["op"] = "regex_replace" 481 | e.setdefault("text", "") 482 | normalized_edits.append(e) 483 | continue 484 | if op == "regex_replace" and ("replacement" not in e): 485 | if "text" in e: 486 | e["replacement"] = e.get("text", "") 487 | elif "insert" in e or "content" in e: 488 | e["replacement"] = e.get( 489 | "insert") or e.get("content") or "" 490 | if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): 491 | e["op"] = "anchor_delete" 492 | normalized_edits.append(e) 493 | continue 494 | normalized_edits.append(e) 495 | 496 | edits = normalized_edits 497 | normalized_for_echo = edits 498 | 499 | # Validate required fields and produce machine-parsable hints 500 | def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: 501 | return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) 502 | 503 | for e in edits or []: 504 | op = e.get("op", "") 505 | if op == "replace_method": 506 | if not e.get("methodName"): 507 | return error_with_hint( 508 | "replace_method requires 'methodName'.", 509 | {"op": "replace_method", "required": [ 510 | "className", "methodName", "replacement"]}, 511 | {"edits[0].methodName": "HasTarget"} 512 | ) 513 | if not (e.get("replacement") or e.get("text")): 514 | return error_with_hint( 515 | "replace_method requires 'replacement' (inline or base64).", 516 | {"op": "replace_method", "required": [ 517 | "className", "methodName", "replacement"]}, 518 | {"edits[0].replacement": "public bool X(){ return true; }"} 519 | ) 520 | elif op == "insert_method": 521 | if not (e.get("replacement") or e.get("text")): 522 | return error_with_hint( 523 | "insert_method requires a non-empty 'replacement'.", 524 | {"op": "insert_method", "required": ["className", "replacement"], "position": { 525 | "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, 526 | {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} 527 | ) 528 | pos = (e.get("position") or "").lower() 529 | if pos == "after" and not e.get("afterMethodName"): 530 | return error_with_hint( 531 | "insert_method with position='after' requires 'afterMethodName'.", 532 | {"op": "insert_method", "position": { 533 | "after_requires": "afterMethodName"}}, 534 | {"edits[0].afterMethodName": "GetCurrentTarget"} 535 | ) 536 | if pos == "before" and not e.get("beforeMethodName"): 537 | return error_with_hint( 538 | "insert_method with position='before' requires 'beforeMethodName'.", 539 | {"op": "insert_method", "position": { 540 | "before_requires": "beforeMethodName"}}, 541 | {"edits[0].beforeMethodName": "GetCurrentTarget"} 542 | ) 543 | elif op == "delete_method": 544 | if not e.get("methodName"): 545 | return error_with_hint( 546 | "delete_method requires 'methodName'.", 547 | {"op": "delete_method", "required": [ 548 | "className", "methodName"]}, 549 | {"edits[0].methodName": "PrintSeries"} 550 | ) 551 | elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): 552 | if not e.get("anchor"): 553 | return error_with_hint( 554 | f"{op} requires 'anchor' (regex).", 555 | {"op": op, "required": ["anchor"]}, 556 | {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} 557 | ) 558 | if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): 559 | return error_with_hint( 560 | f"{op} requires 'text'.", 561 | {"op": op, "required": ["anchor", "text"]}, 562 | {"edits[0].text": "/* comment */\n"} 563 | ) 564 | 565 | # Decide routing: structured vs text vs mixed 566 | STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", 567 | "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} 568 | TEXT = {"prepend", "append", "replace_range", "regex_replace"} 569 | ops_set = {(e.get("op") or "").lower() for e in edits or []} 570 | all_struct = ops_set.issubset(STRUCT) 571 | all_text = ops_set.issubset(TEXT) 572 | mixed = not (all_struct or all_text) 573 | 574 | # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. 575 | if all_struct: 576 | opts2 = dict(options or {}) 577 | # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused 578 | opts2.setdefault("refresh", "immediate") 579 | params_struct: dict[str, Any] = { 580 | "action": "edit", 581 | "name": name, 582 | "path": path, 583 | "namespace": namespace, 584 | "scriptType": script_type, 585 | "edits": edits, 586 | "options": opts2, 587 | } 588 | resp_struct = send_command_with_retry( 589 | "manage_script", params_struct) 590 | if isinstance(resp_struct, dict) and resp_struct.get("success"): 591 | pass # Optional sentinel reload removed (deprecated) 592 | return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") 593 | 594 | # 1) read from Unity 595 | read_resp = send_command_with_retry("manage_script", { 596 | "action": "read", 597 | "name": name, 598 | "path": path, 599 | "namespace": namespace, 600 | "scriptType": script_type, 601 | }) 602 | if not isinstance(read_resp, dict) or not read_resp.get("success"): 603 | return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} 604 | 605 | data = read_resp.get("data") or read_resp.get( 606 | "result", {}).get("data") or {} 607 | contents = data.get("contents") 608 | if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): 609 | contents = base64.b64decode( 610 | data["encodedContents"]).decode("utf-8") 611 | if contents is None: 612 | return {"success": False, "message": "No contents returned from Unity read."} 613 | 614 | # Optional preview/dry-run: apply locally and return diff without writing 615 | preview = bool((options or {}).get("preview")) 616 | 617 | # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured 618 | if mixed: 619 | text_edits = [e for e in edits or [] if ( 620 | e.get("op") or "").lower() in TEXT] 621 | struct_edits = [e for e in edits or [] if ( 622 | e.get("op") or "").lower() in STRUCT] 623 | try: 624 | base_text = contents 625 | 626 | def line_col_from_index(idx: int) -> tuple[int, int]: 627 | line = base_text.count("\n", 0, idx) + 1 628 | last_nl = base_text.rfind("\n", 0, idx) 629 | col = (idx - (last_nl + 1)) + \ 630 | 1 if last_nl >= 0 else idx + 1 631 | return line, col 632 | 633 | at_edits: list[dict[str, Any]] = [] 634 | for e in text_edits: 635 | opx = (e.get("op") or e.get("operation") or e.get( 636 | "type") or e.get("mode") or "").strip().lower() 637 | text_field = e.get("text") or e.get("insert") or e.get( 638 | "content") or e.get("replacement") or "" 639 | if opx == "anchor_insert": 640 | anchor = e.get("anchor") or "" 641 | position = (e.get("position") or "after").lower() 642 | flags = re.MULTILINE | ( 643 | re.IGNORECASE if e.get("ignore_case") else 0) 644 | try: 645 | # Use improved anchor matching logic 646 | m = _find_best_anchor_match( 647 | anchor, base_text, flags, prefer_last=True) 648 | except Exception as ex: 649 | return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") 650 | if not m: 651 | return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") 652 | idx = m.start() if position == "before" else m.end() 653 | # Normalize insertion to avoid jammed methods 654 | text_field_norm = text_field 655 | if not text_field_norm.startswith("\n"): 656 | text_field_norm = "\n" + text_field_norm 657 | if not text_field_norm.endswith("\n"): 658 | text_field_norm = text_field_norm + "\n" 659 | sl, sc = line_col_from_index(idx) 660 | at_edits.append( 661 | {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) 662 | # do not mutate base_text when building atomic spans 663 | elif opx == "replace_range": 664 | if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): 665 | at_edits.append({ 666 | "startLine": int(e.get("startLine", 1)), 667 | "startCol": int(e.get("startCol", 1)), 668 | "endLine": int(e.get("endLine", 1)), 669 | "endCol": int(e.get("endCol", 1)), 670 | "newText": text_field 671 | }) 672 | else: 673 | return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") 674 | elif opx == "regex_replace": 675 | pattern = e.get("pattern") or "" 676 | try: 677 | regex_obj = re.compile(pattern, re.MULTILINE | ( 678 | re.IGNORECASE if e.get("ignore_case") else 0)) 679 | except Exception as ex: 680 | return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") 681 | m = regex_obj.search(base_text) 682 | if not m: 683 | continue 684 | # Expand $1, $2... in replacement using this match 685 | 686 | def _expand_dollars(rep: str, _m=m) -> str: 687 | return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) 688 | repl = _expand_dollars(text_field) 689 | sl, sc = line_col_from_index(m.start()) 690 | el, ec = line_col_from_index(m.end()) 691 | at_edits.append( 692 | {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) 693 | # do not mutate base_text when building atomic spans 694 | elif opx in ("prepend", "append"): 695 | if opx == "prepend": 696 | sl, sc = 1, 1 697 | at_edits.append( 698 | {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) 699 | # prepend can be applied atomically without local mutation 700 | else: 701 | # Insert at true EOF position (handles both \n and \r\n correctly) 702 | eof_idx = len(base_text) 703 | sl, sc = line_col_from_index(eof_idx) 704 | new_text = ("\n" if not base_text.endswith( 705 | "\n") else "") + text_field 706 | at_edits.append( 707 | {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) 708 | # do not mutate base_text when building atomic spans 709 | else: 710 | return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") 711 | 712 | sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() 713 | if at_edits: 714 | params_text: dict[str, Any] = { 715 | "action": "apply_text_edits", 716 | "name": name, 717 | "path": path, 718 | "namespace": namespace, 719 | "scriptType": script_type, 720 | "edits": at_edits, 721 | "precondition_sha256": sha, 722 | "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} 723 | } 724 | resp_text = send_command_with_retry( 725 | "manage_script", params_text) 726 | if not (isinstance(resp_text, dict) and resp_text.get("success")): 727 | return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") 728 | # Optional sentinel reload removed (deprecated) 729 | except Exception as e: 730 | return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") 731 | 732 | if struct_edits: 733 | opts2 = dict(options or {}) 734 | # Prefer debounced background refresh unless explicitly overridden 735 | opts2.setdefault("refresh", "debounced") 736 | params_struct: dict[str, Any] = { 737 | "action": "edit", 738 | "name": name, 739 | "path": path, 740 | "namespace": namespace, 741 | "scriptType": script_type, 742 | "edits": struct_edits, 743 | "options": opts2 744 | } 745 | resp_struct = send_command_with_retry( 746 | "manage_script", params_struct) 747 | if isinstance(resp_struct, dict) and resp_struct.get("success"): 748 | pass # Optional sentinel reload removed (deprecated) 749 | return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") 750 | 751 | return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") 752 | 753 | # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition 754 | # so header guards and validation run on the C# side. 755 | # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). 756 | text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( 757 | "mode") or "").strip().lower() for e in (edits or [])} 758 | structured_kinds = {"replace_class", "delete_class", 759 | "replace_method", "delete_method", "insert_method", "anchor_insert"} 760 | if not text_ops.issubset(structured_kinds): 761 | # Convert to apply_text_edits payload 762 | try: 763 | base_text = contents 764 | 765 | def line_col_from_index(idx: int) -> tuple[int, int]: 766 | # 1-based line/col against base buffer 767 | line = base_text.count("\n", 0, idx) + 1 768 | last_nl = base_text.rfind("\n", 0, idx) 769 | col = (idx - (last_nl + 1)) + \ 770 | 1 if last_nl >= 0 else idx + 1 771 | return line, col 772 | 773 | at_edits: list[dict[str, Any]] = [] 774 | import re as _re 775 | for e in edits or []: 776 | op = (e.get("op") or e.get("operation") or e.get( 777 | "type") or e.get("mode") or "").strip().lower() 778 | # aliasing for text field 779 | text_field = e.get("text") or e.get( 780 | "insert") or e.get("content") or "" 781 | if op == "anchor_insert": 782 | anchor = e.get("anchor") or "" 783 | position = (e.get("position") or "after").lower() 784 | # Use improved anchor matching logic with helpful errors, honoring ignore_case 785 | try: 786 | flags = re.MULTILINE | ( 787 | re.IGNORECASE if e.get("ignore_case") else 0) 788 | m = _find_best_anchor_match( 789 | anchor, base_text, flags, prefer_last=True) 790 | except Exception as ex: 791 | return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") 792 | if not m: 793 | return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") 794 | idx = m.start() if position == "before" else m.end() 795 | # Normalize insertion newlines 796 | if text_field and not text_field.startswith("\n"): 797 | text_field = "\n" + text_field 798 | if text_field and not text_field.endswith("\n"): 799 | text_field = text_field + "\n" 800 | sl, sc = line_col_from_index(idx) 801 | at_edits.append({ 802 | "startLine": sl, 803 | "startCol": sc, 804 | "endLine": sl, 805 | "endCol": sc, 806 | "newText": text_field or "" 807 | }) 808 | # Do not mutate base buffer when building an atomic batch 809 | elif op == "replace_range": 810 | # Directly forward if already in line/col form 811 | if "startLine" in e: 812 | at_edits.append({ 813 | "startLine": int(e.get("startLine", 1)), 814 | "startCol": int(e.get("startCol", 1)), 815 | "endLine": int(e.get("endLine", 1)), 816 | "endCol": int(e.get("endCol", 1)), 817 | "newText": text_field 818 | }) 819 | else: 820 | # If only indices provided, skip (we don't support index-based here) 821 | return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") 822 | elif op == "regex_replace": 823 | pattern = e.get("pattern") or "" 824 | repl = text_field 825 | flags = re.MULTILINE | ( 826 | re.IGNORECASE if e.get("ignore_case") else 0) 827 | # Early compile for clearer error messages 828 | try: 829 | regex_obj = re.compile(pattern, flags) 830 | except Exception as ex: 831 | return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") 832 | # Use smart anchor matching for consistent behavior with anchor_insert 833 | m = _find_best_anchor_match( 834 | pattern, base_text, flags, prefer_last=True) 835 | if not m: 836 | continue 837 | # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) 838 | 839 | def _expand_dollars(rep: str, _m=m) -> str: 840 | return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) 841 | repl_expanded = _expand_dollars(repl) 842 | # Let C# side handle validation using Unity's built-in compiler services 843 | sl, sc = line_col_from_index(m.start()) 844 | el, ec = line_col_from_index(m.end()) 845 | at_edits.append({ 846 | "startLine": sl, 847 | "startCol": sc, 848 | "endLine": el, 849 | "endCol": ec, 850 | "newText": repl_expanded 851 | }) 852 | # Do not mutate base buffer when building an atomic batch 853 | else: 854 | return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") 855 | 856 | if not at_edits: 857 | return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") 858 | 859 | sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() 860 | params: dict[str, Any] = { 861 | "action": "apply_text_edits", 862 | "name": name, 863 | "path": path, 864 | "namespace": namespace, 865 | "scriptType": script_type, 866 | "edits": at_edits, 867 | "precondition_sha256": sha, 868 | "options": { 869 | "refresh": (options or {}).get("refresh", "debounced"), 870 | "validate": (options or {}).get("validate", "standard"), 871 | "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) 872 | } 873 | } 874 | resp = send_command_with_retry("manage_script", params) 875 | if isinstance(resp, dict) and resp.get("success"): 876 | pass # Optional sentinel reload removed (deprecated) 877 | return _with_norm( 878 | resp if isinstance(resp, dict) else { 879 | "success": False, "message": str(resp)}, 880 | normalized_for_echo, 881 | routing="text" 882 | ) 883 | except Exception as e: 884 | return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") 885 | 886 | # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. 887 | # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. 888 | if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): 889 | try: 890 | preview_text = _apply_edits_locally(contents, edits) 891 | import difflib 892 | diff = list(difflib.unified_diff(contents.splitlines( 893 | ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) 894 | if len(diff) > 800: 895 | diff = diff[:800] + ["... (diff truncated) ..."] 896 | if preview: 897 | return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} 898 | return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") 899 | except Exception as e: 900 | return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") 901 | # 2) apply edits locally (only if not text-ops) 902 | try: 903 | new_contents = _apply_edits_locally(contents, edits) 904 | except Exception as e: 905 | return {"success": False, "message": f"Edit application failed: {e}"} 906 | 907 | # Short-circuit no-op edits to avoid false "applied" reports downstream 908 | if new_contents == contents: 909 | return _with_norm({ 910 | "success": True, 911 | "message": "No-op: contents unchanged", 912 | "data": {"no_op": True, "evidence": {"reason": "identical_content"}} 913 | }, normalized_for_echo, routing="text") 914 | 915 | if preview: 916 | # Produce a compact unified diff limited to small context 917 | import difflib 918 | a = contents.splitlines() 919 | b = new_contents.splitlines() 920 | diff = list(difflib.unified_diff( 921 | a, b, fromfile="before", tofile="after", n=3)) 922 | # Limit diff size to keep responses small 923 | if len(diff) > 2000: 924 | diff = diff[:2000] + ["... (diff truncated) ..."] 925 | return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} 926 | 927 | # 3) update to Unity 928 | # Default refresh/validate for natural usage on text path as well 929 | options = dict(options or {}) 930 | options.setdefault("validate", "standard") 931 | options.setdefault("refresh", "debounced") 932 | 933 | # Compute the SHA of the current file contents for the precondition 934 | old_lines = contents.splitlines(keepends=True) 935 | end_line = len(old_lines) + 1 # 1-based exclusive end 936 | sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() 937 | 938 | # Apply a whole-file text edit rather than the deprecated 'update' action 939 | params = { 940 | "action": "apply_text_edits", 941 | "name": name, 942 | "path": path, 943 | "namespace": namespace, 944 | "scriptType": script_type, 945 | "edits": [ 946 | { 947 | "startLine": 1, 948 | "startCol": 1, 949 | "endLine": end_line, 950 | "endCol": 1, 951 | "newText": new_contents, 952 | } 953 | ], 954 | "precondition_sha256": sha, 955 | "options": options or {"validate": "standard", "refresh": "debounced"}, 956 | } 957 | 958 | write_resp = send_command_with_retry("manage_script", params) 959 | if isinstance(write_resp, dict) and write_resp.get("success"): 960 | pass # Optional sentinel reload removed (deprecated) 961 | return _with_norm( 962 | write_resp if isinstance(write_resp, dict) 963 | else {"success": False, "message": str(write_resp)}, 964 | normalized_for_echo, 965 | routing="text", 966 | ) 967 | ```