This is page 6 of 13. Use http://codebase.md/justinpbarnett/unity-mcp?lines=false&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/Tools/ManageScene.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using MCPForUnity.Editor.Helpers; // For Response class namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles scene management operations like loading, saving, creating, and querying hierarchy. /// </summary> [McpForUnityTool("manage_scene")] public static class ManageScene { private sealed class SceneCommand { public string action { get; set; } = string.Empty; public string name { get; set; } = string.Empty; public string path { get; set; } = string.Empty; public int? buildIndex { get; set; } } private static SceneCommand ToSceneCommand(JObject p) { if (p == null) return new SceneCommand(); int? BI(JToken t) { if (t == null || t.Type == JTokenType.Null) return null; var s = t.ToString().Trim(); if (s.Length == 0) return null; if (int.TryParse(s, out var i)) return i; if (double.TryParse(s, out var d)) return (int)d; return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null; } return new SceneCommand { action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), name = p["name"]?.ToString() ?? string.Empty, path = p["path"]?.ToString() ?? string.Empty, buildIndex = BI(p["buildIndex"] ?? p["build_index"]) }; } /// <summary> /// Main handler for scene management actions. /// </summary> public static object HandleCommand(JObject @params) { try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } var cmd = ToSceneCommand(@params); string action = cmd.action; string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ int? buildIndex = cmd.buildIndex; // bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension // Ensure path is relative to Assets/, removing any leading "Assets/" string relativeDir = path ?? string.Empty; if (!string.IsNullOrEmpty(relativeDir)) { relativeDir = relativeDir.Replace('\\', '/').Trim('/'); if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); } } // Apply default *after* sanitizing, using the original path variable for the check if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness { relativeDir = "Scenes"; // Default relative directory } if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) string fullPath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine(fullPathDir, sceneFileName); // Ensure relativePath always starts with "Assets/" and uses forward slashes string relativePath = string.IsNullOrEmpty(sceneFileName) ? null : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); // Ensure directory exists for 'create' if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) { try { Directory.CreateDirectory(fullPathDir); } catch (Exception e) { return Response.Error( $"Could not create directory '{fullPathDir}': {e.Message}" ); } } // Route action try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } switch (action) { case "create": if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) return Response.Error( "'name' and 'path' parameters are required for 'create' action." ); return CreateScene(fullPath, relativePath); case "load": // Loading can be done by path/name or build index if (!string.IsNullOrEmpty(relativePath)) return LoadScene(relativePath); else if (buildIndex.HasValue) return LoadScene(buildIndex.Value); else return Response.Error( "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." ); case "save": // Save current scene, optionally to a new path return SaveScene(fullPath, relativePath); case "get_hierarchy": try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } var gh = GetSceneHierarchy(); try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } return gh; case "get_active": try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } var ga = GetActiveSceneInfo(); try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } return ga; case "get_build_settings": return GetBuildSettingsScenes(); // Add cases for modifying build settings, additive loading, unloading etc. default: return Response.Error( $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." ); } } private static object CreateScene(string fullPath, string relativePath) { if (File.Exists(fullPath)) { return Response.Error($"Scene already exists at '{relativePath}'."); } try { // Create a new empty scene Scene newScene = EditorSceneManager.NewScene( NewSceneSetup.EmptyScene, NewSceneMode.Single ); // Save it to the specified path bool saved = EditorSceneManager.SaveScene(newScene, relativePath); if (saved) { AssetDatabase.Refresh(); // Ensure Unity sees the new scene file return Response.Success( $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", new { path = relativePath } ); } else { // If SaveScene fails, it might leave an untitled scene open. // Optionally try to close it, but be cautious. return Response.Error($"Failed to save new scene to '{relativePath}'."); } } catch (Exception e) { return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); } } private static object LoadScene(string relativePath) { if ( !File.Exists( Path.Combine( Application.dataPath.Substring( 0, Application.dataPath.Length - "Assets".Length ), relativePath ) ) ) { return Response.Error($"Scene file not found at '{relativePath}'."); } // Check for unsaved changes in the current scene if (EditorSceneManager.GetActiveScene().isDirty) { // Optionally prompt the user or save automatically before loading return Response.Error( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); // if (!saveOK) return Response.Error("Load cancelled by user."); } try { EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); return Response.Success( $"Scene '{relativePath}' loaded successfully.", new { path = relativePath, name = Path.GetFileNameWithoutExtension(relativePath), } ); } catch (Exception e) { return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); } } private static object LoadScene(int buildIndex) { if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) { return Response.Error( $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." ); } // Check for unsaved changes if (EditorSceneManager.GetActiveScene().isDirty) { return Response.Error( "Current scene has unsaved changes. Please save or discard changes before loading a new scene." ); } try { string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); return Response.Success( $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", new { path = scenePath, name = Path.GetFileNameWithoutExtension(scenePath), buildIndex = buildIndex, } ); } catch (Exception e) { return Response.Error( $"Error loading scene with build index {buildIndex}: {e.Message}" ); } } private static object SaveScene(string fullPath, string relativePath) { try { Scene currentScene = EditorSceneManager.GetActiveScene(); if (!currentScene.IsValid()) { return Response.Error("No valid scene is currently active to save."); } bool saved; string finalPath = currentScene.path; // Path where it was last saved or will be saved if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) { // Save As... // Ensure directory exists string dir = Path.GetDirectoryName(fullPath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); saved = EditorSceneManager.SaveScene(currentScene, relativePath); finalPath = relativePath; } else { // Save (overwrite existing or save untitled) if (string.IsNullOrEmpty(currentScene.path)) { // Scene is untitled, needs a path return Response.Error( "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." ); } saved = EditorSceneManager.SaveScene(currentScene); } if (saved) { AssetDatabase.Refresh(); return Response.Success( $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", new { path = finalPath, name = currentScene.name } ); } else { return Response.Error($"Failed to save scene '{currentScene.name}'."); } } catch (Exception e) { return Response.Error($"Error saving scene: {e.Message}"); } } private static object GetActiveSceneInfo() { try { try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } Scene activeScene = EditorSceneManager.GetActiveScene(); try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid()) { return Response.Error("No active scene found."); } var sceneInfo = new { name = activeScene.name, path = activeScene.path, buildIndex = activeScene.buildIndex, // -1 if not in build settings isDirty = activeScene.isDirty, isLoaded = activeScene.isLoaded, rootCount = activeScene.rootCount, }; return Response.Success("Retrieved active scene information.", sceneInfo); } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } return Response.Error($"Error getting active scene info: {e.Message}"); } } private static object GetBuildSettingsScenes() { try { var scenes = new List<object>(); for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) { var scene = EditorBuildSettings.scenes[i]; scenes.Add( new { path = scene.path, guid = scene.guid.ToString(), enabled = scene.enabled, buildIndex = i, // Actual build index considering only enabled scenes might differ } ); } return Response.Success("Retrieved scenes from Build Settings.", scenes); } catch (Exception e) { return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); } } private static object GetSceneHierarchy() { try { try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } Scene activeScene = EditorSceneManager.GetActiveScene(); try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } if (!activeScene.IsValid() || !activeScene.isLoaded) { return Response.Error( "No valid and loaded scene is active to get hierarchy from." ); } try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } GameObject[] rootObjects = activeScene.GetRootGameObjects(); try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); var resp = Response.Success( $"Retrieved hierarchy for scene '{activeScene.name}'.", hierarchy ); try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } return resp; } catch (Exception e) { try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } return Response.Error($"Error getting scene hierarchy: {e.Message}"); } } /// <summary> /// Recursively builds a data representation of a GameObject and its children. /// </summary> private static object GetGameObjectDataRecursive(GameObject go) { if (go == null) return null; var childrenData = new List<object>(); foreach (Transform child in go.transform) { childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); } var gameObjectData = new Dictionary<string, object> { { "name", go.name }, { "activeSelf", go.activeSelf }, { "activeInHierarchy", go.activeInHierarchy }, { "tag", go.tag }, { "layer", go.layer }, { "isStatic", go.isStatic }, { "instanceID", go.GetInstanceID() }, // Useful unique identifier { "transform", new { position = new { x = go.transform.localPosition.x, y = go.transform.localPosition.y, z = go.transform.localPosition.z, }, rotation = new { x = go.transform.localRotation.eulerAngles.x, y = go.transform.localRotation.eulerAngles.y, z = go.transform.localRotation.eulerAngles.z, }, // Euler for simplicity scale = new { x = go.transform.localScale.x, y = go.transform.localScale.y, z = go.transform.localScale.z, }, } }, { "children", childrenData }, }; return gameObjectData; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/ClientConfigurationService.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using System.Linq; using System.Runtime.InteropServices; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using Newtonsoft.Json; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Services { /// <summary> /// Implementation of client configuration service /// </summary> public class ClientConfigurationService : IClientConfigurationService { private readonly Data.McpClients mcpClients = new(); public void ConfigureClient(McpClient client) { try { string configPath = McpConfigurationHelper.GetClientConfigPath(client); McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { throw new InvalidOperationException("Server not found. Please use manual configuration or set server path in Advanced Settings."); } string result = client.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); if (result == "Configured successfully") { client.SetStatus(McpStatus.Configured); Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: {client.name} configured successfully"); } else { Debug.LogWarning($"Configuration completed with message: {result}"); } CheckClientStatus(client); } catch (Exception ex) { Debug.LogError($"Failed to configure {client.name}: {ex.Message}"); throw; } } public ClientConfigurationSummary ConfigureAllDetectedClients() { var summary = new ClientConfigurationSummary(); var pathService = MCPServiceLocator.Paths; foreach (var client in mcpClients.clients) { try { // Skip if already configured CheckClientStatus(client, attemptAutoRewrite: false); if (client.status == McpStatus.Configured) { summary.SkippedCount++; summary.Messages.Add($"✓ {client.name}: Already configured"); continue; } // Check if required tools are available if (client.mcpType == McpTypes.ClaudeCode) { if (!pathService.IsClaudeCliDetected()) { summary.SkippedCount++; summary.Messages.Add($"➜ {client.name}: Claude CLI not found"); continue; } RegisterClaudeCode(); summary.SuccessCount++; summary.Messages.Add($"✓ {client.name}: Registered successfully"); } else { // Other clients require UV if (!pathService.IsUvDetected()) { summary.SkippedCount++; summary.Messages.Add($"➜ {client.name}: UV not found"); continue; } ConfigureClient(client); summary.SuccessCount++; summary.Messages.Add($"✓ {client.name}: Configured successfully"); } } catch (Exception ex) { summary.FailureCount++; summary.Messages.Add($"⚠ {client.name}: {ex.Message}"); } } return summary; } public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) { var previousStatus = client.status; try { // Special handling for Claude Code if (client.mcpType == McpTypes.ClaudeCode) { CheckClaudeCodeConfiguration(client); return client.status != previousStatus; } string configPath = McpConfigurationHelper.GetClientConfigPath(client); if (!File.Exists(configPath)) { client.SetStatus(McpStatus.NotConfigured); return client.status != previousStatus; } string configJson = File.ReadAllText(configPath); string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); // Check configuration based on client type string[] args = null; bool configExists = false; switch (client.mcpType) { case McpTypes.VSCode: dynamic vsConfig = JsonConvert.DeserializeObject(configJson); if (vsConfig?.servers?.unityMCP != null) { args = vsConfig.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } else if (vsConfig?.mcp?.servers?.unityMCP != null) { args = vsConfig.mcp.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } break; case McpTypes.Codex: if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) { args = codexArgs; configExists = true; } break; default: McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { args = standardConfig.mcpServers.unityMCP.args; configExists = true; } break; } if (configExists) { string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); if (matches) { client.SetStatus(McpStatus.Configured); } else if (attemptAutoRewrite) { // Attempt auto-rewrite if path mismatch detected try { string rewriteResult = client.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); if (rewriteResult == "Configured successfully") { bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); if (debugLogsEnabled) { McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false); } client.SetStatus(McpStatus.Configured); } else { client.SetStatus(McpStatus.IncorrectPath); } } catch { client.SetStatus(McpStatus.IncorrectPath); } } else { client.SetStatus(McpStatus.IncorrectPath); } } else { client.SetStatus(McpStatus.MissingConfig); } } catch (Exception ex) { client.SetStatus(McpStatus.Error, ex.Message); } return client.status != previousStatus; } public void RegisterClaudeCode() { var pathService = MCPServiceLocator.Paths; string pythonDir = pathService.GetMcpServerPath(); if (string.IsNullOrEmpty(pythonDir)) { throw new InvalidOperationException("Cannot register: Python directory not found"); } string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } string uvPath = pathService.GetUvPath() ?? "uv"; string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; if (Application.platform == RuntimePlatform.OSXEditor) { pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; } else if (Application.platform == RuntimePlatform.LinuxEditor) { pathPrepend = "/usr/local/bin:/usr/bin:/bin"; } // Add the directory containing Claude CLI to PATH (for node/nvm scenarios) try { string claudeDir = Path.GetDirectoryName(claudePath); if (!string.IsNullOrEmpty(claudeDir)) { pathPrepend = string.IsNullOrEmpty(pathPrepend) ? claudeDir : $"{claudeDir}:{pathPrepend}"; } } catch { } if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { string combined = ($"{stdout}\n{stderr}") ?? string.Empty; if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) { Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP for Unity already registered with Claude Code."); } else { throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); } return; } Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Successfully registered with Claude Code."); // Update status var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { CheckClaudeCodeConfiguration(claudeClient); } } public void UnregisterClaudeCode() { var pathService = MCPServiceLocator.Paths; string claudePath = pathService.GetClaudeCliPath(); if (string.IsNullOrEmpty(claudePath)) { throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); } string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : null; // Check if UnityMCP server exists (fixed - only check for "UnityMCP") bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); if (!serverExists) { // Nothing to unregister var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { claudeClient.SetStatus(McpStatus.NotConfigured); } Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: No MCP for Unity server found - already unregistered."); return; } // Remove the server if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP server successfully unregistered from Claude Code."); } else { throw new InvalidOperationException($"Failed to unregister: {stderr}"); } // Update status var client = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (client != null) { client.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(client); } } public string GetConfigPath(McpClient client) { // Claude Code is managed via CLI, not config files if (client.mcpType == McpTypes.ClaudeCode) { return "Not applicable (managed via Claude CLI)"; } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return client.windowsConfigPath; else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return client.macConfigPath; else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return client.linuxConfigPath; return "Unknown"; } public string GenerateConfigJson(McpClient client) { string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); string uvPath = MCPServiceLocator.Paths.GetUvPath(); // Claude Code uses CLI commands, not JSON config if (client.mcpType == McpTypes.ClaudeCode) { if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) { return "# Error: Configuration not available - check paths in Advanced Settings"; } // Show the actual command that RegisterClaudeCode() uses string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; return "# Register the MCP server with Claude Code:\n" + $"{registerCommand}\n\n" + "# Unregister the MCP server:\n" + "claude mcp remove UnityMCP\n\n" + "# List registered servers:\n" + "claude mcp list # Only works when claude is run in the project's directory"; } if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }"; try { if (client.mcpType == McpTypes.Codex) { return CodexConfigHelper.BuildCodexServerBlock(uvPath, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)); } else { return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client); } } catch (Exception ex) { return $"{{ \"error\": \"{ex.Message}\" }}"; } } public string GetInstallationSteps(McpClient client) { string baseSteps = client.mcpType switch { McpTypes.ClaudeDesktop => "1. Open Claude Desktop\n" + "2. Go to Settings > Developer > Edit Config\n" + " OR open the config file at the path above\n" + "3. Paste the configuration JSON\n" + "4. Save and restart Claude Desktop", McpTypes.Cursor => "1. Open Cursor\n" + "2. Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\n" + " OR open the config file at the path above\n" + "3. Paste the configuration JSON\n" + "4. Save and restart Cursor", McpTypes.Windsurf => "1. Open Windsurf\n" + "2. Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\n" + " OR open the config file at the path above\n" + "3. Paste the configuration JSON\n" + "4. Save and restart Windsurf", McpTypes.VSCode => "1. Ensure VSCode and GitHub Copilot extension are installed\n" + "2. Open or create mcp.json at the path above\n" + "3. Paste the configuration JSON\n" + "4. Save and restart VSCode", McpTypes.Kiro => "1. Open Kiro\n" + "2. Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\n" + " OR open the config file at the path above\n" + "3. Paste the configuration JSON\n" + "4. Save and restart Kiro", McpTypes.Codex => "1. Run 'codex config edit' in a terminal\n" + " OR open the config file at the path above\n" + "2. Paste the configuration TOML\n" + "3. Save and restart Codex", McpTypes.ClaudeCode => "1. Ensure Claude CLI is installed\n" + "2. Use the Register button to register automatically\n" + " OR manually run: claude mcp add UnityMCP\n" + "3. Restart Claude Code", _ => "Configuration steps not available for this client." }; return baseSteps; } private void CheckClaudeCodeConfiguration(McpClient client) { try { string configPath = McpConfigurationHelper.GetClientConfigPath(client); if (!File.Exists(configPath)) { client.SetStatus(McpStatus.NotConfigured); return; } string configJson = File.ReadAllText(configPath); dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); if (claudeConfig?.mcpServers != null) { var servers = claudeConfig.mcpServers; // Only check for UnityMCP (fixed - removed candidate hacks) if (servers.UnityMCP != null) { client.SetStatus(McpStatus.Configured); return; } } client.SetStatus(McpStatus.NotConfigured); } catch (Exception ex) { client.SetStatus(McpStatus.Error, ex.Message); } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Runtime.Serialization; // For Converters namespace MCPForUnity.Editor.Helpers { /// <summary> /// Handles serialization of GameObjects and Components for MCP responses. /// Includes reflection helpers and caching for performance. /// </summary> public static class GameObjectSerializer { // --- Data Serialization --- /// <summary> /// Creates a serializable representation of a GameObject. /// </summary> public static object GetGameObjectData(GameObject go) { if (go == null) return null; return new { name = go.name, instanceID = go.GetInstanceID(), tag = go.tag, layer = go.layer, activeSelf = go.activeSelf, activeInHierarchy = go.activeInHierarchy, isStatic = go.isStatic, scenePath = go.scene.path, // Identify which scene it belongs to transform = new // Serialize transform components carefully to avoid JSON issues { // Serialize Vector3 components individually to prevent self-referencing loops. // The default serializer can struggle with properties like Vector3.normalized. position = new { x = go.transform.position.x, y = go.transform.position.y, z = go.transform.position.z, }, localPosition = new { x = go.transform.localPosition.x, y = go.transform.localPosition.y, z = go.transform.localPosition.z, }, rotation = new { x = go.transform.rotation.eulerAngles.x, y = go.transform.rotation.eulerAngles.y, z = go.transform.rotation.eulerAngles.z, }, localRotation = new { x = go.transform.localRotation.eulerAngles.x, y = go.transform.localRotation.eulerAngles.y, z = go.transform.localRotation.eulerAngles.z, }, scale = new { x = go.transform.localScale.x, y = go.transform.localScale.y, z = go.transform.localScale.z, }, forward = new { x = go.transform.forward.x, y = go.transform.forward.y, z = go.transform.forward.z, }, up = new { x = go.transform.up.x, y = go.transform.up.y, z = go.transform.up.z, }, right = new { x = go.transform.right.x, y = go.transform.right.y, z = go.transform.right.z, }, }, parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent // Optionally include components, but can be large // components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList() // Or just component names: componentNames = go.GetComponents<Component>() .Select(c => c.GetType().FullName) .ToList(), }; } // --- Metadata Caching for Reflection --- private class CachedMetadata { public readonly List<PropertyInfo> SerializableProperties; public readonly List<FieldInfo> SerializableFields; public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields) { SerializableProperties = properties; SerializableFields = fields; } } // Key becomes Tuple<Type, bool> private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>(); // --- End Metadata Caching --- /// <summary> /// Creates a serializable representation of a Component, attempting to serialize /// public properties and fields using reflection, with caching and control over non-public fields. /// </summary> // Add the flag parameter here public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) { // --- Add Early Logging --- // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); // --- End Early Logging --- if (c == null) return null; Type componentType = c.GetType(); // --- Special handling for Transform to avoid reflection crashes and problematic properties --- if (componentType == typeof(Transform)) { Transform tr = c as Transform; // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); return new Dictionary<string, object> { { "typeName", componentType.FullName }, { "instanceID", tr.GetInstanceID() }, // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, { "childCount", tr.childCount }, // Include standard Object/Component properties { "name", tr.name }, { "tag", tr.tag }, { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } }; } // --- End Special handling for Transform --- // --- Special handling for Camera to avoid matrix-related crashes --- if (componentType == typeof(Camera)) { Camera cam = c as Camera; var cameraProperties = new Dictionary<string, object>(); // List of safe properties to serialize var safeProperties = new Dictionary<string, Func<object>> { { "nearClipPlane", () => cam.nearClipPlane }, { "farClipPlane", () => cam.farClipPlane }, { "fieldOfView", () => cam.fieldOfView }, { "renderingPath", () => (int)cam.renderingPath }, { "actualRenderingPath", () => (int)cam.actualRenderingPath }, { "allowHDR", () => cam.allowHDR }, { "allowMSAA", () => cam.allowMSAA }, { "allowDynamicResolution", () => cam.allowDynamicResolution }, { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, { "orthographicSize", () => cam.orthographicSize }, { "orthographic", () => cam.orthographic }, { "opaqueSortMode", () => (int)cam.opaqueSortMode }, { "transparencySortMode", () => (int)cam.transparencySortMode }, { "depth", () => cam.depth }, { "aspect", () => cam.aspect }, { "cullingMask", () => cam.cullingMask }, { "eventMask", () => cam.eventMask }, { "backgroundColor", () => cam.backgroundColor }, { "clearFlags", () => (int)cam.clearFlags }, { "stereoEnabled", () => cam.stereoEnabled }, { "stereoSeparation", () => cam.stereoSeparation }, { "stereoConvergence", () => cam.stereoConvergence }, { "enabled", () => cam.enabled }, { "name", () => cam.name }, { "tag", () => cam.tag }, { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } }; foreach (var prop in safeProperties) { try { var value = prop.Value(); if (value != null) { AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); } } catch (Exception) { // Silently skip any property that fails continue; } } return new Dictionary<string, object> { { "typeName", componentType.FullName }, { "instanceID", cam.GetInstanceID() }, { "properties", cameraProperties } }; } // --- End Special handling for Camera --- var data = new Dictionary<string, object> { { "typeName", componentType.FullName }, { "instanceID", c.GetInstanceID() } }; // --- Get Cached or Generate Metadata (using new cache key) --- Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields); if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) { var propertiesToCache = new List<PropertyInfo>(); var fieldsToCache = new List<FieldInfo>(); // Traverse the hierarchy from the component type up to MonoBehaviour Type currentType = componentType; while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) { // Get properties declared only at the current type level BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; foreach (var propInfo in currentType.GetProperties(propFlags)) { // Basic filtering (readable, not indexer, not transform which is handled elsewhere) if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; // Add if not already added (handles overrides - keep the most derived version) if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { propertiesToCache.Add(propInfo); } } // Get fields declared only at the current type level (both public and non-public) BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; var declaredFields = currentType.GetFields(fieldFlags); // Process the declared Fields for caching foreach (var fieldInfo in declaredFields) { if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields // Add if not already added (handles hiding - keep the most derived version) if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; bool shouldInclude = false; if (includeNonPublicSerializedFields) { // If TRUE, include Public OR NonPublic with [SerializeField] shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); } else // includeNonPublicSerializedFields is FALSE { // If FALSE, include ONLY if it is explicitly Public. shouldInclude = fieldInfo.IsPublic; } if (shouldInclude) { fieldsToCache.Add(fieldInfo); } } // Move to the base type currentType = currentType.BaseType; } // --- End Hierarchy Traversal --- cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); _metadataCache[cacheKey] = cachedData; // Add to cache with combined key } // --- End Get Cached or Generate Metadata --- // --- Use cached metadata --- var serializablePropertiesOutput = new Dictionary<string, object>(); // --- Add Logging Before Property Loop --- // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); // --- End Logging Before Property Loop --- // Use cached properties foreach (var propInfo in cachedData.SerializableProperties) { string propName = propInfo.Name; // --- Skip known obsolete/problematic Component shortcut properties --- bool skipProperty = false; if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || propName == "light" || propName == "animation" || propName == "constantForce" || propName == "renderer" || propName == "audio" || propName == "networkView" || propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || propName == "particleSystem" || // Also skip potentially problematic Matrix properties prone to cycles/errors propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") { // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log skipProperty = true; } // --- End Skip Generic Properties --- // --- Skip specific potentially problematic Camera properties --- if (componentType == typeof(Camera) && (propName == "pixelRect" || propName == "rect" || propName == "cullingMatrix" || propName == "useOcclusionCulling" || propName == "worldToCameraMatrix" || propName == "projectionMatrix" || propName == "nonJitteredProjectionMatrix" || propName == "previousViewProjectionMatrix" || propName == "cameraToWorldMatrix")) { // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}"); skipProperty = true; } // --- End Skip Camera Properties --- // --- Skip specific potentially problematic Transform properties --- if (componentType == typeof(Transform) && (propName == "lossyScale" || propName == "rotation" || propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")) { // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}"); skipProperty = true; } // --- End Skip Transform Properties --- // Skip if flagged if (skipProperty) { continue; } try { // --- Add detailed logging --- // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); // --- End detailed logging --- object value = propInfo.GetValue(c); Type propType = propInfo.PropertyType; AddSerializableValue(serializablePropertiesOutput, propName, propType, value); } catch (Exception) { // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); } } // --- Add Logging Before Field Loop --- // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}..."); // --- End Logging Before Field Loop --- // Use cached fields foreach (var fieldInfo in cachedData.SerializableFields) { try { // --- Add detailed logging for fields --- // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); // --- End detailed logging for fields --- object value = fieldInfo.GetValue(c); string fieldName = fieldInfo.Name; Type fieldType = fieldInfo.FieldType; AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); } catch (Exception) { // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); } } // --- End Use cached metadata --- if (serializablePropertiesOutput.Count > 0) { data["properties"] = serializablePropertiesOutput; } return data; } // Helper function to decide how to serialize different types private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value) { // Simplified: Directly use CreateTokenFromValue which uses the serializer if (value == null) { dict[name] = null; return; } try { // Use the helper that employs our custom serializer settings JToken token = CreateTokenFromValue(value, type); if (token != null) // Check if serialization succeeded in the helper { // Convert JToken back to a basic object structure for the dictionary dict[name] = ConvertJTokenToPlainObject(token); } // If token is null, it means serialization failed and a warning was logged. } catch (Exception e) { // Catch potential errors during JToken conversion or addition to dictionary Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); } } // Helper to convert JToken back to basic object structure private static object ConvertJTokenToPlainObject(JToken token) { if (token == null) return null; switch (token.Type) { case JTokenType.Object: var objDict = new Dictionary<string, object>(); foreach (var prop in ((JObject)token).Properties()) { objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); } return objDict; case JTokenType.Array: var list = new List<object>(); foreach (var item in (JArray)token) { list.Add(ConvertJTokenToPlainObject(item)); } return list; case JTokenType.Integer: return token.ToObject<long>(); // Use long for safety case JTokenType.Float: return token.ToObject<double>(); // Use double for safety case JTokenType.String: return token.ToObject<string>(); case JTokenType.Boolean: return token.ToObject<bool>(); case JTokenType.Date: return token.ToObject<DateTime>(); case JTokenType.Guid: return token.ToObject<Guid>(); case JTokenType.Uri: return token.ToObject<Uri>(); case JTokenType.TimeSpan: return token.ToObject<TimeSpan>(); case JTokenType.Bytes: return token.ToObject<byte[]>(); case JTokenType.Null: return null; case JTokenType.Undefined: return null; // Treat undefined as null default: // Fallback for simple value types not explicitly listed if (token is JValue jValue && jValue.Value != null) { return jValue.Value; } // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null."); return null; } } // --- Define custom JsonSerializerSettings for OUTPUT --- private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings { Converters = new List<JsonConverter> { new Vector3Converter(), new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), new BoundsConverter(), new UnityEngineObjectConverter() // Handles serialization of references }, ReferenceLoopHandling = ReferenceLoopHandling.Ignore, // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed }; private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); // --- End Define custom JsonSerializerSettings --- // Helper to create JToken using the output serializer private static JToken CreateTokenFromValue(object value, Type type) { if (value == null) return JValue.CreateNull(); try { // Use the pre-configured OUTPUT serializer instance return JToken.FromObject(value, _outputSerializer); } catch (JsonSerializationException e) { Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); return null; // Indicate serialization failure } catch (Exception e) // Catch other unexpected errors { Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); return null; // Indicate serialization failure } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ReadConsole.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// </summary> [McpForUnityTool("read_console")] public static class ReadConsole { // (Calibration removed) // Reflection members for accessing internal LogEntry data // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection private static MethodInfo _startGettingEntriesMethod; private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... private static MethodInfo _clearMethod; private static MethodInfo _getCountMethod; private static MethodInfo _getEntryMethod; private static FieldInfo _modeField; private static FieldInfo _messageField; private static FieldInfo _fileField; private static FieldInfo _lineField; private static FieldInfo _instanceIdField; // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? // Static constructor for reflection setup static ReadConsole() { try { Type logEntriesType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntries" ); if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); // Include NonPublic binding flags as internal APIs might change accessibility BindingFlags staticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; _startGettingEntriesMethod = logEntriesType.GetMethod( "StartGettingEntries", staticFlags ); if (_startGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); // Try reflecting EndGettingEntries based on warning message _endGettingEntriesMethod = logEntriesType.GetMethod( "EndGettingEntries", staticFlags ); if (_endGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); if (_clearMethod == null) throw new Exception("Failed to reflect LogEntries.Clear"); _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); if (_getCountMethod == null) throw new Exception("Failed to reflect LogEntries.GetCount"); _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); if (_getEntryMethod == null) throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); Type logEntryType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntry" ); if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry"); _modeField = logEntryType.GetField("mode", instanceFlags); if (_modeField == null) throw new Exception("Failed to reflect LogEntry.mode"); _messageField = logEntryType.GetField("message", instanceFlags); if (_messageField == null) throw new Exception("Failed to reflect LogEntry.message"); _fileField = logEntryType.GetField("file", instanceFlags); if (_fileField == null) throw new Exception("Failed to reflect LogEntry.file"); _lineField = logEntryType.GetField("line", instanceFlags); if (_lineField == null) throw new Exception("Failed to reflect LogEntry.line"); _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID"); // (Calibration removed) } catch (Exception e) { Debug.LogError( $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" ); // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. _startGettingEntriesMethod = _endGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; } } // --- Main Handler --- public static object HandleCommand(JObject @params) { // Check if ALL required reflection members were successfully initialized. if ( _startGettingEntriesMethod == null || _endGettingEntriesMethod == null || _clearMethod == null || _getCountMethod == null || _getEntryMethod == null || _modeField == null || _messageField == null || _fileField == null || _lineField == null || _instanceIdField == null ) { // Log the error here as well for easier debugging in Unity Console Debug.LogError( "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." ); return Response.Error( "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." ); } string action = @params["action"]?.ToString().ToLower() ?? "get"; try { if (action == "clear") { return ClearConsole(); } else if (action == "get") { // Extract parameters for 'get' var types = (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() ?? new List<string> { "error", "warning", "log" }; int? count = @params["count"]?.ToObject<int?>(); string filterText = @params["filterText"]?.ToString(); string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); bool includeStacktrace = @params["includeStacktrace"]?.ToObject<bool?>() ?? true; if (types.Contains("all")) { types = new List<string> { "error", "warning", "log" }; // Expand 'all' } if (!string.IsNullOrEmpty(sinceTimestampStr)) { Debug.LogWarning( "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." ); // Need a way to get timestamp per log entry. } return GetConsoleEntries(types, count, filterText, format, includeStacktrace); } else { return Response.Error( $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." ); } } catch (Exception e) { Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); return Response.Error($"Internal error processing action '{action}': {e.Message}"); } } // --- Action Implementations --- private static object ClearConsole() { try { _clearMethod.Invoke(null, null); // Static method, no instance, no parameters return Response.Success("Console cleared successfully."); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); return Response.Error($"Failed to clear console: {e.Message}"); } } private static object GetConsoleEntries( List<string> types, int? count, string filterText, string format, bool includeStacktrace ) { List<object> formattedEntries = new List<object>(); int retrievedCount = 0; try { // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal _startGettingEntriesMethod.Invoke(null, null); int totalEntries = (int)_getCountMethod.Invoke(null, null); // Create instance to pass to GetEntryInternal - Ensure the type is correct Type logEntryType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntry" ); if (logEntryType == null) throw new Exception( "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." ); object logEntryInstance = Activator.CreateInstance(logEntryType); for (int i = 0; i < totalEntries; i++) { // Get the entry data into our instance using reflection _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); // Extract data using reflection int mode = (int)_modeField.GetValue(logEntryInstance); string message = (string)_messageField.GetValue(logEntryInstance); string file = (string)_fileField.GetValue(logEntryInstance); int line = (int)_lineField.GetValue(logEntryInstance); // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); if (string.IsNullOrEmpty(message)) { continue; // Skip empty messages } // (Calibration removed) // --- Filtering --- // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed LogType unityType = InferTypeFromMessage(message); bool isExplicitDebug = IsExplicitDebugLog(message); if (!isExplicitDebug && unityType == LogType.Log) { unityType = GetLogTypeFromMode(mode); } bool want; // Treat Exception/Assert as errors for filtering convenience if (unityType == LogType.Exception) { want = types.Contains("error") || types.Contains("exception"); } else if (unityType == LogType.Assert) { want = types.Contains("error") || types.Contains("assert"); } else { want = types.Contains(unityType.ToString().ToLowerInvariant()); } if (!want) continue; // Filter by text (case-insensitive) if ( !string.IsNullOrEmpty(filterText) && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 ) { continue; } // TODO: Filter by timestamp (requires timestamp data) // --- Formatting --- string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; // Always get first line for the message, use full message only if no stack trace exists string[] messageLines = message.Split( new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries ); string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; // If not including stacktrace, ensure we only show the first line if (!includeStacktrace) { stackTrace = null; } object formattedEntry = null; switch (format) { case "plain": formattedEntry = messageOnly; break; case "json": case "detailed": // Treat detailed as json for structured return default: formattedEntry = new { type = unityType.ToString(), message = messageOnly, file = file, line = line, // timestamp = "", // TODO stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found }; break; } formattedEntries.Add(formattedEntry); retrievedCount++; // Apply count limit (after filtering) if (count.HasValue && retrievedCount >= count.Value) { break; } } } catch (Exception e) { Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); // Ensure EndGettingEntries is called even if there's an error during iteration try { _endGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ } return Response.Error($"Error retrieving log entries: {e.Message}"); } finally { // Ensure we always call EndGettingEntries try { _endGettingEntriesMethod.Invoke(null, null); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); // Don't return error here as we might have valid data, but log it. } } // Return the filtered and formatted list (might be empty) return Response.Success( $"Retrieved {formattedEntries.Count} log entries.", formattedEntries ); } // --- Internal Helpers --- // Mapping bits from LogEntry.mode. These may vary by Unity version. private const int ModeBitError = 1 << 0; private const int ModeBitAssert = 1 << 1; private const int ModeBitWarning = 1 << 2; private const int ModeBitLog = 1 << 3; private const int ModeBitException = 1 << 4; // often combined with Error bits private const int ModeBitScriptingError = 1 << 9; private const int ModeBitScriptingWarning = 1 << 10; private const int ModeBitScriptingLog = 1 << 11; private const int ModeBitScriptingException = 1 << 18; private const int ModeBitScriptingAssertion = 1 << 22; private static LogType GetLogTypeFromMode(int mode) { // Preserve Unity's real type (no remapping); bits may vary by version if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; return LogType.Log; } // (Calibration helpers removed) /// <summary> /// Classifies severity using message/stacktrace content. Works across Unity versions. /// </summary> private static LogType InferTypeFromMessage(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; // Fast path: look for explicit Debug API names in the appended stack trace // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Error; if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Error; // Exceptions (avoid misclassifying compiler diagnostics) if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Exception; // Unity assertions if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Assert; return LogType.Log; } private static bool IsExplicitDebugLog(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return false; if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } /// <summary> /// Applies the "one level lower" remapping for filtering, like the old version. /// This ensures compatibility with the filtering logic that expects remapped types. /// </summary> private static LogType GetRemappedTypeForFiltering(LogType unityType) { switch (unityType) { case LogType.Error: return LogType.Warning; // Error becomes Warning case LogType.Warning: return LogType.Log; // Warning becomes Log case LogType.Assert: return LogType.Assert; // Assert remains Assert case LogType.Log: return LogType.Log; // Log remains Log case LogType.Exception: return LogType.Warning; // Exception becomes Warning default: return LogType.Log; // Default fallback } } /// <summary> /// Attempts to extract the stack trace part from a log message. /// Unity log messages often have the stack trace appended after the main message, /// starting on a new line and typically indented or beginning with "at ". /// </summary> /// <param name="fullMessage">The complete log message including potential stack trace.</param> /// <returns>The extracted stack trace string, or null if none is found.</returns> private static string ExtractStackTrace(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return null; // Split into lines, removing empty ones to handle different line endings gracefully. // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. string[] lines = fullMessage.Split( new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries ); // If there's only one line or less, there's no separate stack trace. if (lines.Length <= 1) return null; int stackStartIndex = -1; // Start checking from the second line onwards. for (int i = 1; i < lines.Length; ++i) { // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. string trimmedLine = lines[i].TrimStart(); // Check for common stack trace patterns. if ( trimmedLine.StartsWith("at ") || trimmedLine.StartsWith("UnityEngine.") || trimmedLine.StartsWith("UnityEditor.") || trimmedLine.Contains("(at ") || // Covers "(at Assets/..." pattern // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) ( trimmedLine.Length > 0 && char.IsUpper(trimmedLine[0]) && trimmedLine.Contains('.') ) ) { stackStartIndex = i; break; // Found the likely start of the stack trace } } // If a potential start index was found... if (stackStartIndex > 0) { // Join the lines from the stack start index onwards using standard newline characters. // This reconstructs the stack trace part of the message. return string.Join("\n", lines.Skip(stackStartIndex)); } // No clear stack trace found based on the patterns. return null; } /* LogEntry.mode bits exploration (based on Unity decompilation/observation): May change between versions. Basic Types: kError = 1 << 0 (1) kAssert = 1 << 1 (2) kWarning = 1 << 2 (4) kLog = 1 << 3 (8) kFatal = 1 << 4 (16) - Often treated as Exception/Error Modifiers/Context: kAssetImportError = 1 << 7 (128) kAssetImportWarning = 1 << 8 (256) kScriptingError = 1 << 9 (512) kScriptingWarning = 1 << 10 (1024) kScriptingLog = 1 << 11 (2048) kScriptCompileError = 1 << 12 (4096) kScriptCompileWarning = 1 << 13 (8192) kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play kMayIgnoreLineNumber = 1 << 15 (32768) kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) kScriptingException = 1 << 18 (262144) kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior kGraphCompileError = 1 << 21 (2097152) kScriptingAssertion = 1 << 22 (4194304) kVisualScriptingError = 1 << 23 (8388608) Example observed values: Log: 2048 (ScriptingLog) or 8 (Log) Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) Error: 513 (ScriptingError | Error) or 1 (Error) Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) */ } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ReadConsole.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// </summary> [McpForUnityTool("read_console")] public static class ReadConsole { // (Calibration removed) // Reflection members for accessing internal LogEntry data // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection private static MethodInfo _startGettingEntriesMethod; private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... private static MethodInfo _clearMethod; private static MethodInfo _getCountMethod; private static MethodInfo _getEntryMethod; private static FieldInfo _modeField; private static FieldInfo _messageField; private static FieldInfo _fileField; private static FieldInfo _lineField; private static FieldInfo _instanceIdField; // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? // Static constructor for reflection setup static ReadConsole() { try { Type logEntriesType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntries" ); if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); // Include NonPublic binding flags as internal APIs might change accessibility BindingFlags staticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; BindingFlags instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; _startGettingEntriesMethod = logEntriesType.GetMethod( "StartGettingEntries", staticFlags ); if (_startGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); // Try reflecting EndGettingEntries based on warning message _endGettingEntriesMethod = logEntriesType.GetMethod( "EndGettingEntries", staticFlags ); if (_endGettingEntriesMethod == null) throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); if (_clearMethod == null) throw new Exception("Failed to reflect LogEntries.Clear"); _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); if (_getCountMethod == null) throw new Exception("Failed to reflect LogEntries.GetCount"); _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); if (_getEntryMethod == null) throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); Type logEntryType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntry" ); if (logEntryType == null) throw new Exception("Could not find internal type UnityEditor.LogEntry"); _modeField = logEntryType.GetField("mode", instanceFlags); if (_modeField == null) throw new Exception("Failed to reflect LogEntry.mode"); _messageField = logEntryType.GetField("message", instanceFlags); if (_messageField == null) throw new Exception("Failed to reflect LogEntry.message"); _fileField = logEntryType.GetField("file", instanceFlags); if (_fileField == null) throw new Exception("Failed to reflect LogEntry.file"); _lineField = logEntryType.GetField("line", instanceFlags); if (_lineField == null) throw new Exception("Failed to reflect LogEntry.line"); _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID"); // (Calibration removed) } catch (Exception e) { Debug.LogError( $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" ); // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. _startGettingEntriesMethod = _endGettingEntriesMethod = _clearMethod = _getCountMethod = _getEntryMethod = null; _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; } } // --- Main Handler --- public static object HandleCommand(JObject @params) { // Check if ALL required reflection members were successfully initialized. if ( _startGettingEntriesMethod == null || _endGettingEntriesMethod == null || _clearMethod == null || _getCountMethod == null || _getEntryMethod == null || _modeField == null || _messageField == null || _fileField == null || _lineField == null || _instanceIdField == null ) { // Log the error here as well for easier debugging in Unity Console Debug.LogError( "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." ); return Response.Error( "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." ); } string action = @params["action"]?.ToString().ToLower() ?? "get"; try { if (action == "clear") { return ClearConsole(); } else if (action == "get") { // Extract parameters for 'get' var types = (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() ?? new List<string> { "error", "warning", "log" }; int? count = @params["count"]?.ToObject<int?>(); string filterText = @params["filterText"]?.ToString(); string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); bool includeStacktrace = @params["includeStacktrace"]?.ToObject<bool?>() ?? true; if (types.Contains("all")) { types = new List<string> { "error", "warning", "log" }; // Expand 'all' } if (!string.IsNullOrEmpty(sinceTimestampStr)) { Debug.LogWarning( "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." ); // Need a way to get timestamp per log entry. } return GetConsoleEntries(types, count, filterText, format, includeStacktrace); } else { return Response.Error( $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." ); } } catch (Exception e) { Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); return Response.Error($"Internal error processing action '{action}': {e.Message}"); } } // --- Action Implementations --- private static object ClearConsole() { try { _clearMethod.Invoke(null, null); // Static method, no instance, no parameters return Response.Success("Console cleared successfully."); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); return Response.Error($"Failed to clear console: {e.Message}"); } } private static object GetConsoleEntries( List<string> types, int? count, string filterText, string format, bool includeStacktrace ) { List<object> formattedEntries = new List<object>(); int retrievedCount = 0; try { // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal _startGettingEntriesMethod.Invoke(null, null); int totalEntries = (int)_getCountMethod.Invoke(null, null); // Create instance to pass to GetEntryInternal - Ensure the type is correct Type logEntryType = typeof(EditorApplication).Assembly.GetType( "UnityEditor.LogEntry" ); if (logEntryType == null) throw new Exception( "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." ); object logEntryInstance = Activator.CreateInstance(logEntryType); for (int i = 0; i < totalEntries; i++) { // Get the entry data into our instance using reflection _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); // Extract data using reflection int mode = (int)_modeField.GetValue(logEntryInstance); string message = (string)_messageField.GetValue(logEntryInstance); string file = (string)_fileField.GetValue(logEntryInstance); int line = (int)_lineField.GetValue(logEntryInstance); // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); if (string.IsNullOrEmpty(message)) { continue; // Skip empty messages } // (Calibration removed) // --- Filtering --- // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed LogType unityType = InferTypeFromMessage(message); bool isExplicitDebug = IsExplicitDebugLog(message); if (!isExplicitDebug && unityType == LogType.Log) { unityType = GetLogTypeFromMode(mode); } bool want; // Treat Exception/Assert as errors for filtering convenience if (unityType == LogType.Exception) { want = types.Contains("error") || types.Contains("exception"); } else if (unityType == LogType.Assert) { want = types.Contains("error") || types.Contains("assert"); } else { want = types.Contains(unityType.ToString().ToLowerInvariant()); } if (!want) continue; // Filter by text (case-insensitive) if ( !string.IsNullOrEmpty(filterText) && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 ) { continue; } // TODO: Filter by timestamp (requires timestamp data) // --- Formatting --- string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; // Always get first line for the message, use full message only if no stack trace exists string[] messageLines = message.Split( new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries ); string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; // If not including stacktrace, ensure we only show the first line if (!includeStacktrace) { stackTrace = null; } object formattedEntry = null; switch (format) { case "plain": formattedEntry = messageOnly; break; case "json": case "detailed": // Treat detailed as json for structured return default: formattedEntry = new { type = unityType.ToString(), message = messageOnly, file = file, line = line, // timestamp = "", // TODO stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found }; break; } formattedEntries.Add(formattedEntry); retrievedCount++; // Apply count limit (after filtering) if (count.HasValue && retrievedCount >= count.Value) { break; } } } catch (Exception e) { Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); // Ensure EndGettingEntries is called even if there's an error during iteration try { _endGettingEntriesMethod.Invoke(null, null); } catch { /* Ignore nested exception */ } return Response.Error($"Error retrieving log entries: {e.Message}"); } finally { // Ensure we always call EndGettingEntries try { _endGettingEntriesMethod.Invoke(null, null); } catch (Exception e) { Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); // Don't return error here as we might have valid data, but log it. } } // Return the filtered and formatted list (might be empty) return Response.Success( $"Retrieved {formattedEntries.Count} log entries.", formattedEntries ); } // --- Internal Helpers --- // Mapping bits from LogEntry.mode. These may vary by Unity version. private const int ModeBitError = 1 << 0; private const int ModeBitAssert = 1 << 1; private const int ModeBitWarning = 1 << 2; private const int ModeBitLog = 1 << 3; private const int ModeBitException = 1 << 4; // often combined with Error bits private const int ModeBitScriptingError = 1 << 9; private const int ModeBitScriptingWarning = 1 << 10; private const int ModeBitScriptingLog = 1 << 11; private const int ModeBitScriptingException = 1 << 18; private const int ModeBitScriptingAssertion = 1 << 22; private static LogType GetLogTypeFromMode(int mode) { // Preserve Unity's real type (no remapping); bits may vary by version if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; return LogType.Log; } // (Calibration helpers removed) /// <summary> /// Classifies severity using message/stacktrace content. Works across Unity versions. /// </summary> private static LogType InferTypeFromMessage(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; // Fast path: look for explicit Debug API names in the appended stack trace // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Error; if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Warning; if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Error; // Exceptions (avoid misclassifying compiler diagnostics) if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Exception; // Unity assertions if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) return LogType.Assert; return LogType.Log; } private static bool IsExplicitDebugLog(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return false; if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } /// <summary> /// Applies the "one level lower" remapping for filtering, like the old version. /// This ensures compatibility with the filtering logic that expects remapped types. /// </summary> private static LogType GetRemappedTypeForFiltering(LogType unityType) { switch (unityType) { case LogType.Error: return LogType.Warning; // Error becomes Warning case LogType.Warning: return LogType.Log; // Warning becomes Log case LogType.Assert: return LogType.Assert; // Assert remains Assert case LogType.Log: return LogType.Log; // Log remains Log case LogType.Exception: return LogType.Warning; // Exception becomes Warning default: return LogType.Log; // Default fallback } } /// <summary> /// Attempts to extract the stack trace part from a log message. /// Unity log messages often have the stack trace appended after the main message, /// starting on a new line and typically indented or beginning with "at ". /// </summary> /// <param name="fullMessage">The complete log message including potential stack trace.</param> /// <returns>The extracted stack trace string, or null if none is found.</returns> private static string ExtractStackTrace(string fullMessage) { if (string.IsNullOrEmpty(fullMessage)) return null; // Split into lines, removing empty ones to handle different line endings gracefully. // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. string[] lines = fullMessage.Split( new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries ); // If there's only one line or less, there's no separate stack trace. if (lines.Length <= 1) return null; int stackStartIndex = -1; // Start checking from the second line onwards. for (int i = 1; i < lines.Length; ++i) { // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. string trimmedLine = lines[i].TrimStart(); // Check for common stack trace patterns. if ( trimmedLine.StartsWith("at ") || trimmedLine.StartsWith("UnityEngine.") || trimmedLine.StartsWith("UnityEditor.") || trimmedLine.Contains("(at ") || // Covers "(at Assets/..." pattern // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) ( trimmedLine.Length > 0 && char.IsUpper(trimmedLine[0]) && trimmedLine.Contains('.') ) ) { stackStartIndex = i; break; // Found the likely start of the stack trace } } // If a potential start index was found... if (stackStartIndex > 0) { // Join the lines from the stack start index onwards using standard newline characters. // This reconstructs the stack trace part of the message. return string.Join("\n", lines.Skip(stackStartIndex)); } // No clear stack trace found based on the patterns. return null; } /* LogEntry.mode bits exploration (based on Unity decompilation/observation): May change between versions. Basic Types: kError = 1 << 0 (1) kAssert = 1 << 1 (2) kWarning = 1 << 2 (4) kLog = 1 << 3 (8) kFatal = 1 << 4 (16) - Often treated as Exception/Error Modifiers/Context: kAssetImportError = 1 << 7 (128) kAssetImportWarning = 1 << 8 (256) kScriptingError = 1 << 9 (512) kScriptingWarning = 1 << 10 (1024) kScriptingLog = 1 << 11 (2048) kScriptCompileError = 1 << 12 (4096) kScriptCompileWarning = 1 << 13 (8192) kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play kMayIgnoreLineNumber = 1 << 15 (32768) kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) kScriptingException = 1 << 18 (262144) kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior kGraphCompileError = 1 << 21 (2097152) kScriptingAssertion = 1 << 22 (4194304) kVisualScriptingError = 1 << 23 (8388608) Example observed values: Log: 2048 (ScriptingLog) or 8 (Log) Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) Error: 513 (ScriptingError | Error) or 1 (Error) Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) */ } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_script.py: -------------------------------------------------------------------------------- ```python import base64 import os from typing import Annotated, Any, Literal from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry def _split_uri(uri: str) -> tuple[str, str]: """Split an incoming URI or path into (name, directory) suitable for Unity. Rules: - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) - file://... → percent-decode, normalize, strip host and leading slashes, then, if any 'Assets' segment exists, return path relative to that 'Assets' root. Otherwise, fall back to original name/dir behavior. - plain paths → decode/normalize separators; if they contain an 'Assets' segment, return relative to 'Assets'. """ raw_path: str if uri.startswith("unity://path/"): raw_path = uri[len("unity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) host = (parsed.netloc or "").strip() p = parsed.path or "" # UNC: file://server/share/... -> //server/share/... if host and host.lower() != "localhost": p = f"//{host}{p}" # Use percent-decoded path, preserving leading slashes raw_path = unquote(p) else: raw_path = uri # Percent-decode any residual encodings and normalize separators raw_path = unquote(raw_path).replace("\\", "/") # Strip leading slash only for Windows drive-letter forms like "/C:/..." if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": raw_path = raw_path[1:] # Normalize path (collapse ../, ./) norm = os.path.normpath(raw_path).replace("\\", "/") # If an 'Assets' segment exists, compute path relative to it (case-insensitive) parts = [p for p in norm.split("/") if p not in ("", ".")] idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm # For POSIX absolute paths outside Assets, drop the leading '/' # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). if effective_path.startswith("/"): effective_path = effective_path[1:] name = os.path.splitext(os.path.basename(effective_path))[0] directory = os.path.dirname(effective_path) return name, directory @mcp_for_unity_tool(description=( """Apply small text edits to a C# script identified by URI. IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! RECOMMENDED WORKFLOW: 1. First call resources/read with start_line/line_count to verify exact content 2. Count columns carefully (or use find_in_file to locate patterns) 3. Apply your edit with precise coordinates 4. Consider script_apply_edits with anchors for safer pattern-based replacements Notes: - For method/class operations, use script_apply_edits (safer, structured edits) - For pattern-based replacements, consider anchor operations in script_apply_edits - Lines, columns are 1-indexed - Tabs count as 1 column""" )) def apply_text_edits( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 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!)"], precondition_sha256: Annotated[str, "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, strict: Annotated[bool, "Optional strict flag, used to enforce strict mode"] | None = None, options: Annotated[dict[str, Any], "Optional options, used to pass additional options to the script editor"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing apply_text_edits: {uri}") name, directory = _split_uri(uri) # Normalize common aliases/misuses for resilience: # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} # If normalization is required, read current contents to map indices -> 1-based line/col. def _needs_normalization(arr: list[dict[str, Any]]) -> bool: for e in arr or []: 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): return True return False normalized_edits: list[dict[str, Any]] = [] warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed read_resp = send_command_with_retry("manage_script", { "action": "read", "name": name, "path": directory, }) if not (isinstance(read_resp, dict) and read_resp.get("success")): return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} data = read_resp.get("data", {}) contents = data.get("contents") if not contents and data.get("contentsEncoded"): try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace") except Exception: contents = contents or "" # Helper to map 0-based character index to 1-based line/col def line_col_from_index(idx: int) -> tuple[int, int]: if idx <= 0: return 1, 1 # Count lines up to idx and position within line nl_count = contents.count("\n", 0, idx) line = nl_count + 1 last_nl = contents.rfind("\n", 0, idx) col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 return line, col for e in edits or []: e2 = dict(e) # Map text->newText if needed if "newText" not in e2 and "text" in e2: e2["newText"] = e2.pop("text") if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: # Guard: explicit fields must be 1-based. zero_based = False for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True except Exception: pass if zero_based: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} # Normalize by clamping to 1 and warn for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass warnings.append( "zero_based_explicit_fields_normalized") normalized_edits.append(e2) continue rng = e2.get("range") if isinstance(rng, dict): # LSP style: 0-based s = rng.get("start", {}) t = rng.get("end", {}) e2["startLine"] = int(s.get("line", 0)) + 1 e2["startCol"] = int(s.get("character", 0)) + 1 e2["endLine"] = int(t.get("line", 0)) + 1 e2["endCol"] = int(t.get("character", 0)) + 1 e2.pop("range", None) normalized_edits.append(e2) continue if isinstance(rng, (list, tuple)) and len(rng) == 2: try: a = int(rng[0]) b = int(rng[1]) if b < a: a, b = b, a sl, sc = line_col_from_index(a) el, ec = line_col_from_index(b) e2["startLine"] = sl e2["startCol"] = sc e2["endLine"] = el e2["endCol"] = ec e2.pop("range", None) normalized_edits.append(e2) continue except Exception: pass # Could not normalize this edit return { "success": False, "code": "missing_field", "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} } else: # Even when edits appear already in explicit form, validate 1-based coordinates. normalized_edits = [] for e in edits or []: e2 = dict(e) has_all = all(k in e2 for k in ( "startLine", "startCol", "endLine", "endCol")) if has_all: zero_based = False for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True except Exception: pass if zero_based: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass if "zero_based_explicit_fields_normalized" not in warnings: warnings.append( "zero_based_explicit_fields_normalized") normalized_edits.append(e2) # Preflight: detect overlapping ranges among normalized line/col spans def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: return ( int(e.get("startLine", 1)) if key_start else int( e.get("endLine", 1)), int(e.get("startCol", 1)) if key_start else int( e.get("endCol", 1)), ) def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. spans = [] for e in normalized_edits or []: try: s = _pos_tuple(e, True) t = _pos_tuple(e, False) if s != t: spans.append((s, t)) except Exception: # If coordinates missing or invalid, let the server validate later pass if spans: spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) for i in range(1, len(spans_sorted)): prev_end = spans_sorted[i-1][1] curr_start = spans_sorted[i][0] # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start if not _le(prev_end, curr_start): conflicts = [{ "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, }] return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} # Note: Do not auto-compute precondition if missing; callers should supply it # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and # preserves existing call-count expectations in clients/tests. # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance opts: dict[str, Any] = dict(options or {}) try: if len(normalized_edits) > 1 and "applyMode" not in opts: opts["applyMode"] = "atomic" except Exception: pass # Support optional debug preview for span-by-span simulation without write if opts.get("debug_preview"): try: import difflib # Apply locally to preview final result lines = [] # Build an indexable original from a read if we normalized from read; otherwise skip prev = "" # We cannot guarantee file contents here without a read; return normalized spans only return { "success": True, "message": "Preview only (no write)", "data": { "normalizedEdits": normalized_edits, "preview": True } } except Exception as e: return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} params = { "action": "apply_text_edits", "name": name, "path": directory, "edits": normalized_edits, "precondition_sha256": precondition_sha256, "options": opts, } params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict): data = resp.setdefault("data", {}) data.setdefault("normalizedEdits", normalized_edits) if warnings: data.setdefault("warnings", warnings) if resp.get("success") and (options or {}).get("force_sentinel_reload"): # Optional: flip sentinel via menu if explicitly requested try: import threading import time import json import glob import os def _latest_status() -> dict | None: try: files = sorted(glob.glob(os.path.expanduser( "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) if not files: return None with open(files[0], "r") as f: return json.loads(f.read()) except Exception: return None def _flip_async(): try: time.sleep(0.1) st = _latest_status() if st and st.get("reloading"): return send_command_with_retry( "execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}, max_retries=0, retry_ms=0, ) except Exception: pass threading.Thread(target=_flip_async, daemon=True).start() except Exception: pass return resp return resp return {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) def create_script( ctx: Context, path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing create_script: {path}") name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input norm_path = os.path.normpath( (path or "").replace("\\", "/")).replace("\\", "/") if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} if ".." in norm_path.split("/") or norm_path.startswith("/"): return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} if not name: return {"success": False, "code": "bad_path", "message": "path must include a script file name."} if not norm_path.lower().endswith(".cs"): return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} params: dict[str, Any] = { "action": "create", "name": name, "path": directory, "namespace": namespace, "scriptType": script_type, } if contents: params["encodedContents"] = base64.b64encode( contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) def delete_script( ctx: Context, uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: """Delete a C# script by URI.""" ctx.info(f"Processing delete_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} params = {"action": "delete", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) def validate_script( ctx: Context, uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], level: Annotated[Literal['basic', 'standard'], "Validation level"] = "basic", include_diagnostics: Annotated[bool, "Include full diagnostics and summary"] = False ) -> dict[str, Any]: ctx.info(f"Processing validate_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} if level not in ("basic", "standard"): return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} params = { "action": "validate", "name": name, "path": directory, "level": level, } resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] warnings = sum(1 for d in diags if str( d.get("severity", "")).lower() == "warning") errors = sum(1 for d in diags if str( d.get("severity", "")).lower() in ("error", "fatal")) if include_diagnostics: return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} return {"success": True, "data": {"warnings": warnings, "errors": errors}} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) def manage_script( ctx: Context, action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create", "C# code for 'create'/'update'"] | None = None, script_type: Annotated[str, "Script type (e.g., 'C#')", "Type hint (e.g., 'MonoBehaviour')"] | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_script: {action}") try: # Prepare parameters for Unity params = { "action": action, "name": name, "path": path, "namespace": namespace, "scriptType": script_type, } # Base64 encode the contents if they exist to avoid JSON escaping issues if contents: if action == 'create': params["encodedContents"] = base64.b64encode( contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents params = {k: v for k, v in params.items() if v is not None} response = send_command_with_retry("manage_script", params) if isinstance(response, dict): if response.get("success"): if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode( response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] return { "success": True, "message": response.get("message", "Operation successful."), "data": response.get("data"), } return response return {"success": False, "message": str(response)} except Exception as e: return { "success": False, "message": f"Python error managing script: {str(e)}", } @mcp_for_unity_tool(description=( """Get manage_script capabilities (supported ops, limits, and guards). Returns: - ops: list of supported structured ops - text_ops: list of supported text ops - max_edit_payload_bytes: server edit payload cap - guards: header/using guard enabled flag""" )) def manage_script_capabilities(ctx: Context) -> dict[str, Any]: ctx.info("Processing manage_script_capabilities") try: # Keep in sync with server/Editor ManageScript implementation ops = [ "replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" ] text_ops = ["replace_range", "regex_replace", "prepend", "append"] # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback max_edit_payload_bytes = 256 * 1024 guards = {"using_guard": True} extras = {"get_sha": True} return {"success": True, "data": { "ops": ops, "text_ops": text_ops, "max_edit_payload_bytes": max_edit_payload_bytes, "guards": guards, "extras": extras, }} except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") def get_sha( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: ctx.info(f"Processing get_sha: {uri}") try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) minimal = {"sha256": data.get( "sha256"), "lengthBytes": data.get("lengthBytes")} return {"success": True, "data": minimal} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: return {"success": False, "message": f"get_sha error: {e}"} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py: -------------------------------------------------------------------------------- ```python import base64 import os from typing import Annotated, Any, Literal from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry def _split_uri(uri: str) -> tuple[str, str]: """Split an incoming URI or path into (name, directory) suitable for Unity. Rules: - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) - file://... → percent-decode, normalize, strip host and leading slashes, then, if any 'Assets' segment exists, return path relative to that 'Assets' root. Otherwise, fall back to original name/dir behavior. - plain paths → decode/normalize separators; if they contain an 'Assets' segment, return relative to 'Assets'. """ raw_path: str if uri.startswith("unity://path/"): raw_path = uri[len("unity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) host = (parsed.netloc or "").strip() p = parsed.path or "" # UNC: file://server/share/... -> //server/share/... if host and host.lower() != "localhost": p = f"//{host}{p}" # Use percent-decoded path, preserving leading slashes raw_path = unquote(p) else: raw_path = uri # Percent-decode any residual encodings and normalize separators raw_path = unquote(raw_path).replace("\\", "/") # Strip leading slash only for Windows drive-letter forms like "/C:/..." if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": raw_path = raw_path[1:] # Normalize path (collapse ../, ./) norm = os.path.normpath(raw_path).replace("\\", "/") # If an 'Assets' segment exists, compute path relative to it (case-insensitive) parts = [p for p in norm.split("/") if p not in ("", ".")] idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm # For POSIX absolute paths outside Assets, drop the leading '/' # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). if effective_path.startswith("/"): effective_path = effective_path[1:] name = os.path.splitext(os.path.basename(effective_path))[0] directory = os.path.dirname(effective_path) return name, directory @mcp_for_unity_tool(description=( """Apply small text edits to a C# script identified by URI. IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! RECOMMENDED WORKFLOW: 1. First call resources/read with start_line/line_count to verify exact content 2. Count columns carefully (or use find_in_file to locate patterns) 3. Apply your edit with precise coordinates 4. Consider script_apply_edits with anchors for safer pattern-based replacements Notes: - For method/class operations, use script_apply_edits (safer, structured edits) - For pattern-based replacements, consider anchor operations in script_apply_edits - Lines, columns are 1-indexed - Tabs count as 1 column""" )) def apply_text_edits( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], 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!)"], precondition_sha256: Annotated[str, "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, strict: Annotated[bool, "Optional strict flag, used to enforce strict mode"] | None = None, options: Annotated[dict[str, Any], "Optional options, used to pass additional options to the script editor"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing apply_text_edits: {uri}") name, directory = _split_uri(uri) # Normalize common aliases/misuses for resilience: # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} # If normalization is required, read current contents to map indices -> 1-based line/col. def _needs_normalization(arr: list[dict[str, Any]]) -> bool: for e in arr or []: 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): return True return False normalized_edits: list[dict[str, Any]] = [] warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed read_resp = send_command_with_retry("manage_script", { "action": "read", "name": name, "path": directory, }) if not (isinstance(read_resp, dict) and read_resp.get("success")): return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} data = read_resp.get("data", {}) contents = data.get("contents") if not contents and data.get("contentsEncoded"): try: contents = base64.b64decode(data.get("encodedContents", "").encode( "utf-8")).decode("utf-8", "replace") except Exception: contents = contents or "" # Helper to map 0-based character index to 1-based line/col def line_col_from_index(idx: int) -> tuple[int, int]: if idx <= 0: return 1, 1 # Count lines up to idx and position within line nl_count = contents.count("\n", 0, idx) line = nl_count + 1 last_nl = contents.rfind("\n", 0, idx) col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 return line, col for e in edits or []: e2 = dict(e) # Map text->newText if needed if "newText" not in e2 and "text" in e2: e2["newText"] = e2.pop("text") if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: # Guard: explicit fields must be 1-based. zero_based = False for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True except Exception: pass if zero_based: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} # Normalize by clamping to 1 and warn for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass warnings.append( "zero_based_explicit_fields_normalized") normalized_edits.append(e2) continue rng = e2.get("range") if isinstance(rng, dict): # LSP style: 0-based s = rng.get("start", {}) t = rng.get("end", {}) e2["startLine"] = int(s.get("line", 0)) + 1 e2["startCol"] = int(s.get("character", 0)) + 1 e2["endLine"] = int(t.get("line", 0)) + 1 e2["endCol"] = int(t.get("character", 0)) + 1 e2.pop("range", None) normalized_edits.append(e2) continue if isinstance(rng, (list, tuple)) and len(rng) == 2: try: a = int(rng[0]) b = int(rng[1]) if b < a: a, b = b, a sl, sc = line_col_from_index(a) el, ec = line_col_from_index(b) e2["startLine"] = sl e2["startCol"] = sc e2["endLine"] = el e2["endCol"] = ec e2.pop("range", None) normalized_edits.append(e2) continue except Exception: pass # Could not normalize this edit return { "success": False, "code": "missing_field", "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} } else: # Even when edits appear already in explicit form, validate 1-based coordinates. normalized_edits = [] for e in edits or []: e2 = dict(e) has_all = all(k in e2 for k in ( "startLine", "startCol", "endLine", "endCol")) if has_all: zero_based = False for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True except Exception: pass if zero_based: if strict: return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass if "zero_based_explicit_fields_normalized" not in warnings: warnings.append( "zero_based_explicit_fields_normalized") normalized_edits.append(e2) # Preflight: detect overlapping ranges among normalized line/col spans def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: return ( int(e.get("startLine", 1)) if key_start else int( e.get("endLine", 1)), int(e.get("startCol", 1)) if key_start else int( e.get("endCol", 1)), ) def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. spans = [] for e in normalized_edits or []: try: s = _pos_tuple(e, True) t = _pos_tuple(e, False) if s != t: spans.append((s, t)) except Exception: # If coordinates missing or invalid, let the server validate later pass if spans: spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) for i in range(1, len(spans_sorted)): prev_end = spans_sorted[i-1][1] curr_start = spans_sorted[i][0] # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start if not _le(prev_end, curr_start): conflicts = [{ "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, }] return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} # Note: Do not auto-compute precondition if missing; callers should supply it # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and # preserves existing call-count expectations in clients/tests. # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance opts: dict[str, Any] = dict(options or {}) try: if len(normalized_edits) > 1 and "applyMode" not in opts: opts["applyMode"] = "atomic" except Exception: pass # Support optional debug preview for span-by-span simulation without write if opts.get("debug_preview"): try: import difflib # Apply locally to preview final result lines = [] # Build an indexable original from a read if we normalized from read; otherwise skip prev = "" # We cannot guarantee file contents here without a read; return normalized spans only return { "success": True, "message": "Preview only (no write)", "data": { "normalizedEdits": normalized_edits, "preview": True } } except Exception as e: return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} params = { "action": "apply_text_edits", "name": name, "path": directory, "edits": normalized_edits, "precondition_sha256": precondition_sha256, "options": opts, } params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict): data = resp.setdefault("data", {}) data.setdefault("normalizedEdits", normalized_edits) if warnings: data.setdefault("warnings", warnings) if resp.get("success") and (options or {}).get("force_sentinel_reload"): # Optional: flip sentinel via menu if explicitly requested try: import threading import time import json import glob import os def _latest_status() -> dict | None: try: files = sorted(glob.glob(os.path.expanduser( "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) if not files: return None with open(files[0], "r") as f: return json.loads(f.read()) except Exception: return None def _flip_async(): try: time.sleep(0.1) st = _latest_status() if st and st.get("reloading"): return send_command_with_retry( "execute_menu_item", {"menuPath": "MCP/Flip Reload Sentinel"}, max_retries=0, retry_ms=0, ) except Exception: pass threading.Thread(target=_flip_async, daemon=True).start() except Exception: pass return resp return resp return {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) def create_script( ctx: Context, path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing create_script: {path}") name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input norm_path = os.path.normpath( (path or "").replace("\\", "/")).replace("\\", "/") if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} if ".." in norm_path.split("/") or norm_path.startswith("/"): return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} if not name: return {"success": False, "code": "bad_path", "message": "path must include a script file name."} if not norm_path.lower().endswith(".cs"): return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} params: dict[str, Any] = { "action": "create", "name": name, "path": directory, "namespace": namespace, "scriptType": script_type, } if contents: params["encodedContents"] = base64.b64encode( contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) def delete_script( ctx: Context, uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: """Delete a C# script by URI.""" ctx.info(f"Processing delete_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} params = {"action": "delete", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) def validate_script( ctx: Context, uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], level: Annotated[Literal['basic', 'standard'], "Validation level"] = "basic", include_diagnostics: Annotated[bool, "Include full diagnostics and summary"] = False ) -> dict[str, Any]: ctx.info(f"Processing validate_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} if level not in ("basic", "standard"): return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} params = { "action": "validate", "name": name, "path": directory, "level": level, } resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] warnings = sum(1 for d in diags if str( d.get("severity", "")).lower() == "warning") errors = sum(1 for d in diags if str( d.get("severity", "")).lower() in ("error", "fatal")) if include_diagnostics: return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} return {"success": True, "data": {"warnings": warnings, "errors": errors}} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) def manage_script( ctx: Context, action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], contents: Annotated[str, "Contents of the script to create", "C# code for 'create'/'update'"] | None = None, script_type: Annotated[str, "Script type (e.g., 'C#')", "Type hint (e.g., 'MonoBehaviour')"] | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_script: {action}") try: # Prepare parameters for Unity params = { "action": action, "name": name, "path": path, "namespace": namespace, "scriptType": script_type, } # Base64 encode the contents if they exist to avoid JSON escaping issues if contents: if action == 'create': params["encodedContents"] = base64.b64encode( contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents params = {k: v for k, v in params.items() if v is not None} response = send_command_with_retry("manage_script", params) if isinstance(response, dict): if response.get("success"): if response.get("data", {}).get("contentsEncoded"): decoded_contents = base64.b64decode( response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] return { "success": True, "message": response.get("message", "Operation successful."), "data": response.get("data"), } return response return {"success": False, "message": str(response)} except Exception as e: return { "success": False, "message": f"Python error managing script: {str(e)}", } @mcp_for_unity_tool(description=( """Get manage_script capabilities (supported ops, limits, and guards). Returns: - ops: list of supported structured ops - text_ops: list of supported text ops - max_edit_payload_bytes: server edit payload cap - guards: header/using guard enabled flag""" )) def manage_script_capabilities(ctx: Context) -> dict[str, Any]: ctx.info("Processing manage_script_capabilities") try: # Keep in sync with server/Editor ManageScript implementation ops = [ "replace_class", "delete_class", "replace_method", "delete_method", "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" ] text_ops = ["replace_range", "regex_replace", "prepend", "append"] # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback max_edit_payload_bytes = 256 * 1024 guards = {"using_guard": True} extras = {"get_sha": True} return {"success": True, "data": { "ops": ops, "text_ops": text_ops, "max_edit_payload_bytes": max_edit_payload_bytes, "guards": guards, "extras": extras, }} except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") def get_sha( ctx: Context, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] ) -> dict[str, Any]: ctx.info(f"Processing get_sha: {uri}") try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) minimal = {"sha256": data.get( "sha256"), "lengthBytes": data.get("lengthBytes")} return {"success": True, "data": minimal} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: return {"success": False, "message": f"get_sha error: {e}"} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageEditor.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management using UnityEditor.SceneManagement; using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles operations related to controlling and querying the Unity Editor state, /// including managing Tags and Layers. /// </summary> [McpForUnityTool("manage_editor")] public static class ManageEditor { // Constant for starting user layer index private const int FirstUserLayerIndex = 8; // Constant for total layer count private const int TotalLayerCount = 32; /// <summary> /// Main handler for editor management actions. /// </summary> public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString().ToLower(); // Parameters for specific actions string tagName = @params["tagName"]?.ToString(); string layerName = @params["layerName"]?.ToString(); bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Route action switch (action) { // Play Mode Control case "play": try { if (!EditorApplication.isPlaying) { EditorApplication.isPlaying = true; return Response.Success("Entered play mode."); } return Response.Success("Already in play mode."); } catch (Exception e) { return Response.Error($"Error entering play mode: {e.Message}"); } case "pause": try { if (EditorApplication.isPlaying) { EditorApplication.isPaused = !EditorApplication.isPaused; return Response.Success( EditorApplication.isPaused ? "Game paused." : "Game resumed." ); } return Response.Error("Cannot pause/resume: Not in play mode."); } catch (Exception e) { return Response.Error($"Error pausing/resuming game: {e.Message}"); } case "stop": try { if (EditorApplication.isPlaying) { EditorApplication.isPlaying = false; return Response.Success("Exited play mode."); } return Response.Success("Already stopped (not in play mode)."); } catch (Exception e) { return Response.Error($"Error stopping play mode: {e.Message}"); } // Editor State/Info case "get_state": return GetEditorState(); case "get_project_root": return GetProjectRoot(); case "get_windows": return GetEditorWindows(); case "get_active_tool": return GetActiveTool(); case "get_selection": return GetSelection(); case "get_prefab_stage": return GetPrefabStageInfo(); case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) return Response.Error("'toolName' parameter required for set_active_tool."); return SetActiveTool(toolName); // Tag Management case "add_tag": if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for add_tag."); return AddTag(tagName); case "remove_tag": if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); case "get_tags": return GetTags(); // Helper to list current tags // Layer Management case "add_layer": if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for add_layer."); return AddLayer(layerName); case "remove_layer": if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); case "get_layers": return GetLayers(); // Helper to list current layers // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject<int?>(); // int? height = @params["height"]?.ToObject<int?>(); // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); // return SetGameViewResolution(width.Value, height.Value); // case "set_quality": // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); default: return Response.Error( $"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." ); } } // --- Editor State/Info Methods --- private static object GetEditorState() { try { var state = new { isPlaying = EditorApplication.isPlaying, isPaused = EditorApplication.isPaused, isCompiling = EditorApplication.isCompiling, isUpdating = EditorApplication.isUpdating, applicationPath = EditorApplication.applicationPath, applicationContentsPath = EditorApplication.applicationContentsPath, timeSinceStartup = EditorApplication.timeSinceStartup, }; return Response.Success("Retrieved editor state.", state); } catch (Exception e) { return Response.Error($"Error getting editor state: {e.Message}"); } } private static object GetProjectRoot() { try { // Application.dataPath points to <Project>/Assets string assetsPath = Application.dataPath.Replace('\\', '/'); string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); if (string.IsNullOrEmpty(projectRoot)) { return Response.Error("Could not determine project root from Application.dataPath"); } return Response.Success("Project root resolved.", new { projectRoot }); } catch (Exception e) { return Response.Error($"Error getting project root: {e.Message}"); } } private static object GetEditorWindows() { try { // Get all types deriving from EditorWindow var windowTypes = AppDomain .CurrentDomain.GetAssemblies() .SelectMany(assembly => assembly.GetTypes()) .Where(type => type.IsSubclassOf(typeof(EditorWindow))) .ToList(); var openWindows = new List<object>(); // Find currently open instances // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>(); foreach (EditorWindow window in allWindows) { if (window == null) continue; // Skip potentially destroyed windows try { openWindows.Add( new { title = window.titleContent.text, typeName = window.GetType().FullName, isFocused = EditorWindow.focusedWindow == window, position = new { x = window.position.x, y = window.position.y, width = window.position.width, height = window.position.height, }, instanceID = window.GetInstanceID(), } ); } catch (Exception ex) { Debug.LogWarning( $"Could not get info for window {window.GetType().Name}: {ex.Message}" ); } } return Response.Success("Retrieved list of open editor windows.", openWindows); } catch (Exception e) { return Response.Error($"Error getting editor windows: {e.Message}"); } } private static object GetPrefabStageInfo() { try { PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { return Response.Success ("No prefab stage is currently open.", new { isOpen = false }); } return Response.Success( "Prefab stage info retrieved.", new { isOpen = true, assetPath = stage.assetPath, prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, mode = stage.mode.ToString(), isDirty = stage.scene.isDirty } ); } catch (Exception e) { return Response.Error($"Error getting prefab stage info: {e.Message}"); } } private static object GetActiveTool() { try { Tool currentTool = UnityEditor.Tools.current; string toolName = currentTool.ToString(); // Enum to string bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; // Get custom name if needed var toolInfo = new { activeTool = activeToolName, isCustom = customToolActive, pivotMode = UnityEditor.Tools.pivotMode.ToString(), pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity handlePosition = UnityEditor.Tools.handlePosition, }; return Response.Success("Retrieved active tool information.", toolInfo); } catch (Exception e) { return Response.Error($"Error getting active tool: {e.Message}"); } } private static object SetActiveTool(string toolName) { try { Tool targetTool; if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse { // Check if it's a valid built-in tool if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool { UnityEditor.Tools.current = targetTool; return Response.Success($"Set active tool to '{targetTool}'."); } else { return Response.Error( $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." ); } } else { // Potentially try activating a custom tool by name here if needed // This often requires specific editor scripting knowledge for that tool. return Response.Error( $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." ); } } catch (Exception e) { return Response.Error($"Error setting active tool: {e.Message}"); } } private static object GetSelection() { try { var selectionInfo = new { activeObject = Selection.activeObject?.name, activeGameObject = Selection.activeGameObject?.name, activeTransform = Selection.activeTransform?.name, activeInstanceID = Selection.activeInstanceID, count = Selection.count, objects = Selection .objects.Select(obj => new { name = obj?.name, type = obj?.GetType().FullName, instanceID = obj?.GetInstanceID(), }) .ToList(), gameObjects = Selection .gameObjects.Select(go => new { name = go?.name, instanceID = go?.GetInstanceID(), }) .ToList(), assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view }; return Response.Success("Retrieved current selection details.", selectionInfo); } catch (Exception e) { return Response.Error($"Error getting selection: {e.Message}"); } } // --- Tag Management Methods --- private static object AddTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) return Response.Error("Tag name cannot be empty or whitespace."); // Check if tag already exists if (InternalEditorUtility.tags.Contains(tagName)) { return Response.Error($"Tag '{tagName}' already exists."); } try { // Add the tag using the internal utility InternalEditorUtility.AddTag(tagName); // Force save assets to ensure the change persists in the TagManager asset AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' added successfully."); } catch (Exception e) { return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); } } private static object RemoveTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) return Response.Error("Tag name cannot be empty or whitespace."); if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) return Response.Error("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal if (!InternalEditorUtility.tags.Contains(tagName)) { return Response.Error($"Tag '{tagName}' does not exist."); } try { // Remove the tag using the internal utility InternalEditorUtility.RemoveTag(tagName); // Force save assets AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' removed successfully."); } catch (Exception e) { // Catch potential issues if the tag is somehow in use or removal fails return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); } } private static object GetTags() { try { string[] tags = InternalEditorUtility.tags; return Response.Success("Retrieved current tags.", tags); } catch (Exception e) { return Response.Error($"Failed to retrieve tags: {e.Message}"); } } // --- Layer Management Methods --- private static object AddLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) return Response.Error("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) return Response.Error("Could not find 'layers' property in TagManager."); // Check if layer name already exists (case-insensitive check recommended) for (int i = 0; i < TotalLayerCount; i++) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); if ( layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { return Response.Error($"Layer '{layerName}' already exists at index {i}."); } } // Find the first empty user layer slot (indices 8 to 31) int firstEmptyUserLayer = -1; for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) { firstEmptyUserLayer = i; break; } } if (firstEmptyUserLayer == -1) { return Response.Error("No empty User Layer slots available (8-31 are full)."); } // Assign the name to the found slot try { SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( firstEmptyUserLayer ); targetLayerSP.stringValue = layerName; // Apply the changes to the TagManager asset tagManager.ApplyModifiedProperties(); // Save assets to make sure it's written to disk AssetDatabase.SaveAssets(); return Response.Success( $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." ); } catch (Exception e) { return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); } } private static object RemoveLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) return Response.Error("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) return Response.Error("Could not find 'layers' property in TagManager."); // Find the layer by name (must be user layer) int layerIndexToRemove = -1; for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); // Case-insensitive comparison is safer if ( layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { layerIndexToRemove = i; break; } } if (layerIndexToRemove == -1) { return Response.Error($"User layer '{layerName}' not found."); } // Clear the name for that index try { SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( layerIndexToRemove ); targetLayerSP.stringValue = string.Empty; // Set to empty string to remove // Apply the changes tagManager.ApplyModifiedProperties(); // Save assets AssetDatabase.SaveAssets(); return Response.Success( $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." ); } catch (Exception e) { return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); } } private static object GetLayers() { try { var layers = new Dictionary<int, string>(); for (int i = 0; i < TotalLayerCount; i++) { string layerName = LayerMask.LayerToName(i); if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names { layers.Add(i, layerName); } } return Response.Success("Retrieved current named layers.", layers); } catch (Exception e) { return Response.Error($"Failed to retrieve layers: {e.Message}"); } } // --- Helper Methods --- /// <summary> /// Gets the SerializedObject for the TagManager asset. /// </summary> private static SerializedObject GetTagManager() { try { // Load the TagManager asset from the ProjectSettings folder UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( "ProjectSettings/TagManager.asset" ); if (tagManagerAssets == null || tagManagerAssets.Length == 0) { Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); return null; } // The first object in the asset file should be the TagManager return new SerializedObject(tagManagerAssets[0]); } catch (Exception e) { Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); return null; } } // --- Example Implementations for Settings --- /* private static object SetGameViewResolution(int width, int height) { ... } private static object SetQualityLevel(JToken qualityLevelToken) { ... } */ } // Helper class to get custom tool names (remains the same) internal static class EditorTools { public static string GetActiveToolName() { // This is a placeholder. Real implementation depends on how custom tools // are registered and tracked in the specific Unity project setup. // It might involve checking static variables, calling methods on specific tool managers, etc. if (UnityEditor.Tools.current == Tool.Custom) { // Example: Check a known custom tool manager // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; return "Unknown Custom Tool"; } return UnityEditor.Tools.current.ToString(); } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageEditor.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management using UnityEditor.SceneManagement; using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles operations related to controlling and querying the Unity Editor state, /// including managing Tags and Layers. /// </summary> [McpForUnityTool("manage_editor")] public static class ManageEditor { // Constant for starting user layer index private const int FirstUserLayerIndex = 8; // Constant for total layer count private const int TotalLayerCount = 32; /// <summary> /// Main handler for editor management actions. /// </summary> public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString().ToLower(); // Parameters for specific actions string tagName = @params["tagName"]?.ToString(); string layerName = @params["layerName"]?.ToString(); bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Route action switch (action) { // Play Mode Control case "play": try { if (!EditorApplication.isPlaying) { EditorApplication.isPlaying = true; return Response.Success("Entered play mode."); } return Response.Success("Already in play mode."); } catch (Exception e) { return Response.Error($"Error entering play mode: {e.Message}"); } case "pause": try { if (EditorApplication.isPlaying) { EditorApplication.isPaused = !EditorApplication.isPaused; return Response.Success( EditorApplication.isPaused ? "Game paused." : "Game resumed." ); } return Response.Error("Cannot pause/resume: Not in play mode."); } catch (Exception e) { return Response.Error($"Error pausing/resuming game: {e.Message}"); } case "stop": try { if (EditorApplication.isPlaying) { EditorApplication.isPlaying = false; return Response.Success("Exited play mode."); } return Response.Success("Already stopped (not in play mode)."); } catch (Exception e) { return Response.Error($"Error stopping play mode: {e.Message}"); } // Editor State/Info case "get_state": return GetEditorState(); case "get_project_root": return GetProjectRoot(); case "get_windows": return GetEditorWindows(); case "get_active_tool": return GetActiveTool(); case "get_selection": return GetSelection(); case "get_prefab_stage": return GetPrefabStageInfo(); case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) return Response.Error("'toolName' parameter required for set_active_tool."); return SetActiveTool(toolName); // Tag Management case "add_tag": if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for add_tag."); return AddTag(tagName); case "remove_tag": if (string.IsNullOrEmpty(tagName)) return Response.Error("'tagName' parameter required for remove_tag."); return RemoveTag(tagName); case "get_tags": return GetTags(); // Helper to list current tags // Layer Management case "add_layer": if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for add_layer."); return AddLayer(layerName); case "remove_layer": if (string.IsNullOrEmpty(layerName)) return Response.Error("'layerName' parameter required for remove_layer."); return RemoveLayer(layerName); case "get_layers": return GetLayers(); // Helper to list current layers // --- Settings (Example) --- // case "set_resolution": // int? width = @params["width"]?.ToObject<int?>(); // int? height = @params["height"]?.ToObject<int?>(); // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); // return SetGameViewResolution(width.Value, height.Value); // case "set_quality": // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); default: return Response.Error( $"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." ); } } // --- Editor State/Info Methods --- private static object GetEditorState() { try { var state = new { isPlaying = EditorApplication.isPlaying, isPaused = EditorApplication.isPaused, isCompiling = EditorApplication.isCompiling, isUpdating = EditorApplication.isUpdating, applicationPath = EditorApplication.applicationPath, applicationContentsPath = EditorApplication.applicationContentsPath, timeSinceStartup = EditorApplication.timeSinceStartup, }; return Response.Success("Retrieved editor state.", state); } catch (Exception e) { return Response.Error($"Error getting editor state: {e.Message}"); } } private static object GetProjectRoot() { try { // Application.dataPath points to <Project>/Assets string assetsPath = Application.dataPath.Replace('\\', '/'); string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); if (string.IsNullOrEmpty(projectRoot)) { return Response.Error("Could not determine project root from Application.dataPath"); } return Response.Success("Project root resolved.", new { projectRoot }); } catch (Exception e) { return Response.Error($"Error getting project root: {e.Message}"); } } private static object GetEditorWindows() { try { // Get all types deriving from EditorWindow var windowTypes = AppDomain .CurrentDomain.GetAssemblies() .SelectMany(assembly => assembly.GetTypes()) .Where(type => type.IsSubclassOf(typeof(EditorWindow))) .ToList(); var openWindows = new List<object>(); // Find currently open instances // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll<EditorWindow>(); foreach (EditorWindow window in allWindows) { if (window == null) continue; // Skip potentially destroyed windows try { openWindows.Add( new { title = window.titleContent.text, typeName = window.GetType().FullName, isFocused = EditorWindow.focusedWindow == window, position = new { x = window.position.x, y = window.position.y, width = window.position.width, height = window.position.height, }, instanceID = window.GetInstanceID(), } ); } catch (Exception ex) { Debug.LogWarning( $"Could not get info for window {window.GetType().Name}: {ex.Message}" ); } } return Response.Success("Retrieved list of open editor windows.", openWindows); } catch (Exception e) { return Response.Error($"Error getting editor windows: {e.Message}"); } } private static object GetPrefabStageInfo() { try { PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); if (stage == null) { return Response.Success ("No prefab stage is currently open.", new { isOpen = false }); } return Response.Success( "Prefab stage info retrieved.", new { isOpen = true, assetPath = stage.assetPath, prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, mode = stage.mode.ToString(), isDirty = stage.scene.isDirty } ); } catch (Exception e) { return Response.Error($"Error getting prefab stage info: {e.Message}"); } } private static object GetActiveTool() { try { Tool currentTool = UnityEditor.Tools.current; string toolName = currentTool.ToString(); // Enum to string bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; // Get custom name if needed var toolInfo = new { activeTool = activeToolName, isCustom = customToolActive, pivotMode = UnityEditor.Tools.pivotMode.ToString(), pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity handlePosition = UnityEditor.Tools.handlePosition, }; return Response.Success("Retrieved active tool information.", toolInfo); } catch (Exception e) { return Response.Error($"Error getting active tool: {e.Message}"); } } private static object SetActiveTool(string toolName) { try { Tool targetTool; if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse { // Check if it's a valid built-in tool if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool { UnityEditor.Tools.current = targetTool; return Response.Success($"Set active tool to '{targetTool}'."); } else { return Response.Error( $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." ); } } else { // Potentially try activating a custom tool by name here if needed // This often requires specific editor scripting knowledge for that tool. return Response.Error( $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." ); } } catch (Exception e) { return Response.Error($"Error setting active tool: {e.Message}"); } } private static object GetSelection() { try { var selectionInfo = new { activeObject = Selection.activeObject?.name, activeGameObject = Selection.activeGameObject?.name, activeTransform = Selection.activeTransform?.name, activeInstanceID = Selection.activeInstanceID, count = Selection.count, objects = Selection .objects.Select(obj => new { name = obj?.name, type = obj?.GetType().FullName, instanceID = obj?.GetInstanceID(), }) .ToList(), gameObjects = Selection .gameObjects.Select(go => new { name = go?.name, instanceID = go?.GetInstanceID(), }) .ToList(), assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view }; return Response.Success("Retrieved current selection details.", selectionInfo); } catch (Exception e) { return Response.Error($"Error getting selection: {e.Message}"); } } // --- Tag Management Methods --- private static object AddTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) return Response.Error("Tag name cannot be empty or whitespace."); // Check if tag already exists if (InternalEditorUtility.tags.Contains(tagName)) { return Response.Error($"Tag '{tagName}' already exists."); } try { // Add the tag using the internal utility InternalEditorUtility.AddTag(tagName); // Force save assets to ensure the change persists in the TagManager asset AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' added successfully."); } catch (Exception e) { return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); } } private static object RemoveTag(string tagName) { if (string.IsNullOrWhiteSpace(tagName)) return Response.Error("Tag name cannot be empty or whitespace."); if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) return Response.Error("Cannot remove the built-in 'Untagged' tag."); // Check if tag exists before attempting removal if (!InternalEditorUtility.tags.Contains(tagName)) { return Response.Error($"Tag '{tagName}' does not exist."); } try { // Remove the tag using the internal utility InternalEditorUtility.RemoveTag(tagName); // Force save assets AssetDatabase.SaveAssets(); return Response.Success($"Tag '{tagName}' removed successfully."); } catch (Exception e) { // Catch potential issues if the tag is somehow in use or removal fails return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); } } private static object GetTags() { try { string[] tags = InternalEditorUtility.tags; return Response.Success("Retrieved current tags.", tags); } catch (Exception e) { return Response.Error($"Failed to retrieve tags: {e.Message}"); } } // --- Layer Management Methods --- private static object AddLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) return Response.Error("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) return Response.Error("Could not find 'layers' property in TagManager."); // Check if layer name already exists (case-insensitive check recommended) for (int i = 0; i < TotalLayerCount; i++) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); if ( layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { return Response.Error($"Layer '{layerName}' already exists at index {i}."); } } // Find the first empty user layer slot (indices 8 to 31) int firstEmptyUserLayer = -1; for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) { firstEmptyUserLayer = i; break; } } if (firstEmptyUserLayer == -1) { return Response.Error("No empty User Layer slots available (8-31 are full)."); } // Assign the name to the found slot try { SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( firstEmptyUserLayer ); targetLayerSP.stringValue = layerName; // Apply the changes to the TagManager asset tagManager.ApplyModifiedProperties(); // Save assets to make sure it's written to disk AssetDatabase.SaveAssets(); return Response.Success( $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." ); } catch (Exception e) { return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); } } private static object RemoveLayer(string layerName) { if (string.IsNullOrWhiteSpace(layerName)) return Response.Error("Layer name cannot be empty or whitespace."); // Access the TagManager asset SerializedObject tagManager = GetTagManager(); if (tagManager == null) return Response.Error("Could not access TagManager asset."); SerializedProperty layersProp = tagManager.FindProperty("layers"); if (layersProp == null || !layersProp.isArray) return Response.Error("Could not find 'layers' property in TagManager."); // Find the layer by name (must be user layer) int layerIndexToRemove = -1; for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers { SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); // Case-insensitive comparison is safer if ( layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) ) { layerIndexToRemove = i; break; } } if (layerIndexToRemove == -1) { return Response.Error($"User layer '{layerName}' not found."); } // Clear the name for that index try { SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( layerIndexToRemove ); targetLayerSP.stringValue = string.Empty; // Set to empty string to remove // Apply the changes tagManager.ApplyModifiedProperties(); // Save assets AssetDatabase.SaveAssets(); return Response.Success( $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." ); } catch (Exception e) { return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); } } private static object GetLayers() { try { var layers = new Dictionary<int, string>(); for (int i = 0; i < TotalLayerCount; i++) { string layerName = LayerMask.LayerToName(i); if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names { layers.Add(i, layerName); } } return Response.Success("Retrieved current named layers.", layers); } catch (Exception e) { return Response.Error($"Failed to retrieve layers: {e.Message}"); } } // --- Helper Methods --- /// <summary> /// Gets the SerializedObject for the TagManager asset. /// </summary> private static SerializedObject GetTagManager() { try { // Load the TagManager asset from the ProjectSettings folder UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( "ProjectSettings/TagManager.asset" ); if (tagManagerAssets == null || tagManagerAssets.Length == 0) { Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); return null; } // The first object in the asset file should be the TagManager return new SerializedObject(tagManagerAssets[0]); } catch (Exception e) { Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); return null; } } // --- Example Implementations for Settings --- /* private static object SetGameViewResolution(int width, int height) { ... } private static object SetQualityLevel(JToken qualityLevelToken) { ... } */ } // Helper class to get custom tool names (remains the same) internal static class EditorTools { public static string GetActiveToolName() { // This is a placeholder. Real implementation depends on how custom tools // are registered and tracked in the specific Unity project setup. // It might involve checking static variables, calling methods on specific tool managers, etc. if (UnityEditor.Tools.current == Tool.Custom) { // Example: Check a known custom tool manager // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; return "Unknown Custom Tool"; } return UnityEditor.Tools.current.ToString(); } } } ```