This is page 8 of 19. 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 │ │ │ │ │ └── MaterialMeshInstantiationTests.cs │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using UnityEditor; 8 | using UnityEngine; 9 | using MCPForUnity.Runtime.Serialization; // For Converters 10 | 11 | namespace MCPForUnity.Editor.Helpers 12 | { 13 | /// <summary> 14 | /// Handles serialization of GameObjects and Components for MCP responses. 15 | /// Includes reflection helpers and caching for performance. 16 | /// </summary> 17 | public static class GameObjectSerializer 18 | { 19 | // --- Data Serialization --- 20 | 21 | /// <summary> 22 | /// Creates a serializable representation of a GameObject. 23 | /// </summary> 24 | public static object GetGameObjectData(GameObject go) 25 | { 26 | if (go == null) 27 | return null; 28 | return new 29 | { 30 | name = go.name, 31 | instanceID = go.GetInstanceID(), 32 | tag = go.tag, 33 | layer = go.layer, 34 | activeSelf = go.activeSelf, 35 | activeInHierarchy = go.activeInHierarchy, 36 | isStatic = go.isStatic, 37 | scenePath = go.scene.path, // Identify which scene it belongs to 38 | transform = new // Serialize transform components carefully to avoid JSON issues 39 | { 40 | // Serialize Vector3 components individually to prevent self-referencing loops. 41 | // The default serializer can struggle with properties like Vector3.normalized. 42 | position = new 43 | { 44 | x = go.transform.position.x, 45 | y = go.transform.position.y, 46 | z = go.transform.position.z, 47 | }, 48 | localPosition = new 49 | { 50 | x = go.transform.localPosition.x, 51 | y = go.transform.localPosition.y, 52 | z = go.transform.localPosition.z, 53 | }, 54 | rotation = new 55 | { 56 | x = go.transform.rotation.eulerAngles.x, 57 | y = go.transform.rotation.eulerAngles.y, 58 | z = go.transform.rotation.eulerAngles.z, 59 | }, 60 | localRotation = new 61 | { 62 | x = go.transform.localRotation.eulerAngles.x, 63 | y = go.transform.localRotation.eulerAngles.y, 64 | z = go.transform.localRotation.eulerAngles.z, 65 | }, 66 | scale = new 67 | { 68 | x = go.transform.localScale.x, 69 | y = go.transform.localScale.y, 70 | z = go.transform.localScale.z, 71 | }, 72 | forward = new 73 | { 74 | x = go.transform.forward.x, 75 | y = go.transform.forward.y, 76 | z = go.transform.forward.z, 77 | }, 78 | up = new 79 | { 80 | x = go.transform.up.x, 81 | y = go.transform.up.y, 82 | z = go.transform.up.z, 83 | }, 84 | right = new 85 | { 86 | x = go.transform.right.x, 87 | y = go.transform.right.y, 88 | z = go.transform.right.z, 89 | }, 90 | }, 91 | parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent 92 | // Optionally include components, but can be large 93 | // components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList() 94 | // Or just component names: 95 | componentNames = go.GetComponents<Component>() 96 | .Select(c => c.GetType().FullName) 97 | .ToList(), 98 | }; 99 | } 100 | 101 | // --- Metadata Caching for Reflection --- 102 | private class CachedMetadata 103 | { 104 | public readonly List<PropertyInfo> SerializableProperties; 105 | public readonly List<FieldInfo> SerializableFields; 106 | 107 | public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields) 108 | { 109 | SerializableProperties = properties; 110 | SerializableFields = fields; 111 | } 112 | } 113 | // Key becomes Tuple<Type, bool> 114 | private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>(); 115 | // --- End Metadata Caching --- 116 | 117 | /// <summary> 118 | /// Creates a serializable representation of a Component, attempting to serialize 119 | /// public properties and fields using reflection, with caching and control over non-public fields. 120 | /// </summary> 121 | // Add the flag parameter here 122 | public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) 123 | { 124 | // --- Add Early Logging --- 125 | // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); 126 | // --- End Early Logging --- 127 | 128 | if (c == null) return null; 129 | Type componentType = c.GetType(); 130 | 131 | // --- Special handling for Transform to avoid reflection crashes and problematic properties --- 132 | if (componentType == typeof(Transform)) 133 | { 134 | Transform tr = c as Transform; 135 | // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); 136 | return new Dictionary<string, object> 137 | { 138 | { "typeName", componentType.FullName }, 139 | { "instanceID", tr.GetInstanceID() }, 140 | // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. 141 | { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 142 | { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 143 | { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles 144 | { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 145 | { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 146 | { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 147 | { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 148 | { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, 149 | { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, 150 | { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, 151 | { "childCount", tr.childCount }, 152 | // Include standard Object/Component properties 153 | { "name", tr.name }, 154 | { "tag", tr.tag }, 155 | { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } 156 | }; 157 | } 158 | // --- End Special handling for Transform --- 159 | 160 | // --- Special handling for Camera to avoid matrix-related crashes --- 161 | if (componentType == typeof(Camera)) 162 | { 163 | Camera cam = c as Camera; 164 | var cameraProperties = new Dictionary<string, object>(); 165 | 166 | // List of safe properties to serialize 167 | var safeProperties = new Dictionary<string, Func<object>> 168 | { 169 | { "nearClipPlane", () => cam.nearClipPlane }, 170 | { "farClipPlane", () => cam.farClipPlane }, 171 | { "fieldOfView", () => cam.fieldOfView }, 172 | { "renderingPath", () => (int)cam.renderingPath }, 173 | { "actualRenderingPath", () => (int)cam.actualRenderingPath }, 174 | { "allowHDR", () => cam.allowHDR }, 175 | { "allowMSAA", () => cam.allowMSAA }, 176 | { "allowDynamicResolution", () => cam.allowDynamicResolution }, 177 | { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, 178 | { "orthographicSize", () => cam.orthographicSize }, 179 | { "orthographic", () => cam.orthographic }, 180 | { "opaqueSortMode", () => (int)cam.opaqueSortMode }, 181 | { "transparencySortMode", () => (int)cam.transparencySortMode }, 182 | { "depth", () => cam.depth }, 183 | { "aspect", () => cam.aspect }, 184 | { "cullingMask", () => cam.cullingMask }, 185 | { "eventMask", () => cam.eventMask }, 186 | { "backgroundColor", () => cam.backgroundColor }, 187 | { "clearFlags", () => (int)cam.clearFlags }, 188 | { "stereoEnabled", () => cam.stereoEnabled }, 189 | { "stereoSeparation", () => cam.stereoSeparation }, 190 | { "stereoConvergence", () => cam.stereoConvergence }, 191 | { "enabled", () => cam.enabled }, 192 | { "name", () => cam.name }, 193 | { "tag", () => cam.tag }, 194 | { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } 195 | }; 196 | 197 | foreach (var prop in safeProperties) 198 | { 199 | try 200 | { 201 | var value = prop.Value(); 202 | if (value != null) 203 | { 204 | AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); 205 | } 206 | } 207 | catch (Exception) 208 | { 209 | // Silently skip any property that fails 210 | continue; 211 | } 212 | } 213 | 214 | return new Dictionary<string, object> 215 | { 216 | { "typeName", componentType.FullName }, 217 | { "instanceID", cam.GetInstanceID() }, 218 | { "properties", cameraProperties } 219 | }; 220 | } 221 | // --- End Special handling for Camera --- 222 | 223 | var data = new Dictionary<string, object> 224 | { 225 | { "typeName", componentType.FullName }, 226 | { "instanceID", c.GetInstanceID() } 227 | }; 228 | 229 | // --- Get Cached or Generate Metadata (using new cache key) --- 230 | Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields); 231 | if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) 232 | { 233 | var propertiesToCache = new List<PropertyInfo>(); 234 | var fieldsToCache = new List<FieldInfo>(); 235 | 236 | // Traverse the hierarchy from the component type up to MonoBehaviour 237 | Type currentType = componentType; 238 | while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) 239 | { 240 | // Get properties declared only at the current type level 241 | BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; 242 | foreach (var propInfo in currentType.GetProperties(propFlags)) 243 | { 244 | // Basic filtering (readable, not indexer, not transform which is handled elsewhere) 245 | if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; 246 | // Add if not already added (handles overrides - keep the most derived version) 247 | if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) 248 | { 249 | propertiesToCache.Add(propInfo); 250 | } 251 | } 252 | 253 | // Get fields declared only at the current type level (both public and non-public) 254 | BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; 255 | var declaredFields = currentType.GetFields(fieldFlags); 256 | 257 | // Process the declared Fields for caching 258 | foreach (var fieldInfo in declaredFields) 259 | { 260 | if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields 261 | 262 | // Add if not already added (handles hiding - keep the most derived version) 263 | if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; 264 | 265 | bool shouldInclude = false; 266 | if (includeNonPublicSerializedFields) 267 | { 268 | // If TRUE, include Public OR NonPublic with [SerializeField] 269 | shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); 270 | } 271 | else // includeNonPublicSerializedFields is FALSE 272 | { 273 | // If FALSE, include ONLY if it is explicitly Public. 274 | shouldInclude = fieldInfo.IsPublic; 275 | } 276 | 277 | if (shouldInclude) 278 | { 279 | fieldsToCache.Add(fieldInfo); 280 | } 281 | } 282 | 283 | // Move to the base type 284 | currentType = currentType.BaseType; 285 | } 286 | // --- End Hierarchy Traversal --- 287 | 288 | cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); 289 | _metadataCache[cacheKey] = cachedData; // Add to cache with combined key 290 | } 291 | // --- End Get Cached or Generate Metadata --- 292 | 293 | // --- Use cached metadata --- 294 | var serializablePropertiesOutput = new Dictionary<string, object>(); 295 | 296 | // --- Add Logging Before Property Loop --- 297 | // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); 298 | // --- End Logging Before Property Loop --- 299 | 300 | // Use cached properties 301 | foreach (var propInfo in cachedData.SerializableProperties) 302 | { 303 | string propName = propInfo.Name; 304 | 305 | // --- Skip known obsolete/problematic Component shortcut properties --- 306 | bool skipProperty = false; 307 | if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || 308 | propName == "light" || propName == "animation" || propName == "constantForce" || 309 | propName == "renderer" || propName == "audio" || propName == "networkView" || 310 | propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || 311 | propName == "particleSystem" || 312 | // Also skip potentially problematic Matrix properties prone to cycles/errors 313 | propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") 314 | { 315 | // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log 316 | skipProperty = true; 317 | } 318 | // --- End Skip Generic Properties --- 319 | 320 | // --- Skip specific potentially problematic Camera properties --- 321 | if (componentType == typeof(Camera) && 322 | (propName == "pixelRect" || 323 | propName == "rect" || 324 | propName == "cullingMatrix" || 325 | propName == "useOcclusionCulling" || 326 | propName == "worldToCameraMatrix" || 327 | propName == "projectionMatrix" || 328 | propName == "nonJitteredProjectionMatrix" || 329 | propName == "previousViewProjectionMatrix" || 330 | propName == "cameraToWorldMatrix")) 331 | { 332 | // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}"); 333 | skipProperty = true; 334 | } 335 | // --- End Skip Camera Properties --- 336 | 337 | // --- Skip specific potentially problematic Transform properties --- 338 | if (componentType == typeof(Transform) && 339 | (propName == "lossyScale" || 340 | propName == "rotation" || 341 | propName == "worldToLocalMatrix" || 342 | propName == "localToWorldMatrix")) 343 | { 344 | // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}"); 345 | skipProperty = true; 346 | } 347 | // --- End Skip Transform Properties --- 348 | 349 | // Skip if flagged 350 | if (skipProperty) 351 | { 352 | continue; 353 | } 354 | 355 | try 356 | { 357 | // --- Add detailed logging --- 358 | // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); 359 | // --- End detailed logging --- 360 | object value = propInfo.GetValue(c); 361 | Type propType = propInfo.PropertyType; 362 | AddSerializableValue(serializablePropertiesOutput, propName, propType, value); 363 | } 364 | catch (Exception) 365 | { 366 | // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); 367 | } 368 | } 369 | 370 | // --- Add Logging Before Field Loop --- 371 | // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}..."); 372 | // --- End Logging Before Field Loop --- 373 | 374 | // Use cached fields 375 | foreach (var fieldInfo in cachedData.SerializableFields) 376 | { 377 | try 378 | { 379 | // --- Add detailed logging for fields --- 380 | // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); 381 | // --- End detailed logging for fields --- 382 | object value = fieldInfo.GetValue(c); 383 | string fieldName = fieldInfo.Name; 384 | Type fieldType = fieldInfo.FieldType; 385 | AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); 386 | } 387 | catch (Exception) 388 | { 389 | // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); 390 | } 391 | } 392 | // --- End Use cached metadata --- 393 | 394 | if (serializablePropertiesOutput.Count > 0) 395 | { 396 | data["properties"] = serializablePropertiesOutput; 397 | } 398 | 399 | return data; 400 | } 401 | 402 | // Helper function to decide how to serialize different types 403 | private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value) 404 | { 405 | // Simplified: Directly use CreateTokenFromValue which uses the serializer 406 | if (value == null) 407 | { 408 | dict[name] = null; 409 | return; 410 | } 411 | 412 | try 413 | { 414 | // Use the helper that employs our custom serializer settings 415 | JToken token = CreateTokenFromValue(value, type); 416 | if (token != null) // Check if serialization succeeded in the helper 417 | { 418 | // Convert JToken back to a basic object structure for the dictionary 419 | dict[name] = ConvertJTokenToPlainObject(token); 420 | } 421 | // If token is null, it means serialization failed and a warning was logged. 422 | } 423 | catch (Exception e) 424 | { 425 | // Catch potential errors during JToken conversion or addition to dictionary 426 | Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); 427 | } 428 | } 429 | 430 | // Helper to convert JToken back to basic object structure 431 | private static object ConvertJTokenToPlainObject(JToken token) 432 | { 433 | if (token == null) return null; 434 | 435 | switch (token.Type) 436 | { 437 | case JTokenType.Object: 438 | var objDict = new Dictionary<string, object>(); 439 | foreach (var prop in ((JObject)token).Properties()) 440 | { 441 | objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); 442 | } 443 | return objDict; 444 | 445 | case JTokenType.Array: 446 | var list = new List<object>(); 447 | foreach (var item in (JArray)token) 448 | { 449 | list.Add(ConvertJTokenToPlainObject(item)); 450 | } 451 | return list; 452 | 453 | case JTokenType.Integer: 454 | return token.ToObject<long>(); // Use long for safety 455 | case JTokenType.Float: 456 | return token.ToObject<double>(); // Use double for safety 457 | case JTokenType.String: 458 | return token.ToObject<string>(); 459 | case JTokenType.Boolean: 460 | return token.ToObject<bool>(); 461 | case JTokenType.Date: 462 | return token.ToObject<DateTime>(); 463 | case JTokenType.Guid: 464 | return token.ToObject<Guid>(); 465 | case JTokenType.Uri: 466 | return token.ToObject<Uri>(); 467 | case JTokenType.TimeSpan: 468 | return token.ToObject<TimeSpan>(); 469 | case JTokenType.Bytes: 470 | return token.ToObject<byte[]>(); 471 | case JTokenType.Null: 472 | return null; 473 | case JTokenType.Undefined: 474 | return null; // Treat undefined as null 475 | 476 | default: 477 | // Fallback for simple value types not explicitly listed 478 | if (token is JValue jValue && jValue.Value != null) 479 | { 480 | return jValue.Value; 481 | } 482 | // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null."); 483 | return null; 484 | } 485 | } 486 | 487 | // --- Define custom JsonSerializerSettings for OUTPUT --- 488 | private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings 489 | { 490 | Converters = new List<JsonConverter> 491 | { 492 | new Vector3Converter(), 493 | new Vector2Converter(), 494 | new QuaternionConverter(), 495 | new ColorConverter(), 496 | new RectConverter(), 497 | new BoundsConverter(), 498 | new UnityEngineObjectConverter() // Handles serialization of references 499 | }, 500 | ReferenceLoopHandling = ReferenceLoopHandling.Ignore, 501 | // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed 502 | }; 503 | private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); 504 | // --- End Define custom JsonSerializerSettings --- 505 | 506 | // Helper to create JToken using the output serializer 507 | private static JToken CreateTokenFromValue(object value, Type type) 508 | { 509 | if (value == null) return JValue.CreateNull(); 510 | 511 | try 512 | { 513 | // Use the pre-configured OUTPUT serializer instance 514 | return JToken.FromObject(value, _outputSerializer); 515 | } 516 | catch (JsonSerializationException e) 517 | { 518 | Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); 519 | return null; // Indicate serialization failure 520 | } 521 | catch (Exception e) // Catch other unexpected errors 522 | { 523 | Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); 524 | return null; // Indicate serialization failure 525 | } 526 | } 527 | } 528 | } 529 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ReadConsole.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditorInternal; 8 | using UnityEngine; 9 | using MCPForUnity.Editor.Helpers; // For Response class 10 | 11 | namespace MCPForUnity.Editor.Tools 12 | { 13 | /// <summary> 14 | /// Handles reading and clearing Unity Editor console log entries. 15 | /// Uses reflection to access internal LogEntry methods/properties. 16 | /// </summary> 17 | [McpForUnityTool("read_console")] 18 | public static class ReadConsole 19 | { 20 | // (Calibration removed) 21 | 22 | // Reflection members for accessing internal LogEntry data 23 | // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection 24 | private static MethodInfo _startGettingEntriesMethod; 25 | private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... 26 | private static MethodInfo _clearMethod; 27 | private static MethodInfo _getCountMethod; 28 | private static MethodInfo _getEntryMethod; 29 | private static FieldInfo _modeField; 30 | private static FieldInfo _messageField; 31 | private static FieldInfo _fileField; 32 | private static FieldInfo _lineField; 33 | private static FieldInfo _instanceIdField; 34 | 35 | // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? 36 | 37 | // Static constructor for reflection setup 38 | static ReadConsole() 39 | { 40 | try 41 | { 42 | Type logEntriesType = typeof(EditorApplication).Assembly.GetType( 43 | "UnityEditor.LogEntries" 44 | ); 45 | if (logEntriesType == null) 46 | throw new Exception("Could not find internal type UnityEditor.LogEntries"); 47 | 48 | 49 | 50 | // Include NonPublic binding flags as internal APIs might change accessibility 51 | BindingFlags staticFlags = 52 | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; 53 | BindingFlags instanceFlags = 54 | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; 55 | 56 | _startGettingEntriesMethod = logEntriesType.GetMethod( 57 | "StartGettingEntries", 58 | staticFlags 59 | ); 60 | if (_startGettingEntriesMethod == null) 61 | throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); 62 | 63 | // Try reflecting EndGettingEntries based on warning message 64 | _endGettingEntriesMethod = logEntriesType.GetMethod( 65 | "EndGettingEntries", 66 | staticFlags 67 | ); 68 | if (_endGettingEntriesMethod == null) 69 | throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); 70 | 71 | _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); 72 | if (_clearMethod == null) 73 | throw new Exception("Failed to reflect LogEntries.Clear"); 74 | 75 | _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); 76 | if (_getCountMethod == null) 77 | throw new Exception("Failed to reflect LogEntries.GetCount"); 78 | 79 | _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); 80 | if (_getEntryMethod == null) 81 | throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); 82 | 83 | Type logEntryType = typeof(EditorApplication).Assembly.GetType( 84 | "UnityEditor.LogEntry" 85 | ); 86 | if (logEntryType == null) 87 | throw new Exception("Could not find internal type UnityEditor.LogEntry"); 88 | 89 | _modeField = logEntryType.GetField("mode", instanceFlags); 90 | if (_modeField == null) 91 | throw new Exception("Failed to reflect LogEntry.mode"); 92 | 93 | _messageField = logEntryType.GetField("message", instanceFlags); 94 | if (_messageField == null) 95 | throw new Exception("Failed to reflect LogEntry.message"); 96 | 97 | _fileField = logEntryType.GetField("file", instanceFlags); 98 | if (_fileField == null) 99 | throw new Exception("Failed to reflect LogEntry.file"); 100 | 101 | _lineField = logEntryType.GetField("line", instanceFlags); 102 | if (_lineField == null) 103 | throw new Exception("Failed to reflect LogEntry.line"); 104 | 105 | _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); 106 | if (_instanceIdField == null) 107 | throw new Exception("Failed to reflect LogEntry.instanceID"); 108 | 109 | // (Calibration removed) 110 | 111 | } 112 | catch (Exception e) 113 | { 114 | Debug.LogError( 115 | $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" 116 | ); 117 | // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. 118 | _startGettingEntriesMethod = 119 | _endGettingEntriesMethod = 120 | _clearMethod = 121 | _getCountMethod = 122 | _getEntryMethod = 123 | null; 124 | _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; 125 | } 126 | } 127 | 128 | // --- Main Handler --- 129 | 130 | public static object HandleCommand(JObject @params) 131 | { 132 | // Check if ALL required reflection members were successfully initialized. 133 | if ( 134 | _startGettingEntriesMethod == null 135 | || _endGettingEntriesMethod == null 136 | || _clearMethod == null 137 | || _getCountMethod == null 138 | || _getEntryMethod == null 139 | || _modeField == null 140 | || _messageField == null 141 | || _fileField == null 142 | || _lineField == null 143 | || _instanceIdField == null 144 | ) 145 | { 146 | // Log the error here as well for easier debugging in Unity Console 147 | Debug.LogError( 148 | "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." 149 | ); 150 | return Response.Error( 151 | "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." 152 | ); 153 | } 154 | 155 | string action = @params["action"]?.ToString().ToLower() ?? "get"; 156 | 157 | try 158 | { 159 | if (action == "clear") 160 | { 161 | return ClearConsole(); 162 | } 163 | else if (action == "get") 164 | { 165 | // Extract parameters for 'get' 166 | var types = 167 | (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() 168 | ?? new List<string> { "error", "warning", "log" }; 169 | int? count = @params["count"]?.ToObject<int?>(); 170 | string filterText = @params["filterText"]?.ToString(); 171 | string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering 172 | string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); 173 | bool includeStacktrace = 174 | @params["includeStacktrace"]?.ToObject<bool?>() ?? true; 175 | 176 | if (types.Contains("all")) 177 | { 178 | types = new List<string> { "error", "warning", "log" }; // Expand 'all' 179 | } 180 | 181 | if (!string.IsNullOrEmpty(sinceTimestampStr)) 182 | { 183 | Debug.LogWarning( 184 | "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." 185 | ); 186 | // Need a way to get timestamp per log entry. 187 | } 188 | 189 | return GetConsoleEntries(types, count, filterText, format, includeStacktrace); 190 | } 191 | else 192 | { 193 | return Response.Error( 194 | $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." 195 | ); 196 | } 197 | } 198 | catch (Exception e) 199 | { 200 | Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); 201 | return Response.Error($"Internal error processing action '{action}': {e.Message}"); 202 | } 203 | } 204 | 205 | // --- Action Implementations --- 206 | 207 | private static object ClearConsole() 208 | { 209 | try 210 | { 211 | _clearMethod.Invoke(null, null); // Static method, no instance, no parameters 212 | return Response.Success("Console cleared successfully."); 213 | } 214 | catch (Exception e) 215 | { 216 | Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); 217 | return Response.Error($"Failed to clear console: {e.Message}"); 218 | } 219 | } 220 | 221 | private static object GetConsoleEntries( 222 | List<string> types, 223 | int? count, 224 | string filterText, 225 | string format, 226 | bool includeStacktrace 227 | ) 228 | { 229 | List<object> formattedEntries = new List<object>(); 230 | int retrievedCount = 0; 231 | 232 | try 233 | { 234 | // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal 235 | _startGettingEntriesMethod.Invoke(null, null); 236 | 237 | int totalEntries = (int)_getCountMethod.Invoke(null, null); 238 | // Create instance to pass to GetEntryInternal - Ensure the type is correct 239 | Type logEntryType = typeof(EditorApplication).Assembly.GetType( 240 | "UnityEditor.LogEntry" 241 | ); 242 | if (logEntryType == null) 243 | throw new Exception( 244 | "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." 245 | ); 246 | object logEntryInstance = Activator.CreateInstance(logEntryType); 247 | 248 | for (int i = 0; i < totalEntries; i++) 249 | { 250 | // Get the entry data into our instance using reflection 251 | _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); 252 | 253 | // Extract data using reflection 254 | int mode = (int)_modeField.GetValue(logEntryInstance); 255 | string message = (string)_messageField.GetValue(logEntryInstance); 256 | string file = (string)_fileField.GetValue(logEntryInstance); 257 | 258 | int line = (int)_lineField.GetValue(logEntryInstance); 259 | // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); 260 | 261 | if (string.IsNullOrEmpty(message)) 262 | { 263 | continue; // Skip empty messages 264 | } 265 | 266 | // (Calibration removed) 267 | 268 | // --- Filtering --- 269 | // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed 270 | LogType unityType = InferTypeFromMessage(message); 271 | bool isExplicitDebug = IsExplicitDebugLog(message); 272 | if (!isExplicitDebug && unityType == LogType.Log) 273 | { 274 | unityType = GetLogTypeFromMode(mode); 275 | } 276 | 277 | bool want; 278 | // Treat Exception/Assert as errors for filtering convenience 279 | if (unityType == LogType.Exception) 280 | { 281 | want = types.Contains("error") || types.Contains("exception"); 282 | } 283 | else if (unityType == LogType.Assert) 284 | { 285 | want = types.Contains("error") || types.Contains("assert"); 286 | } 287 | else 288 | { 289 | want = types.Contains(unityType.ToString().ToLowerInvariant()); 290 | } 291 | 292 | if (!want) continue; 293 | 294 | // Filter by text (case-insensitive) 295 | if ( 296 | !string.IsNullOrEmpty(filterText) 297 | && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 298 | ) 299 | { 300 | continue; 301 | } 302 | 303 | // TODO: Filter by timestamp (requires timestamp data) 304 | 305 | // --- Formatting --- 306 | string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; 307 | // Always get first line for the message, use full message only if no stack trace exists 308 | string[] messageLines = message.Split( 309 | new[] { '\n', '\r' }, 310 | StringSplitOptions.RemoveEmptyEntries 311 | ); 312 | string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; 313 | 314 | // If not including stacktrace, ensure we only show the first line 315 | if (!includeStacktrace) 316 | { 317 | stackTrace = null; 318 | } 319 | 320 | object formattedEntry = null; 321 | switch (format) 322 | { 323 | case "plain": 324 | formattedEntry = messageOnly; 325 | break; 326 | case "json": 327 | case "detailed": // Treat detailed as json for structured return 328 | default: 329 | formattedEntry = new 330 | { 331 | type = unityType.ToString(), 332 | message = messageOnly, 333 | file = file, 334 | line = line, 335 | // timestamp = "", // TODO 336 | stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found 337 | }; 338 | break; 339 | } 340 | 341 | formattedEntries.Add(formattedEntry); 342 | retrievedCount++; 343 | 344 | // Apply count limit (after filtering) 345 | if (count.HasValue && retrievedCount >= count.Value) 346 | { 347 | break; 348 | } 349 | } 350 | } 351 | catch (Exception e) 352 | { 353 | Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); 354 | // Ensure EndGettingEntries is called even if there's an error during iteration 355 | try 356 | { 357 | _endGettingEntriesMethod.Invoke(null, null); 358 | } 359 | catch 360 | { /* Ignore nested exception */ 361 | } 362 | return Response.Error($"Error retrieving log entries: {e.Message}"); 363 | } 364 | finally 365 | { 366 | // Ensure we always call EndGettingEntries 367 | try 368 | { 369 | _endGettingEntriesMethod.Invoke(null, null); 370 | } 371 | catch (Exception e) 372 | { 373 | Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); 374 | // Don't return error here as we might have valid data, but log it. 375 | } 376 | } 377 | 378 | // Return the filtered and formatted list (might be empty) 379 | return Response.Success( 380 | $"Retrieved {formattedEntries.Count} log entries.", 381 | formattedEntries 382 | ); 383 | } 384 | 385 | // --- Internal Helpers --- 386 | 387 | // Mapping bits from LogEntry.mode. These may vary by Unity version. 388 | private const int ModeBitError = 1 << 0; 389 | private const int ModeBitAssert = 1 << 1; 390 | private const int ModeBitWarning = 1 << 2; 391 | private const int ModeBitLog = 1 << 3; 392 | private const int ModeBitException = 1 << 4; // often combined with Error bits 393 | private const int ModeBitScriptingError = 1 << 9; 394 | private const int ModeBitScriptingWarning = 1 << 10; 395 | private const int ModeBitScriptingLog = 1 << 11; 396 | private const int ModeBitScriptingException = 1 << 18; 397 | private const int ModeBitScriptingAssertion = 1 << 22; 398 | 399 | private static LogType GetLogTypeFromMode(int mode) 400 | { 401 | // Preserve Unity's real type (no remapping); bits may vary by version 402 | if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; 403 | if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; 404 | if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; 405 | if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; 406 | return LogType.Log; 407 | } 408 | 409 | // (Calibration helpers removed) 410 | 411 | /// <summary> 412 | /// Classifies severity using message/stacktrace content. Works across Unity versions. 413 | /// </summary> 414 | private static LogType InferTypeFromMessage(string fullMessage) 415 | { 416 | if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; 417 | 418 | // Fast path: look for explicit Debug API names in the appended stack trace 419 | // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" 420 | if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) 421 | return LogType.Error; 422 | if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) 423 | return LogType.Warning; 424 | 425 | // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" 426 | if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 427 | || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) 428 | return LogType.Warning; 429 | if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 430 | || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) 431 | return LogType.Error; 432 | 433 | // Exceptions (avoid misclassifying compiler diagnostics) 434 | if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) 435 | return LogType.Exception; 436 | 437 | // Unity assertions 438 | if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) 439 | return LogType.Assert; 440 | 441 | return LogType.Log; 442 | } 443 | 444 | private static bool IsExplicitDebugLog(string fullMessage) 445 | { 446 | if (string.IsNullOrEmpty(fullMessage)) return false; 447 | if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; 448 | if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; 449 | return false; 450 | } 451 | 452 | /// <summary> 453 | /// Applies the "one level lower" remapping for filtering, like the old version. 454 | /// This ensures compatibility with the filtering logic that expects remapped types. 455 | /// </summary> 456 | private static LogType GetRemappedTypeForFiltering(LogType unityType) 457 | { 458 | switch (unityType) 459 | { 460 | case LogType.Error: 461 | return LogType.Warning; // Error becomes Warning 462 | case LogType.Warning: 463 | return LogType.Log; // Warning becomes Log 464 | case LogType.Assert: 465 | return LogType.Assert; // Assert remains Assert 466 | case LogType.Log: 467 | return LogType.Log; // Log remains Log 468 | case LogType.Exception: 469 | return LogType.Warning; // Exception becomes Warning 470 | default: 471 | return LogType.Log; // Default fallback 472 | } 473 | } 474 | 475 | /// <summary> 476 | /// Attempts to extract the stack trace part from a log message. 477 | /// Unity log messages often have the stack trace appended after the main message, 478 | /// starting on a new line and typically indented or beginning with "at ". 479 | /// </summary> 480 | /// <param name="fullMessage">The complete log message including potential stack trace.</param> 481 | /// <returns>The extracted stack trace string, or null if none is found.</returns> 482 | private static string ExtractStackTrace(string fullMessage) 483 | { 484 | if (string.IsNullOrEmpty(fullMessage)) 485 | return null; 486 | 487 | // Split into lines, removing empty ones to handle different line endings gracefully. 488 | // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. 489 | string[] lines = fullMessage.Split( 490 | new[] { '\r', '\n' }, 491 | StringSplitOptions.RemoveEmptyEntries 492 | ); 493 | 494 | // If there's only one line or less, there's no separate stack trace. 495 | if (lines.Length <= 1) 496 | return null; 497 | 498 | int stackStartIndex = -1; 499 | 500 | // Start checking from the second line onwards. 501 | for (int i = 1; i < lines.Length; ++i) 502 | { 503 | // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. 504 | string trimmedLine = lines[i].TrimStart(); 505 | 506 | // Check for common stack trace patterns. 507 | if ( 508 | trimmedLine.StartsWith("at ") 509 | || trimmedLine.StartsWith("UnityEngine.") 510 | || trimmedLine.StartsWith("UnityEditor.") 511 | || trimmedLine.Contains("(at ") 512 | || // Covers "(at Assets/..." pattern 513 | // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) 514 | ( 515 | trimmedLine.Length > 0 516 | && char.IsUpper(trimmedLine[0]) 517 | && trimmedLine.Contains('.') 518 | ) 519 | ) 520 | { 521 | stackStartIndex = i; 522 | break; // Found the likely start of the stack trace 523 | } 524 | } 525 | 526 | // If a potential start index was found... 527 | if (stackStartIndex > 0) 528 | { 529 | // Join the lines from the stack start index onwards using standard newline characters. 530 | // This reconstructs the stack trace part of the message. 531 | return string.Join("\n", lines.Skip(stackStartIndex)); 532 | } 533 | 534 | // No clear stack trace found based on the patterns. 535 | return null; 536 | } 537 | 538 | /* LogEntry.mode bits exploration (based on Unity decompilation/observation): 539 | May change between versions. 540 | 541 | Basic Types: 542 | kError = 1 << 0 (1) 543 | kAssert = 1 << 1 (2) 544 | kWarning = 1 << 2 (4) 545 | kLog = 1 << 3 (8) 546 | kFatal = 1 << 4 (16) - Often treated as Exception/Error 547 | 548 | Modifiers/Context: 549 | kAssetImportError = 1 << 7 (128) 550 | kAssetImportWarning = 1 << 8 (256) 551 | kScriptingError = 1 << 9 (512) 552 | kScriptingWarning = 1 << 10 (1024) 553 | kScriptingLog = 1 << 11 (2048) 554 | kScriptCompileError = 1 << 12 (4096) 555 | kScriptCompileWarning = 1 << 13 (8192) 556 | kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play 557 | kMayIgnoreLineNumber = 1 << 15 (32768) 558 | kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button 559 | kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) 560 | kScriptingException = 1 << 18 (262144) 561 | kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI 562 | kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior 563 | kGraphCompileError = 1 << 21 (2097152) 564 | kScriptingAssertion = 1 << 22 (4194304) 565 | kVisualScriptingError = 1 << 23 (8388608) 566 | 567 | Example observed values: 568 | Log: 2048 (ScriptingLog) or 8 (Log) 569 | Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) 570 | Error: 513 (ScriptingError | Error) or 1 (Error) 571 | Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination 572 | Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) 573 | */ 574 | } 575 | } 576 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ReadConsole.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditorInternal; 8 | using UnityEngine; 9 | using MCPForUnity.Editor.Helpers; // For Response class 10 | 11 | namespace MCPForUnity.Editor.Tools 12 | { 13 | /// <summary> 14 | /// Handles reading and clearing Unity Editor console log entries. 15 | /// Uses reflection to access internal LogEntry methods/properties. 16 | /// </summary> 17 | [McpForUnityTool("read_console")] 18 | public static class ReadConsole 19 | { 20 | // (Calibration removed) 21 | 22 | // Reflection members for accessing internal LogEntry data 23 | // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection 24 | private static MethodInfo _startGettingEntriesMethod; 25 | private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... 26 | private static MethodInfo _clearMethod; 27 | private static MethodInfo _getCountMethod; 28 | private static MethodInfo _getEntryMethod; 29 | private static FieldInfo _modeField; 30 | private static FieldInfo _messageField; 31 | private static FieldInfo _fileField; 32 | private static FieldInfo _lineField; 33 | private static FieldInfo _instanceIdField; 34 | 35 | // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? 36 | 37 | // Static constructor for reflection setup 38 | static ReadConsole() 39 | { 40 | try 41 | { 42 | Type logEntriesType = typeof(EditorApplication).Assembly.GetType( 43 | "UnityEditor.LogEntries" 44 | ); 45 | if (logEntriesType == null) 46 | throw new Exception("Could not find internal type UnityEditor.LogEntries"); 47 | 48 | 49 | 50 | // Include NonPublic binding flags as internal APIs might change accessibility 51 | BindingFlags staticFlags = 52 | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; 53 | BindingFlags instanceFlags = 54 | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; 55 | 56 | _startGettingEntriesMethod = logEntriesType.GetMethod( 57 | "StartGettingEntries", 58 | staticFlags 59 | ); 60 | if (_startGettingEntriesMethod == null) 61 | throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); 62 | 63 | // Try reflecting EndGettingEntries based on warning message 64 | _endGettingEntriesMethod = logEntriesType.GetMethod( 65 | "EndGettingEntries", 66 | staticFlags 67 | ); 68 | if (_endGettingEntriesMethod == null) 69 | throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); 70 | 71 | _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); 72 | if (_clearMethod == null) 73 | throw new Exception("Failed to reflect LogEntries.Clear"); 74 | 75 | _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); 76 | if (_getCountMethod == null) 77 | throw new Exception("Failed to reflect LogEntries.GetCount"); 78 | 79 | _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); 80 | if (_getEntryMethod == null) 81 | throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); 82 | 83 | Type logEntryType = typeof(EditorApplication).Assembly.GetType( 84 | "UnityEditor.LogEntry" 85 | ); 86 | if (logEntryType == null) 87 | throw new Exception("Could not find internal type UnityEditor.LogEntry"); 88 | 89 | _modeField = logEntryType.GetField("mode", instanceFlags); 90 | if (_modeField == null) 91 | throw new Exception("Failed to reflect LogEntry.mode"); 92 | 93 | _messageField = logEntryType.GetField("message", instanceFlags); 94 | if (_messageField == null) 95 | throw new Exception("Failed to reflect LogEntry.message"); 96 | 97 | _fileField = logEntryType.GetField("file", instanceFlags); 98 | if (_fileField == null) 99 | throw new Exception("Failed to reflect LogEntry.file"); 100 | 101 | _lineField = logEntryType.GetField("line", instanceFlags); 102 | if (_lineField == null) 103 | throw new Exception("Failed to reflect LogEntry.line"); 104 | 105 | _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); 106 | if (_instanceIdField == null) 107 | throw new Exception("Failed to reflect LogEntry.instanceID"); 108 | 109 | // (Calibration removed) 110 | 111 | } 112 | catch (Exception e) 113 | { 114 | Debug.LogError( 115 | $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" 116 | ); 117 | // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. 118 | _startGettingEntriesMethod = 119 | _endGettingEntriesMethod = 120 | _clearMethod = 121 | _getCountMethod = 122 | _getEntryMethod = 123 | null; 124 | _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; 125 | } 126 | } 127 | 128 | // --- Main Handler --- 129 | 130 | public static object HandleCommand(JObject @params) 131 | { 132 | // Check if ALL required reflection members were successfully initialized. 133 | if ( 134 | _startGettingEntriesMethod == null 135 | || _endGettingEntriesMethod == null 136 | || _clearMethod == null 137 | || _getCountMethod == null 138 | || _getEntryMethod == null 139 | || _modeField == null 140 | || _messageField == null 141 | || _fileField == null 142 | || _lineField == null 143 | || _instanceIdField == null 144 | ) 145 | { 146 | // Log the error here as well for easier debugging in Unity Console 147 | Debug.LogError( 148 | "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." 149 | ); 150 | return Response.Error( 151 | "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." 152 | ); 153 | } 154 | 155 | string action = @params["action"]?.ToString().ToLower() ?? "get"; 156 | 157 | try 158 | { 159 | if (action == "clear") 160 | { 161 | return ClearConsole(); 162 | } 163 | else if (action == "get") 164 | { 165 | // Extract parameters for 'get' 166 | var types = 167 | (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() 168 | ?? new List<string> { "error", "warning", "log" }; 169 | int? count = @params["count"]?.ToObject<int?>(); 170 | string filterText = @params["filterText"]?.ToString(); 171 | string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering 172 | string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); 173 | bool includeStacktrace = 174 | @params["includeStacktrace"]?.ToObject<bool?>() ?? true; 175 | 176 | if (types.Contains("all")) 177 | { 178 | types = new List<string> { "error", "warning", "log" }; // Expand 'all' 179 | } 180 | 181 | if (!string.IsNullOrEmpty(sinceTimestampStr)) 182 | { 183 | Debug.LogWarning( 184 | "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." 185 | ); 186 | // Need a way to get timestamp per log entry. 187 | } 188 | 189 | return GetConsoleEntries(types, count, filterText, format, includeStacktrace); 190 | } 191 | else 192 | { 193 | return Response.Error( 194 | $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." 195 | ); 196 | } 197 | } 198 | catch (Exception e) 199 | { 200 | Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); 201 | return Response.Error($"Internal error processing action '{action}': {e.Message}"); 202 | } 203 | } 204 | 205 | // --- Action Implementations --- 206 | 207 | private static object ClearConsole() 208 | { 209 | try 210 | { 211 | _clearMethod.Invoke(null, null); // Static method, no instance, no parameters 212 | return Response.Success("Console cleared successfully."); 213 | } 214 | catch (Exception e) 215 | { 216 | Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); 217 | return Response.Error($"Failed to clear console: {e.Message}"); 218 | } 219 | } 220 | 221 | private static object GetConsoleEntries( 222 | List<string> types, 223 | int? count, 224 | string filterText, 225 | string format, 226 | bool includeStacktrace 227 | ) 228 | { 229 | List<object> formattedEntries = new List<object>(); 230 | int retrievedCount = 0; 231 | 232 | try 233 | { 234 | // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal 235 | _startGettingEntriesMethod.Invoke(null, null); 236 | 237 | int totalEntries = (int)_getCountMethod.Invoke(null, null); 238 | // Create instance to pass to GetEntryInternal - Ensure the type is correct 239 | Type logEntryType = typeof(EditorApplication).Assembly.GetType( 240 | "UnityEditor.LogEntry" 241 | ); 242 | if (logEntryType == null) 243 | throw new Exception( 244 | "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." 245 | ); 246 | object logEntryInstance = Activator.CreateInstance(logEntryType); 247 | 248 | for (int i = 0; i < totalEntries; i++) 249 | { 250 | // Get the entry data into our instance using reflection 251 | _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); 252 | 253 | // Extract data using reflection 254 | int mode = (int)_modeField.GetValue(logEntryInstance); 255 | string message = (string)_messageField.GetValue(logEntryInstance); 256 | string file = (string)_fileField.GetValue(logEntryInstance); 257 | 258 | int line = (int)_lineField.GetValue(logEntryInstance); 259 | // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); 260 | 261 | if (string.IsNullOrEmpty(message)) 262 | { 263 | continue; // Skip empty messages 264 | } 265 | 266 | // (Calibration removed) 267 | 268 | // --- Filtering --- 269 | // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed 270 | LogType unityType = InferTypeFromMessage(message); 271 | bool isExplicitDebug = IsExplicitDebugLog(message); 272 | if (!isExplicitDebug && unityType == LogType.Log) 273 | { 274 | unityType = GetLogTypeFromMode(mode); 275 | } 276 | 277 | bool want; 278 | // Treat Exception/Assert as errors for filtering convenience 279 | if (unityType == LogType.Exception) 280 | { 281 | want = types.Contains("error") || types.Contains("exception"); 282 | } 283 | else if (unityType == LogType.Assert) 284 | { 285 | want = types.Contains("error") || types.Contains("assert"); 286 | } 287 | else 288 | { 289 | want = types.Contains(unityType.ToString().ToLowerInvariant()); 290 | } 291 | 292 | if (!want) continue; 293 | 294 | // Filter by text (case-insensitive) 295 | if ( 296 | !string.IsNullOrEmpty(filterText) 297 | && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 298 | ) 299 | { 300 | continue; 301 | } 302 | 303 | // TODO: Filter by timestamp (requires timestamp data) 304 | 305 | // --- Formatting --- 306 | string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; 307 | // Always get first line for the message, use full message only if no stack trace exists 308 | string[] messageLines = message.Split( 309 | new[] { '\n', '\r' }, 310 | StringSplitOptions.RemoveEmptyEntries 311 | ); 312 | string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; 313 | 314 | // If not including stacktrace, ensure we only show the first line 315 | if (!includeStacktrace) 316 | { 317 | stackTrace = null; 318 | } 319 | 320 | object formattedEntry = null; 321 | switch (format) 322 | { 323 | case "plain": 324 | formattedEntry = messageOnly; 325 | break; 326 | case "json": 327 | case "detailed": // Treat detailed as json for structured return 328 | default: 329 | formattedEntry = new 330 | { 331 | type = unityType.ToString(), 332 | message = messageOnly, 333 | file = file, 334 | line = line, 335 | // timestamp = "", // TODO 336 | stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found 337 | }; 338 | break; 339 | } 340 | 341 | formattedEntries.Add(formattedEntry); 342 | retrievedCount++; 343 | 344 | // Apply count limit (after filtering) 345 | if (count.HasValue && retrievedCount >= count.Value) 346 | { 347 | break; 348 | } 349 | } 350 | } 351 | catch (Exception e) 352 | { 353 | Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); 354 | // Ensure EndGettingEntries is called even if there's an error during iteration 355 | try 356 | { 357 | _endGettingEntriesMethod.Invoke(null, null); 358 | } 359 | catch 360 | { /* Ignore nested exception */ 361 | } 362 | return Response.Error($"Error retrieving log entries: {e.Message}"); 363 | } 364 | finally 365 | { 366 | // Ensure we always call EndGettingEntries 367 | try 368 | { 369 | _endGettingEntriesMethod.Invoke(null, null); 370 | } 371 | catch (Exception e) 372 | { 373 | Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); 374 | // Don't return error here as we might have valid data, but log it. 375 | } 376 | } 377 | 378 | // Return the filtered and formatted list (might be empty) 379 | return Response.Success( 380 | $"Retrieved {formattedEntries.Count} log entries.", 381 | formattedEntries 382 | ); 383 | } 384 | 385 | // --- Internal Helpers --- 386 | 387 | // Mapping bits from LogEntry.mode. These may vary by Unity version. 388 | private const int ModeBitError = 1 << 0; 389 | private const int ModeBitAssert = 1 << 1; 390 | private const int ModeBitWarning = 1 << 2; 391 | private const int ModeBitLog = 1 << 3; 392 | private const int ModeBitException = 1 << 4; // often combined with Error bits 393 | private const int ModeBitScriptingError = 1 << 9; 394 | private const int ModeBitScriptingWarning = 1 << 10; 395 | private const int ModeBitScriptingLog = 1 << 11; 396 | private const int ModeBitScriptingException = 1 << 18; 397 | private const int ModeBitScriptingAssertion = 1 << 22; 398 | 399 | private static LogType GetLogTypeFromMode(int mode) 400 | { 401 | // Preserve Unity's real type (no remapping); bits may vary by version 402 | if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; 403 | if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; 404 | if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; 405 | if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; 406 | return LogType.Log; 407 | } 408 | 409 | // (Calibration helpers removed) 410 | 411 | /// <summary> 412 | /// Classifies severity using message/stacktrace content. Works across Unity versions. 413 | /// </summary> 414 | private static LogType InferTypeFromMessage(string fullMessage) 415 | { 416 | if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; 417 | 418 | // Fast path: look for explicit Debug API names in the appended stack trace 419 | // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" 420 | if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) 421 | return LogType.Error; 422 | if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) 423 | return LogType.Warning; 424 | 425 | // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" 426 | if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 427 | || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) 428 | return LogType.Warning; 429 | if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 430 | || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) 431 | return LogType.Error; 432 | 433 | // Exceptions (avoid misclassifying compiler diagnostics) 434 | if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) 435 | return LogType.Exception; 436 | 437 | // Unity assertions 438 | if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) 439 | return LogType.Assert; 440 | 441 | return LogType.Log; 442 | } 443 | 444 | private static bool IsExplicitDebugLog(string fullMessage) 445 | { 446 | if (string.IsNullOrEmpty(fullMessage)) return false; 447 | if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; 448 | if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; 449 | return false; 450 | } 451 | 452 | /// <summary> 453 | /// Applies the "one level lower" remapping for filtering, like the old version. 454 | /// This ensures compatibility with the filtering logic that expects remapped types. 455 | /// </summary> 456 | private static LogType GetRemappedTypeForFiltering(LogType unityType) 457 | { 458 | switch (unityType) 459 | { 460 | case LogType.Error: 461 | return LogType.Warning; // Error becomes Warning 462 | case LogType.Warning: 463 | return LogType.Log; // Warning becomes Log 464 | case LogType.Assert: 465 | return LogType.Assert; // Assert remains Assert 466 | case LogType.Log: 467 | return LogType.Log; // Log remains Log 468 | case LogType.Exception: 469 | return LogType.Warning; // Exception becomes Warning 470 | default: 471 | return LogType.Log; // Default fallback 472 | } 473 | } 474 | 475 | /// <summary> 476 | /// Attempts to extract the stack trace part from a log message. 477 | /// Unity log messages often have the stack trace appended after the main message, 478 | /// starting on a new line and typically indented or beginning with "at ". 479 | /// </summary> 480 | /// <param name="fullMessage">The complete log message including potential stack trace.</param> 481 | /// <returns>The extracted stack trace string, or null if none is found.</returns> 482 | private static string ExtractStackTrace(string fullMessage) 483 | { 484 | if (string.IsNullOrEmpty(fullMessage)) 485 | return null; 486 | 487 | // Split into lines, removing empty ones to handle different line endings gracefully. 488 | // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. 489 | string[] lines = fullMessage.Split( 490 | new[] { '\r', '\n' }, 491 | StringSplitOptions.RemoveEmptyEntries 492 | ); 493 | 494 | // If there's only one line or less, there's no separate stack trace. 495 | if (lines.Length <= 1) 496 | return null; 497 | 498 | int stackStartIndex = -1; 499 | 500 | // Start checking from the second line onwards. 501 | for (int i = 1; i < lines.Length; ++i) 502 | { 503 | // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. 504 | string trimmedLine = lines[i].TrimStart(); 505 | 506 | // Check for common stack trace patterns. 507 | if ( 508 | trimmedLine.StartsWith("at ") 509 | || trimmedLine.StartsWith("UnityEngine.") 510 | || trimmedLine.StartsWith("UnityEditor.") 511 | || trimmedLine.Contains("(at ") 512 | || // Covers "(at Assets/..." pattern 513 | // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) 514 | ( 515 | trimmedLine.Length > 0 516 | && char.IsUpper(trimmedLine[0]) 517 | && trimmedLine.Contains('.') 518 | ) 519 | ) 520 | { 521 | stackStartIndex = i; 522 | break; // Found the likely start of the stack trace 523 | } 524 | } 525 | 526 | // If a potential start index was found... 527 | if (stackStartIndex > 0) 528 | { 529 | // Join the lines from the stack start index onwards using standard newline characters. 530 | // This reconstructs the stack trace part of the message. 531 | return string.Join("\n", lines.Skip(stackStartIndex)); 532 | } 533 | 534 | // No clear stack trace found based on the patterns. 535 | return null; 536 | } 537 | 538 | /* LogEntry.mode bits exploration (based on Unity decompilation/observation): 539 | May change between versions. 540 | 541 | Basic Types: 542 | kError = 1 << 0 (1) 543 | kAssert = 1 << 1 (2) 544 | kWarning = 1 << 2 (4) 545 | kLog = 1 << 3 (8) 546 | kFatal = 1 << 4 (16) - Often treated as Exception/Error 547 | 548 | Modifiers/Context: 549 | kAssetImportError = 1 << 7 (128) 550 | kAssetImportWarning = 1 << 8 (256) 551 | kScriptingError = 1 << 9 (512) 552 | kScriptingWarning = 1 << 10 (1024) 553 | kScriptingLog = 1 << 11 (2048) 554 | kScriptCompileError = 1 << 12 (4096) 555 | kScriptCompileWarning = 1 << 13 (8192) 556 | kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play 557 | kMayIgnoreLineNumber = 1 << 15 (32768) 558 | kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button 559 | kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) 560 | kScriptingException = 1 << 18 (262144) 561 | kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI 562 | kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior 563 | kGraphCompileError = 1 << 21 (2097152) 564 | kScriptingAssertion = 1 << 22 (4194304) 565 | kVisualScriptingError = 1 << 23 (8388608) 566 | 567 | Example observed values: 568 | Log: 2048 (ScriptingLog) or 8 (Log) 569 | Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) 570 | Error: 513 (ScriptingError | Error) or 1 (Error) 571 | Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination 572 | Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) 573 | */ 574 | } 575 | } 576 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_script.py: -------------------------------------------------------------------------------- ```python 1 | import base64 2 | import os 3 | from typing import Annotated, Any, Literal 4 | from urllib.parse import urlparse, unquote 5 | 6 | from mcp.server.fastmcp import FastMCP, Context 7 | 8 | from registry import mcp_for_unity_tool 9 | from unity_connection import send_command_with_retry 10 | 11 | 12 | def _split_uri(uri: str) -> tuple[str, str]: 13 | """Split an incoming URI or path into (name, directory) suitable for Unity. 14 | 15 | Rules: 16 | - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) 17 | - file://... → percent-decode, normalize, strip host and leading slashes, 18 | then, if any 'Assets' segment exists, return path relative to that 'Assets' root. 19 | Otherwise, fall back to original name/dir behavior. 20 | - plain paths → decode/normalize separators; if they contain an 'Assets' segment, 21 | return relative to 'Assets'. 22 | """ 23 | raw_path: str 24 | if uri.startswith("unity://path/"): 25 | raw_path = uri[len("unity://path/"):] 26 | elif uri.startswith("file://"): 27 | parsed = urlparse(uri) 28 | host = (parsed.netloc or "").strip() 29 | p = parsed.path or "" 30 | # UNC: file://server/share/... -> //server/share/... 31 | if host and host.lower() != "localhost": 32 | p = f"//{host}{p}" 33 | # Use percent-decoded path, preserving leading slashes 34 | raw_path = unquote(p) 35 | else: 36 | raw_path = uri 37 | 38 | # Percent-decode any residual encodings and normalize separators 39 | raw_path = unquote(raw_path).replace("\\", "/") 40 | # Strip leading slash only for Windows drive-letter forms like "/C:/..." 41 | if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": 42 | raw_path = raw_path[1:] 43 | 44 | # Normalize path (collapse ../, ./) 45 | norm = os.path.normpath(raw_path).replace("\\", "/") 46 | 47 | # If an 'Assets' segment exists, compute path relative to it (case-insensitive) 48 | parts = [p for p in norm.split("/") if p not in ("", ".")] 49 | idx = next((i for i, seg in enumerate(parts) 50 | if seg.lower() == "assets"), None) 51 | assets_rel = "/".join(parts[idx:]) if idx is not None else None 52 | 53 | effective_path = assets_rel if assets_rel else norm 54 | # For POSIX absolute paths outside Assets, drop the leading '/' 55 | # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). 56 | if effective_path.startswith("/"): 57 | effective_path = effective_path[1:] 58 | 59 | name = os.path.splitext(os.path.basename(effective_path))[0] 60 | directory = os.path.dirname(effective_path) 61 | return name, directory 62 | 63 | 64 | @mcp_for_unity_tool(description=( 65 | """Apply small text edits to a C# script identified by URI. 66 | IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! 67 | RECOMMENDED WORKFLOW: 68 | 1. First call resources/read with start_line/line_count to verify exact content 69 | 2. Count columns carefully (or use find_in_file to locate patterns) 70 | 3. Apply your edit with precise coordinates 71 | 4. Consider script_apply_edits with anchors for safer pattern-based replacements 72 | Notes: 73 | - For method/class operations, use script_apply_edits (safer, structured edits) 74 | - For pattern-based replacements, consider anchor operations in script_apply_edits 75 | - Lines, columns are 1-indexed 76 | - Tabs count as 1 column""" 77 | )) 78 | def apply_text_edits( 79 | ctx: Context, 80 | uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 81 | edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], 82 | precondition_sha256: Annotated[str, 83 | "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, 84 | strict: Annotated[bool, 85 | "Optional strict flag, used to enforce strict mode"] | None = None, 86 | options: Annotated[dict[str, Any], 87 | "Optional options, used to pass additional options to the script editor"] | None = None, 88 | ) -> dict[str, Any]: 89 | ctx.info(f"Processing apply_text_edits: {uri}") 90 | name, directory = _split_uri(uri) 91 | 92 | # Normalize common aliases/misuses for resilience: 93 | # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} 94 | # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} 95 | # If normalization is required, read current contents to map indices -> 1-based line/col. 96 | def _needs_normalization(arr: list[dict[str, Any]]) -> bool: 97 | for e in arr or []: 98 | if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): 99 | return True 100 | return False 101 | 102 | normalized_edits: list[dict[str, Any]] = [] 103 | warnings: list[str] = [] 104 | if _needs_normalization(edits): 105 | # Read file to support index->line/col conversion when needed 106 | read_resp = send_command_with_retry("manage_script", { 107 | "action": "read", 108 | "name": name, 109 | "path": directory, 110 | }) 111 | if not (isinstance(read_resp, dict) and read_resp.get("success")): 112 | return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} 113 | data = read_resp.get("data", {}) 114 | contents = data.get("contents") 115 | if not contents and data.get("contentsEncoded"): 116 | try: 117 | contents = base64.b64decode(data.get("encodedContents", "").encode( 118 | "utf-8")).decode("utf-8", "replace") 119 | except Exception: 120 | contents = contents or "" 121 | 122 | # Helper to map 0-based character index to 1-based line/col 123 | def line_col_from_index(idx: int) -> tuple[int, int]: 124 | if idx <= 0: 125 | return 1, 1 126 | # Count lines up to idx and position within line 127 | nl_count = contents.count("\n", 0, idx) 128 | line = nl_count + 1 129 | last_nl = contents.rfind("\n", 0, idx) 130 | col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 131 | return line, col 132 | 133 | for e in edits or []: 134 | e2 = dict(e) 135 | # Map text->newText if needed 136 | if "newText" not in e2 and "text" in e2: 137 | e2["newText"] = e2.pop("text") 138 | 139 | if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: 140 | # Guard: explicit fields must be 1-based. 141 | zero_based = False 142 | for k in ("startLine", "startCol", "endLine", "endCol"): 143 | try: 144 | if int(e2.get(k, 1)) < 1: 145 | zero_based = True 146 | except Exception: 147 | pass 148 | if zero_based: 149 | if strict: 150 | return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} 151 | # Normalize by clamping to 1 and warn 152 | for k in ("startLine", "startCol", "endLine", "endCol"): 153 | try: 154 | if int(e2.get(k, 1)) < 1: 155 | e2[k] = 1 156 | except Exception: 157 | pass 158 | warnings.append( 159 | "zero_based_explicit_fields_normalized") 160 | normalized_edits.append(e2) 161 | continue 162 | 163 | rng = e2.get("range") 164 | if isinstance(rng, dict): 165 | # LSP style: 0-based 166 | s = rng.get("start", {}) 167 | t = rng.get("end", {}) 168 | e2["startLine"] = int(s.get("line", 0)) + 1 169 | e2["startCol"] = int(s.get("character", 0)) + 1 170 | e2["endLine"] = int(t.get("line", 0)) + 1 171 | e2["endCol"] = int(t.get("character", 0)) + 1 172 | e2.pop("range", None) 173 | normalized_edits.append(e2) 174 | continue 175 | if isinstance(rng, (list, tuple)) and len(rng) == 2: 176 | try: 177 | a = int(rng[0]) 178 | b = int(rng[1]) 179 | if b < a: 180 | a, b = b, a 181 | sl, sc = line_col_from_index(a) 182 | el, ec = line_col_from_index(b) 183 | e2["startLine"] = sl 184 | e2["startCol"] = sc 185 | e2["endLine"] = el 186 | e2["endCol"] = ec 187 | e2.pop("range", None) 188 | normalized_edits.append(e2) 189 | continue 190 | except Exception: 191 | pass 192 | # Could not normalize this edit 193 | return { 194 | "success": False, 195 | "code": "missing_field", 196 | "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", 197 | "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} 198 | } 199 | else: 200 | # Even when edits appear already in explicit form, validate 1-based coordinates. 201 | normalized_edits = [] 202 | for e in edits or []: 203 | e2 = dict(e) 204 | has_all = all(k in e2 for k in ( 205 | "startLine", "startCol", "endLine", "endCol")) 206 | if has_all: 207 | zero_based = False 208 | for k in ("startLine", "startCol", "endLine", "endCol"): 209 | try: 210 | if int(e2.get(k, 1)) < 1: 211 | zero_based = True 212 | except Exception: 213 | pass 214 | if zero_based: 215 | if strict: 216 | return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} 217 | for k in ("startLine", "startCol", "endLine", "endCol"): 218 | try: 219 | if int(e2.get(k, 1)) < 1: 220 | e2[k] = 1 221 | except Exception: 222 | pass 223 | if "zero_based_explicit_fields_normalized" not in warnings: 224 | warnings.append( 225 | "zero_based_explicit_fields_normalized") 226 | normalized_edits.append(e2) 227 | 228 | # Preflight: detect overlapping ranges among normalized line/col spans 229 | def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: 230 | return ( 231 | int(e.get("startLine", 1)) if key_start else int( 232 | e.get("endLine", 1)), 233 | int(e.get("startCol", 1)) if key_start else int( 234 | e.get("endCol", 1)), 235 | ) 236 | 237 | def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: 238 | return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) 239 | 240 | # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. 241 | spans = [] 242 | for e in normalized_edits or []: 243 | try: 244 | s = _pos_tuple(e, True) 245 | t = _pos_tuple(e, False) 246 | if s != t: 247 | spans.append((s, t)) 248 | except Exception: 249 | # If coordinates missing or invalid, let the server validate later 250 | pass 251 | 252 | if spans: 253 | spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) 254 | for i in range(1, len(spans_sorted)): 255 | prev_end = spans_sorted[i-1][1] 256 | curr_start = spans_sorted[i][0] 257 | # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start 258 | if not _le(prev_end, curr_start): 259 | conflicts = [{ 260 | "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, 261 | "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, 262 | "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, 263 | "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, 264 | }] 265 | return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} 266 | 267 | # Note: Do not auto-compute precondition if missing; callers should supply it 268 | # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and 269 | # preserves existing call-count expectations in clients/tests. 270 | 271 | # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance 272 | opts: dict[str, Any] = dict(options or {}) 273 | try: 274 | if len(normalized_edits) > 1 and "applyMode" not in opts: 275 | opts["applyMode"] = "atomic" 276 | except Exception: 277 | pass 278 | # Support optional debug preview for span-by-span simulation without write 279 | if opts.get("debug_preview"): 280 | try: 281 | import difflib 282 | # Apply locally to preview final result 283 | lines = [] 284 | # Build an indexable original from a read if we normalized from read; otherwise skip 285 | prev = "" 286 | # We cannot guarantee file contents here without a read; return normalized spans only 287 | return { 288 | "success": True, 289 | "message": "Preview only (no write)", 290 | "data": { 291 | "normalizedEdits": normalized_edits, 292 | "preview": True 293 | } 294 | } 295 | except Exception as e: 296 | return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} 297 | 298 | params = { 299 | "action": "apply_text_edits", 300 | "name": name, 301 | "path": directory, 302 | "edits": normalized_edits, 303 | "precondition_sha256": precondition_sha256, 304 | "options": opts, 305 | } 306 | params = {k: v for k, v in params.items() if v is not None} 307 | resp = send_command_with_retry("manage_script", params) 308 | if isinstance(resp, dict): 309 | data = resp.setdefault("data", {}) 310 | data.setdefault("normalizedEdits", normalized_edits) 311 | if warnings: 312 | data.setdefault("warnings", warnings) 313 | if resp.get("success") and (options or {}).get("force_sentinel_reload"): 314 | # Optional: flip sentinel via menu if explicitly requested 315 | try: 316 | import threading 317 | import time 318 | import json 319 | import glob 320 | import os 321 | 322 | def _latest_status() -> dict | None: 323 | try: 324 | files = sorted(glob.glob(os.path.expanduser( 325 | "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) 326 | if not files: 327 | return None 328 | with open(files[0], "r") as f: 329 | return json.loads(f.read()) 330 | except Exception: 331 | return None 332 | 333 | def _flip_async(): 334 | try: 335 | time.sleep(0.1) 336 | st = _latest_status() 337 | if st and st.get("reloading"): 338 | return 339 | send_command_with_retry( 340 | "execute_menu_item", 341 | {"menuPath": "MCP/Flip Reload Sentinel"}, 342 | max_retries=0, 343 | retry_ms=0, 344 | ) 345 | except Exception: 346 | pass 347 | threading.Thread(target=_flip_async, daemon=True).start() 348 | except Exception: 349 | pass 350 | return resp 351 | return resp 352 | return {"success": False, "message": str(resp)} 353 | 354 | 355 | @mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) 356 | def create_script( 357 | ctx: Context, 358 | path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], 359 | contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], 360 | script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, 361 | namespace: Annotated[str, "Namespace for the script"] | None = None, 362 | ) -> dict[str, Any]: 363 | ctx.info(f"Processing create_script: {path}") 364 | name = os.path.splitext(os.path.basename(path))[0] 365 | directory = os.path.dirname(path) 366 | # Local validation to avoid round-trips on obviously bad input 367 | norm_path = os.path.normpath( 368 | (path or "").replace("\\", "/")).replace("\\", "/") 369 | if not directory or directory.split("/")[0].lower() != "assets": 370 | return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} 371 | if ".." in norm_path.split("/") or norm_path.startswith("/"): 372 | return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} 373 | if not name: 374 | return {"success": False, "code": "bad_path", "message": "path must include a script file name."} 375 | if not norm_path.lower().endswith(".cs"): 376 | return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} 377 | params: dict[str, Any] = { 378 | "action": "create", 379 | "name": name, 380 | "path": directory, 381 | "namespace": namespace, 382 | "scriptType": script_type, 383 | } 384 | if contents: 385 | params["encodedContents"] = base64.b64encode( 386 | contents.encode("utf-8")).decode("utf-8") 387 | params["contentsEncoded"] = True 388 | params = {k: v for k, v in params.items() if v is not None} 389 | resp = send_command_with_retry("manage_script", params) 390 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 391 | 392 | 393 | @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) 394 | def delete_script( 395 | ctx: Context, 396 | uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] 397 | ) -> dict[str, Any]: 398 | """Delete a C# script by URI.""" 399 | ctx.info(f"Processing delete_script: {uri}") 400 | name, directory = _split_uri(uri) 401 | if not directory or directory.split("/")[0].lower() != "assets": 402 | return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} 403 | params = {"action": "delete", "name": name, "path": directory} 404 | resp = send_command_with_retry("manage_script", params) 405 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 406 | 407 | 408 | @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) 409 | def validate_script( 410 | ctx: Context, 411 | uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 412 | level: Annotated[Literal['basic', 'standard'], 413 | "Validation level"] = "basic", 414 | include_diagnostics: Annotated[bool, 415 | "Include full diagnostics and summary"] = False 416 | ) -> dict[str, Any]: 417 | ctx.info(f"Processing validate_script: {uri}") 418 | name, directory = _split_uri(uri) 419 | if not directory or directory.split("/")[0].lower() != "assets": 420 | return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} 421 | if level not in ("basic", "standard"): 422 | return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} 423 | params = { 424 | "action": "validate", 425 | "name": name, 426 | "path": directory, 427 | "level": level, 428 | } 429 | resp = send_command_with_retry("manage_script", params) 430 | if isinstance(resp, dict) and resp.get("success"): 431 | diags = resp.get("data", {}).get("diagnostics", []) or [] 432 | warnings = sum(1 for d in diags if str( 433 | d.get("severity", "")).lower() == "warning") 434 | errors = sum(1 for d in diags if str( 435 | d.get("severity", "")).lower() in ("error", "fatal")) 436 | if include_diagnostics: 437 | return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} 438 | return {"success": True, "data": {"warnings": warnings, "errors": errors}} 439 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 440 | 441 | 442 | @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) 443 | def manage_script( 444 | ctx: Context, 445 | action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], 446 | name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], 447 | path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], 448 | contents: Annotated[str, "Contents of the script to create", 449 | "C# code for 'create'/'update'"] | None = None, 450 | script_type: Annotated[str, "Script type (e.g., 'C#')", 451 | "Type hint (e.g., 'MonoBehaviour')"] | None = None, 452 | namespace: Annotated[str, "Namespace for the script"] | None = None, 453 | ) -> dict[str, Any]: 454 | ctx.info(f"Processing manage_script: {action}") 455 | try: 456 | # Prepare parameters for Unity 457 | params = { 458 | "action": action, 459 | "name": name, 460 | "path": path, 461 | "namespace": namespace, 462 | "scriptType": script_type, 463 | } 464 | 465 | # Base64 encode the contents if they exist to avoid JSON escaping issues 466 | if contents: 467 | if action == 'create': 468 | params["encodedContents"] = base64.b64encode( 469 | contents.encode('utf-8')).decode('utf-8') 470 | params["contentsEncoded"] = True 471 | else: 472 | params["contents"] = contents 473 | 474 | params = {k: v for k, v in params.items() if v is not None} 475 | 476 | response = send_command_with_retry("manage_script", params) 477 | 478 | if isinstance(response, dict): 479 | if response.get("success"): 480 | if response.get("data", {}).get("contentsEncoded"): 481 | decoded_contents = base64.b64decode( 482 | response["data"]["encodedContents"]).decode('utf-8') 483 | response["data"]["contents"] = decoded_contents 484 | del response["data"]["encodedContents"] 485 | del response["data"]["contentsEncoded"] 486 | 487 | return { 488 | "success": True, 489 | "message": response.get("message", "Operation successful."), 490 | "data": response.get("data"), 491 | } 492 | return response 493 | 494 | return {"success": False, "message": str(response)} 495 | 496 | except Exception as e: 497 | return { 498 | "success": False, 499 | "message": f"Python error managing script: {str(e)}", 500 | } 501 | 502 | 503 | @mcp_for_unity_tool(description=( 504 | """Get manage_script capabilities (supported ops, limits, and guards). 505 | Returns: 506 | - ops: list of supported structured ops 507 | - text_ops: list of supported text ops 508 | - max_edit_payload_bytes: server edit payload cap 509 | - guards: header/using guard enabled flag""" 510 | )) 511 | def manage_script_capabilities(ctx: Context) -> dict[str, Any]: 512 | ctx.info("Processing manage_script_capabilities") 513 | try: 514 | # Keep in sync with server/Editor ManageScript implementation 515 | ops = [ 516 | "replace_class", "delete_class", "replace_method", "delete_method", 517 | "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" 518 | ] 519 | text_ops = ["replace_range", "regex_replace", "prepend", "append"] 520 | # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback 521 | max_edit_payload_bytes = 256 * 1024 522 | guards = {"using_guard": True} 523 | extras = {"get_sha": True} 524 | return {"success": True, "data": { 525 | "ops": ops, 526 | "text_ops": text_ops, 527 | "max_edit_payload_bytes": max_edit_payload_bytes, 528 | "guards": guards, 529 | "extras": extras, 530 | }} 531 | except Exception as e: 532 | return {"success": False, "error": f"capabilities error: {e}"} 533 | 534 | 535 | @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") 536 | def get_sha( 537 | ctx: Context, 538 | uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] 539 | ) -> dict[str, Any]: 540 | ctx.info(f"Processing get_sha: {uri}") 541 | try: 542 | name, directory = _split_uri(uri) 543 | params = {"action": "get_sha", "name": name, "path": directory} 544 | resp = send_command_with_retry("manage_script", params) 545 | if isinstance(resp, dict) and resp.get("success"): 546 | data = resp.get("data", {}) 547 | minimal = {"sha256": data.get( 548 | "sha256"), "lengthBytes": data.get("lengthBytes")} 549 | return {"success": True, "data": minimal} 550 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 551 | except Exception as e: 552 | return {"success": False, "message": f"get_sha error: {e}"} 553 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py: -------------------------------------------------------------------------------- ```python 1 | import base64 2 | import os 3 | from typing import Annotated, Any, Literal 4 | from urllib.parse import urlparse, unquote 5 | 6 | from mcp.server.fastmcp import FastMCP, Context 7 | 8 | from registry import mcp_for_unity_tool 9 | from unity_connection import send_command_with_retry 10 | 11 | 12 | def _split_uri(uri: str) -> tuple[str, str]: 13 | """Split an incoming URI or path into (name, directory) suitable for Unity. 14 | 15 | Rules: 16 | - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) 17 | - file://... → percent-decode, normalize, strip host and leading slashes, 18 | then, if any 'Assets' segment exists, return path relative to that 'Assets' root. 19 | Otherwise, fall back to original name/dir behavior. 20 | - plain paths → decode/normalize separators; if they contain an 'Assets' segment, 21 | return relative to 'Assets'. 22 | """ 23 | raw_path: str 24 | if uri.startswith("unity://path/"): 25 | raw_path = uri[len("unity://path/"):] 26 | elif uri.startswith("file://"): 27 | parsed = urlparse(uri) 28 | host = (parsed.netloc or "").strip() 29 | p = parsed.path or "" 30 | # UNC: file://server/share/... -> //server/share/... 31 | if host and host.lower() != "localhost": 32 | p = f"//{host}{p}" 33 | # Use percent-decoded path, preserving leading slashes 34 | raw_path = unquote(p) 35 | else: 36 | raw_path = uri 37 | 38 | # Percent-decode any residual encodings and normalize separators 39 | raw_path = unquote(raw_path).replace("\\", "/") 40 | # Strip leading slash only for Windows drive-letter forms like "/C:/..." 41 | if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": 42 | raw_path = raw_path[1:] 43 | 44 | # Normalize path (collapse ../, ./) 45 | norm = os.path.normpath(raw_path).replace("\\", "/") 46 | 47 | # If an 'Assets' segment exists, compute path relative to it (case-insensitive) 48 | parts = [p for p in norm.split("/") if p not in ("", ".")] 49 | idx = next((i for i, seg in enumerate(parts) 50 | if seg.lower() == "assets"), None) 51 | assets_rel = "/".join(parts[idx:]) if idx is not None else None 52 | 53 | effective_path = assets_rel if assets_rel else norm 54 | # For POSIX absolute paths outside Assets, drop the leading '/' 55 | # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). 56 | if effective_path.startswith("/"): 57 | effective_path = effective_path[1:] 58 | 59 | name = os.path.splitext(os.path.basename(effective_path))[0] 60 | directory = os.path.dirname(effective_path) 61 | return name, directory 62 | 63 | 64 | @mcp_for_unity_tool(description=( 65 | """Apply small text edits to a C# script identified by URI. 66 | IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! 67 | RECOMMENDED WORKFLOW: 68 | 1. First call resources/read with start_line/line_count to verify exact content 69 | 2. Count columns carefully (or use find_in_file to locate patterns) 70 | 3. Apply your edit with precise coordinates 71 | 4. Consider script_apply_edits with anchors for safer pattern-based replacements 72 | Notes: 73 | - For method/class operations, use script_apply_edits (safer, structured edits) 74 | - For pattern-based replacements, consider anchor operations in script_apply_edits 75 | - Lines, columns are 1-indexed 76 | - Tabs count as 1 column""" 77 | )) 78 | def apply_text_edits( 79 | ctx: Context, 80 | uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 81 | edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], 82 | precondition_sha256: Annotated[str, 83 | "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, 84 | strict: Annotated[bool, 85 | "Optional strict flag, used to enforce strict mode"] | None = None, 86 | options: Annotated[dict[str, Any], 87 | "Optional options, used to pass additional options to the script editor"] | None = None, 88 | ) -> dict[str, Any]: 89 | ctx.info(f"Processing apply_text_edits: {uri}") 90 | name, directory = _split_uri(uri) 91 | 92 | # Normalize common aliases/misuses for resilience: 93 | # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} 94 | # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} 95 | # If normalization is required, read current contents to map indices -> 1-based line/col. 96 | def _needs_normalization(arr: list[dict[str, Any]]) -> bool: 97 | for e in arr or []: 98 | if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): 99 | return True 100 | return False 101 | 102 | normalized_edits: list[dict[str, Any]] = [] 103 | warnings: list[str] = [] 104 | if _needs_normalization(edits): 105 | # Read file to support index->line/col conversion when needed 106 | read_resp = send_command_with_retry("manage_script", { 107 | "action": "read", 108 | "name": name, 109 | "path": directory, 110 | }) 111 | if not (isinstance(read_resp, dict) and read_resp.get("success")): 112 | return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} 113 | data = read_resp.get("data", {}) 114 | contents = data.get("contents") 115 | if not contents and data.get("contentsEncoded"): 116 | try: 117 | contents = base64.b64decode(data.get("encodedContents", "").encode( 118 | "utf-8")).decode("utf-8", "replace") 119 | except Exception: 120 | contents = contents or "" 121 | 122 | # Helper to map 0-based character index to 1-based line/col 123 | def line_col_from_index(idx: int) -> tuple[int, int]: 124 | if idx <= 0: 125 | return 1, 1 126 | # Count lines up to idx and position within line 127 | nl_count = contents.count("\n", 0, idx) 128 | line = nl_count + 1 129 | last_nl = contents.rfind("\n", 0, idx) 130 | col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 131 | return line, col 132 | 133 | for e in edits or []: 134 | e2 = dict(e) 135 | # Map text->newText if needed 136 | if "newText" not in e2 and "text" in e2: 137 | e2["newText"] = e2.pop("text") 138 | 139 | if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: 140 | # Guard: explicit fields must be 1-based. 141 | zero_based = False 142 | for k in ("startLine", "startCol", "endLine", "endCol"): 143 | try: 144 | if int(e2.get(k, 1)) < 1: 145 | zero_based = True 146 | except Exception: 147 | pass 148 | if zero_based: 149 | if strict: 150 | return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} 151 | # Normalize by clamping to 1 and warn 152 | for k in ("startLine", "startCol", "endLine", "endCol"): 153 | try: 154 | if int(e2.get(k, 1)) < 1: 155 | e2[k] = 1 156 | except Exception: 157 | pass 158 | warnings.append( 159 | "zero_based_explicit_fields_normalized") 160 | normalized_edits.append(e2) 161 | continue 162 | 163 | rng = e2.get("range") 164 | if isinstance(rng, dict): 165 | # LSP style: 0-based 166 | s = rng.get("start", {}) 167 | t = rng.get("end", {}) 168 | e2["startLine"] = int(s.get("line", 0)) + 1 169 | e2["startCol"] = int(s.get("character", 0)) + 1 170 | e2["endLine"] = int(t.get("line", 0)) + 1 171 | e2["endCol"] = int(t.get("character", 0)) + 1 172 | e2.pop("range", None) 173 | normalized_edits.append(e2) 174 | continue 175 | if isinstance(rng, (list, tuple)) and len(rng) == 2: 176 | try: 177 | a = int(rng[0]) 178 | b = int(rng[1]) 179 | if b < a: 180 | a, b = b, a 181 | sl, sc = line_col_from_index(a) 182 | el, ec = line_col_from_index(b) 183 | e2["startLine"] = sl 184 | e2["startCol"] = sc 185 | e2["endLine"] = el 186 | e2["endCol"] = ec 187 | e2.pop("range", None) 188 | normalized_edits.append(e2) 189 | continue 190 | except Exception: 191 | pass 192 | # Could not normalize this edit 193 | return { 194 | "success": False, 195 | "code": "missing_field", 196 | "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", 197 | "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} 198 | } 199 | else: 200 | # Even when edits appear already in explicit form, validate 1-based coordinates. 201 | normalized_edits = [] 202 | for e in edits or []: 203 | e2 = dict(e) 204 | has_all = all(k in e2 for k in ( 205 | "startLine", "startCol", "endLine", "endCol")) 206 | if has_all: 207 | zero_based = False 208 | for k in ("startLine", "startCol", "endLine", "endCol"): 209 | try: 210 | if int(e2.get(k, 1)) < 1: 211 | zero_based = True 212 | except Exception: 213 | pass 214 | if zero_based: 215 | if strict: 216 | return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} 217 | for k in ("startLine", "startCol", "endLine", "endCol"): 218 | try: 219 | if int(e2.get(k, 1)) < 1: 220 | e2[k] = 1 221 | except Exception: 222 | pass 223 | if "zero_based_explicit_fields_normalized" not in warnings: 224 | warnings.append( 225 | "zero_based_explicit_fields_normalized") 226 | normalized_edits.append(e2) 227 | 228 | # Preflight: detect overlapping ranges among normalized line/col spans 229 | def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: 230 | return ( 231 | int(e.get("startLine", 1)) if key_start else int( 232 | e.get("endLine", 1)), 233 | int(e.get("startCol", 1)) if key_start else int( 234 | e.get("endCol", 1)), 235 | ) 236 | 237 | def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: 238 | return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) 239 | 240 | # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. 241 | spans = [] 242 | for e in normalized_edits or []: 243 | try: 244 | s = _pos_tuple(e, True) 245 | t = _pos_tuple(e, False) 246 | if s != t: 247 | spans.append((s, t)) 248 | except Exception: 249 | # If coordinates missing or invalid, let the server validate later 250 | pass 251 | 252 | if spans: 253 | spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) 254 | for i in range(1, len(spans_sorted)): 255 | prev_end = spans_sorted[i-1][1] 256 | curr_start = spans_sorted[i][0] 257 | # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start 258 | if not _le(prev_end, curr_start): 259 | conflicts = [{ 260 | "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, 261 | "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, 262 | "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, 263 | "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, 264 | }] 265 | return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} 266 | 267 | # Note: Do not auto-compute precondition if missing; callers should supply it 268 | # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and 269 | # preserves existing call-count expectations in clients/tests. 270 | 271 | # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance 272 | opts: dict[str, Any] = dict(options or {}) 273 | try: 274 | if len(normalized_edits) > 1 and "applyMode" not in opts: 275 | opts["applyMode"] = "atomic" 276 | except Exception: 277 | pass 278 | # Support optional debug preview for span-by-span simulation without write 279 | if opts.get("debug_preview"): 280 | try: 281 | import difflib 282 | # Apply locally to preview final result 283 | lines = [] 284 | # Build an indexable original from a read if we normalized from read; otherwise skip 285 | prev = "" 286 | # We cannot guarantee file contents here without a read; return normalized spans only 287 | return { 288 | "success": True, 289 | "message": "Preview only (no write)", 290 | "data": { 291 | "normalizedEdits": normalized_edits, 292 | "preview": True 293 | } 294 | } 295 | except Exception as e: 296 | return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} 297 | 298 | params = { 299 | "action": "apply_text_edits", 300 | "name": name, 301 | "path": directory, 302 | "edits": normalized_edits, 303 | "precondition_sha256": precondition_sha256, 304 | "options": opts, 305 | } 306 | params = {k: v for k, v in params.items() if v is not None} 307 | resp = send_command_with_retry("manage_script", params) 308 | if isinstance(resp, dict): 309 | data = resp.setdefault("data", {}) 310 | data.setdefault("normalizedEdits", normalized_edits) 311 | if warnings: 312 | data.setdefault("warnings", warnings) 313 | if resp.get("success") and (options or {}).get("force_sentinel_reload"): 314 | # Optional: flip sentinel via menu if explicitly requested 315 | try: 316 | import threading 317 | import time 318 | import json 319 | import glob 320 | import os 321 | 322 | def _latest_status() -> dict | None: 323 | try: 324 | files = sorted(glob.glob(os.path.expanduser( 325 | "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) 326 | if not files: 327 | return None 328 | with open(files[0], "r") as f: 329 | return json.loads(f.read()) 330 | except Exception: 331 | return None 332 | 333 | def _flip_async(): 334 | try: 335 | time.sleep(0.1) 336 | st = _latest_status() 337 | if st and st.get("reloading"): 338 | return 339 | send_command_with_retry( 340 | "execute_menu_item", 341 | {"menuPath": "MCP/Flip Reload Sentinel"}, 342 | max_retries=0, 343 | retry_ms=0, 344 | ) 345 | except Exception: 346 | pass 347 | threading.Thread(target=_flip_async, daemon=True).start() 348 | except Exception: 349 | pass 350 | return resp 351 | return resp 352 | return {"success": False, "message": str(resp)} 353 | 354 | 355 | @mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) 356 | def create_script( 357 | ctx: Context, 358 | path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], 359 | contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], 360 | script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, 361 | namespace: Annotated[str, "Namespace for the script"] | None = None, 362 | ) -> dict[str, Any]: 363 | ctx.info(f"Processing create_script: {path}") 364 | name = os.path.splitext(os.path.basename(path))[0] 365 | directory = os.path.dirname(path) 366 | # Local validation to avoid round-trips on obviously bad input 367 | norm_path = os.path.normpath( 368 | (path or "").replace("\\", "/")).replace("\\", "/") 369 | if not directory or directory.split("/")[0].lower() != "assets": 370 | return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} 371 | if ".." in norm_path.split("/") or norm_path.startswith("/"): 372 | return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} 373 | if not name: 374 | return {"success": False, "code": "bad_path", "message": "path must include a script file name."} 375 | if not norm_path.lower().endswith(".cs"): 376 | return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} 377 | params: dict[str, Any] = { 378 | "action": "create", 379 | "name": name, 380 | "path": directory, 381 | "namespace": namespace, 382 | "scriptType": script_type, 383 | } 384 | if contents: 385 | params["encodedContents"] = base64.b64encode( 386 | contents.encode("utf-8")).decode("utf-8") 387 | params["contentsEncoded"] = True 388 | params = {k: v for k, v in params.items() if v is not None} 389 | resp = send_command_with_retry("manage_script", params) 390 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 391 | 392 | 393 | @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) 394 | def delete_script( 395 | ctx: Context, 396 | uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] 397 | ) -> dict[str, Any]: 398 | """Delete a C# script by URI.""" 399 | ctx.info(f"Processing delete_script: {uri}") 400 | name, directory = _split_uri(uri) 401 | if not directory or directory.split("/")[0].lower() != "assets": 402 | return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} 403 | params = {"action": "delete", "name": name, "path": directory} 404 | resp = send_command_with_retry("manage_script", params) 405 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 406 | 407 | 408 | @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) 409 | def validate_script( 410 | ctx: Context, 411 | uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 412 | level: Annotated[Literal['basic', 'standard'], 413 | "Validation level"] = "basic", 414 | include_diagnostics: Annotated[bool, 415 | "Include full diagnostics and summary"] = False 416 | ) -> dict[str, Any]: 417 | ctx.info(f"Processing validate_script: {uri}") 418 | name, directory = _split_uri(uri) 419 | if not directory or directory.split("/")[0].lower() != "assets": 420 | return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} 421 | if level not in ("basic", "standard"): 422 | return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} 423 | params = { 424 | "action": "validate", 425 | "name": name, 426 | "path": directory, 427 | "level": level, 428 | } 429 | resp = send_command_with_retry("manage_script", params) 430 | if isinstance(resp, dict) and resp.get("success"): 431 | diags = resp.get("data", {}).get("diagnostics", []) or [] 432 | warnings = sum(1 for d in diags if str( 433 | d.get("severity", "")).lower() == "warning") 434 | errors = sum(1 for d in diags if str( 435 | d.get("severity", "")).lower() in ("error", "fatal")) 436 | if include_diagnostics: 437 | return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} 438 | return {"success": True, "data": {"warnings": warnings, "errors": errors}} 439 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 440 | 441 | 442 | @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) 443 | def manage_script( 444 | ctx: Context, 445 | action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], 446 | name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], 447 | path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], 448 | contents: Annotated[str, "Contents of the script to create", 449 | "C# code for 'create'/'update'"] | None = None, 450 | script_type: Annotated[str, "Script type (e.g., 'C#')", 451 | "Type hint (e.g., 'MonoBehaviour')"] | None = None, 452 | namespace: Annotated[str, "Namespace for the script"] | None = None, 453 | ) -> dict[str, Any]: 454 | ctx.info(f"Processing manage_script: {action}") 455 | try: 456 | # Prepare parameters for Unity 457 | params = { 458 | "action": action, 459 | "name": name, 460 | "path": path, 461 | "namespace": namespace, 462 | "scriptType": script_type, 463 | } 464 | 465 | # Base64 encode the contents if they exist to avoid JSON escaping issues 466 | if contents: 467 | if action == 'create': 468 | params["encodedContents"] = base64.b64encode( 469 | contents.encode('utf-8')).decode('utf-8') 470 | params["contentsEncoded"] = True 471 | else: 472 | params["contents"] = contents 473 | 474 | params = {k: v for k, v in params.items() if v is not None} 475 | 476 | response = send_command_with_retry("manage_script", params) 477 | 478 | if isinstance(response, dict): 479 | if response.get("success"): 480 | if response.get("data", {}).get("contentsEncoded"): 481 | decoded_contents = base64.b64decode( 482 | response["data"]["encodedContents"]).decode('utf-8') 483 | response["data"]["contents"] = decoded_contents 484 | del response["data"]["encodedContents"] 485 | del response["data"]["contentsEncoded"] 486 | 487 | return { 488 | "success": True, 489 | "message": response.get("message", "Operation successful."), 490 | "data": response.get("data"), 491 | } 492 | return response 493 | 494 | return {"success": False, "message": str(response)} 495 | 496 | except Exception as e: 497 | return { 498 | "success": False, 499 | "message": f"Python error managing script: {str(e)}", 500 | } 501 | 502 | 503 | @mcp_for_unity_tool(description=( 504 | """Get manage_script capabilities (supported ops, limits, and guards). 505 | Returns: 506 | - ops: list of supported structured ops 507 | - text_ops: list of supported text ops 508 | - max_edit_payload_bytes: server edit payload cap 509 | - guards: header/using guard enabled flag""" 510 | )) 511 | def manage_script_capabilities(ctx: Context) -> dict[str, Any]: 512 | ctx.info("Processing manage_script_capabilities") 513 | try: 514 | # Keep in sync with server/Editor ManageScript implementation 515 | ops = [ 516 | "replace_class", "delete_class", "replace_method", "delete_method", 517 | "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" 518 | ] 519 | text_ops = ["replace_range", "regex_replace", "prepend", "append"] 520 | # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback 521 | max_edit_payload_bytes = 256 * 1024 522 | guards = {"using_guard": True} 523 | extras = {"get_sha": True} 524 | return {"success": True, "data": { 525 | "ops": ops, 526 | "text_ops": text_ops, 527 | "max_edit_payload_bytes": max_edit_payload_bytes, 528 | "guards": guards, 529 | "extras": extras, 530 | }} 531 | except Exception as e: 532 | return {"success": False, "error": f"capabilities error: {e}"} 533 | 534 | 535 | @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") 536 | def get_sha( 537 | ctx: Context, 538 | uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] 539 | ) -> dict[str, Any]: 540 | ctx.info(f"Processing get_sha: {uri}") 541 | try: 542 | name, directory = _split_uri(uri) 543 | params = {"action": "get_sha", "name": name, "path": directory} 544 | resp = send_command_with_retry("manage_script", params) 545 | if isinstance(resp, dict) and resp.get("success"): 546 | data = resp.get("data", {}) 547 | minimal = {"sha256": data.get( 548 | "sha256"), "lengthBytes": data.get("lengthBytes")} 549 | return {"success": True, "data": minimal} 550 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 551 | except Exception as e: 552 | return {"success": False, "message": f"get_sha error: {e}"} 553 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageEditor.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.IO; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditorInternal; // Required for tag management 8 | using UnityEditor.SceneManagement; 9 | using UnityEngine; 10 | using MCPForUnity.Editor.Helpers; 11 | 12 | namespace MCPForUnity.Editor.Tools 13 | { 14 | /// <summary> 15 | /// Handles operations related to controlling and querying the Unity Editor state, 16 | /// including managing Tags and Layers. 17 | /// </summary> 18 | [McpForUnityTool("manage_editor")] 19 | public static class ManageEditor 20 | { 21 | // Constant for starting user layer index 22 | private const int FirstUserLayerIndex = 8; 23 | 24 | // Constant for total layer count 25 | private const int TotalLayerCount = 32; 26 | 27 | /// <summary> 28 | /// Main handler for editor management actions. 29 | /// </summary> 30 | public static object HandleCommand(JObject @params) 31 | { 32 | string action = @params["action"]?.ToString().ToLower(); 33 | // Parameters for specific actions 34 | string tagName = @params["tagName"]?.ToString(); 35 | string layerName = @params["layerName"]?.ToString(); 36 | bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere 37 | 38 | if (string.IsNullOrEmpty(action)) 39 | { 40 | return Response.Error("Action parameter is required."); 41 | } 42 | 43 | // Route action 44 | switch (action) 45 | { 46 | // Play Mode Control 47 | case "play": 48 | try 49 | { 50 | if (!EditorApplication.isPlaying) 51 | { 52 | EditorApplication.isPlaying = true; 53 | return Response.Success("Entered play mode."); 54 | } 55 | return Response.Success("Already in play mode."); 56 | } 57 | catch (Exception e) 58 | { 59 | return Response.Error($"Error entering play mode: {e.Message}"); 60 | } 61 | case "pause": 62 | try 63 | { 64 | if (EditorApplication.isPlaying) 65 | { 66 | EditorApplication.isPaused = !EditorApplication.isPaused; 67 | return Response.Success( 68 | EditorApplication.isPaused ? "Game paused." : "Game resumed." 69 | ); 70 | } 71 | return Response.Error("Cannot pause/resume: Not in play mode."); 72 | } 73 | catch (Exception e) 74 | { 75 | return Response.Error($"Error pausing/resuming game: {e.Message}"); 76 | } 77 | case "stop": 78 | try 79 | { 80 | if (EditorApplication.isPlaying) 81 | { 82 | EditorApplication.isPlaying = false; 83 | return Response.Success("Exited play mode."); 84 | } 85 | return Response.Success("Already stopped (not in play mode)."); 86 | } 87 | catch (Exception e) 88 | { 89 | return Response.Error($"Error stopping play mode: {e.Message}"); 90 | } 91 | 92 | // Editor State/Info 93 | case "get_state": 94 | return GetEditorState(); 95 | case "get_project_root": 96 | return GetProjectRoot(); 97 | case "get_windows": 98 | return GetEditorWindows(); 99 | case "get_active_tool": 100 | return GetActiveTool(); 101 | case "get_selection": 102 | return GetSelection(); 103 | case "get_prefab_stage": 104 | return GetPrefabStageInfo(); 105 | case "set_active_tool": 106 | string toolName = @params["toolName"]?.ToString(); 107 | if (string.IsNullOrEmpty(toolName)) 108 | return Response.Error("'toolName' parameter required for set_active_tool."); 109 | return SetActiveTool(toolName); 110 | 111 | // Tag Management 112 | case "add_tag": 113 | if (string.IsNullOrEmpty(tagName)) 114 | return Response.Error("'tagName' parameter required for add_tag."); 115 | return AddTag(tagName); 116 | case "remove_tag": 117 | if (string.IsNullOrEmpty(tagName)) 118 | return Response.Error("'tagName' parameter required for remove_tag."); 119 | return RemoveTag(tagName); 120 | case "get_tags": 121 | return GetTags(); // Helper to list current tags 122 | 123 | // Layer Management 124 | case "add_layer": 125 | if (string.IsNullOrEmpty(layerName)) 126 | return Response.Error("'layerName' parameter required for add_layer."); 127 | return AddLayer(layerName); 128 | case "remove_layer": 129 | if (string.IsNullOrEmpty(layerName)) 130 | return Response.Error("'layerName' parameter required for remove_layer."); 131 | return RemoveLayer(layerName); 132 | case "get_layers": 133 | return GetLayers(); // Helper to list current layers 134 | 135 | // --- Settings (Example) --- 136 | // case "set_resolution": 137 | // int? width = @params["width"]?.ToObject<int?>(); 138 | // int? height = @params["height"]?.ToObject<int?>(); 139 | // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); 140 | // return SetGameViewResolution(width.Value, height.Value); 141 | // case "set_quality": 142 | // // Handle string name or int index 143 | // return SetQualityLevel(@params["qualityLevel"]); 144 | 145 | default: 146 | return Response.Error( 147 | $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." 148 | ); 149 | } 150 | } 151 | 152 | // --- Editor State/Info Methods --- 153 | private static object GetEditorState() 154 | { 155 | try 156 | { 157 | var state = new 158 | { 159 | isPlaying = EditorApplication.isPlaying, 160 | isPaused = EditorApplication.isPaused, 161 | isCompiling = EditorApplication.isCompiling, 162 | isUpdating = EditorApplication.isUpdating, 163 | applicationPath = EditorApplication.applicationPath, 164 | applicationContentsPath = EditorApplication.applicationContentsPath, 165 | timeSinceStartup = EditorApplication.timeSinceStartup, 166 | }; 167 | return Response.Success("Retrieved editor state.", state); 168 | } 169 | catch (Exception e) 170 | { 171 | return Response.Error($"Error getting editor state: {e.Message}"); 172 | } 173 | } 174 | 175 | private static object GetProjectRoot() 176 | { 177 | try 178 | { 179 | // Application.dataPath points to <Project>/Assets 180 | string assetsPath = Application.dataPath.Replace('\\', '/'); 181 | string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); 182 | if (string.IsNullOrEmpty(projectRoot)) 183 | { 184 | return Response.Error("Could not determine project root from Application.dataPath"); 185 | } 186 | return Response.Success("Project root resolved.", new { projectRoot }); 187 | } 188 | catch (Exception e) 189 | { 190 | return Response.Error($"Error getting project root: {e.Message}"); 191 | } 192 | } 193 | 194 | private static object GetEditorWindows() 195 | { 196 | try 197 | { 198 | // Get all types deriving from EditorWindow 199 | var windowTypes = AppDomain 200 | .CurrentDomain.GetAssemblies() 201 | .SelectMany(assembly => assembly.GetTypes()) 202 | .Where(type => type.IsSubclassOf(typeof(EditorWindow))) 203 | .ToList(); 204 | 205 | var openWindows = new List<object>(); 206 | 207 | // Find currently open instances 208 | // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows 209 | EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>(); 210 | 211 | foreach (EditorWindow window in allWindows) 212 | { 213 | if (window == null) 214 | continue; // Skip potentially destroyed windows 215 | 216 | try 217 | { 218 | openWindows.Add( 219 | new 220 | { 221 | title = window.titleContent.text, 222 | typeName = window.GetType().FullName, 223 | isFocused = EditorWindow.focusedWindow == window, 224 | position = new 225 | { 226 | x = window.position.x, 227 | y = window.position.y, 228 | width = window.position.width, 229 | height = window.position.height, 230 | }, 231 | instanceID = window.GetInstanceID(), 232 | } 233 | ); 234 | } 235 | catch (Exception ex) 236 | { 237 | Debug.LogWarning( 238 | $"Could not get info for window {window.GetType().Name}: {ex.Message}" 239 | ); 240 | } 241 | } 242 | 243 | return Response.Success("Retrieved list of open editor windows.", openWindows); 244 | } 245 | catch (Exception e) 246 | { 247 | return Response.Error($"Error getting editor windows: {e.Message}"); 248 | } 249 | } 250 | 251 | private static object GetPrefabStageInfo() 252 | { 253 | try 254 | { 255 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 256 | if (stage == null) 257 | { 258 | return Response.Success 259 | ("No prefab stage is currently open.", new { isOpen = false }); 260 | } 261 | 262 | return Response.Success( 263 | "Prefab stage info retrieved.", 264 | new 265 | { 266 | isOpen = true, 267 | assetPath = stage.assetPath, 268 | prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, 269 | mode = stage.mode.ToString(), 270 | isDirty = stage.scene.isDirty 271 | } 272 | ); 273 | } 274 | catch (Exception e) 275 | { 276 | return Response.Error($"Error getting prefab stage info: {e.Message}"); 277 | } 278 | } 279 | 280 | private static object GetActiveTool() 281 | { 282 | try 283 | { 284 | Tool currentTool = UnityEditor.Tools.current; 285 | string toolName = currentTool.ToString(); // Enum to string 286 | bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active 287 | string activeToolName = customToolActive 288 | ? EditorTools.GetActiveToolName() 289 | : toolName; // Get custom name if needed 290 | 291 | var toolInfo = new 292 | { 293 | activeTool = activeToolName, 294 | isCustom = customToolActive, 295 | pivotMode = UnityEditor.Tools.pivotMode.ToString(), 296 | pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), 297 | handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity 298 | handlePosition = UnityEditor.Tools.handlePosition, 299 | }; 300 | 301 | return Response.Success("Retrieved active tool information.", toolInfo); 302 | } 303 | catch (Exception e) 304 | { 305 | return Response.Error($"Error getting active tool: {e.Message}"); 306 | } 307 | } 308 | 309 | private static object SetActiveTool(string toolName) 310 | { 311 | try 312 | { 313 | Tool targetTool; 314 | if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse 315 | { 316 | // Check if it's a valid built-in tool 317 | if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool 318 | { 319 | UnityEditor.Tools.current = targetTool; 320 | return Response.Success($"Set active tool to '{targetTool}'."); 321 | } 322 | else 323 | { 324 | return Response.Error( 325 | $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." 326 | ); 327 | } 328 | } 329 | else 330 | { 331 | // Potentially try activating a custom tool by name here if needed 332 | // This often requires specific editor scripting knowledge for that tool. 333 | return Response.Error( 334 | $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." 335 | ); 336 | } 337 | } 338 | catch (Exception e) 339 | { 340 | return Response.Error($"Error setting active tool: {e.Message}"); 341 | } 342 | } 343 | 344 | private static object GetSelection() 345 | { 346 | try 347 | { 348 | var selectionInfo = new 349 | { 350 | activeObject = Selection.activeObject?.name, 351 | activeGameObject = Selection.activeGameObject?.name, 352 | activeTransform = Selection.activeTransform?.name, 353 | activeInstanceID = Selection.activeInstanceID, 354 | count = Selection.count, 355 | objects = Selection 356 | .objects.Select(obj => new 357 | { 358 | name = obj?.name, 359 | type = obj?.GetType().FullName, 360 | instanceID = obj?.GetInstanceID(), 361 | }) 362 | .ToList(), 363 | gameObjects = Selection 364 | .gameObjects.Select(go => new 365 | { 366 | name = go?.name, 367 | instanceID = go?.GetInstanceID(), 368 | }) 369 | .ToList(), 370 | assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view 371 | }; 372 | 373 | return Response.Success("Retrieved current selection details.", selectionInfo); 374 | } 375 | catch (Exception e) 376 | { 377 | return Response.Error($"Error getting selection: {e.Message}"); 378 | } 379 | } 380 | 381 | // --- Tag Management Methods --- 382 | 383 | private static object AddTag(string tagName) 384 | { 385 | if (string.IsNullOrWhiteSpace(tagName)) 386 | return Response.Error("Tag name cannot be empty or whitespace."); 387 | 388 | // Check if tag already exists 389 | if (InternalEditorUtility.tags.Contains(tagName)) 390 | { 391 | return Response.Error($"Tag '{tagName}' already exists."); 392 | } 393 | 394 | try 395 | { 396 | // Add the tag using the internal utility 397 | InternalEditorUtility.AddTag(tagName); 398 | // Force save assets to ensure the change persists in the TagManager asset 399 | AssetDatabase.SaveAssets(); 400 | return Response.Success($"Tag '{tagName}' added successfully."); 401 | } 402 | catch (Exception e) 403 | { 404 | return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); 405 | } 406 | } 407 | 408 | private static object RemoveTag(string tagName) 409 | { 410 | if (string.IsNullOrWhiteSpace(tagName)) 411 | return Response.Error("Tag name cannot be empty or whitespace."); 412 | if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) 413 | return Response.Error("Cannot remove the built-in 'Untagged' tag."); 414 | 415 | // Check if tag exists before attempting removal 416 | if (!InternalEditorUtility.tags.Contains(tagName)) 417 | { 418 | return Response.Error($"Tag '{tagName}' does not exist."); 419 | } 420 | 421 | try 422 | { 423 | // Remove the tag using the internal utility 424 | InternalEditorUtility.RemoveTag(tagName); 425 | // Force save assets 426 | AssetDatabase.SaveAssets(); 427 | return Response.Success($"Tag '{tagName}' removed successfully."); 428 | } 429 | catch (Exception e) 430 | { 431 | // Catch potential issues if the tag is somehow in use or removal fails 432 | return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); 433 | } 434 | } 435 | 436 | private static object GetTags() 437 | { 438 | try 439 | { 440 | string[] tags = InternalEditorUtility.tags; 441 | return Response.Success("Retrieved current tags.", tags); 442 | } 443 | catch (Exception e) 444 | { 445 | return Response.Error($"Failed to retrieve tags: {e.Message}"); 446 | } 447 | } 448 | 449 | // --- Layer Management Methods --- 450 | 451 | private static object AddLayer(string layerName) 452 | { 453 | if (string.IsNullOrWhiteSpace(layerName)) 454 | return Response.Error("Layer name cannot be empty or whitespace."); 455 | 456 | // Access the TagManager asset 457 | SerializedObject tagManager = GetTagManager(); 458 | if (tagManager == null) 459 | return Response.Error("Could not access TagManager asset."); 460 | 461 | SerializedProperty layersProp = tagManager.FindProperty("layers"); 462 | if (layersProp == null || !layersProp.isArray) 463 | return Response.Error("Could not find 'layers' property in TagManager."); 464 | 465 | // Check if layer name already exists (case-insensitive check recommended) 466 | for (int i = 0; i < TotalLayerCount; i++) 467 | { 468 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 469 | if ( 470 | layerSP != null 471 | && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) 472 | ) 473 | { 474 | return Response.Error($"Layer '{layerName}' already exists at index {i}."); 475 | } 476 | } 477 | 478 | // Find the first empty user layer slot (indices 8 to 31) 479 | int firstEmptyUserLayer = -1; 480 | for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) 481 | { 482 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 483 | if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) 484 | { 485 | firstEmptyUserLayer = i; 486 | break; 487 | } 488 | } 489 | 490 | if (firstEmptyUserLayer == -1) 491 | { 492 | return Response.Error("No empty User Layer slots available (8-31 are full)."); 493 | } 494 | 495 | // Assign the name to the found slot 496 | try 497 | { 498 | SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( 499 | firstEmptyUserLayer 500 | ); 501 | targetLayerSP.stringValue = layerName; 502 | // Apply the changes to the TagManager asset 503 | tagManager.ApplyModifiedProperties(); 504 | // Save assets to make sure it's written to disk 505 | AssetDatabase.SaveAssets(); 506 | return Response.Success( 507 | $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." 508 | ); 509 | } 510 | catch (Exception e) 511 | { 512 | return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); 513 | } 514 | } 515 | 516 | private static object RemoveLayer(string layerName) 517 | { 518 | if (string.IsNullOrWhiteSpace(layerName)) 519 | return Response.Error("Layer name cannot be empty or whitespace."); 520 | 521 | // Access the TagManager asset 522 | SerializedObject tagManager = GetTagManager(); 523 | if (tagManager == null) 524 | return Response.Error("Could not access TagManager asset."); 525 | 526 | SerializedProperty layersProp = tagManager.FindProperty("layers"); 527 | if (layersProp == null || !layersProp.isArray) 528 | return Response.Error("Could not find 'layers' property in TagManager."); 529 | 530 | // Find the layer by name (must be user layer) 531 | int layerIndexToRemove = -1; 532 | for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers 533 | { 534 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 535 | // Case-insensitive comparison is safer 536 | if ( 537 | layerSP != null 538 | && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) 539 | ) 540 | { 541 | layerIndexToRemove = i; 542 | break; 543 | } 544 | } 545 | 546 | if (layerIndexToRemove == -1) 547 | { 548 | return Response.Error($"User layer '{layerName}' not found."); 549 | } 550 | 551 | // Clear the name for that index 552 | try 553 | { 554 | SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( 555 | layerIndexToRemove 556 | ); 557 | targetLayerSP.stringValue = string.Empty; // Set to empty string to remove 558 | // Apply the changes 559 | tagManager.ApplyModifiedProperties(); 560 | // Save assets 561 | AssetDatabase.SaveAssets(); 562 | return Response.Success( 563 | $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." 564 | ); 565 | } 566 | catch (Exception e) 567 | { 568 | return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); 569 | } 570 | } 571 | 572 | private static object GetLayers() 573 | { 574 | try 575 | { 576 | var layers = new Dictionary<int, string>(); 577 | for (int i = 0; i < TotalLayerCount; i++) 578 | { 579 | string layerName = LayerMask.LayerToName(i); 580 | if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names 581 | { 582 | layers.Add(i, layerName); 583 | } 584 | } 585 | return Response.Success("Retrieved current named layers.", layers); 586 | } 587 | catch (Exception e) 588 | { 589 | return Response.Error($"Failed to retrieve layers: {e.Message}"); 590 | } 591 | } 592 | 593 | // --- Helper Methods --- 594 | 595 | /// <summary> 596 | /// Gets the SerializedObject for the TagManager asset. 597 | /// </summary> 598 | private static SerializedObject GetTagManager() 599 | { 600 | try 601 | { 602 | // Load the TagManager asset from the ProjectSettings folder 603 | UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( 604 | "ProjectSettings/TagManager.asset" 605 | ); 606 | if (tagManagerAssets == null || tagManagerAssets.Length == 0) 607 | { 608 | Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); 609 | return null; 610 | } 611 | // The first object in the asset file should be the TagManager 612 | return new SerializedObject(tagManagerAssets[0]); 613 | } 614 | catch (Exception e) 615 | { 616 | Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); 617 | return null; 618 | } 619 | } 620 | 621 | // --- Example Implementations for Settings --- 622 | /* 623 | private static object SetGameViewResolution(int width, int height) { ... } 624 | private static object SetQualityLevel(JToken qualityLevelToken) { ... } 625 | */ 626 | } 627 | 628 | // Helper class to get custom tool names (remains the same) 629 | internal static class EditorTools 630 | { 631 | public static string GetActiveToolName() 632 | { 633 | // This is a placeholder. Real implementation depends on how custom tools 634 | // are registered and tracked in the specific Unity project setup. 635 | // It might involve checking static variables, calling methods on specific tool managers, etc. 636 | if (UnityEditor.Tools.current == Tool.Custom) 637 | { 638 | // Example: Check a known custom tool manager 639 | // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; 640 | return "Unknown Custom Tool"; 641 | } 642 | return UnityEditor.Tools.current.ToString(); 643 | } 644 | } 645 | } 646 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageEditor.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.IO; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditorInternal; // Required for tag management 8 | using UnityEditor.SceneManagement; 9 | using UnityEngine; 10 | using MCPForUnity.Editor.Helpers; 11 | 12 | namespace MCPForUnity.Editor.Tools 13 | { 14 | /// <summary> 15 | /// Handles operations related to controlling and querying the Unity Editor state, 16 | /// including managing Tags and Layers. 17 | /// </summary> 18 | [McpForUnityTool("manage_editor")] 19 | public static class ManageEditor 20 | { 21 | // Constant for starting user layer index 22 | private const int FirstUserLayerIndex = 8; 23 | 24 | // Constant for total layer count 25 | private const int TotalLayerCount = 32; 26 | 27 | /// <summary> 28 | /// Main handler for editor management actions. 29 | /// </summary> 30 | public static object HandleCommand(JObject @params) 31 | { 32 | string action = @params["action"]?.ToString().ToLower(); 33 | // Parameters for specific actions 34 | string tagName = @params["tagName"]?.ToString(); 35 | string layerName = @params["layerName"]?.ToString(); 36 | bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere 37 | 38 | if (string.IsNullOrEmpty(action)) 39 | { 40 | return Response.Error("Action parameter is required."); 41 | } 42 | 43 | // Route action 44 | switch (action) 45 | { 46 | // Play Mode Control 47 | case "play": 48 | try 49 | { 50 | if (!EditorApplication.isPlaying) 51 | { 52 | EditorApplication.isPlaying = true; 53 | return Response.Success("Entered play mode."); 54 | } 55 | return Response.Success("Already in play mode."); 56 | } 57 | catch (Exception e) 58 | { 59 | return Response.Error($"Error entering play mode: {e.Message}"); 60 | } 61 | case "pause": 62 | try 63 | { 64 | if (EditorApplication.isPlaying) 65 | { 66 | EditorApplication.isPaused = !EditorApplication.isPaused; 67 | return Response.Success( 68 | EditorApplication.isPaused ? "Game paused." : "Game resumed." 69 | ); 70 | } 71 | return Response.Error("Cannot pause/resume: Not in play mode."); 72 | } 73 | catch (Exception e) 74 | { 75 | return Response.Error($"Error pausing/resuming game: {e.Message}"); 76 | } 77 | case "stop": 78 | try 79 | { 80 | if (EditorApplication.isPlaying) 81 | { 82 | EditorApplication.isPlaying = false; 83 | return Response.Success("Exited play mode."); 84 | } 85 | return Response.Success("Already stopped (not in play mode)."); 86 | } 87 | catch (Exception e) 88 | { 89 | return Response.Error($"Error stopping play mode: {e.Message}"); 90 | } 91 | 92 | // Editor State/Info 93 | case "get_state": 94 | return GetEditorState(); 95 | case "get_project_root": 96 | return GetProjectRoot(); 97 | case "get_windows": 98 | return GetEditorWindows(); 99 | case "get_active_tool": 100 | return GetActiveTool(); 101 | case "get_selection": 102 | return GetSelection(); 103 | case "get_prefab_stage": 104 | return GetPrefabStageInfo(); 105 | case "set_active_tool": 106 | string toolName = @params["toolName"]?.ToString(); 107 | if (string.IsNullOrEmpty(toolName)) 108 | return Response.Error("'toolName' parameter required for set_active_tool."); 109 | return SetActiveTool(toolName); 110 | 111 | // Tag Management 112 | case "add_tag": 113 | if (string.IsNullOrEmpty(tagName)) 114 | return Response.Error("'tagName' parameter required for add_tag."); 115 | return AddTag(tagName); 116 | case "remove_tag": 117 | if (string.IsNullOrEmpty(tagName)) 118 | return Response.Error("'tagName' parameter required for remove_tag."); 119 | return RemoveTag(tagName); 120 | case "get_tags": 121 | return GetTags(); // Helper to list current tags 122 | 123 | // Layer Management 124 | case "add_layer": 125 | if (string.IsNullOrEmpty(layerName)) 126 | return Response.Error("'layerName' parameter required for add_layer."); 127 | return AddLayer(layerName); 128 | case "remove_layer": 129 | if (string.IsNullOrEmpty(layerName)) 130 | return Response.Error("'layerName' parameter required for remove_layer."); 131 | return RemoveLayer(layerName); 132 | case "get_layers": 133 | return GetLayers(); // Helper to list current layers 134 | 135 | // --- Settings (Example) --- 136 | // case "set_resolution": 137 | // int? width = @params["width"]?.ToObject<int?>(); 138 | // int? height = @params["height"]?.ToObject<int?>(); 139 | // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); 140 | // return SetGameViewResolution(width.Value, height.Value); 141 | // case "set_quality": 142 | // // Handle string name or int index 143 | // return SetQualityLevel(@params["qualityLevel"]); 144 | 145 | default: 146 | return Response.Error( 147 | $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." 148 | ); 149 | } 150 | } 151 | 152 | // --- Editor State/Info Methods --- 153 | private static object GetEditorState() 154 | { 155 | try 156 | { 157 | var state = new 158 | { 159 | isPlaying = EditorApplication.isPlaying, 160 | isPaused = EditorApplication.isPaused, 161 | isCompiling = EditorApplication.isCompiling, 162 | isUpdating = EditorApplication.isUpdating, 163 | applicationPath = EditorApplication.applicationPath, 164 | applicationContentsPath = EditorApplication.applicationContentsPath, 165 | timeSinceStartup = EditorApplication.timeSinceStartup, 166 | }; 167 | return Response.Success("Retrieved editor state.", state); 168 | } 169 | catch (Exception e) 170 | { 171 | return Response.Error($"Error getting editor state: {e.Message}"); 172 | } 173 | } 174 | 175 | private static object GetProjectRoot() 176 | { 177 | try 178 | { 179 | // Application.dataPath points to <Project>/Assets 180 | string assetsPath = Application.dataPath.Replace('\\', '/'); 181 | string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); 182 | if (string.IsNullOrEmpty(projectRoot)) 183 | { 184 | return Response.Error("Could not determine project root from Application.dataPath"); 185 | } 186 | return Response.Success("Project root resolved.", new { projectRoot }); 187 | } 188 | catch (Exception e) 189 | { 190 | return Response.Error($"Error getting project root: {e.Message}"); 191 | } 192 | } 193 | 194 | private static object GetEditorWindows() 195 | { 196 | try 197 | { 198 | // Get all types deriving from EditorWindow 199 | var windowTypes = AppDomain 200 | .CurrentDomain.GetAssemblies() 201 | .SelectMany(assembly => assembly.GetTypes()) 202 | .Where(type => type.IsSubclassOf(typeof(EditorWindow))) 203 | .ToList(); 204 | 205 | var openWindows = new List<object>(); 206 | 207 | // Find currently open instances 208 | // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows 209 | EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll<EditorWindow>(); 210 | 211 | foreach (EditorWindow window in allWindows) 212 | { 213 | if (window == null) 214 | continue; // Skip potentially destroyed windows 215 | 216 | try 217 | { 218 | openWindows.Add( 219 | new 220 | { 221 | title = window.titleContent.text, 222 | typeName = window.GetType().FullName, 223 | isFocused = EditorWindow.focusedWindow == window, 224 | position = new 225 | { 226 | x = window.position.x, 227 | y = window.position.y, 228 | width = window.position.width, 229 | height = window.position.height, 230 | }, 231 | instanceID = window.GetInstanceID(), 232 | } 233 | ); 234 | } 235 | catch (Exception ex) 236 | { 237 | Debug.LogWarning( 238 | $"Could not get info for window {window.GetType().Name}: {ex.Message}" 239 | ); 240 | } 241 | } 242 | 243 | return Response.Success("Retrieved list of open editor windows.", openWindows); 244 | } 245 | catch (Exception e) 246 | { 247 | return Response.Error($"Error getting editor windows: {e.Message}"); 248 | } 249 | } 250 | 251 | private static object GetPrefabStageInfo() 252 | { 253 | try 254 | { 255 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 256 | if (stage == null) 257 | { 258 | return Response.Success 259 | ("No prefab stage is currently open.", new { isOpen = false }); 260 | } 261 | 262 | return Response.Success( 263 | "Prefab stage info retrieved.", 264 | new 265 | { 266 | isOpen = true, 267 | assetPath = stage.assetPath, 268 | prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, 269 | mode = stage.mode.ToString(), 270 | isDirty = stage.scene.isDirty 271 | } 272 | ); 273 | } 274 | catch (Exception e) 275 | { 276 | return Response.Error($"Error getting prefab stage info: {e.Message}"); 277 | } 278 | } 279 | 280 | private static object GetActiveTool() 281 | { 282 | try 283 | { 284 | Tool currentTool = UnityEditor.Tools.current; 285 | string toolName = currentTool.ToString(); // Enum to string 286 | bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active 287 | string activeToolName = customToolActive 288 | ? EditorTools.GetActiveToolName() 289 | : toolName; // Get custom name if needed 290 | 291 | var toolInfo = new 292 | { 293 | activeTool = activeToolName, 294 | isCustom = customToolActive, 295 | pivotMode = UnityEditor.Tools.pivotMode.ToString(), 296 | pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), 297 | handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity 298 | handlePosition = UnityEditor.Tools.handlePosition, 299 | }; 300 | 301 | return Response.Success("Retrieved active tool information.", toolInfo); 302 | } 303 | catch (Exception e) 304 | { 305 | return Response.Error($"Error getting active tool: {e.Message}"); 306 | } 307 | } 308 | 309 | private static object SetActiveTool(string toolName) 310 | { 311 | try 312 | { 313 | Tool targetTool; 314 | if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse 315 | { 316 | // Check if it's a valid built-in tool 317 | if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool 318 | { 319 | UnityEditor.Tools.current = targetTool; 320 | return Response.Success($"Set active tool to '{targetTool}'."); 321 | } 322 | else 323 | { 324 | return Response.Error( 325 | $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." 326 | ); 327 | } 328 | } 329 | else 330 | { 331 | // Potentially try activating a custom tool by name here if needed 332 | // This often requires specific editor scripting knowledge for that tool. 333 | return Response.Error( 334 | $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." 335 | ); 336 | } 337 | } 338 | catch (Exception e) 339 | { 340 | return Response.Error($"Error setting active tool: {e.Message}"); 341 | } 342 | } 343 | 344 | private static object GetSelection() 345 | { 346 | try 347 | { 348 | var selectionInfo = new 349 | { 350 | activeObject = Selection.activeObject?.name, 351 | activeGameObject = Selection.activeGameObject?.name, 352 | activeTransform = Selection.activeTransform?.name, 353 | activeInstanceID = Selection.activeInstanceID, 354 | count = Selection.count, 355 | objects = Selection 356 | .objects.Select(obj => new 357 | { 358 | name = obj?.name, 359 | type = obj?.GetType().FullName, 360 | instanceID = obj?.GetInstanceID(), 361 | }) 362 | .ToList(), 363 | gameObjects = Selection 364 | .gameObjects.Select(go => new 365 | { 366 | name = go?.name, 367 | instanceID = go?.GetInstanceID(), 368 | }) 369 | .ToList(), 370 | assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view 371 | }; 372 | 373 | return Response.Success("Retrieved current selection details.", selectionInfo); 374 | } 375 | catch (Exception e) 376 | { 377 | return Response.Error($"Error getting selection: {e.Message}"); 378 | } 379 | } 380 | 381 | // --- Tag Management Methods --- 382 | 383 | private static object AddTag(string tagName) 384 | { 385 | if (string.IsNullOrWhiteSpace(tagName)) 386 | return Response.Error("Tag name cannot be empty or whitespace."); 387 | 388 | // Check if tag already exists 389 | if (InternalEditorUtility.tags.Contains(tagName)) 390 | { 391 | return Response.Error($"Tag '{tagName}' already exists."); 392 | } 393 | 394 | try 395 | { 396 | // Add the tag using the internal utility 397 | InternalEditorUtility.AddTag(tagName); 398 | // Force save assets to ensure the change persists in the TagManager asset 399 | AssetDatabase.SaveAssets(); 400 | return Response.Success($"Tag '{tagName}' added successfully."); 401 | } 402 | catch (Exception e) 403 | { 404 | return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); 405 | } 406 | } 407 | 408 | private static object RemoveTag(string tagName) 409 | { 410 | if (string.IsNullOrWhiteSpace(tagName)) 411 | return Response.Error("Tag name cannot be empty or whitespace."); 412 | if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) 413 | return Response.Error("Cannot remove the built-in 'Untagged' tag."); 414 | 415 | // Check if tag exists before attempting removal 416 | if (!InternalEditorUtility.tags.Contains(tagName)) 417 | { 418 | return Response.Error($"Tag '{tagName}' does not exist."); 419 | } 420 | 421 | try 422 | { 423 | // Remove the tag using the internal utility 424 | InternalEditorUtility.RemoveTag(tagName); 425 | // Force save assets 426 | AssetDatabase.SaveAssets(); 427 | return Response.Success($"Tag '{tagName}' removed successfully."); 428 | } 429 | catch (Exception e) 430 | { 431 | // Catch potential issues if the tag is somehow in use or removal fails 432 | return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); 433 | } 434 | } 435 | 436 | private static object GetTags() 437 | { 438 | try 439 | { 440 | string[] tags = InternalEditorUtility.tags; 441 | return Response.Success("Retrieved current tags.", tags); 442 | } 443 | catch (Exception e) 444 | { 445 | return Response.Error($"Failed to retrieve tags: {e.Message}"); 446 | } 447 | } 448 | 449 | // --- Layer Management Methods --- 450 | 451 | private static object AddLayer(string layerName) 452 | { 453 | if (string.IsNullOrWhiteSpace(layerName)) 454 | return Response.Error("Layer name cannot be empty or whitespace."); 455 | 456 | // Access the TagManager asset 457 | SerializedObject tagManager = GetTagManager(); 458 | if (tagManager == null) 459 | return Response.Error("Could not access TagManager asset."); 460 | 461 | SerializedProperty layersProp = tagManager.FindProperty("layers"); 462 | if (layersProp == null || !layersProp.isArray) 463 | return Response.Error("Could not find 'layers' property in TagManager."); 464 | 465 | // Check if layer name already exists (case-insensitive check recommended) 466 | for (int i = 0; i < TotalLayerCount; i++) 467 | { 468 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 469 | if ( 470 | layerSP != null 471 | && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) 472 | ) 473 | { 474 | return Response.Error($"Layer '{layerName}' already exists at index {i}."); 475 | } 476 | } 477 | 478 | // Find the first empty user layer slot (indices 8 to 31) 479 | int firstEmptyUserLayer = -1; 480 | for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) 481 | { 482 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 483 | if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) 484 | { 485 | firstEmptyUserLayer = i; 486 | break; 487 | } 488 | } 489 | 490 | if (firstEmptyUserLayer == -1) 491 | { 492 | return Response.Error("No empty User Layer slots available (8-31 are full)."); 493 | } 494 | 495 | // Assign the name to the found slot 496 | try 497 | { 498 | SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( 499 | firstEmptyUserLayer 500 | ); 501 | targetLayerSP.stringValue = layerName; 502 | // Apply the changes to the TagManager asset 503 | tagManager.ApplyModifiedProperties(); 504 | // Save assets to make sure it's written to disk 505 | AssetDatabase.SaveAssets(); 506 | return Response.Success( 507 | $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." 508 | ); 509 | } 510 | catch (Exception e) 511 | { 512 | return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); 513 | } 514 | } 515 | 516 | private static object RemoveLayer(string layerName) 517 | { 518 | if (string.IsNullOrWhiteSpace(layerName)) 519 | return Response.Error("Layer name cannot be empty or whitespace."); 520 | 521 | // Access the TagManager asset 522 | SerializedObject tagManager = GetTagManager(); 523 | if (tagManager == null) 524 | return Response.Error("Could not access TagManager asset."); 525 | 526 | SerializedProperty layersProp = tagManager.FindProperty("layers"); 527 | if (layersProp == null || !layersProp.isArray) 528 | return Response.Error("Could not find 'layers' property in TagManager."); 529 | 530 | // Find the layer by name (must be user layer) 531 | int layerIndexToRemove = -1; 532 | for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers 533 | { 534 | SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); 535 | // Case-insensitive comparison is safer 536 | if ( 537 | layerSP != null 538 | && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) 539 | ) 540 | { 541 | layerIndexToRemove = i; 542 | break; 543 | } 544 | } 545 | 546 | if (layerIndexToRemove == -1) 547 | { 548 | return Response.Error($"User layer '{layerName}' not found."); 549 | } 550 | 551 | // Clear the name for that index 552 | try 553 | { 554 | SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( 555 | layerIndexToRemove 556 | ); 557 | targetLayerSP.stringValue = string.Empty; // Set to empty string to remove 558 | // Apply the changes 559 | tagManager.ApplyModifiedProperties(); 560 | // Save assets 561 | AssetDatabase.SaveAssets(); 562 | return Response.Success( 563 | $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." 564 | ); 565 | } 566 | catch (Exception e) 567 | { 568 | return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); 569 | } 570 | } 571 | 572 | private static object GetLayers() 573 | { 574 | try 575 | { 576 | var layers = new Dictionary<int, string>(); 577 | for (int i = 0; i < TotalLayerCount; i++) 578 | { 579 | string layerName = LayerMask.LayerToName(i); 580 | if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names 581 | { 582 | layers.Add(i, layerName); 583 | } 584 | } 585 | return Response.Success("Retrieved current named layers.", layers); 586 | } 587 | catch (Exception e) 588 | { 589 | return Response.Error($"Failed to retrieve layers: {e.Message}"); 590 | } 591 | } 592 | 593 | // --- Helper Methods --- 594 | 595 | /// <summary> 596 | /// Gets the SerializedObject for the TagManager asset. 597 | /// </summary> 598 | private static SerializedObject GetTagManager() 599 | { 600 | try 601 | { 602 | // Load the TagManager asset from the ProjectSettings folder 603 | UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( 604 | "ProjectSettings/TagManager.asset" 605 | ); 606 | if (tagManagerAssets == null || tagManagerAssets.Length == 0) 607 | { 608 | Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); 609 | return null; 610 | } 611 | // The first object in the asset file should be the TagManager 612 | return new SerializedObject(tagManagerAssets[0]); 613 | } 614 | catch (Exception e) 615 | { 616 | Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); 617 | return null; 618 | } 619 | } 620 | 621 | // --- Example Implementations for Settings --- 622 | /* 623 | private static object SetGameViewResolution(int width, int height) { ... } 624 | private static object SetQualityLevel(JToken qualityLevelToken) { ... } 625 | */ 626 | } 627 | 628 | // Helper class to get custom tool names (remains the same) 629 | internal static class EditorTools 630 | { 631 | public static string GetActiveToolName() 632 | { 633 | // This is a placeholder. Real implementation depends on how custom tools 634 | // are registered and tracked in the specific Unity project setup. 635 | // It might involve checking static variables, calling methods on specific tool managers, etc. 636 | if (UnityEditor.Tools.current == Tool.Custom) 637 | { 638 | // Example: Check a known custom tool manager 639 | // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; 640 | return "Unknown Custom Tool"; 641 | } 642 | return UnityEditor.Tools.current.ToString(); 643 | } 644 | } 645 | } 646 | ```