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

# Directory Structure

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

# Files

--------------------------------------------------------------------------------
/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Linq;
  4 | using System.Reflection;
  5 | using Newtonsoft.Json;
  6 | using Newtonsoft.Json.Linq;
  7 | using UnityEditor;
  8 | using UnityEngine;
  9 | using MCPForUnity.Runtime.Serialization; // For Converters
 10 | 
 11 | namespace MCPForUnity.Editor.Helpers
 12 | {
 13 |     /// <summary>
 14 |     /// Handles serialization of GameObjects and Components for MCP responses.
 15 |     /// Includes reflection helpers and caching for performance.
 16 |     /// </summary> 
 17 |     public static class GameObjectSerializer
 18 |     {
 19 |         // --- Data Serialization ---
 20 | 
 21 |         /// <summary>
 22 |         /// Creates a serializable representation of a GameObject.
 23 |         /// </summary>
 24 |         public static object GetGameObjectData(GameObject go)
 25 |         {
 26 |             if (go == null)
 27 |                 return null;
 28 |             return new
 29 |             {
 30 |                 name = go.name,
 31 |                 instanceID = go.GetInstanceID(),
 32 |                 tag = go.tag,
 33 |                 layer = go.layer,
 34 |                 activeSelf = go.activeSelf,
 35 |                 activeInHierarchy = go.activeInHierarchy,
 36 |                 isStatic = go.isStatic,
 37 |                 scenePath = go.scene.path, // Identify which scene it belongs to
 38 |                 transform = new // Serialize transform components carefully to avoid JSON issues
 39 |                 {
 40 |                     // Serialize Vector3 components individually to prevent self-referencing loops.
 41 |                     // The default serializer can struggle with properties like Vector3.normalized.
 42 |                     position = new
 43 |                     {
 44 |                         x = go.transform.position.x,
 45 |                         y = go.transform.position.y,
 46 |                         z = go.transform.position.z,
 47 |                     },
 48 |                     localPosition = new
 49 |                     {
 50 |                         x = go.transform.localPosition.x,
 51 |                         y = go.transform.localPosition.y,
 52 |                         z = go.transform.localPosition.z,
 53 |                     },
 54 |                     rotation = new
 55 |                     {
 56 |                         x = go.transform.rotation.eulerAngles.x,
 57 |                         y = go.transform.rotation.eulerAngles.y,
 58 |                         z = go.transform.rotation.eulerAngles.z,
 59 |                     },
 60 |                     localRotation = new
 61 |                     {
 62 |                         x = go.transform.localRotation.eulerAngles.x,
 63 |                         y = go.transform.localRotation.eulerAngles.y,
 64 |                         z = go.transform.localRotation.eulerAngles.z,
 65 |                     },
 66 |                     scale = new
 67 |                     {
 68 |                         x = go.transform.localScale.x,
 69 |                         y = go.transform.localScale.y,
 70 |                         z = go.transform.localScale.z,
 71 |                     },
 72 |                     forward = new
 73 |                     {
 74 |                         x = go.transform.forward.x,
 75 |                         y = go.transform.forward.y,
 76 |                         z = go.transform.forward.z,
 77 |                     },
 78 |                     up = new
 79 |                     {
 80 |                         x = go.transform.up.x,
 81 |                         y = go.transform.up.y,
 82 |                         z = go.transform.up.z,
 83 |                     },
 84 |                     right = new
 85 |                     {
 86 |                         x = go.transform.right.x,
 87 |                         y = go.transform.right.y,
 88 |                         z = go.transform.right.z,
 89 |                     },
 90 |                 },
 91 |                 parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent
 92 |                 // Optionally include components, but can be large
 93 |                 // components = go.GetComponents<Component>().Select(c => GetComponentData(c)).ToList()
 94 |                 // Or just component names:
 95 |                 componentNames = go.GetComponents<Component>()
 96 |                     .Select(c => c.GetType().FullName)
 97 |                     .ToList(),
 98 |             };
 99 |         }
100 | 
101 |         // --- Metadata Caching for Reflection ---
102 |         private class CachedMetadata
103 |         {
104 |             public readonly List<PropertyInfo> SerializableProperties;
105 |             public readonly List<FieldInfo> SerializableFields;
106 | 
107 |             public CachedMetadata(List<PropertyInfo> properties, List<FieldInfo> fields)
108 |             {
109 |                 SerializableProperties = properties;
110 |                 SerializableFields = fields;
111 |             }
112 |         }
113 |         // Key becomes Tuple<Type, bool>
114 |         private static readonly Dictionary<Tuple<Type, bool>, CachedMetadata> _metadataCache = new Dictionary<Tuple<Type, bool>, CachedMetadata>();
115 |         // --- End Metadata Caching ---
116 | 
117 |         /// <summary>
118 |         /// Creates a serializable representation of a Component, attempting to serialize
119 |         /// public properties and fields using reflection, with caching and control over non-public fields.
120 |         /// </summary>
121 |         // Add the flag parameter here
122 |         public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true)
123 |         {
124 |             // --- Add Early Logging --- 
125 |             // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})");
126 |             // --- End Early Logging ---
127 | 
128 |             if (c == null) return null;
129 |             Type componentType = c.GetType();
130 | 
131 |             // --- Special handling for Transform to avoid reflection crashes and problematic properties --- 
132 |             if (componentType == typeof(Transform))
133 |             {
134 |                 Transform tr = c as Transform;
135 |                 // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})");
136 |                 return new Dictionary<string, object>
137 |                 {
138 |                     { "typeName", componentType.FullName },
139 |                     { "instanceID", tr.GetInstanceID() },
140 |                     // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'.
141 |                     { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
142 |                     { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
143 |                     { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() }, // Use Euler angles
144 |                     { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
145 |                     { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
146 |                     { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
147 |                     { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
148 |                     { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject<object>() ?? new JObject() },
149 |                     { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 },
150 |                     { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 },
151 |                     { "childCount", tr.childCount },
152 |                     // Include standard Object/Component properties
153 |                     { "name", tr.name },
154 |                     { "tag", tr.tag },
155 |                     { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 }
156 |                 };
157 |             }
158 |             // --- End Special handling for Transform --- 
159 | 
160 |             // --- Special handling for Camera to avoid matrix-related crashes ---
161 |             if (componentType == typeof(Camera))
162 |             {
163 |                 Camera cam = c as Camera;
164 |                 var cameraProperties = new Dictionary<string, object>();
165 | 
166 |                 // List of safe properties to serialize
167 |                 var safeProperties = new Dictionary<string, Func<object>>
168 |                 {
169 |                     { "nearClipPlane", () => cam.nearClipPlane },
170 |                     { "farClipPlane", () => cam.farClipPlane },
171 |                     { "fieldOfView", () => cam.fieldOfView },
172 |                     { "renderingPath", () => (int)cam.renderingPath },
173 |                     { "actualRenderingPath", () => (int)cam.actualRenderingPath },
174 |                     { "allowHDR", () => cam.allowHDR },
175 |                     { "allowMSAA", () => cam.allowMSAA },
176 |                     { "allowDynamicResolution", () => cam.allowDynamicResolution },
177 |                     { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture },
178 |                     { "orthographicSize", () => cam.orthographicSize },
179 |                     { "orthographic", () => cam.orthographic },
180 |                     { "opaqueSortMode", () => (int)cam.opaqueSortMode },
181 |                     { "transparencySortMode", () => (int)cam.transparencySortMode },
182 |                     { "depth", () => cam.depth },
183 |                     { "aspect", () => cam.aspect },
184 |                     { "cullingMask", () => cam.cullingMask },
185 |                     { "eventMask", () => cam.eventMask },
186 |                     { "backgroundColor", () => cam.backgroundColor },
187 |                     { "clearFlags", () => (int)cam.clearFlags },
188 |                     { "stereoEnabled", () => cam.stereoEnabled },
189 |                     { "stereoSeparation", () => cam.stereoSeparation },
190 |                     { "stereoConvergence", () => cam.stereoConvergence },
191 |                     { "enabled", () => cam.enabled },
192 |                     { "name", () => cam.name },
193 |                     { "tag", () => cam.tag },
194 |                     { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } }
195 |                 };
196 | 
197 |                 foreach (var prop in safeProperties)
198 |                 {
199 |                     try
200 |                     {
201 |                         var value = prop.Value();
202 |                         if (value != null)
203 |                         {
204 |                             AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value);
205 |                         }
206 |                     }
207 |                     catch (Exception)
208 |                     {
209 |                         // Silently skip any property that fails
210 |                         continue;
211 |                     }
212 |                 }
213 | 
214 |                 return new Dictionary<string, object>
215 |                 {
216 |                     { "typeName", componentType.FullName },
217 |                     { "instanceID", cam.GetInstanceID() },
218 |                     { "properties", cameraProperties }
219 |                 };
220 |             }
221 |             // --- End Special handling for Camera ---
222 | 
223 |             var data = new Dictionary<string, object>
224 |             {
225 |                 { "typeName", componentType.FullName },
226 |                 { "instanceID", c.GetInstanceID() }
227 |             };
228 | 
229 |             // --- Get Cached or Generate Metadata (using new cache key) ---
230 |             Tuple<Type, bool> cacheKey = new Tuple<Type, bool>(componentType, includeNonPublicSerializedFields);
231 |             if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData))
232 |             {
233 |                 var propertiesToCache = new List<PropertyInfo>();
234 |                 var fieldsToCache = new List<FieldInfo>();
235 | 
236 |                 // Traverse the hierarchy from the component type up to MonoBehaviour
237 |                 Type currentType = componentType;
238 |                 while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object))
239 |                 {
240 |                     // Get properties declared only at the current type level
241 |                     BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly;
242 |                     foreach (var propInfo in currentType.GetProperties(propFlags))
243 |                     {
244 |                         // Basic filtering (readable, not indexer, not transform which is handled elsewhere)
245 |                         if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue;
246 |                         // Add if not already added (handles overrides - keep the most derived version)
247 |                         if (!propertiesToCache.Any(p => p.Name == propInfo.Name))
248 |                         {
249 |                             propertiesToCache.Add(propInfo);
250 |                         }
251 |                     }
252 | 
253 |                     // Get fields declared only at the current type level (both public and non-public)
254 |                     BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly;
255 |                     var declaredFields = currentType.GetFields(fieldFlags);
256 | 
257 |                     // Process the declared Fields for caching
258 |                     foreach (var fieldInfo in declaredFields)
259 |                     {
260 |                         if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields
261 | 
262 |                         // Add if not already added (handles hiding - keep the most derived version)
263 |                         if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue;
264 | 
265 |                         bool shouldInclude = false;
266 |                         if (includeNonPublicSerializedFields)
267 |                         {
268 |                             // If TRUE, include Public OR NonPublic with [SerializeField]
269 |                             shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false));
270 |                         }
271 |                         else // includeNonPublicSerializedFields is FALSE
272 |                         {
273 |                             // If FALSE, include ONLY if it is explicitly Public.
274 |                             shouldInclude = fieldInfo.IsPublic;
275 |                         }
276 | 
277 |                         if (shouldInclude)
278 |                         {
279 |                             fieldsToCache.Add(fieldInfo);
280 |                         }
281 |                     }
282 | 
283 |                     // Move to the base type
284 |                     currentType = currentType.BaseType;
285 |                 }
286 |                 // --- End Hierarchy Traversal ---
287 | 
288 |                 cachedData = new CachedMetadata(propertiesToCache, fieldsToCache);
289 |                 _metadataCache[cacheKey] = cachedData; // Add to cache with combined key
290 |             }
291 |             // --- End Get Cached or Generate Metadata ---
292 | 
293 |             // --- Use cached metadata ---
294 |             var serializablePropertiesOutput = new Dictionary<string, object>();
295 | 
296 |             // --- Add Logging Before Property Loop ---
297 |             // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}...");
298 |             // --- End Logging Before Property Loop ---
299 | 
300 |             // Use cached properties
301 |             foreach (var propInfo in cachedData.SerializableProperties)
302 |             {
303 |                 string propName = propInfo.Name;
304 | 
305 |                 // --- Skip known obsolete/problematic Component shortcut properties ---
306 |                 bool skipProperty = false;
307 |                 if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" ||
308 |                     propName == "light" || propName == "animation" || propName == "constantForce" ||
309 |                     propName == "renderer" || propName == "audio" || propName == "networkView" ||
310 |                     propName == "collider" || propName == "collider2D" || propName == "hingeJoint" ||
311 |                     propName == "particleSystem" ||
312 |                     // Also skip potentially problematic Matrix properties prone to cycles/errors
313 |                     propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")
314 |                 {
315 |                     // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log
316 |                     skipProperty = true;
317 |                 }
318 |                 // --- End Skip Generic Properties ---
319 | 
320 |                 // --- Skip specific potentially problematic Camera properties ---
321 |                 if (componentType == typeof(Camera) &&
322 |                     (propName == "pixelRect" ||
323 |                      propName == "rect" ||
324 |                      propName == "cullingMatrix" ||
325 |                      propName == "useOcclusionCulling" ||
326 |                      propName == "worldToCameraMatrix" ||
327 |                      propName == "projectionMatrix" ||
328 |                      propName == "nonJitteredProjectionMatrix" ||
329 |                      propName == "previousViewProjectionMatrix" ||
330 |                      propName == "cameraToWorldMatrix"))
331 |                 {
332 |                     // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}");
333 |                     skipProperty = true;
334 |                 }
335 |                 // --- End Skip Camera Properties ---
336 | 
337 |                 // --- Skip specific potentially problematic Transform properties ---
338 |                 if (componentType == typeof(Transform) &&
339 |                     (propName == "lossyScale" ||
340 |                      propName == "rotation" ||
341 |                      propName == "worldToLocalMatrix" ||
342 |                      propName == "localToWorldMatrix"))
343 |                 {
344 |                     // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}");
345 |                     skipProperty = true;
346 |                 }
347 |                 // --- End Skip Transform Properties ---
348 | 
349 |                 // Skip if flagged
350 |                 if (skipProperty)
351 |                 {
352 |                     continue;
353 |                 }
354 | 
355 |                 try
356 |                 {
357 |                     // --- Add detailed logging --- 
358 |                     // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}");
359 |                     // --- End detailed logging ---
360 |                     object value = propInfo.GetValue(c);
361 |                     Type propType = propInfo.PropertyType;
362 |                     AddSerializableValue(serializablePropertiesOutput, propName, propType, value);
363 |                 }
364 |                 catch (Exception)
365 |                 {
366 |                     // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}");
367 |                 }
368 |             }
369 | 
370 |             // --- Add Logging Before Field Loop ---
371 |             // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}...");
372 |             // --- End Logging Before Field Loop ---
373 | 
374 |             // Use cached fields
375 |             foreach (var fieldInfo in cachedData.SerializableFields)
376 |             {
377 |                 try
378 |                 {
379 |                     // --- Add detailed logging for fields --- 
380 |                     // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}");
381 |                     // --- End detailed logging for fields ---
382 |                     object value = fieldInfo.GetValue(c);
383 |                     string fieldName = fieldInfo.Name;
384 |                     Type fieldType = fieldInfo.FieldType;
385 |                     AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value);
386 |                 }
387 |                 catch (Exception)
388 |                 {
389 |                     // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}");
390 |                 }
391 |             }
392 |             // --- End Use cached metadata ---
393 | 
394 |             if (serializablePropertiesOutput.Count > 0)
395 |             {
396 |                 data["properties"] = serializablePropertiesOutput;
397 |             }
398 | 
399 |             return data;
400 |         }
401 | 
402 |         // Helper function to decide how to serialize different types
403 |         private static void AddSerializableValue(Dictionary<string, object> dict, string name, Type type, object value)
404 |         {
405 |             // Simplified: Directly use CreateTokenFromValue which uses the serializer
406 |             if (value == null)
407 |             {
408 |                 dict[name] = null;
409 |                 return;
410 |             }
411 | 
412 |             try
413 |             {
414 |                 // Use the helper that employs our custom serializer settings
415 |                 JToken token = CreateTokenFromValue(value, type);
416 |                 if (token != null) // Check if serialization succeeded in the helper
417 |                 {
418 |                     // Convert JToken back to a basic object structure for the dictionary
419 |                     dict[name] = ConvertJTokenToPlainObject(token);
420 |                 }
421 |                 // If token is null, it means serialization failed and a warning was logged.
422 |             }
423 |             catch (Exception e)
424 |             {
425 |                 // Catch potential errors during JToken conversion or addition to dictionary
426 |                 Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping.");
427 |             }
428 |         }
429 | 
430 |         // Helper to convert JToken back to basic object structure
431 |         private static object ConvertJTokenToPlainObject(JToken token)
432 |         {
433 |             if (token == null) return null;
434 | 
435 |             switch (token.Type)
436 |             {
437 |                 case JTokenType.Object:
438 |                     var objDict = new Dictionary<string, object>();
439 |                     foreach (var prop in ((JObject)token).Properties())
440 |                     {
441 |                         objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value);
442 |                     }
443 |                     return objDict;
444 | 
445 |                 case JTokenType.Array:
446 |                     var list = new List<object>();
447 |                     foreach (var item in (JArray)token)
448 |                     {
449 |                         list.Add(ConvertJTokenToPlainObject(item));
450 |                     }
451 |                     return list;
452 | 
453 |                 case JTokenType.Integer:
454 |                     return token.ToObject<long>(); // Use long for safety
455 |                 case JTokenType.Float:
456 |                     return token.ToObject<double>(); // Use double for safety
457 |                 case JTokenType.String:
458 |                     return token.ToObject<string>();
459 |                 case JTokenType.Boolean:
460 |                     return token.ToObject<bool>();
461 |                 case JTokenType.Date:
462 |                     return token.ToObject<DateTime>();
463 |                 case JTokenType.Guid:
464 |                     return token.ToObject<Guid>();
465 |                 case JTokenType.Uri:
466 |                     return token.ToObject<Uri>();
467 |                 case JTokenType.TimeSpan:
468 |                     return token.ToObject<TimeSpan>();
469 |                 case JTokenType.Bytes:
470 |                     return token.ToObject<byte[]>();
471 |                 case JTokenType.Null:
472 |                     return null;
473 |                 case JTokenType.Undefined:
474 |                     return null; // Treat undefined as null
475 | 
476 |                 default:
477 |                     // Fallback for simple value types not explicitly listed
478 |                     if (token is JValue jValue && jValue.Value != null)
479 |                     {
480 |                         return jValue.Value;
481 |                     }
482 |                     // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null.");
483 |                     return null;
484 |             }
485 |         }
486 | 
487 |         // --- Define custom JsonSerializerSettings for OUTPUT ---
488 |         private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings
489 |         {
490 |             Converters = new List<JsonConverter>
491 |             {
492 |                 new Vector3Converter(),
493 |                 new Vector2Converter(),
494 |                 new QuaternionConverter(),
495 |                 new ColorConverter(),
496 |                 new RectConverter(),
497 |                 new BoundsConverter(),
498 |                 new UnityEngineObjectConverter() // Handles serialization of references
499 |             },
500 |             ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
501 |             // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed
502 |         };
503 |         private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings);
504 |         // --- End Define custom JsonSerializerSettings ---
505 | 
506 |         // Helper to create JToken using the output serializer
507 |         private static JToken CreateTokenFromValue(object value, Type type)
508 |         {
509 |             if (value == null) return JValue.CreateNull();
510 | 
511 |             try
512 |             {
513 |                 // Use the pre-configured OUTPUT serializer instance
514 |                 return JToken.FromObject(value, _outputSerializer);
515 |             }
516 |             catch (JsonSerializationException e)
517 |             {
518 |                 Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field.");
519 |                 return null; // Indicate serialization failure
520 |             }
521 |             catch (Exception e) // Catch other unexpected errors
522 |             {
523 |                 Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field.");
524 |                 return null; // Indicate serialization failure
525 |             }
526 |         }
527 |     }
528 | }
529 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/Editor/Tools/ReadConsole.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Linq;
  4 | using System.Reflection;
  5 | using Newtonsoft.Json.Linq;
  6 | using UnityEditor;
  7 | using UnityEditorInternal;
  8 | using UnityEngine;
  9 | using MCPForUnity.Editor.Helpers; // For Response class
 10 | 
 11 | namespace MCPForUnity.Editor.Tools
 12 | {
 13 |     /// <summary>
 14 |     /// Handles reading and clearing Unity Editor console log entries.
 15 |     /// Uses reflection to access internal LogEntry methods/properties.
 16 |     /// </summary>
 17 |     [McpForUnityTool("read_console")]
 18 |     public static class ReadConsole
 19 |     {
 20 |         // (Calibration removed)
 21 | 
 22 |         // Reflection members for accessing internal LogEntry data
 23 |         // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
 24 |         private static MethodInfo _startGettingEntriesMethod;
 25 |         private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
 26 |         private static MethodInfo _clearMethod;
 27 |         private static MethodInfo _getCountMethod;
 28 |         private static MethodInfo _getEntryMethod;
 29 |         private static FieldInfo _modeField;
 30 |         private static FieldInfo _messageField;
 31 |         private static FieldInfo _fileField;
 32 |         private static FieldInfo _lineField;
 33 |         private static FieldInfo _instanceIdField;
 34 | 
 35 |         // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
 36 | 
 37 |         // Static constructor for reflection setup
 38 |         static ReadConsole()
 39 |         {
 40 |             try
 41 |             {
 42 |                 Type logEntriesType = typeof(EditorApplication).Assembly.GetType(
 43 |                     "UnityEditor.LogEntries"
 44 |                 );
 45 |                 if (logEntriesType == null)
 46 |                     throw new Exception("Could not find internal type UnityEditor.LogEntries");
 47 | 
 48 | 
 49 | 
 50 |                 // Include NonPublic binding flags as internal APIs might change accessibility
 51 |                 BindingFlags staticFlags =
 52 |                     BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
 53 |                 BindingFlags instanceFlags =
 54 |                     BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
 55 | 
 56 |                 _startGettingEntriesMethod = logEntriesType.GetMethod(
 57 |                     "StartGettingEntries",
 58 |                     staticFlags
 59 |                 );
 60 |                 if (_startGettingEntriesMethod == null)
 61 |                     throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
 62 | 
 63 |                 // Try reflecting EndGettingEntries based on warning message
 64 |                 _endGettingEntriesMethod = logEntriesType.GetMethod(
 65 |                     "EndGettingEntries",
 66 |                     staticFlags
 67 |                 );
 68 |                 if (_endGettingEntriesMethod == null)
 69 |                     throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
 70 | 
 71 |                 _clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
 72 |                 if (_clearMethod == null)
 73 |                     throw new Exception("Failed to reflect LogEntries.Clear");
 74 | 
 75 |                 _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
 76 |                 if (_getCountMethod == null)
 77 |                     throw new Exception("Failed to reflect LogEntries.GetCount");
 78 | 
 79 |                 _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
 80 |                 if (_getEntryMethod == null)
 81 |                     throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
 82 | 
 83 |                 Type logEntryType = typeof(EditorApplication).Assembly.GetType(
 84 |                     "UnityEditor.LogEntry"
 85 |                 );
 86 |                 if (logEntryType == null)
 87 |                     throw new Exception("Could not find internal type UnityEditor.LogEntry");
 88 | 
 89 |                 _modeField = logEntryType.GetField("mode", instanceFlags);
 90 |                 if (_modeField == null)
 91 |                     throw new Exception("Failed to reflect LogEntry.mode");
 92 | 
 93 |                 _messageField = logEntryType.GetField("message", instanceFlags);
 94 |                 if (_messageField == null)
 95 |                     throw new Exception("Failed to reflect LogEntry.message");
 96 | 
 97 |                 _fileField = logEntryType.GetField("file", instanceFlags);
 98 |                 if (_fileField == null)
 99 |                     throw new Exception("Failed to reflect LogEntry.file");
100 | 
101 |                 _lineField = logEntryType.GetField("line", instanceFlags);
102 |                 if (_lineField == null)
103 |                     throw new Exception("Failed to reflect LogEntry.line");
104 | 
105 |                 _instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
106 |                 if (_instanceIdField == null)
107 |                     throw new Exception("Failed to reflect LogEntry.instanceID");
108 | 
109 |                 // (Calibration removed)
110 | 
111 |             }
112 |             catch (Exception e)
113 |             {
114 |                 Debug.LogError(
115 |                     $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"
116 |                 );
117 |                 // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
118 |                 _startGettingEntriesMethod =
119 |                     _endGettingEntriesMethod =
120 |                     _clearMethod =
121 |                     _getCountMethod =
122 |                     _getEntryMethod =
123 |                         null;
124 |                 _modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
125 |             }
126 |         }
127 | 
128 |         // --- Main Handler ---
129 | 
130 |         public static object HandleCommand(JObject @params)
131 |         {
132 |             // Check if ALL required reflection members were successfully initialized.
133 |             if (
134 |                 _startGettingEntriesMethod == null
135 |                 || _endGettingEntriesMethod == null
136 |                 || _clearMethod == null
137 |                 || _getCountMethod == null
138 |                 || _getEntryMethod == null
139 |                 || _modeField == null
140 |                 || _messageField == null
141 |                 || _fileField == null
142 |                 || _lineField == null
143 |                 || _instanceIdField == null
144 |             )
145 |             {
146 |                 // Log the error here as well for easier debugging in Unity Console
147 |                 Debug.LogError(
148 |                     "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."
149 |                 );
150 |                 return Response.Error(
151 |                     "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."
152 |                 );
153 |             }
154 | 
155 |             string action = @params["action"]?.ToString().ToLower() ?? "get";
156 | 
157 |             try
158 |             {
159 |                 if (action == "clear")
160 |                 {
161 |                     return ClearConsole();
162 |                 }
163 |                 else if (action == "get")
164 |                 {
165 |                     // Extract parameters for 'get'
166 |                     var types =
167 |                         (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
168 |                         ?? new List<string> { "error", "warning", "log" };
169 |                     int? count = @params["count"]?.ToObject<int?>();
170 |                     string filterText = @params["filterText"]?.ToString();
171 |                     string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
172 |                     string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
173 |                     bool includeStacktrace =
174 |                         @params["includeStacktrace"]?.ToObject<bool?>() ?? true;
175 | 
176 |                     if (types.Contains("all"))
177 |                     {
178 |                         types = new List<string> { "error", "warning", "log" }; // Expand 'all'
179 |                     }
180 | 
181 |                     if (!string.IsNullOrEmpty(sinceTimestampStr))
182 |                     {
183 |                         Debug.LogWarning(
184 |                             "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented."
185 |                         );
186 |                         // Need a way to get timestamp per log entry.
187 |                     }
188 | 
189 |                     return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
190 |                 }
191 |                 else
192 |                 {
193 |                     return Response.Error(
194 |                         $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."
195 |                     );
196 |                 }
197 |             }
198 |             catch (Exception e)
199 |             {
200 |                 Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}");
201 |                 return Response.Error($"Internal error processing action '{action}': {e.Message}");
202 |             }
203 |         }
204 | 
205 |         // --- Action Implementations ---
206 | 
207 |         private static object ClearConsole()
208 |         {
209 |             try
210 |             {
211 |                 _clearMethod.Invoke(null, null); // Static method, no instance, no parameters
212 |                 return Response.Success("Console cleared successfully.");
213 |             }
214 |             catch (Exception e)
215 |             {
216 |                 Debug.LogError($"[ReadConsole] Failed to clear console: {e}");
217 |                 return Response.Error($"Failed to clear console: {e.Message}");
218 |             }
219 |         }
220 | 
221 |         private static object GetConsoleEntries(
222 |             List<string> types,
223 |             int? count,
224 |             string filterText,
225 |             string format,
226 |             bool includeStacktrace
227 |         )
228 |         {
229 |             List<object> formattedEntries = new List<object>();
230 |             int retrievedCount = 0;
231 | 
232 |             try
233 |             {
234 |                 // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
235 |                 _startGettingEntriesMethod.Invoke(null, null);
236 | 
237 |                 int totalEntries = (int)_getCountMethod.Invoke(null, null);
238 |                 // Create instance to pass to GetEntryInternal - Ensure the type is correct
239 |                 Type logEntryType = typeof(EditorApplication).Assembly.GetType(
240 |                     "UnityEditor.LogEntry"
241 |                 );
242 |                 if (logEntryType == null)
243 |                     throw new Exception(
244 |                         "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."
245 |                     );
246 |                 object logEntryInstance = Activator.CreateInstance(logEntryType);
247 | 
248 |                 for (int i = 0; i < totalEntries; i++)
249 |                 {
250 |                     // Get the entry data into our instance using reflection
251 |                     _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
252 | 
253 |                     // Extract data using reflection
254 |                     int mode = (int)_modeField.GetValue(logEntryInstance);
255 |                     string message = (string)_messageField.GetValue(logEntryInstance);
256 |                     string file = (string)_fileField.GetValue(logEntryInstance);
257 | 
258 |                     int line = (int)_lineField.GetValue(logEntryInstance);
259 |                     // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
260 | 
261 |                     if (string.IsNullOrEmpty(message))
262 |                     {
263 |                         continue; // Skip empty messages
264 |                     }
265 | 
266 |                     // (Calibration removed)
267 | 
268 |                     // --- Filtering ---
269 |                     // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed
270 |                     LogType unityType = InferTypeFromMessage(message);
271 |                     bool isExplicitDebug = IsExplicitDebugLog(message);
272 |                     if (!isExplicitDebug && unityType == LogType.Log)
273 |                     {
274 |                         unityType = GetLogTypeFromMode(mode);
275 |                     }
276 | 
277 |                     bool want;
278 |                     // Treat Exception/Assert as errors for filtering convenience
279 |                     if (unityType == LogType.Exception)
280 |                     {
281 |                         want = types.Contains("error") || types.Contains("exception");
282 |                     }
283 |                     else if (unityType == LogType.Assert)
284 |                     {
285 |                         want = types.Contains("error") || types.Contains("assert");
286 |                     }
287 |                     else
288 |                     {
289 |                         want = types.Contains(unityType.ToString().ToLowerInvariant());
290 |                     }
291 | 
292 |                     if (!want) continue;
293 | 
294 |                     // Filter by text (case-insensitive)
295 |                     if (
296 |                         !string.IsNullOrEmpty(filterText)
297 |                         && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0
298 |                     )
299 |                     {
300 |                         continue;
301 |                     }
302 | 
303 |                     // TODO: Filter by timestamp (requires timestamp data)
304 | 
305 |                     // --- Formatting ---
306 |                     string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
307 |                     // Always get first line for the message, use full message only if no stack trace exists
308 |                     string[] messageLines = message.Split(
309 |                         new[] { '\n', '\r' },
310 |                         StringSplitOptions.RemoveEmptyEntries
311 |                     );
312 |                     string messageOnly = messageLines.Length > 0 ? messageLines[0] : message;
313 | 
314 |                     // If not including stacktrace, ensure we only show the first line
315 |                     if (!includeStacktrace)
316 |                     {
317 |                         stackTrace = null;
318 |                     }
319 | 
320 |                     object formattedEntry = null;
321 |                     switch (format)
322 |                     {
323 |                         case "plain":
324 |                             formattedEntry = messageOnly;
325 |                             break;
326 |                         case "json":
327 |                         case "detailed": // Treat detailed as json for structured return
328 |                         default:
329 |                             formattedEntry = new
330 |                             {
331 |                                 type = unityType.ToString(),
332 |                                 message = messageOnly,
333 |                                 file = file,
334 |                                 line = line,
335 |                                 // timestamp = "", // TODO
336 |                                 stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found
337 |                             };
338 |                             break;
339 |                     }
340 | 
341 |                     formattedEntries.Add(formattedEntry);
342 |                     retrievedCount++;
343 | 
344 |                     // Apply count limit (after filtering)
345 |                     if (count.HasValue && retrievedCount >= count.Value)
346 |                     {
347 |                         break;
348 |                     }
349 |                 }
350 |             }
351 |             catch (Exception e)
352 |             {
353 |                 Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
354 |                 // Ensure EndGettingEntries is called even if there's an error during iteration
355 |                 try
356 |                 {
357 |                     _endGettingEntriesMethod.Invoke(null, null);
358 |                 }
359 |                 catch
360 |                 { /* Ignore nested exception */
361 |                 }
362 |                 return Response.Error($"Error retrieving log entries: {e.Message}");
363 |             }
364 |             finally
365 |             {
366 |                 // Ensure we always call EndGettingEntries
367 |                 try
368 |                 {
369 |                     _endGettingEntriesMethod.Invoke(null, null);
370 |                 }
371 |                 catch (Exception e)
372 |                 {
373 |                     Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}");
374 |                     // Don't return error here as we might have valid data, but log it.
375 |                 }
376 |             }
377 | 
378 |             // Return the filtered and formatted list (might be empty)
379 |             return Response.Success(
380 |                 $"Retrieved {formattedEntries.Count} log entries.",
381 |                 formattedEntries
382 |             );
383 |         }
384 | 
385 |         // --- Internal Helpers ---
386 | 
387 |         // Mapping bits from LogEntry.mode. These may vary by Unity version.
388 |         private const int ModeBitError = 1 << 0;
389 |         private const int ModeBitAssert = 1 << 1;
390 |         private const int ModeBitWarning = 1 << 2;
391 |         private const int ModeBitLog = 1 << 3;
392 |         private const int ModeBitException = 1 << 4; // often combined with Error bits
393 |         private const int ModeBitScriptingError = 1 << 9;
394 |         private const int ModeBitScriptingWarning = 1 << 10;
395 |         private const int ModeBitScriptingLog = 1 << 11;
396 |         private const int ModeBitScriptingException = 1 << 18;
397 |         private const int ModeBitScriptingAssertion = 1 << 22;
398 | 
399 |         private static LogType GetLogTypeFromMode(int mode)
400 |         {
401 |             // Preserve Unity's real type (no remapping); bits may vary by version
402 |             if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception;
403 |             if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error;
404 |             if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert;
405 |             if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning;
406 |             return LogType.Log;
407 |         }
408 | 
409 |         // (Calibration helpers removed)
410 | 
411 |         /// <summary>
412 |         /// Classifies severity using message/stacktrace content. Works across Unity versions.
413 |         /// </summary>
414 |         private static LogType InferTypeFromMessage(string fullMessage)
415 |         {
416 |             if (string.IsNullOrEmpty(fullMessage)) return LogType.Log;
417 | 
418 |             // Fast path: look for explicit Debug API names in the appended stack trace
419 |             // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning"
420 |             if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0)
421 |                 return LogType.Error;
422 |             if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0)
423 |                 return LogType.Warning;
424 | 
425 |             // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx"
426 |             if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0
427 |                 || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0)
428 |                 return LogType.Warning;
429 |             if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0
430 |                 || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0)
431 |                 return LogType.Error;
432 | 
433 |             // Exceptions (avoid misclassifying compiler diagnostics)
434 |             if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0)
435 |                 return LogType.Exception;
436 | 
437 |             // Unity assertions
438 |             if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0)
439 |                 return LogType.Assert;
440 | 
441 |             return LogType.Log;
442 |         }
443 | 
444 |         private static bool IsExplicitDebugLog(string fullMessage)
445 |         {
446 |             if (string.IsNullOrEmpty(fullMessage)) return false;
447 |             if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
448 |             if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
449 |             return false;
450 |         }
451 | 
452 |         /// <summary>
453 |         /// Applies the "one level lower" remapping for filtering, like the old version.
454 |         /// This ensures compatibility with the filtering logic that expects remapped types.
455 |         /// </summary>
456 |         private static LogType GetRemappedTypeForFiltering(LogType unityType)
457 |         {
458 |             switch (unityType)
459 |             {
460 |                 case LogType.Error:
461 |                     return LogType.Warning; // Error becomes Warning
462 |                 case LogType.Warning:
463 |                     return LogType.Log; // Warning becomes Log
464 |                 case LogType.Assert:
465 |                     return LogType.Assert; // Assert remains Assert
466 |                 case LogType.Log:
467 |                     return LogType.Log; // Log remains Log
468 |                 case LogType.Exception:
469 |                     return LogType.Warning; // Exception becomes Warning
470 |                 default:
471 |                     return LogType.Log; // Default fallback
472 |             }
473 |         }
474 | 
475 |         /// <summary>
476 |         /// Attempts to extract the stack trace part from a log message.
477 |         /// Unity log messages often have the stack trace appended after the main message,
478 |         /// starting on a new line and typically indented or beginning with "at ".
479 |         /// </summary>
480 |         /// <param name="fullMessage">The complete log message including potential stack trace.</param>
481 |         /// <returns>The extracted stack trace string, or null if none is found.</returns>
482 |         private static string ExtractStackTrace(string fullMessage)
483 |         {
484 |             if (string.IsNullOrEmpty(fullMessage))
485 |                 return null;
486 | 
487 |             // Split into lines, removing empty ones to handle different line endings gracefully.
488 |             // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
489 |             string[] lines = fullMessage.Split(
490 |                 new[] { '\r', '\n' },
491 |                 StringSplitOptions.RemoveEmptyEntries
492 |             );
493 | 
494 |             // If there's only one line or less, there's no separate stack trace.
495 |             if (lines.Length <= 1)
496 |                 return null;
497 | 
498 |             int stackStartIndex = -1;
499 | 
500 |             // Start checking from the second line onwards.
501 |             for (int i = 1; i < lines.Length; ++i)
502 |             {
503 |                 // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
504 |                 string trimmedLine = lines[i].TrimStart();
505 | 
506 |                 // Check for common stack trace patterns.
507 |                 if (
508 |                     trimmedLine.StartsWith("at ")
509 |                     || trimmedLine.StartsWith("UnityEngine.")
510 |                     || trimmedLine.StartsWith("UnityEditor.")
511 |                     || trimmedLine.Contains("(at ")
512 |                     || // Covers "(at Assets/..." pattern
513 |                        // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
514 |                     (
515 |                         trimmedLine.Length > 0
516 |                         && char.IsUpper(trimmedLine[0])
517 |                         && trimmedLine.Contains('.')
518 |                     )
519 |                 )
520 |                 {
521 |                     stackStartIndex = i;
522 |                     break; // Found the likely start of the stack trace
523 |                 }
524 |             }
525 | 
526 |             // If a potential start index was found...
527 |             if (stackStartIndex > 0)
528 |             {
529 |                 // Join the lines from the stack start index onwards using standard newline characters.
530 |                 // This reconstructs the stack trace part of the message.
531 |                 return string.Join("\n", lines.Skip(stackStartIndex));
532 |             }
533 | 
534 |             // No clear stack trace found based on the patterns.
535 |             return null;
536 |         }
537 | 
538 |         /* LogEntry.mode bits exploration (based on Unity decompilation/observation):
539 |            May change between versions.
540 | 
541 |            Basic Types:
542 |            kError = 1 << 0 (1)
543 |            kAssert = 1 << 1 (2)
544 |            kWarning = 1 << 2 (4)
545 |            kLog = 1 << 3 (8)
546 |            kFatal = 1 << 4 (16) - Often treated as Exception/Error
547 | 
548 |            Modifiers/Context:
549 |            kAssetImportError = 1 << 7 (128)
550 |            kAssetImportWarning = 1 << 8 (256)
551 |            kScriptingError = 1 << 9 (512)
552 |            kScriptingWarning = 1 << 10 (1024)
553 |            kScriptingLog = 1 << 11 (2048)
554 |            kScriptCompileError = 1 << 12 (4096)
555 |            kScriptCompileWarning = 1 << 13 (8192)
556 |            kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
557 |            kMayIgnoreLineNumber = 1 << 15 (32768)
558 |            kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
559 |            kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
560 |            kScriptingException = 1 << 18 (262144)
561 |            kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
562 |            kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
563 |            kGraphCompileError = 1 << 21 (2097152)
564 |            kScriptingAssertion = 1 << 22 (4194304)
565 |            kVisualScriptingError = 1 << 23 (8388608)
566 | 
567 |            Example observed values:
568 |            Log: 2048 (ScriptingLog) or 8 (Log)
569 |            Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
570 |            Error: 513 (ScriptingError | Error) or 1 (Error)
571 |            Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
572 |            Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
573 |         */
574 |     }
575 | }
576 | 
```

--------------------------------------------------------------------------------
/UnityMcpBridge/Editor/Tools/ReadConsole.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Linq;
  4 | using System.Reflection;
  5 | using Newtonsoft.Json.Linq;
  6 | using UnityEditor;
  7 | using UnityEditorInternal;
  8 | using UnityEngine;
  9 | using MCPForUnity.Editor.Helpers; // For Response class
 10 | 
 11 | namespace MCPForUnity.Editor.Tools
 12 | {
 13 |     /// <summary>
 14 |     /// Handles reading and clearing Unity Editor console log entries.
 15 |     /// Uses reflection to access internal LogEntry methods/properties.
 16 |     /// </summary>
 17 |     [McpForUnityTool("read_console")]
 18 |     public static class ReadConsole
 19 |     {
 20 |         // (Calibration removed)
 21 | 
 22 |         // Reflection members for accessing internal LogEntry data
 23 |         // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
 24 |         private static MethodInfo _startGettingEntriesMethod;
 25 |         private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
 26 |         private static MethodInfo _clearMethod;
 27 |         private static MethodInfo _getCountMethod;
 28 |         private static MethodInfo _getEntryMethod;
 29 |         private static FieldInfo _modeField;
 30 |         private static FieldInfo _messageField;
 31 |         private static FieldInfo _fileField;
 32 |         private static FieldInfo _lineField;
 33 |         private static FieldInfo _instanceIdField;
 34 | 
 35 |         // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
 36 | 
 37 |         // Static constructor for reflection setup
 38 |         static ReadConsole()
 39 |         {
 40 |             try
 41 |             {
 42 |                 Type logEntriesType = typeof(EditorApplication).Assembly.GetType(
 43 |                     "UnityEditor.LogEntries"
 44 |                 );
 45 |                 if (logEntriesType == null)
 46 |                     throw new Exception("Could not find internal type UnityEditor.LogEntries");
 47 | 
 48 | 
 49 | 
 50 |                 // Include NonPublic binding flags as internal APIs might change accessibility
 51 |                 BindingFlags staticFlags =
 52 |                     BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
 53 |                 BindingFlags instanceFlags =
 54 |                     BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
 55 | 
 56 |                 _startGettingEntriesMethod = logEntriesType.GetMethod(
 57 |                     "StartGettingEntries",
 58 |                     staticFlags
 59 |                 );
 60 |                 if (_startGettingEntriesMethod == null)
 61 |                     throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
 62 | 
 63 |                 // Try reflecting EndGettingEntries based on warning message
 64 |                 _endGettingEntriesMethod = logEntriesType.GetMethod(
 65 |                     "EndGettingEntries",
 66 |                     staticFlags
 67 |                 );
 68 |                 if (_endGettingEntriesMethod == null)
 69 |                     throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
 70 | 
 71 |                 _clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
 72 |                 if (_clearMethod == null)
 73 |                     throw new Exception("Failed to reflect LogEntries.Clear");
 74 | 
 75 |                 _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
 76 |                 if (_getCountMethod == null)
 77 |                     throw new Exception("Failed to reflect LogEntries.GetCount");
 78 | 
 79 |                 _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
 80 |                 if (_getEntryMethod == null)
 81 |                     throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
 82 | 
 83 |                 Type logEntryType = typeof(EditorApplication).Assembly.GetType(
 84 |                     "UnityEditor.LogEntry"
 85 |                 );
 86 |                 if (logEntryType == null)
 87 |                     throw new Exception("Could not find internal type UnityEditor.LogEntry");
 88 | 
 89 |                 _modeField = logEntryType.GetField("mode", instanceFlags);
 90 |                 if (_modeField == null)
 91 |                     throw new Exception("Failed to reflect LogEntry.mode");
 92 | 
 93 |                 _messageField = logEntryType.GetField("message", instanceFlags);
 94 |                 if (_messageField == null)
 95 |                     throw new Exception("Failed to reflect LogEntry.message");
 96 | 
 97 |                 _fileField = logEntryType.GetField("file", instanceFlags);
 98 |                 if (_fileField == null)
 99 |                     throw new Exception("Failed to reflect LogEntry.file");
100 | 
101 |                 _lineField = logEntryType.GetField("line", instanceFlags);
102 |                 if (_lineField == null)
103 |                     throw new Exception("Failed to reflect LogEntry.line");
104 | 
105 |                 _instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
106 |                 if (_instanceIdField == null)
107 |                     throw new Exception("Failed to reflect LogEntry.instanceID");
108 | 
109 |                 // (Calibration removed)
110 | 
111 |             }
112 |             catch (Exception e)
113 |             {
114 |                 Debug.LogError(
115 |                     $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"
116 |                 );
117 |                 // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
118 |                 _startGettingEntriesMethod =
119 |                     _endGettingEntriesMethod =
120 |                     _clearMethod =
121 |                     _getCountMethod =
122 |                     _getEntryMethod =
123 |                         null;
124 |                 _modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
125 |             }
126 |         }
127 | 
128 |         // --- Main Handler ---
129 | 
130 |         public static object HandleCommand(JObject @params)
131 |         {
132 |             // Check if ALL required reflection members were successfully initialized.
133 |             if (
134 |                 _startGettingEntriesMethod == null
135 |                 || _endGettingEntriesMethod == null
136 |                 || _clearMethod == null
137 |                 || _getCountMethod == null
138 |                 || _getEntryMethod == null
139 |                 || _modeField == null
140 |                 || _messageField == null
141 |                 || _fileField == null
142 |                 || _lineField == null
143 |                 || _instanceIdField == null
144 |             )
145 |             {
146 |                 // Log the error here as well for easier debugging in Unity Console
147 |                 Debug.LogError(
148 |                     "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."
149 |                 );
150 |                 return Response.Error(
151 |                     "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."
152 |                 );
153 |             }
154 | 
155 |             string action = @params["action"]?.ToString().ToLower() ?? "get";
156 | 
157 |             try
158 |             {
159 |                 if (action == "clear")
160 |                 {
161 |                     return ClearConsole();
162 |                 }
163 |                 else if (action == "get")
164 |                 {
165 |                     // Extract parameters for 'get'
166 |                     var types =
167 |                         (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
168 |                         ?? new List<string> { "error", "warning", "log" };
169 |                     int? count = @params["count"]?.ToObject<int?>();
170 |                     string filterText = @params["filterText"]?.ToString();
171 |                     string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering
172 |                     string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
173 |                     bool includeStacktrace =
174 |                         @params["includeStacktrace"]?.ToObject<bool?>() ?? true;
175 | 
176 |                     if (types.Contains("all"))
177 |                     {
178 |                         types = new List<string> { "error", "warning", "log" }; // Expand 'all'
179 |                     }
180 | 
181 |                     if (!string.IsNullOrEmpty(sinceTimestampStr))
182 |                     {
183 |                         Debug.LogWarning(
184 |                             "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented."
185 |                         );
186 |                         // Need a way to get timestamp per log entry.
187 |                     }
188 | 
189 |                     return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
190 |                 }
191 |                 else
192 |                 {
193 |                     return Response.Error(
194 |                         $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."
195 |                     );
196 |                 }
197 |             }
198 |             catch (Exception e)
199 |             {
200 |                 Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}");
201 |                 return Response.Error($"Internal error processing action '{action}': {e.Message}");
202 |             }
203 |         }
204 | 
205 |         // --- Action Implementations ---
206 | 
207 |         private static object ClearConsole()
208 |         {
209 |             try
210 |             {
211 |                 _clearMethod.Invoke(null, null); // Static method, no instance, no parameters
212 |                 return Response.Success("Console cleared successfully.");
213 |             }
214 |             catch (Exception e)
215 |             {
216 |                 Debug.LogError($"[ReadConsole] Failed to clear console: {e}");
217 |                 return Response.Error($"Failed to clear console: {e.Message}");
218 |             }
219 |         }
220 | 
221 |         private static object GetConsoleEntries(
222 |             List<string> types,
223 |             int? count,
224 |             string filterText,
225 |             string format,
226 |             bool includeStacktrace
227 |         )
228 |         {
229 |             List<object> formattedEntries = new List<object>();
230 |             int retrievedCount = 0;
231 | 
232 |             try
233 |             {
234 |                 // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
235 |                 _startGettingEntriesMethod.Invoke(null, null);
236 | 
237 |                 int totalEntries = (int)_getCountMethod.Invoke(null, null);
238 |                 // Create instance to pass to GetEntryInternal - Ensure the type is correct
239 |                 Type logEntryType = typeof(EditorApplication).Assembly.GetType(
240 |                     "UnityEditor.LogEntry"
241 |                 );
242 |                 if (logEntryType == null)
243 |                     throw new Exception(
244 |                         "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."
245 |                     );
246 |                 object logEntryInstance = Activator.CreateInstance(logEntryType);
247 | 
248 |                 for (int i = 0; i < totalEntries; i++)
249 |                 {
250 |                     // Get the entry data into our instance using reflection
251 |                     _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
252 | 
253 |                     // Extract data using reflection
254 |                     int mode = (int)_modeField.GetValue(logEntryInstance);
255 |                     string message = (string)_messageField.GetValue(logEntryInstance);
256 |                     string file = (string)_fileField.GetValue(logEntryInstance);
257 | 
258 |                     int line = (int)_lineField.GetValue(logEntryInstance);
259 |                     // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
260 | 
261 |                     if (string.IsNullOrEmpty(message))
262 |                     {
263 |                         continue; // Skip empty messages
264 |                     }
265 | 
266 |                     // (Calibration removed)
267 | 
268 |                     // --- Filtering ---
269 |                     // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed
270 |                     LogType unityType = InferTypeFromMessage(message);
271 |                     bool isExplicitDebug = IsExplicitDebugLog(message);
272 |                     if (!isExplicitDebug && unityType == LogType.Log)
273 |                     {
274 |                         unityType = GetLogTypeFromMode(mode);
275 |                     }
276 | 
277 |                     bool want;
278 |                     // Treat Exception/Assert as errors for filtering convenience
279 |                     if (unityType == LogType.Exception)
280 |                     {
281 |                         want = types.Contains("error") || types.Contains("exception");
282 |                     }
283 |                     else if (unityType == LogType.Assert)
284 |                     {
285 |                         want = types.Contains("error") || types.Contains("assert");
286 |                     }
287 |                     else
288 |                     {
289 |                         want = types.Contains(unityType.ToString().ToLowerInvariant());
290 |                     }
291 | 
292 |                     if (!want) continue;
293 | 
294 |                     // Filter by text (case-insensitive)
295 |                     if (
296 |                         !string.IsNullOrEmpty(filterText)
297 |                         && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0
298 |                     )
299 |                     {
300 |                         continue;
301 |                     }
302 | 
303 |                     // TODO: Filter by timestamp (requires timestamp data)
304 | 
305 |                     // --- Formatting ---
306 |                     string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
307 |                     // Always get first line for the message, use full message only if no stack trace exists
308 |                     string[] messageLines = message.Split(
309 |                         new[] { '\n', '\r' },
310 |                         StringSplitOptions.RemoveEmptyEntries
311 |                     );
312 |                     string messageOnly = messageLines.Length > 0 ? messageLines[0] : message;
313 | 
314 |                     // If not including stacktrace, ensure we only show the first line
315 |                     if (!includeStacktrace)
316 |                     {
317 |                         stackTrace = null;
318 |                     }
319 | 
320 |                     object formattedEntry = null;
321 |                     switch (format)
322 |                     {
323 |                         case "plain":
324 |                             formattedEntry = messageOnly;
325 |                             break;
326 |                         case "json":
327 |                         case "detailed": // Treat detailed as json for structured return
328 |                         default:
329 |                             formattedEntry = new
330 |                             {
331 |                                 type = unityType.ToString(),
332 |                                 message = messageOnly,
333 |                                 file = file,
334 |                                 line = line,
335 |                                 // timestamp = "", // TODO
336 |                                 stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found
337 |                             };
338 |                             break;
339 |                     }
340 | 
341 |                     formattedEntries.Add(formattedEntry);
342 |                     retrievedCount++;
343 | 
344 |                     // Apply count limit (after filtering)
345 |                     if (count.HasValue && retrievedCount >= count.Value)
346 |                     {
347 |                         break;
348 |                     }
349 |                 }
350 |             }
351 |             catch (Exception e)
352 |             {
353 |                 Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
354 |                 // Ensure EndGettingEntries is called even if there's an error during iteration
355 |                 try
356 |                 {
357 |                     _endGettingEntriesMethod.Invoke(null, null);
358 |                 }
359 |                 catch
360 |                 { /* Ignore nested exception */
361 |                 }
362 |                 return Response.Error($"Error retrieving log entries: {e.Message}");
363 |             }
364 |             finally
365 |             {
366 |                 // Ensure we always call EndGettingEntries
367 |                 try
368 |                 {
369 |                     _endGettingEntriesMethod.Invoke(null, null);
370 |                 }
371 |                 catch (Exception e)
372 |                 {
373 |                     Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}");
374 |                     // Don't return error here as we might have valid data, but log it.
375 |                 }
376 |             }
377 | 
378 |             // Return the filtered and formatted list (might be empty)
379 |             return Response.Success(
380 |                 $"Retrieved {formattedEntries.Count} log entries.",
381 |                 formattedEntries
382 |             );
383 |         }
384 | 
385 |         // --- Internal Helpers ---
386 | 
387 |         // Mapping bits from LogEntry.mode. These may vary by Unity version.
388 |         private const int ModeBitError = 1 << 0;
389 |         private const int ModeBitAssert = 1 << 1;
390 |         private const int ModeBitWarning = 1 << 2;
391 |         private const int ModeBitLog = 1 << 3;
392 |         private const int ModeBitException = 1 << 4; // often combined with Error bits
393 |         private const int ModeBitScriptingError = 1 << 9;
394 |         private const int ModeBitScriptingWarning = 1 << 10;
395 |         private const int ModeBitScriptingLog = 1 << 11;
396 |         private const int ModeBitScriptingException = 1 << 18;
397 |         private const int ModeBitScriptingAssertion = 1 << 22;
398 | 
399 |         private static LogType GetLogTypeFromMode(int mode)
400 |         {
401 |             // Preserve Unity's real type (no remapping); bits may vary by version
402 |             if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception;
403 |             if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error;
404 |             if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert;
405 |             if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning;
406 |             return LogType.Log;
407 |         }
408 | 
409 |         // (Calibration helpers removed)
410 | 
411 |         /// <summary>
412 |         /// Classifies severity using message/stacktrace content. Works across Unity versions.
413 |         /// </summary>
414 |         private static LogType InferTypeFromMessage(string fullMessage)
415 |         {
416 |             if (string.IsNullOrEmpty(fullMessage)) return LogType.Log;
417 | 
418 |             // Fast path: look for explicit Debug API names in the appended stack trace
419 |             // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning"
420 |             if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0)
421 |                 return LogType.Error;
422 |             if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0)
423 |                 return LogType.Warning;
424 | 
425 |             // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx"
426 |             if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0
427 |                 || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0)
428 |                 return LogType.Warning;
429 |             if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0
430 |                 || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0)
431 |                 return LogType.Error;
432 | 
433 |             // Exceptions (avoid misclassifying compiler diagnostics)
434 |             if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0)
435 |                 return LogType.Exception;
436 | 
437 |             // Unity assertions
438 |             if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0)
439 |                 return LogType.Assert;
440 | 
441 |             return LogType.Log;
442 |         }
443 | 
444 |         private static bool IsExplicitDebugLog(string fullMessage)
445 |         {
446 |             if (string.IsNullOrEmpty(fullMessage)) return false;
447 |             if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
448 |             if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
449 |             return false;
450 |         }
451 | 
452 |         /// <summary>
453 |         /// Applies the "one level lower" remapping for filtering, like the old version.
454 |         /// This ensures compatibility with the filtering logic that expects remapped types.
455 |         /// </summary>
456 |         private static LogType GetRemappedTypeForFiltering(LogType unityType)
457 |         {
458 |             switch (unityType)
459 |             {
460 |                 case LogType.Error:
461 |                     return LogType.Warning; // Error becomes Warning
462 |                 case LogType.Warning:
463 |                     return LogType.Log; // Warning becomes Log
464 |                 case LogType.Assert:
465 |                     return LogType.Assert; // Assert remains Assert
466 |                 case LogType.Log:
467 |                     return LogType.Log; // Log remains Log
468 |                 case LogType.Exception:
469 |                     return LogType.Warning; // Exception becomes Warning
470 |                 default:
471 |                     return LogType.Log; // Default fallback
472 |             }
473 |         }
474 | 
475 |         /// <summary>
476 |         /// Attempts to extract the stack trace part from a log message.
477 |         /// Unity log messages often have the stack trace appended after the main message,
478 |         /// starting on a new line and typically indented or beginning with "at ".
479 |         /// </summary>
480 |         /// <param name="fullMessage">The complete log message including potential stack trace.</param>
481 |         /// <returns>The extracted stack trace string, or null if none is found.</returns>
482 |         private static string ExtractStackTrace(string fullMessage)
483 |         {
484 |             if (string.IsNullOrEmpty(fullMessage))
485 |                 return null;
486 | 
487 |             // Split into lines, removing empty ones to handle different line endings gracefully.
488 |             // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
489 |             string[] lines = fullMessage.Split(
490 |                 new[] { '\r', '\n' },
491 |                 StringSplitOptions.RemoveEmptyEntries
492 |             );
493 | 
494 |             // If there's only one line or less, there's no separate stack trace.
495 |             if (lines.Length <= 1)
496 |                 return null;
497 | 
498 |             int stackStartIndex = -1;
499 | 
500 |             // Start checking from the second line onwards.
501 |             for (int i = 1; i < lines.Length; ++i)
502 |             {
503 |                 // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
504 |                 string trimmedLine = lines[i].TrimStart();
505 | 
506 |                 // Check for common stack trace patterns.
507 |                 if (
508 |                     trimmedLine.StartsWith("at ")
509 |                     || trimmedLine.StartsWith("UnityEngine.")
510 |                     || trimmedLine.StartsWith("UnityEditor.")
511 |                     || trimmedLine.Contains("(at ")
512 |                     || // Covers "(at Assets/..." pattern
513 |                        // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
514 |                     (
515 |                         trimmedLine.Length > 0
516 |                         && char.IsUpper(trimmedLine[0])
517 |                         && trimmedLine.Contains('.')
518 |                     )
519 |                 )
520 |                 {
521 |                     stackStartIndex = i;
522 |                     break; // Found the likely start of the stack trace
523 |                 }
524 |             }
525 | 
526 |             // If a potential start index was found...
527 |             if (stackStartIndex > 0)
528 |             {
529 |                 // Join the lines from the stack start index onwards using standard newline characters.
530 |                 // This reconstructs the stack trace part of the message.
531 |                 return string.Join("\n", lines.Skip(stackStartIndex));
532 |             }
533 | 
534 |             // No clear stack trace found based on the patterns.
535 |             return null;
536 |         }
537 | 
538 |         /* LogEntry.mode bits exploration (based on Unity decompilation/observation):
539 |            May change between versions.
540 | 
541 |            Basic Types:
542 |            kError = 1 << 0 (1)
543 |            kAssert = 1 << 1 (2)
544 |            kWarning = 1 << 2 (4)
545 |            kLog = 1 << 3 (8)
546 |            kFatal = 1 << 4 (16) - Often treated as Exception/Error
547 | 
548 |            Modifiers/Context:
549 |            kAssetImportError = 1 << 7 (128)
550 |            kAssetImportWarning = 1 << 8 (256)
551 |            kScriptingError = 1 << 9 (512)
552 |            kScriptingWarning = 1 << 10 (1024)
553 |            kScriptingLog = 1 << 11 (2048)
554 |            kScriptCompileError = 1 << 12 (4096)
555 |            kScriptCompileWarning = 1 << 13 (8192)
556 |            kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
557 |            kMayIgnoreLineNumber = 1 << 15 (32768)
558 |            kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
559 |            kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
560 |            kScriptingException = 1 << 18 (262144)
561 |            kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
562 |            kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
563 |            kGraphCompileError = 1 << 21 (2097152)
564 |            kScriptingAssertion = 1 << 22 (4194304)
565 |            kVisualScriptingError = 1 << 23 (8388608)
566 | 
567 |            Example observed values:
568 |            Log: 2048 (ScriptingLog) or 8 (Log)
569 |            Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
570 |            Error: 513 (ScriptingError | Error) or 1 (Error)
571 |            Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
572 |            Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
573 |         */
574 |     }
575 | }
576 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py:
--------------------------------------------------------------------------------

```python
  1 | import base64
  2 | import os
  3 | from typing import Annotated, Any, Literal
  4 | from urllib.parse import urlparse, unquote
  5 | 
  6 | from mcp.server.fastmcp import FastMCP, Context
  7 | 
  8 | from registry import mcp_for_unity_tool
  9 | from unity_connection import send_command_with_retry
 10 | 
 11 | 
 12 | def _split_uri(uri: str) -> tuple[str, str]:
 13 |     """Split an incoming URI or path into (name, directory) suitable for Unity.
 14 | 
 15 |     Rules:
 16 |     - unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
 17 |     - file://... → percent-decode, normalize, strip host and leading slashes,
 18 |         then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
 19 |         Otherwise, fall back to original name/dir behavior.
 20 |     - plain paths → decode/normalize separators; if they contain an 'Assets' segment,
 21 |         return relative to 'Assets'.
 22 |     """
 23 |     raw_path: str
 24 |     if uri.startswith("unity://path/"):
 25 |         raw_path = uri[len("unity://path/"):]
 26 |     elif uri.startswith("file://"):
 27 |         parsed = urlparse(uri)
 28 |         host = (parsed.netloc or "").strip()
 29 |         p = parsed.path or ""
 30 |         # UNC: file://server/share/... -> //server/share/...
 31 |         if host and host.lower() != "localhost":
 32 |             p = f"//{host}{p}"
 33 |         # Use percent-decoded path, preserving leading slashes
 34 |         raw_path = unquote(p)
 35 |     else:
 36 |         raw_path = uri
 37 | 
 38 |     # Percent-decode any residual encodings and normalize separators
 39 |     raw_path = unquote(raw_path).replace("\\", "/")
 40 |     # Strip leading slash only for Windows drive-letter forms like "/C:/..."
 41 |     if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
 42 |         raw_path = raw_path[1:]
 43 | 
 44 |     # Normalize path (collapse ../, ./)
 45 |     norm = os.path.normpath(raw_path).replace("\\", "/")
 46 | 
 47 |     # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
 48 |     parts = [p for p in norm.split("/") if p not in ("", ".")]
 49 |     idx = next((i for i, seg in enumerate(parts)
 50 |                 if seg.lower() == "assets"), None)
 51 |     assets_rel = "/".join(parts[idx:]) if idx is not None else None
 52 | 
 53 |     effective_path = assets_rel if assets_rel else norm
 54 |     # For POSIX absolute paths outside Assets, drop the leading '/'
 55 |     # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
 56 |     if effective_path.startswith("/"):
 57 |         effective_path = effective_path[1:]
 58 | 
 59 |     name = os.path.splitext(os.path.basename(effective_path))[0]
 60 |     directory = os.path.dirname(effective_path)
 61 |     return name, directory
 62 | 
 63 | 
 64 | @mcp_for_unity_tool(description=(
 65 |     """Apply small text edits to a C# script identified by URI.
 66 |     IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!
 67 |     RECOMMENDED WORKFLOW:
 68 |         1. First call resources/read with start_line/line_count to verify exact content
 69 |         2. Count columns carefully (or use find_in_file to locate patterns)
 70 |         3. Apply your edit with precise coordinates
 71 |         4. Consider script_apply_edits with anchors for safer pattern-based replacements
 72 |     Notes:
 73 |         - For method/class operations, use script_apply_edits (safer, structured edits)
 74 |         - For pattern-based replacements, consider anchor operations in script_apply_edits
 75 |         - Lines, columns are 1-indexed
 76 |         - Tabs count as 1 column"""
 77 | ))
 78 | def apply_text_edits(
 79 |     ctx: Context,
 80 |     uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
 81 |     edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"],
 82 |     precondition_sha256: Annotated[str,
 83 |                                    "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None,
 84 |     strict: Annotated[bool,
 85 |                       "Optional strict flag, used to enforce strict mode"] | None = None,
 86 |     options: Annotated[dict[str, Any],
 87 |                        "Optional options, used to pass additional options to the script editor"] | None = None,
 88 | ) -> dict[str, Any]:
 89 |     ctx.info(f"Processing apply_text_edits: {uri}")
 90 |     name, directory = _split_uri(uri)
 91 | 
 92 |     # Normalize common aliases/misuses for resilience:
 93 |     # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
 94 |     # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text}
 95 |     # If normalization is required, read current contents to map indices -> 1-based line/col.
 96 |     def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
 97 |         for e in arr or []:
 98 |             if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e):
 99 |                 return True
100 |         return False
101 | 
102 |     normalized_edits: list[dict[str, Any]] = []
103 |     warnings: list[str] = []
104 |     if _needs_normalization(edits):
105 |         # Read file to support index->line/col conversion when needed
106 |         read_resp = send_command_with_retry("manage_script", {
107 |             "action": "read",
108 |             "name": name,
109 |             "path": directory,
110 |         })
111 |         if not (isinstance(read_resp, dict) and read_resp.get("success")):
112 |             return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
113 |         data = read_resp.get("data", {})
114 |         contents = data.get("contents")
115 |         if not contents and data.get("contentsEncoded"):
116 |             try:
117 |                 contents = base64.b64decode(data.get("encodedContents", "").encode(
118 |                     "utf-8")).decode("utf-8", "replace")
119 |             except Exception:
120 |                 contents = contents or ""
121 | 
122 |         # Helper to map 0-based character index to 1-based line/col
123 |         def line_col_from_index(idx: int) -> tuple[int, int]:
124 |             if idx <= 0:
125 |                 return 1, 1
126 |             # Count lines up to idx and position within line
127 |             nl_count = contents.count("\n", 0, idx)
128 |             line = nl_count + 1
129 |             last_nl = contents.rfind("\n", 0, idx)
130 |             col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
131 |             return line, col
132 | 
133 |         for e in edits or []:
134 |             e2 = dict(e)
135 |             # Map text->newText if needed
136 |             if "newText" not in e2 and "text" in e2:
137 |                 e2["newText"] = e2.pop("text")
138 | 
139 |             if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2:
140 |                 # Guard: explicit fields must be 1-based.
141 |                 zero_based = False
142 |                 for k in ("startLine", "startCol", "endLine", "endCol"):
143 |                     try:
144 |                         if int(e2.get(k, 1)) < 1:
145 |                             zero_based = True
146 |                     except Exception:
147 |                         pass
148 |                 if zero_based:
149 |                     if strict:
150 |                         return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}}
151 |                     # Normalize by clamping to 1 and warn
152 |                     for k in ("startLine", "startCol", "endLine", "endCol"):
153 |                         try:
154 |                             if int(e2.get(k, 1)) < 1:
155 |                                 e2[k] = 1
156 |                         except Exception:
157 |                             pass
158 |                     warnings.append(
159 |                         "zero_based_explicit_fields_normalized")
160 |                 normalized_edits.append(e2)
161 |                 continue
162 | 
163 |             rng = e2.get("range")
164 |             if isinstance(rng, dict):
165 |                 # LSP style: 0-based
166 |                 s = rng.get("start", {})
167 |                 t = rng.get("end", {})
168 |                 e2["startLine"] = int(s.get("line", 0)) + 1
169 |                 e2["startCol"] = int(s.get("character", 0)) + 1
170 |                 e2["endLine"] = int(t.get("line", 0)) + 1
171 |                 e2["endCol"] = int(t.get("character", 0)) + 1
172 |                 e2.pop("range", None)
173 |                 normalized_edits.append(e2)
174 |                 continue
175 |             if isinstance(rng, (list, tuple)) and len(rng) == 2:
176 |                 try:
177 |                     a = int(rng[0])
178 |                     b = int(rng[1])
179 |                     if b < a:
180 |                         a, b = b, a
181 |                     sl, sc = line_col_from_index(a)
182 |                     el, ec = line_col_from_index(b)
183 |                     e2["startLine"] = sl
184 |                     e2["startCol"] = sc
185 |                     e2["endLine"] = el
186 |                     e2["endCol"] = ec
187 |                     e2.pop("range", None)
188 |                     normalized_edits.append(e2)
189 |                     continue
190 |                 except Exception:
191 |                     pass
192 |             # Could not normalize this edit
193 |             return {
194 |                 "success": False,
195 |                 "code": "missing_field",
196 |                 "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'",
197 |                 "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e}
198 |             }
199 |     else:
200 |         # Even when edits appear already in explicit form, validate 1-based coordinates.
201 |         normalized_edits = []
202 |         for e in edits or []:
203 |             e2 = dict(e)
204 |             has_all = all(k in e2 for k in (
205 |                 "startLine", "startCol", "endLine", "endCol"))
206 |             if has_all:
207 |                 zero_based = False
208 |                 for k in ("startLine", "startCol", "endLine", "endCol"):
209 |                     try:
210 |                         if int(e2.get(k, 1)) < 1:
211 |                             zero_based = True
212 |                     except Exception:
213 |                         pass
214 |                 if zero_based:
215 |                     if strict:
216 |                         return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}}
217 |                     for k in ("startLine", "startCol", "endLine", "endCol"):
218 |                         try:
219 |                             if int(e2.get(k, 1)) < 1:
220 |                                 e2[k] = 1
221 |                         except Exception:
222 |                             pass
223 |                     if "zero_based_explicit_fields_normalized" not in warnings:
224 |                         warnings.append(
225 |                             "zero_based_explicit_fields_normalized")
226 |             normalized_edits.append(e2)
227 | 
228 |     # Preflight: detect overlapping ranges among normalized line/col spans
229 |     def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]:
230 |         return (
231 |             int(e.get("startLine", 1)) if key_start else int(
232 |                 e.get("endLine", 1)),
233 |             int(e.get("startCol", 1)) if key_start else int(
234 |                 e.get("endCol", 1)),
235 |         )
236 | 
237 |     def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
238 |         return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1])
239 | 
240 |     # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap.
241 |     spans = []
242 |     for e in normalized_edits or []:
243 |         try:
244 |             s = _pos_tuple(e, True)
245 |             t = _pos_tuple(e, False)
246 |             if s != t:
247 |                 spans.append((s, t))
248 |         except Exception:
249 |             # If coordinates missing or invalid, let the server validate later
250 |             pass
251 | 
252 |     if spans:
253 |         spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1]))
254 |         for i in range(1, len(spans_sorted)):
255 |             prev_end = spans_sorted[i-1][1]
256 |             curr_start = spans_sorted[i][0]
257 |             # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start
258 |             if not _le(prev_end, curr_start):
259 |                 conflicts = [{
260 |                     "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]},
261 |                     "endA":   {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]},
262 |                     "startB": {"line": spans_sorted[i][0][0],  "col": spans_sorted[i][0][1]},
263 |                     "endB":   {"line": spans_sorted[i][1][0],  "col": spans_sorted[i][1][1]},
264 |                 }]
265 |                 return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}}
266 | 
267 |     # Note: Do not auto-compute precondition if missing; callers should supply it
268 |     # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and
269 |     # preserves existing call-count expectations in clients/tests.
270 | 
271 |     # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance
272 |     opts: dict[str, Any] = dict(options or {})
273 |     try:
274 |         if len(normalized_edits) > 1 and "applyMode" not in opts:
275 |             opts["applyMode"] = "atomic"
276 |     except Exception:
277 |         pass
278 |     # Support optional debug preview for span-by-span simulation without write
279 |     if opts.get("debug_preview"):
280 |         try:
281 |             import difflib
282 |             # Apply locally to preview final result
283 |             lines = []
284 |             # Build an indexable original from a read if we normalized from read; otherwise skip
285 |             prev = ""
286 |             # We cannot guarantee file contents here without a read; return normalized spans only
287 |             return {
288 |                 "success": True,
289 |                 "message": "Preview only (no write)",
290 |                 "data": {
291 |                     "normalizedEdits": normalized_edits,
292 |                     "preview": True
293 |                 }
294 |             }
295 |         except Exception as e:
296 |             return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}}
297 | 
298 |     params = {
299 |         "action": "apply_text_edits",
300 |         "name": name,
301 |         "path": directory,
302 |         "edits": normalized_edits,
303 |         "precondition_sha256": precondition_sha256,
304 |         "options": opts,
305 |     }
306 |     params = {k: v for k, v in params.items() if v is not None}
307 |     resp = send_command_with_retry("manage_script", params)
308 |     if isinstance(resp, dict):
309 |         data = resp.setdefault("data", {})
310 |         data.setdefault("normalizedEdits", normalized_edits)
311 |         if warnings:
312 |             data.setdefault("warnings", warnings)
313 |         if resp.get("success") and (options or {}).get("force_sentinel_reload"):
314 |             # Optional: flip sentinel via menu if explicitly requested
315 |             try:
316 |                 import threading
317 |                 import time
318 |                 import json
319 |                 import glob
320 |                 import os
321 | 
322 |                 def _latest_status() -> dict | None:
323 |                     try:
324 |                         files = sorted(glob.glob(os.path.expanduser(
325 |                             "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
326 |                         if not files:
327 |                             return None
328 |                         with open(files[0], "r") as f:
329 |                             return json.loads(f.read())
330 |                     except Exception:
331 |                         return None
332 | 
333 |                 def _flip_async():
334 |                     try:
335 |                         time.sleep(0.1)
336 |                         st = _latest_status()
337 |                         if st and st.get("reloading"):
338 |                             return
339 |                         send_command_with_retry(
340 |                             "execute_menu_item",
341 |                             {"menuPath": "MCP/Flip Reload Sentinel"},
342 |                             max_retries=0,
343 |                             retry_ms=0,
344 |                         )
345 |                     except Exception:
346 |                         pass
347 |                 threading.Thread(target=_flip_async, daemon=True).start()
348 |             except Exception:
349 |                 pass
350 |             return resp
351 |         return resp
352 |     return {"success": False, "message": str(resp)}
353 | 
354 | 
355 | @mcp_for_unity_tool(description=("Create a new C# script at the given project path."))
356 | def create_script(
357 |     ctx: Context,
358 |     path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
359 |     contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
360 |     script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
361 |     namespace: Annotated[str, "Namespace for the script"] | None = None,
362 | ) -> dict[str, Any]:
363 |     ctx.info(f"Processing create_script: {path}")
364 |     name = os.path.splitext(os.path.basename(path))[0]
365 |     directory = os.path.dirname(path)
366 |     # Local validation to avoid round-trips on obviously bad input
367 |     norm_path = os.path.normpath(
368 |         (path or "").replace("\\", "/")).replace("\\", "/")
369 |     if not directory or directory.split("/")[0].lower() != "assets":
370 |         return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."}
371 |     if ".." in norm_path.split("/") or norm_path.startswith("/"):
372 |         return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."}
373 |     if not name:
374 |         return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
375 |     if not norm_path.lower().endswith(".cs"):
376 |         return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
377 |     params: dict[str, Any] = {
378 |         "action": "create",
379 |         "name": name,
380 |         "path": directory,
381 |         "namespace": namespace,
382 |         "scriptType": script_type,
383 |     }
384 |     if contents:
385 |         params["encodedContents"] = base64.b64encode(
386 |             contents.encode("utf-8")).decode("utf-8")
387 |         params["contentsEncoded"] = True
388 |     params = {k: v for k, v in params.items() if v is not None}
389 |     resp = send_command_with_retry("manage_script", params)
390 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
391 | 
392 | 
393 | @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path."))
394 | def delete_script(
395 |     ctx: Context,
396 |     uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
397 | ) -> dict[str, Any]:
398 |     """Delete a C# script by URI."""
399 |     ctx.info(f"Processing delete_script: {uri}")
400 |     name, directory = _split_uri(uri)
401 |     if not directory or directory.split("/")[0].lower() != "assets":
402 |         return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
403 |     params = {"action": "delete", "name": name, "path": directory}
404 |     resp = send_command_with_retry("manage_script", params)
405 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
406 | 
407 | 
408 | @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics."))
409 | def validate_script(
410 |     ctx: Context,
411 |     uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
412 |     level: Annotated[Literal['basic', 'standard'],
413 |                      "Validation level"] = "basic",
414 |     include_diagnostics: Annotated[bool,
415 |                                    "Include full diagnostics and summary"] = False
416 | ) -> dict[str, Any]:
417 |     ctx.info(f"Processing validate_script: {uri}")
418 |     name, directory = _split_uri(uri)
419 |     if not directory or directory.split("/")[0].lower() != "assets":
420 |         return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
421 |     if level not in ("basic", "standard"):
422 |         return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."}
423 |     params = {
424 |         "action": "validate",
425 |         "name": name,
426 |         "path": directory,
427 |         "level": level,
428 |     }
429 |     resp = send_command_with_retry("manage_script", params)
430 |     if isinstance(resp, dict) and resp.get("success"):
431 |         diags = resp.get("data", {}).get("diagnostics", []) or []
432 |         warnings = sum(1 for d in diags if str(
433 |             d.get("severity", "")).lower() == "warning")
434 |         errors = sum(1 for d in diags if str(
435 |             d.get("severity", "")).lower() in ("error", "fatal"))
436 |         if include_diagnostics:
437 |             return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
438 |         return {"success": True, "data": {"warnings": warnings, "errors": errors}}
439 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
440 | 
441 | 
442 | @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits."))
443 | def manage_script(
444 |     ctx: Context,
445 |     action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."],
446 |     name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
447 |     path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
448 |     contents: Annotated[str, "Contents of the script to create",
449 |                         "C# code for 'create'/'update'"] | None = None,
450 |     script_type: Annotated[str, "Script type (e.g., 'C#')",
451 |                            "Type hint (e.g., 'MonoBehaviour')"] | None = None,
452 |     namespace: Annotated[str, "Namespace for the script"] | None = None,
453 | ) -> dict[str, Any]:
454 |     ctx.info(f"Processing manage_script: {action}")
455 |     try:
456 |         # Prepare parameters for Unity
457 |         params = {
458 |             "action": action,
459 |             "name": name,
460 |             "path": path,
461 |             "namespace": namespace,
462 |             "scriptType": script_type,
463 |         }
464 | 
465 |         # Base64 encode the contents if they exist to avoid JSON escaping issues
466 |         if contents:
467 |             if action == 'create':
468 |                 params["encodedContents"] = base64.b64encode(
469 |                     contents.encode('utf-8')).decode('utf-8')
470 |                 params["contentsEncoded"] = True
471 |             else:
472 |                 params["contents"] = contents
473 | 
474 |         params = {k: v for k, v in params.items() if v is not None}
475 | 
476 |         response = send_command_with_retry("manage_script", params)
477 | 
478 |         if isinstance(response, dict):
479 |             if response.get("success"):
480 |                 if response.get("data", {}).get("contentsEncoded"):
481 |                     decoded_contents = base64.b64decode(
482 |                         response["data"]["encodedContents"]).decode('utf-8')
483 |                     response["data"]["contents"] = decoded_contents
484 |                     del response["data"]["encodedContents"]
485 |                     del response["data"]["contentsEncoded"]
486 | 
487 |                 return {
488 |                     "success": True,
489 |                     "message": response.get("message", "Operation successful."),
490 |                     "data": response.get("data"),
491 |                 }
492 |             return response
493 | 
494 |         return {"success": False, "message": str(response)}
495 | 
496 |     except Exception as e:
497 |         return {
498 |             "success": False,
499 |             "message": f"Python error managing script: {str(e)}",
500 |         }
501 | 
502 | 
503 | @mcp_for_unity_tool(description=(
504 |     """Get manage_script capabilities (supported ops, limits, and guards).
505 |     Returns:
506 |         - ops: list of supported structured ops
507 |         - text_ops: list of supported text ops
508 |         - max_edit_payload_bytes: server edit payload cap
509 |         - guards: header/using guard enabled flag"""
510 | ))
511 | def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
512 |     ctx.info("Processing manage_script_capabilities")
513 |     try:
514 |         # Keep in sync with server/Editor ManageScript implementation
515 |         ops = [
516 |             "replace_class", "delete_class", "replace_method", "delete_method",
517 |             "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"
518 |         ]
519 |         text_ops = ["replace_range", "regex_replace", "prepend", "append"]
520 |         # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback
521 |         max_edit_payload_bytes = 256 * 1024
522 |         guards = {"using_guard": True}
523 |         extras = {"get_sha": True}
524 |         return {"success": True, "data": {
525 |             "ops": ops,
526 |             "text_ops": text_ops,
527 |             "max_edit_payload_bytes": max_edit_payload_bytes,
528 |             "guards": guards,
529 |             "extras": extras,
530 |         }}
531 |     except Exception as e:
532 |         return {"success": False, "error": f"capabilities error: {e}"}
533 | 
534 | 
535 | @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
536 | def get_sha(
537 |     ctx: Context,
538 |     uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
539 | ) -> dict[str, Any]:
540 |     ctx.info(f"Processing get_sha: {uri}")
541 |     try:
542 |         name, directory = _split_uri(uri)
543 |         params = {"action": "get_sha", "name": name, "path": directory}
544 |         resp = send_command_with_retry("manage_script", params)
545 |         if isinstance(resp, dict) and resp.get("success"):
546 |             data = resp.get("data", {})
547 |             minimal = {"sha256": data.get(
548 |                 "sha256"), "lengthBytes": data.get("lengthBytes")}
549 |             return {"success": True, "data": minimal}
550 |         return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
551 |     except Exception as e:
552 |         return {"success": False, "message": f"get_sha error: {e}"}
553 | 
```

--------------------------------------------------------------------------------
/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py:
--------------------------------------------------------------------------------

```python
  1 | import base64
  2 | import os
  3 | from typing import Annotated, Any, Literal
  4 | from urllib.parse import urlparse, unquote
  5 | 
  6 | from mcp.server.fastmcp import FastMCP, Context
  7 | 
  8 | from registry import mcp_for_unity_tool
  9 | from unity_connection import send_command_with_retry
 10 | 
 11 | 
 12 | def _split_uri(uri: str) -> tuple[str, str]:
 13 |     """Split an incoming URI or path into (name, directory) suitable for Unity.
 14 | 
 15 |     Rules:
 16 |     - unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
 17 |     - file://... → percent-decode, normalize, strip host and leading slashes,
 18 |         then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
 19 |         Otherwise, fall back to original name/dir behavior.
 20 |     - plain paths → decode/normalize separators; if they contain an 'Assets' segment,
 21 |         return relative to 'Assets'.
 22 |     """
 23 |     raw_path: str
 24 |     if uri.startswith("unity://path/"):
 25 |         raw_path = uri[len("unity://path/"):]
 26 |     elif uri.startswith("file://"):
 27 |         parsed = urlparse(uri)
 28 |         host = (parsed.netloc or "").strip()
 29 |         p = parsed.path or ""
 30 |         # UNC: file://server/share/... -> //server/share/...
 31 |         if host and host.lower() != "localhost":
 32 |             p = f"//{host}{p}"
 33 |         # Use percent-decoded path, preserving leading slashes
 34 |         raw_path = unquote(p)
 35 |     else:
 36 |         raw_path = uri
 37 | 
 38 |     # Percent-decode any residual encodings and normalize separators
 39 |     raw_path = unquote(raw_path).replace("\\", "/")
 40 |     # Strip leading slash only for Windows drive-letter forms like "/C:/..."
 41 |     if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
 42 |         raw_path = raw_path[1:]
 43 | 
 44 |     # Normalize path (collapse ../, ./)
 45 |     norm = os.path.normpath(raw_path).replace("\\", "/")
 46 | 
 47 |     # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
 48 |     parts = [p for p in norm.split("/") if p not in ("", ".")]
 49 |     idx = next((i for i, seg in enumerate(parts)
 50 |                 if seg.lower() == "assets"), None)
 51 |     assets_rel = "/".join(parts[idx:]) if idx is not None else None
 52 | 
 53 |     effective_path = assets_rel if assets_rel else norm
 54 |     # For POSIX absolute paths outside Assets, drop the leading '/'
 55 |     # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
 56 |     if effective_path.startswith("/"):
 57 |         effective_path = effective_path[1:]
 58 | 
 59 |     name = os.path.splitext(os.path.basename(effective_path))[0]
 60 |     directory = os.path.dirname(effective_path)
 61 |     return name, directory
 62 | 
 63 | 
 64 | @mcp_for_unity_tool(description=(
 65 |     """Apply small text edits to a C# script identified by URI.
 66 |     IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!
 67 |     RECOMMENDED WORKFLOW:
 68 |         1. First call resources/read with start_line/line_count to verify exact content
 69 |         2. Count columns carefully (or use find_in_file to locate patterns)
 70 |         3. Apply your edit with precise coordinates
 71 |         4. Consider script_apply_edits with anchors for safer pattern-based replacements
 72 |     Notes:
 73 |         - For method/class operations, use script_apply_edits (safer, structured edits)
 74 |         - For pattern-based replacements, consider anchor operations in script_apply_edits
 75 |         - Lines, columns are 1-indexed
 76 |         - Tabs count as 1 column"""
 77 | ))
 78 | def apply_text_edits(
 79 |     ctx: Context,
 80 |     uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
 81 |     edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"],
 82 |     precondition_sha256: Annotated[str,
 83 |                                    "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None,
 84 |     strict: Annotated[bool,
 85 |                       "Optional strict flag, used to enforce strict mode"] | None = None,
 86 |     options: Annotated[dict[str, Any],
 87 |                        "Optional options, used to pass additional options to the script editor"] | None = None,
 88 | ) -> dict[str, Any]:
 89 |     ctx.info(f"Processing apply_text_edits: {uri}")
 90 |     name, directory = _split_uri(uri)
 91 | 
 92 |     # Normalize common aliases/misuses for resilience:
 93 |     # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
 94 |     # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text}
 95 |     # If normalization is required, read current contents to map indices -> 1-based line/col.
 96 |     def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
 97 |         for e in arr or []:
 98 |             if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e):
 99 |                 return True
100 |         return False
101 | 
102 |     normalized_edits: list[dict[str, Any]] = []
103 |     warnings: list[str] = []
104 |     if _needs_normalization(edits):
105 |         # Read file to support index->line/col conversion when needed
106 |         read_resp = send_command_with_retry("manage_script", {
107 |             "action": "read",
108 |             "name": name,
109 |             "path": directory,
110 |         })
111 |         if not (isinstance(read_resp, dict) and read_resp.get("success")):
112 |             return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
113 |         data = read_resp.get("data", {})
114 |         contents = data.get("contents")
115 |         if not contents and data.get("contentsEncoded"):
116 |             try:
117 |                 contents = base64.b64decode(data.get("encodedContents", "").encode(
118 |                     "utf-8")).decode("utf-8", "replace")
119 |             except Exception:
120 |                 contents = contents or ""
121 | 
122 |         # Helper to map 0-based character index to 1-based line/col
123 |         def line_col_from_index(idx: int) -> tuple[int, int]:
124 |             if idx <= 0:
125 |                 return 1, 1
126 |             # Count lines up to idx and position within line
127 |             nl_count = contents.count("\n", 0, idx)
128 |             line = nl_count + 1
129 |             last_nl = contents.rfind("\n", 0, idx)
130 |             col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
131 |             return line, col
132 | 
133 |         for e in edits or []:
134 |             e2 = dict(e)
135 |             # Map text->newText if needed
136 |             if "newText" not in e2 and "text" in e2:
137 |                 e2["newText"] = e2.pop("text")
138 | 
139 |             if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2:
140 |                 # Guard: explicit fields must be 1-based.
141 |                 zero_based = False
142 |                 for k in ("startLine", "startCol", "endLine", "endCol"):
143 |                     try:
144 |                         if int(e2.get(k, 1)) < 1:
145 |                             zero_based = True
146 |                     except Exception:
147 |                         pass
148 |                 if zero_based:
149 |                     if strict:
150 |                         return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}}
151 |                     # Normalize by clamping to 1 and warn
152 |                     for k in ("startLine", "startCol", "endLine", "endCol"):
153 |                         try:
154 |                             if int(e2.get(k, 1)) < 1:
155 |                                 e2[k] = 1
156 |                         except Exception:
157 |                             pass
158 |                     warnings.append(
159 |                         "zero_based_explicit_fields_normalized")
160 |                 normalized_edits.append(e2)
161 |                 continue
162 | 
163 |             rng = e2.get("range")
164 |             if isinstance(rng, dict):
165 |                 # LSP style: 0-based
166 |                 s = rng.get("start", {})
167 |                 t = rng.get("end", {})
168 |                 e2["startLine"] = int(s.get("line", 0)) + 1
169 |                 e2["startCol"] = int(s.get("character", 0)) + 1
170 |                 e2["endLine"] = int(t.get("line", 0)) + 1
171 |                 e2["endCol"] = int(t.get("character", 0)) + 1
172 |                 e2.pop("range", None)
173 |                 normalized_edits.append(e2)
174 |                 continue
175 |             if isinstance(rng, (list, tuple)) and len(rng) == 2:
176 |                 try:
177 |                     a = int(rng[0])
178 |                     b = int(rng[1])
179 |                     if b < a:
180 |                         a, b = b, a
181 |                     sl, sc = line_col_from_index(a)
182 |                     el, ec = line_col_from_index(b)
183 |                     e2["startLine"] = sl
184 |                     e2["startCol"] = sc
185 |                     e2["endLine"] = el
186 |                     e2["endCol"] = ec
187 |                     e2.pop("range", None)
188 |                     normalized_edits.append(e2)
189 |                     continue
190 |                 except Exception:
191 |                     pass
192 |             # Could not normalize this edit
193 |             return {
194 |                 "success": False,
195 |                 "code": "missing_field",
196 |                 "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'",
197 |                 "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e}
198 |             }
199 |     else:
200 |         # Even when edits appear already in explicit form, validate 1-based coordinates.
201 |         normalized_edits = []
202 |         for e in edits or []:
203 |             e2 = dict(e)
204 |             has_all = all(k in e2 for k in (
205 |                 "startLine", "startCol", "endLine", "endCol"))
206 |             if has_all:
207 |                 zero_based = False
208 |                 for k in ("startLine", "startCol", "endLine", "endCol"):
209 |                     try:
210 |                         if int(e2.get(k, 1)) < 1:
211 |                             zero_based = True
212 |                     except Exception:
213 |                         pass
214 |                 if zero_based:
215 |                     if strict:
216 |                         return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}}
217 |                     for k in ("startLine", "startCol", "endLine", "endCol"):
218 |                         try:
219 |                             if int(e2.get(k, 1)) < 1:
220 |                                 e2[k] = 1
221 |                         except Exception:
222 |                             pass
223 |                     if "zero_based_explicit_fields_normalized" not in warnings:
224 |                         warnings.append(
225 |                             "zero_based_explicit_fields_normalized")
226 |             normalized_edits.append(e2)
227 | 
228 |     # Preflight: detect overlapping ranges among normalized line/col spans
229 |     def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]:
230 |         return (
231 |             int(e.get("startLine", 1)) if key_start else int(
232 |                 e.get("endLine", 1)),
233 |             int(e.get("startCol", 1)) if key_start else int(
234 |                 e.get("endCol", 1)),
235 |         )
236 | 
237 |     def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
238 |         return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1])
239 | 
240 |     # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap.
241 |     spans = []
242 |     for e in normalized_edits or []:
243 |         try:
244 |             s = _pos_tuple(e, True)
245 |             t = _pos_tuple(e, False)
246 |             if s != t:
247 |                 spans.append((s, t))
248 |         except Exception:
249 |             # If coordinates missing or invalid, let the server validate later
250 |             pass
251 | 
252 |     if spans:
253 |         spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1]))
254 |         for i in range(1, len(spans_sorted)):
255 |             prev_end = spans_sorted[i-1][1]
256 |             curr_start = spans_sorted[i][0]
257 |             # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start
258 |             if not _le(prev_end, curr_start):
259 |                 conflicts = [{
260 |                     "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]},
261 |                     "endA":   {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]},
262 |                     "startB": {"line": spans_sorted[i][0][0],  "col": spans_sorted[i][0][1]},
263 |                     "endB":   {"line": spans_sorted[i][1][0],  "col": spans_sorted[i][1][1]},
264 |                 }]
265 |                 return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}}
266 | 
267 |     # Note: Do not auto-compute precondition if missing; callers should supply it
268 |     # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and
269 |     # preserves existing call-count expectations in clients/tests.
270 | 
271 |     # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance
272 |     opts: dict[str, Any] = dict(options or {})
273 |     try:
274 |         if len(normalized_edits) > 1 and "applyMode" not in opts:
275 |             opts["applyMode"] = "atomic"
276 |     except Exception:
277 |         pass
278 |     # Support optional debug preview for span-by-span simulation without write
279 |     if opts.get("debug_preview"):
280 |         try:
281 |             import difflib
282 |             # Apply locally to preview final result
283 |             lines = []
284 |             # Build an indexable original from a read if we normalized from read; otherwise skip
285 |             prev = ""
286 |             # We cannot guarantee file contents here without a read; return normalized spans only
287 |             return {
288 |                 "success": True,
289 |                 "message": "Preview only (no write)",
290 |                 "data": {
291 |                     "normalizedEdits": normalized_edits,
292 |                     "preview": True
293 |                 }
294 |             }
295 |         except Exception as e:
296 |             return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}}
297 | 
298 |     params = {
299 |         "action": "apply_text_edits",
300 |         "name": name,
301 |         "path": directory,
302 |         "edits": normalized_edits,
303 |         "precondition_sha256": precondition_sha256,
304 |         "options": opts,
305 |     }
306 |     params = {k: v for k, v in params.items() if v is not None}
307 |     resp = send_command_with_retry("manage_script", params)
308 |     if isinstance(resp, dict):
309 |         data = resp.setdefault("data", {})
310 |         data.setdefault("normalizedEdits", normalized_edits)
311 |         if warnings:
312 |             data.setdefault("warnings", warnings)
313 |         if resp.get("success") and (options or {}).get("force_sentinel_reload"):
314 |             # Optional: flip sentinel via menu if explicitly requested
315 |             try:
316 |                 import threading
317 |                 import time
318 |                 import json
319 |                 import glob
320 |                 import os
321 | 
322 |                 def _latest_status() -> dict | None:
323 |                     try:
324 |                         files = sorted(glob.glob(os.path.expanduser(
325 |                             "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
326 |                         if not files:
327 |                             return None
328 |                         with open(files[0], "r") as f:
329 |                             return json.loads(f.read())
330 |                     except Exception:
331 |                         return None
332 | 
333 |                 def _flip_async():
334 |                     try:
335 |                         time.sleep(0.1)
336 |                         st = _latest_status()
337 |                         if st and st.get("reloading"):
338 |                             return
339 |                         send_command_with_retry(
340 |                             "execute_menu_item",
341 |                             {"menuPath": "MCP/Flip Reload Sentinel"},
342 |                             max_retries=0,
343 |                             retry_ms=0,
344 |                         )
345 |                     except Exception:
346 |                         pass
347 |                 threading.Thread(target=_flip_async, daemon=True).start()
348 |             except Exception:
349 |                 pass
350 |             return resp
351 |         return resp
352 |     return {"success": False, "message": str(resp)}
353 | 
354 | 
355 | @mcp_for_unity_tool(description=("Create a new C# script at the given project path."))
356 | def create_script(
357 |     ctx: Context,
358 |     path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
359 |     contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
360 |     script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
361 |     namespace: Annotated[str, "Namespace for the script"] | None = None,
362 | ) -> dict[str, Any]:
363 |     ctx.info(f"Processing create_script: {path}")
364 |     name = os.path.splitext(os.path.basename(path))[0]
365 |     directory = os.path.dirname(path)
366 |     # Local validation to avoid round-trips on obviously bad input
367 |     norm_path = os.path.normpath(
368 |         (path or "").replace("\\", "/")).replace("\\", "/")
369 |     if not directory or directory.split("/")[0].lower() != "assets":
370 |         return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."}
371 |     if ".." in norm_path.split("/") or norm_path.startswith("/"):
372 |         return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."}
373 |     if not name:
374 |         return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
375 |     if not norm_path.lower().endswith(".cs"):
376 |         return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
377 |     params: dict[str, Any] = {
378 |         "action": "create",
379 |         "name": name,
380 |         "path": directory,
381 |         "namespace": namespace,
382 |         "scriptType": script_type,
383 |     }
384 |     if contents:
385 |         params["encodedContents"] = base64.b64encode(
386 |             contents.encode("utf-8")).decode("utf-8")
387 |         params["contentsEncoded"] = True
388 |     params = {k: v for k, v in params.items() if v is not None}
389 |     resp = send_command_with_retry("manage_script", params)
390 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
391 | 
392 | 
393 | @mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path."))
394 | def delete_script(
395 |     ctx: Context,
396 |     uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
397 | ) -> dict[str, Any]:
398 |     """Delete a C# script by URI."""
399 |     ctx.info(f"Processing delete_script: {uri}")
400 |     name, directory = _split_uri(uri)
401 |     if not directory or directory.split("/")[0].lower() != "assets":
402 |         return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
403 |     params = {"action": "delete", "name": name, "path": directory}
404 |     resp = send_command_with_retry("manage_script", params)
405 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
406 | 
407 | 
408 | @mcp_for_unity_tool(description=("Validate a C# script and return diagnostics."))
409 | def validate_script(
410 |     ctx: Context,
411 |     uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
412 |     level: Annotated[Literal['basic', 'standard'],
413 |                      "Validation level"] = "basic",
414 |     include_diagnostics: Annotated[bool,
415 |                                    "Include full diagnostics and summary"] = False
416 | ) -> dict[str, Any]:
417 |     ctx.info(f"Processing validate_script: {uri}")
418 |     name, directory = _split_uri(uri)
419 |     if not directory or directory.split("/")[0].lower() != "assets":
420 |         return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
421 |     if level not in ("basic", "standard"):
422 |         return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."}
423 |     params = {
424 |         "action": "validate",
425 |         "name": name,
426 |         "path": directory,
427 |         "level": level,
428 |     }
429 |     resp = send_command_with_retry("manage_script", params)
430 |     if isinstance(resp, dict) and resp.get("success"):
431 |         diags = resp.get("data", {}).get("diagnostics", []) or []
432 |         warnings = sum(1 for d in diags if str(
433 |             d.get("severity", "")).lower() == "warning")
434 |         errors = sum(1 for d in diags if str(
435 |             d.get("severity", "")).lower() in ("error", "fatal"))
436 |         if include_diagnostics:
437 |             return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
438 |         return {"success": True, "data": {"warnings": warnings, "errors": errors}}
439 |     return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
440 | 
441 | 
442 | @mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits."))
443 | def manage_script(
444 |     ctx: Context,
445 |     action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."],
446 |     name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
447 |     path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
448 |     contents: Annotated[str, "Contents of the script to create",
449 |                         "C# code for 'create'/'update'"] | None = None,
450 |     script_type: Annotated[str, "Script type (e.g., 'C#')",
451 |                            "Type hint (e.g., 'MonoBehaviour')"] | None = None,
452 |     namespace: Annotated[str, "Namespace for the script"] | None = None,
453 | ) -> dict[str, Any]:
454 |     ctx.info(f"Processing manage_script: {action}")
455 |     try:
456 |         # Prepare parameters for Unity
457 |         params = {
458 |             "action": action,
459 |             "name": name,
460 |             "path": path,
461 |             "namespace": namespace,
462 |             "scriptType": script_type,
463 |         }
464 | 
465 |         # Base64 encode the contents if they exist to avoid JSON escaping issues
466 |         if contents:
467 |             if action == 'create':
468 |                 params["encodedContents"] = base64.b64encode(
469 |                     contents.encode('utf-8')).decode('utf-8')
470 |                 params["contentsEncoded"] = True
471 |             else:
472 |                 params["contents"] = contents
473 | 
474 |         params = {k: v for k, v in params.items() if v is not None}
475 | 
476 |         response = send_command_with_retry("manage_script", params)
477 | 
478 |         if isinstance(response, dict):
479 |             if response.get("success"):
480 |                 if response.get("data", {}).get("contentsEncoded"):
481 |                     decoded_contents = base64.b64decode(
482 |                         response["data"]["encodedContents"]).decode('utf-8')
483 |                     response["data"]["contents"] = decoded_contents
484 |                     del response["data"]["encodedContents"]
485 |                     del response["data"]["contentsEncoded"]
486 | 
487 |                 return {
488 |                     "success": True,
489 |                     "message": response.get("message", "Operation successful."),
490 |                     "data": response.get("data"),
491 |                 }
492 |             return response
493 | 
494 |         return {"success": False, "message": str(response)}
495 | 
496 |     except Exception as e:
497 |         return {
498 |             "success": False,
499 |             "message": f"Python error managing script: {str(e)}",
500 |         }
501 | 
502 | 
503 | @mcp_for_unity_tool(description=(
504 |     """Get manage_script capabilities (supported ops, limits, and guards).
505 |     Returns:
506 |         - ops: list of supported structured ops
507 |         - text_ops: list of supported text ops
508 |         - max_edit_payload_bytes: server edit payload cap
509 |         - guards: header/using guard enabled flag"""
510 | ))
511 | def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
512 |     ctx.info("Processing manage_script_capabilities")
513 |     try:
514 |         # Keep in sync with server/Editor ManageScript implementation
515 |         ops = [
516 |             "replace_class", "delete_class", "replace_method", "delete_method",
517 |             "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"
518 |         ]
519 |         text_ops = ["replace_range", "regex_replace", "prepend", "append"]
520 |         # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback
521 |         max_edit_payload_bytes = 256 * 1024
522 |         guards = {"using_guard": True}
523 |         extras = {"get_sha": True}
524 |         return {"success": True, "data": {
525 |             "ops": ops,
526 |             "text_ops": text_ops,
527 |             "max_edit_payload_bytes": max_edit_payload_bytes,
528 |             "guards": guards,
529 |             "extras": extras,
530 |         }}
531 |     except Exception as e:
532 |         return {"success": False, "error": f"capabilities error: {e}"}
533 | 
534 | 
535 | @mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
536 | def get_sha(
537 |     ctx: Context,
538 |     uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
539 | ) -> dict[str, Any]:
540 |     ctx.info(f"Processing get_sha: {uri}")
541 |     try:
542 |         name, directory = _split_uri(uri)
543 |         params = {"action": "get_sha", "name": name, "path": directory}
544 |         resp = send_command_with_retry("manage_script", params)
545 |         if isinstance(resp, dict) and resp.get("success"):
546 |             data = resp.get("data", {})
547 |             minimal = {"sha256": data.get(
548 |                 "sha256"), "lengthBytes": data.get("lengthBytes")}
549 |             return {"success": True, "data": minimal}
550 |         return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
551 |     except Exception as e:
552 |         return {"success": False, "message": f"get_sha error: {e}"}
553 | 
```

--------------------------------------------------------------------------------
/UnityMcpBridge/Editor/Tools/ManageEditor.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Linq;
  4 | using System.IO;
  5 | using Newtonsoft.Json.Linq;
  6 | using UnityEditor;
  7 | using UnityEditorInternal; // Required for tag management
  8 | using UnityEditor.SceneManagement;
  9 | using UnityEngine;
 10 | using MCPForUnity.Editor.Helpers;
 11 | 
 12 | namespace MCPForUnity.Editor.Tools
 13 | {
 14 |     /// <summary>
 15 |     /// Handles operations related to controlling and querying the Unity Editor state,
 16 |     /// including managing Tags and Layers.
 17 |     /// </summary>
 18 |     [McpForUnityTool("manage_editor")]
 19 |     public static class ManageEditor
 20 |     {
 21 |         // Constant for starting user layer index
 22 |         private const int FirstUserLayerIndex = 8;
 23 | 
 24 |         // Constant for total layer count
 25 |         private const int TotalLayerCount = 32;
 26 | 
 27 |         /// <summary>
 28 |         /// Main handler for editor management actions.
 29 |         /// </summary>
 30 |         public static object HandleCommand(JObject @params)
 31 |         {
 32 |             string action = @params["action"]?.ToString().ToLower();
 33 |             // Parameters for specific actions
 34 |             string tagName = @params["tagName"]?.ToString();
 35 |             string layerName = @params["layerName"]?.ToString();
 36 |             bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere
 37 | 
 38 |             if (string.IsNullOrEmpty(action))
 39 |             {
 40 |                 return Response.Error("Action parameter is required.");
 41 |             }
 42 | 
 43 |             // Route action
 44 |             switch (action)
 45 |             {
 46 |                 // Play Mode Control
 47 |                 case "play":
 48 |                     try
 49 |                     {
 50 |                         if (!EditorApplication.isPlaying)
 51 |                         {
 52 |                             EditorApplication.isPlaying = true;
 53 |                             return Response.Success("Entered play mode.");
 54 |                         }
 55 |                         return Response.Success("Already in play mode.");
 56 |                     }
 57 |                     catch (Exception e)
 58 |                     {
 59 |                         return Response.Error($"Error entering play mode: {e.Message}");
 60 |                     }
 61 |                 case "pause":
 62 |                     try
 63 |                     {
 64 |                         if (EditorApplication.isPlaying)
 65 |                         {
 66 |                             EditorApplication.isPaused = !EditorApplication.isPaused;
 67 |                             return Response.Success(
 68 |                                 EditorApplication.isPaused ? "Game paused." : "Game resumed."
 69 |                             );
 70 |                         }
 71 |                         return Response.Error("Cannot pause/resume: Not in play mode.");
 72 |                     }
 73 |                     catch (Exception e)
 74 |                     {
 75 |                         return Response.Error($"Error pausing/resuming game: {e.Message}");
 76 |                     }
 77 |                 case "stop":
 78 |                     try
 79 |                     {
 80 |                         if (EditorApplication.isPlaying)
 81 |                         {
 82 |                             EditorApplication.isPlaying = false;
 83 |                             return Response.Success("Exited play mode.");
 84 |                         }
 85 |                         return Response.Success("Already stopped (not in play mode).");
 86 |                     }
 87 |                     catch (Exception e)
 88 |                     {
 89 |                         return Response.Error($"Error stopping play mode: {e.Message}");
 90 |                     }
 91 | 
 92 |                 // Editor State/Info
 93 |                 case "get_state":
 94 |                     return GetEditorState();
 95 |                 case "get_project_root":
 96 |                     return GetProjectRoot();
 97 |                 case "get_windows":
 98 |                     return GetEditorWindows();
 99 |                 case "get_active_tool":
100 |                     return GetActiveTool();
101 |                 case "get_selection":
102 |                     return GetSelection();
103 |                 case "get_prefab_stage":
104 |                     return GetPrefabStageInfo();
105 |                 case "set_active_tool":
106 |                     string toolName = @params["toolName"]?.ToString();
107 |                     if (string.IsNullOrEmpty(toolName))
108 |                         return Response.Error("'toolName' parameter required for set_active_tool.");
109 |                     return SetActiveTool(toolName);
110 | 
111 |                 // Tag Management
112 |                 case "add_tag":
113 |                     if (string.IsNullOrEmpty(tagName))
114 |                         return Response.Error("'tagName' parameter required for add_tag.");
115 |                     return AddTag(tagName);
116 |                 case "remove_tag":
117 |                     if (string.IsNullOrEmpty(tagName))
118 |                         return Response.Error("'tagName' parameter required for remove_tag.");
119 |                     return RemoveTag(tagName);
120 |                 case "get_tags":
121 |                     return GetTags(); // Helper to list current tags
122 | 
123 |                 // Layer Management
124 |                 case "add_layer":
125 |                     if (string.IsNullOrEmpty(layerName))
126 |                         return Response.Error("'layerName' parameter required for add_layer.");
127 |                     return AddLayer(layerName);
128 |                 case "remove_layer":
129 |                     if (string.IsNullOrEmpty(layerName))
130 |                         return Response.Error("'layerName' parameter required for remove_layer.");
131 |                     return RemoveLayer(layerName);
132 |                 case "get_layers":
133 |                     return GetLayers(); // Helper to list current layers
134 | 
135 |                 // --- Settings (Example) ---
136 |                 // case "set_resolution":
137 |                 //     int? width = @params["width"]?.ToObject<int?>();
138 |                 //     int? height = @params["height"]?.ToObject<int?>();
139 |                 //     if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required.");
140 |                 //     return SetGameViewResolution(width.Value, height.Value);
141 |                 // case "set_quality":
142 |                 //     // Handle string name or int index
143 |                 //     return SetQualityLevel(@params["qualityLevel"]);
144 | 
145 |                 default:
146 |                     return Response.Error(
147 |                         $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
148 |                     );
149 |             }
150 |         }
151 | 
152 |         // --- Editor State/Info Methods ---
153 |         private static object GetEditorState()
154 |         {
155 |             try
156 |             {
157 |                 var state = new
158 |                 {
159 |                     isPlaying = EditorApplication.isPlaying,
160 |                     isPaused = EditorApplication.isPaused,
161 |                     isCompiling = EditorApplication.isCompiling,
162 |                     isUpdating = EditorApplication.isUpdating,
163 |                     applicationPath = EditorApplication.applicationPath,
164 |                     applicationContentsPath = EditorApplication.applicationContentsPath,
165 |                     timeSinceStartup = EditorApplication.timeSinceStartup,
166 |                 };
167 |                 return Response.Success("Retrieved editor state.", state);
168 |             }
169 |             catch (Exception e)
170 |             {
171 |                 return Response.Error($"Error getting editor state: {e.Message}");
172 |             }
173 |         }
174 | 
175 |         private static object GetProjectRoot()
176 |         {
177 |             try
178 |             {
179 |                 // Application.dataPath points to <Project>/Assets
180 |                 string assetsPath = Application.dataPath.Replace('\\', '/');
181 |                 string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/');
182 |                 if (string.IsNullOrEmpty(projectRoot))
183 |                 {
184 |                     return Response.Error("Could not determine project root from Application.dataPath");
185 |                 }
186 |                 return Response.Success("Project root resolved.", new { projectRoot });
187 |             }
188 |             catch (Exception e)
189 |             {
190 |                 return Response.Error($"Error getting project root: {e.Message}");
191 |             }
192 |         }
193 | 
194 |         private static object GetEditorWindows()
195 |         {
196 |             try
197 |             {
198 |                 // Get all types deriving from EditorWindow
199 |                 var windowTypes = AppDomain
200 |                     .CurrentDomain.GetAssemblies()
201 |                     .SelectMany(assembly => assembly.GetTypes())
202 |                     .Where(type => type.IsSubclassOf(typeof(EditorWindow)))
203 |                     .ToList();
204 | 
205 |                 var openWindows = new List<object>();
206 | 
207 |                 // Find currently open instances
208 |                 // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows
209 |                 EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll<EditorWindow>();
210 | 
211 |                 foreach (EditorWindow window in allWindows)
212 |                 {
213 |                     if (window == null)
214 |                         continue; // Skip potentially destroyed windows
215 | 
216 |                     try
217 |                     {
218 |                         openWindows.Add(
219 |                             new
220 |                             {
221 |                                 title = window.titleContent.text,
222 |                                 typeName = window.GetType().FullName,
223 |                                 isFocused = EditorWindow.focusedWindow == window,
224 |                                 position = new
225 |                                 {
226 |                                     x = window.position.x,
227 |                                     y = window.position.y,
228 |                                     width = window.position.width,
229 |                                     height = window.position.height,
230 |                                 },
231 |                                 instanceID = window.GetInstanceID(),
232 |                             }
233 |                         );
234 |                     }
235 |                     catch (Exception ex)
236 |                     {
237 |                         Debug.LogWarning(
238 |                             $"Could not get info for window {window.GetType().Name}: {ex.Message}"
239 |                         );
240 |                     }
241 |                 }
242 | 
243 |                 return Response.Success("Retrieved list of open editor windows.", openWindows);
244 |             }
245 |             catch (Exception e)
246 |             {
247 |                 return Response.Error($"Error getting editor windows: {e.Message}");
248 |             }
249 |         }
250 | 
251 |         private static object GetPrefabStageInfo()
252 |         {
253 |             try
254 |             {
255 |                 PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
256 |                 if (stage == null)
257 |                 {
258 |                     return Response.Success
259 |                     ("No prefab stage is currently open.", new { isOpen = false });
260 |                 }
261 | 
262 |                 return Response.Success(
263 |                     "Prefab stage info retrieved.",
264 |                     new
265 |                     {
266 |                         isOpen = true,
267 |                         assetPath = stage.assetPath,
268 |                         prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
269 |                         mode = stage.mode.ToString(),
270 |                         isDirty = stage.scene.isDirty
271 |                     }
272 |                 );
273 |             }
274 |             catch (Exception e)
275 |             {
276 |                 return Response.Error($"Error getting prefab stage info: {e.Message}");
277 |             }
278 |         }
279 | 
280 |         private static object GetActiveTool()
281 |         {
282 |             try
283 |             {
284 |                 Tool currentTool = UnityEditor.Tools.current;
285 |                 string toolName = currentTool.ToString(); // Enum to string
286 |                 bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active
287 |                 string activeToolName = customToolActive
288 |                     ? EditorTools.GetActiveToolName()
289 |                     : toolName; // Get custom name if needed
290 | 
291 |                 var toolInfo = new
292 |                 {
293 |                     activeTool = activeToolName,
294 |                     isCustom = customToolActive,
295 |                     pivotMode = UnityEditor.Tools.pivotMode.ToString(),
296 |                     pivotRotation = UnityEditor.Tools.pivotRotation.ToString(),
297 |                     handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity
298 |                     handlePosition = UnityEditor.Tools.handlePosition,
299 |                 };
300 | 
301 |                 return Response.Success("Retrieved active tool information.", toolInfo);
302 |             }
303 |             catch (Exception e)
304 |             {
305 |                 return Response.Error($"Error getting active tool: {e.Message}");
306 |             }
307 |         }
308 | 
309 |         private static object SetActiveTool(string toolName)
310 |         {
311 |             try
312 |             {
313 |                 Tool targetTool;
314 |                 if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse
315 |                 {
316 |                     // Check if it's a valid built-in tool
317 |                     if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool
318 |                     {
319 |                         UnityEditor.Tools.current = targetTool;
320 |                         return Response.Success($"Set active tool to '{targetTool}'.");
321 |                     }
322 |                     else
323 |                     {
324 |                         return Response.Error(
325 |                             $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid."
326 |                         );
327 |                     }
328 |                 }
329 |                 else
330 |                 {
331 |                     // Potentially try activating a custom tool by name here if needed
332 |                     // This often requires specific editor scripting knowledge for that tool.
333 |                     return Response.Error(
334 |                         $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)."
335 |                     );
336 |                 }
337 |             }
338 |             catch (Exception e)
339 |             {
340 |                 return Response.Error($"Error setting active tool: {e.Message}");
341 |             }
342 |         }
343 | 
344 |         private static object GetSelection()
345 |         {
346 |             try
347 |             {
348 |                 var selectionInfo = new
349 |                 {
350 |                     activeObject = Selection.activeObject?.name,
351 |                     activeGameObject = Selection.activeGameObject?.name,
352 |                     activeTransform = Selection.activeTransform?.name,
353 |                     activeInstanceID = Selection.activeInstanceID,
354 |                     count = Selection.count,
355 |                     objects = Selection
356 |                         .objects.Select(obj => new
357 |                         {
358 |                             name = obj?.name,
359 |                             type = obj?.GetType().FullName,
360 |                             instanceID = obj?.GetInstanceID(),
361 |                         })
362 |                         .ToList(),
363 |                     gameObjects = Selection
364 |                         .gameObjects.Select(go => new
365 |                         {
366 |                             name = go?.name,
367 |                             instanceID = go?.GetInstanceID(),
368 |                         })
369 |                         .ToList(),
370 |                     assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view
371 |                 };
372 | 
373 |                 return Response.Success("Retrieved current selection details.", selectionInfo);
374 |             }
375 |             catch (Exception e)
376 |             {
377 |                 return Response.Error($"Error getting selection: {e.Message}");
378 |             }
379 |         }
380 | 
381 |         // --- Tag Management Methods ---
382 | 
383 |         private static object AddTag(string tagName)
384 |         {
385 |             if (string.IsNullOrWhiteSpace(tagName))
386 |                 return Response.Error("Tag name cannot be empty or whitespace.");
387 | 
388 |             // Check if tag already exists
389 |             if (InternalEditorUtility.tags.Contains(tagName))
390 |             {
391 |                 return Response.Error($"Tag '{tagName}' already exists.");
392 |             }
393 | 
394 |             try
395 |             {
396 |                 // Add the tag using the internal utility
397 |                 InternalEditorUtility.AddTag(tagName);
398 |                 // Force save assets to ensure the change persists in the TagManager asset
399 |                 AssetDatabase.SaveAssets();
400 |                 return Response.Success($"Tag '{tagName}' added successfully.");
401 |             }
402 |             catch (Exception e)
403 |             {
404 |                 return Response.Error($"Failed to add tag '{tagName}': {e.Message}");
405 |             }
406 |         }
407 | 
408 |         private static object RemoveTag(string tagName)
409 |         {
410 |             if (string.IsNullOrWhiteSpace(tagName))
411 |                 return Response.Error("Tag name cannot be empty or whitespace.");
412 |             if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase))
413 |                 return Response.Error("Cannot remove the built-in 'Untagged' tag.");
414 | 
415 |             // Check if tag exists before attempting removal
416 |             if (!InternalEditorUtility.tags.Contains(tagName))
417 |             {
418 |                 return Response.Error($"Tag '{tagName}' does not exist.");
419 |             }
420 | 
421 |             try
422 |             {
423 |                 // Remove the tag using the internal utility
424 |                 InternalEditorUtility.RemoveTag(tagName);
425 |                 // Force save assets
426 |                 AssetDatabase.SaveAssets();
427 |                 return Response.Success($"Tag '{tagName}' removed successfully.");
428 |             }
429 |             catch (Exception e)
430 |             {
431 |                 // Catch potential issues if the tag is somehow in use or removal fails
432 |                 return Response.Error($"Failed to remove tag '{tagName}': {e.Message}");
433 |             }
434 |         }
435 | 
436 |         private static object GetTags()
437 |         {
438 |             try
439 |             {
440 |                 string[] tags = InternalEditorUtility.tags;
441 |                 return Response.Success("Retrieved current tags.", tags);
442 |             }
443 |             catch (Exception e)
444 |             {
445 |                 return Response.Error($"Failed to retrieve tags: {e.Message}");
446 |             }
447 |         }
448 | 
449 |         // --- Layer Management Methods ---
450 | 
451 |         private static object AddLayer(string layerName)
452 |         {
453 |             if (string.IsNullOrWhiteSpace(layerName))
454 |                 return Response.Error("Layer name cannot be empty or whitespace.");
455 | 
456 |             // Access the TagManager asset
457 |             SerializedObject tagManager = GetTagManager();
458 |             if (tagManager == null)
459 |                 return Response.Error("Could not access TagManager asset.");
460 | 
461 |             SerializedProperty layersProp = tagManager.FindProperty("layers");
462 |             if (layersProp == null || !layersProp.isArray)
463 |                 return Response.Error("Could not find 'layers' property in TagManager.");
464 | 
465 |             // Check if layer name already exists (case-insensitive check recommended)
466 |             for (int i = 0; i < TotalLayerCount; i++)
467 |             {
468 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
469 |                 if (
470 |                     layerSP != null
471 |                     && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
472 |                 )
473 |                 {
474 |                     return Response.Error($"Layer '{layerName}' already exists at index {i}.");
475 |                 }
476 |             }
477 | 
478 |             // Find the first empty user layer slot (indices 8 to 31)
479 |             int firstEmptyUserLayer = -1;
480 |             for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++)
481 |             {
482 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
483 |                 if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue))
484 |                 {
485 |                     firstEmptyUserLayer = i;
486 |                     break;
487 |                 }
488 |             }
489 | 
490 |             if (firstEmptyUserLayer == -1)
491 |             {
492 |                 return Response.Error("No empty User Layer slots available (8-31 are full).");
493 |             }
494 | 
495 |             // Assign the name to the found slot
496 |             try
497 |             {
498 |                 SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
499 |                     firstEmptyUserLayer
500 |                 );
501 |                 targetLayerSP.stringValue = layerName;
502 |                 // Apply the changes to the TagManager asset
503 |                 tagManager.ApplyModifiedProperties();
504 |                 // Save assets to make sure it's written to disk
505 |                 AssetDatabase.SaveAssets();
506 |                 return Response.Success(
507 |                     $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}."
508 |                 );
509 |             }
510 |             catch (Exception e)
511 |             {
512 |                 return Response.Error($"Failed to add layer '{layerName}': {e.Message}");
513 |             }
514 |         }
515 | 
516 |         private static object RemoveLayer(string layerName)
517 |         {
518 |             if (string.IsNullOrWhiteSpace(layerName))
519 |                 return Response.Error("Layer name cannot be empty or whitespace.");
520 | 
521 |             // Access the TagManager asset
522 |             SerializedObject tagManager = GetTagManager();
523 |             if (tagManager == null)
524 |                 return Response.Error("Could not access TagManager asset.");
525 | 
526 |             SerializedProperty layersProp = tagManager.FindProperty("layers");
527 |             if (layersProp == null || !layersProp.isArray)
528 |                 return Response.Error("Could not find 'layers' property in TagManager.");
529 | 
530 |             // Find the layer by name (must be user layer)
531 |             int layerIndexToRemove = -1;
532 |             for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers
533 |             {
534 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
535 |                 // Case-insensitive comparison is safer
536 |                 if (
537 |                     layerSP != null
538 |                     && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
539 |                 )
540 |                 {
541 |                     layerIndexToRemove = i;
542 |                     break;
543 |                 }
544 |             }
545 | 
546 |             if (layerIndexToRemove == -1)
547 |             {
548 |                 return Response.Error($"User layer '{layerName}' not found.");
549 |             }
550 | 
551 |             // Clear the name for that index
552 |             try
553 |             {
554 |                 SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
555 |                     layerIndexToRemove
556 |                 );
557 |                 targetLayerSP.stringValue = string.Empty; // Set to empty string to remove
558 |                 // Apply the changes
559 |                 tagManager.ApplyModifiedProperties();
560 |                 // Save assets
561 |                 AssetDatabase.SaveAssets();
562 |                 return Response.Success(
563 |                     $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully."
564 |                 );
565 |             }
566 |             catch (Exception e)
567 |             {
568 |                 return Response.Error($"Failed to remove layer '{layerName}': {e.Message}");
569 |             }
570 |         }
571 | 
572 |         private static object GetLayers()
573 |         {
574 |             try
575 |             {
576 |                 var layers = new Dictionary<int, string>();
577 |                 for (int i = 0; i < TotalLayerCount; i++)
578 |                 {
579 |                     string layerName = LayerMask.LayerToName(i);
580 |                     if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names
581 |                     {
582 |                         layers.Add(i, layerName);
583 |                     }
584 |                 }
585 |                 return Response.Success("Retrieved current named layers.", layers);
586 |             }
587 |             catch (Exception e)
588 |             {
589 |                 return Response.Error($"Failed to retrieve layers: {e.Message}");
590 |             }
591 |         }
592 | 
593 |         // --- Helper Methods ---
594 | 
595 |         /// <summary>
596 |         /// Gets the SerializedObject for the TagManager asset.
597 |         /// </summary>
598 |         private static SerializedObject GetTagManager()
599 |         {
600 |             try
601 |             {
602 |                 // Load the TagManager asset from the ProjectSettings folder
603 |                 UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath(
604 |                     "ProjectSettings/TagManager.asset"
605 |                 );
606 |                 if (tagManagerAssets == null || tagManagerAssets.Length == 0)
607 |                 {
608 |                     Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings.");
609 |                     return null;
610 |                 }
611 |                 // The first object in the asset file should be the TagManager
612 |                 return new SerializedObject(tagManagerAssets[0]);
613 |             }
614 |             catch (Exception e)
615 |             {
616 |                 Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}");
617 |                 return null;
618 |             }
619 |         }
620 | 
621 |         // --- Example Implementations for Settings ---
622 |         /*
623 |         private static object SetGameViewResolution(int width, int height) { ... }
624 |         private static object SetQualityLevel(JToken qualityLevelToken) { ... }
625 |         */
626 |     }
627 | 
628 |     // Helper class to get custom tool names (remains the same)
629 |     internal static class EditorTools
630 |     {
631 |         public static string GetActiveToolName()
632 |         {
633 |             // This is a placeholder. Real implementation depends on how custom tools
634 |             // are registered and tracked in the specific Unity project setup.
635 |             // It might involve checking static variables, calling methods on specific tool managers, etc.
636 |             if (UnityEditor.Tools.current == Tool.Custom)
637 |             {
638 |                 // Example: Check a known custom tool manager
639 |                 // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName;
640 |                 return "Unknown Custom Tool";
641 |             }
642 |             return UnityEditor.Tools.current.ToString();
643 |         }
644 |     }
645 | }
646 | 
```

--------------------------------------------------------------------------------
/MCPForUnity/Editor/Tools/ManageEditor.cs:
--------------------------------------------------------------------------------

```csharp
  1 | using System;
  2 | using System.Collections.Generic;
  3 | using System.Linq;
  4 | using System.IO;
  5 | using Newtonsoft.Json.Linq;
  6 | using UnityEditor;
  7 | using UnityEditorInternal; // Required for tag management
  8 | using UnityEditor.SceneManagement;
  9 | using UnityEngine;
 10 | using MCPForUnity.Editor.Helpers;
 11 | 
 12 | namespace MCPForUnity.Editor.Tools
 13 | {
 14 |     /// <summary>
 15 |     /// Handles operations related to controlling and querying the Unity Editor state,
 16 |     /// including managing Tags and Layers.
 17 |     /// </summary>
 18 |     [McpForUnityTool("manage_editor")]
 19 |     public static class ManageEditor
 20 |     {
 21 |         // Constant for starting user layer index
 22 |         private const int FirstUserLayerIndex = 8;
 23 | 
 24 |         // Constant for total layer count
 25 |         private const int TotalLayerCount = 32;
 26 | 
 27 |         /// <summary>
 28 |         /// Main handler for editor management actions.
 29 |         /// </summary>
 30 |         public static object HandleCommand(JObject @params)
 31 |         {
 32 |             string action = @params["action"]?.ToString().ToLower();
 33 |             // Parameters for specific actions
 34 |             string tagName = @params["tagName"]?.ToString();
 35 |             string layerName = @params["layerName"]?.ToString();
 36 |             bool waitForCompletion = @params["waitForCompletion"]?.ToObject<bool>() ?? false; // Example - not used everywhere
 37 | 
 38 |             if (string.IsNullOrEmpty(action))
 39 |             {
 40 |                 return Response.Error("Action parameter is required.");
 41 |             }
 42 | 
 43 |             // Route action
 44 |             switch (action)
 45 |             {
 46 |                 // Play Mode Control
 47 |                 case "play":
 48 |                     try
 49 |                     {
 50 |                         if (!EditorApplication.isPlaying)
 51 |                         {
 52 |                             EditorApplication.isPlaying = true;
 53 |                             return Response.Success("Entered play mode.");
 54 |                         }
 55 |                         return Response.Success("Already in play mode.");
 56 |                     }
 57 |                     catch (Exception e)
 58 |                     {
 59 |                         return Response.Error($"Error entering play mode: {e.Message}");
 60 |                     }
 61 |                 case "pause":
 62 |                     try
 63 |                     {
 64 |                         if (EditorApplication.isPlaying)
 65 |                         {
 66 |                             EditorApplication.isPaused = !EditorApplication.isPaused;
 67 |                             return Response.Success(
 68 |                                 EditorApplication.isPaused ? "Game paused." : "Game resumed."
 69 |                             );
 70 |                         }
 71 |                         return Response.Error("Cannot pause/resume: Not in play mode.");
 72 |                     }
 73 |                     catch (Exception e)
 74 |                     {
 75 |                         return Response.Error($"Error pausing/resuming game: {e.Message}");
 76 |                     }
 77 |                 case "stop":
 78 |                     try
 79 |                     {
 80 |                         if (EditorApplication.isPlaying)
 81 |                         {
 82 |                             EditorApplication.isPlaying = false;
 83 |                             return Response.Success("Exited play mode.");
 84 |                         }
 85 |                         return Response.Success("Already stopped (not in play mode).");
 86 |                     }
 87 |                     catch (Exception e)
 88 |                     {
 89 |                         return Response.Error($"Error stopping play mode: {e.Message}");
 90 |                     }
 91 | 
 92 |                 // Editor State/Info
 93 |                 case "get_state":
 94 |                     return GetEditorState();
 95 |                 case "get_project_root":
 96 |                     return GetProjectRoot();
 97 |                 case "get_windows":
 98 |                     return GetEditorWindows();
 99 |                 case "get_active_tool":
100 |                     return GetActiveTool();
101 |                 case "get_selection":
102 |                     return GetSelection();
103 |                 case "get_prefab_stage":
104 |                     return GetPrefabStageInfo();
105 |                 case "set_active_tool":
106 |                     string toolName = @params["toolName"]?.ToString();
107 |                     if (string.IsNullOrEmpty(toolName))
108 |                         return Response.Error("'toolName' parameter required for set_active_tool.");
109 |                     return SetActiveTool(toolName);
110 | 
111 |                 // Tag Management
112 |                 case "add_tag":
113 |                     if (string.IsNullOrEmpty(tagName))
114 |                         return Response.Error("'tagName' parameter required for add_tag.");
115 |                     return AddTag(tagName);
116 |                 case "remove_tag":
117 |                     if (string.IsNullOrEmpty(tagName))
118 |                         return Response.Error("'tagName' parameter required for remove_tag.");
119 |                     return RemoveTag(tagName);
120 |                 case "get_tags":
121 |                     return GetTags(); // Helper to list current tags
122 | 
123 |                 // Layer Management
124 |                 case "add_layer":
125 |                     if (string.IsNullOrEmpty(layerName))
126 |                         return Response.Error("'layerName' parameter required for add_layer.");
127 |                     return AddLayer(layerName);
128 |                 case "remove_layer":
129 |                     if (string.IsNullOrEmpty(layerName))
130 |                         return Response.Error("'layerName' parameter required for remove_layer.");
131 |                     return RemoveLayer(layerName);
132 |                 case "get_layers":
133 |                     return GetLayers(); // Helper to list current layers
134 | 
135 |                 // --- Settings (Example) ---
136 |                 // case "set_resolution":
137 |                 //     int? width = @params["width"]?.ToObject<int?>();
138 |                 //     int? height = @params["height"]?.ToObject<int?>();
139 |                 //     if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required.");
140 |                 //     return SetGameViewResolution(width.Value, height.Value);
141 |                 // case "set_quality":
142 |                 //     // Handle string name or int index
143 |                 //     return SetQualityLevel(@params["qualityLevel"]);
144 | 
145 |                 default:
146 |                     return Response.Error(
147 |                         $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
148 |                     );
149 |             }
150 |         }
151 | 
152 |         // --- Editor State/Info Methods ---
153 |         private static object GetEditorState()
154 |         {
155 |             try
156 |             {
157 |                 var state = new
158 |                 {
159 |                     isPlaying = EditorApplication.isPlaying,
160 |                     isPaused = EditorApplication.isPaused,
161 |                     isCompiling = EditorApplication.isCompiling,
162 |                     isUpdating = EditorApplication.isUpdating,
163 |                     applicationPath = EditorApplication.applicationPath,
164 |                     applicationContentsPath = EditorApplication.applicationContentsPath,
165 |                     timeSinceStartup = EditorApplication.timeSinceStartup,
166 |                 };
167 |                 return Response.Success("Retrieved editor state.", state);
168 |             }
169 |             catch (Exception e)
170 |             {
171 |                 return Response.Error($"Error getting editor state: {e.Message}");
172 |             }
173 |         }
174 | 
175 |         private static object GetProjectRoot()
176 |         {
177 |             try
178 |             {
179 |                 // Application.dataPath points to <Project>/Assets
180 |                 string assetsPath = Application.dataPath.Replace('\\', '/');
181 |                 string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/');
182 |                 if (string.IsNullOrEmpty(projectRoot))
183 |                 {
184 |                     return Response.Error("Could not determine project root from Application.dataPath");
185 |                 }
186 |                 return Response.Success("Project root resolved.", new { projectRoot });
187 |             }
188 |             catch (Exception e)
189 |             {
190 |                 return Response.Error($"Error getting project root: {e.Message}");
191 |             }
192 |         }
193 | 
194 |         private static object GetEditorWindows()
195 |         {
196 |             try
197 |             {
198 |                 // Get all types deriving from EditorWindow
199 |                 var windowTypes = AppDomain
200 |                     .CurrentDomain.GetAssemblies()
201 |                     .SelectMany(assembly => assembly.GetTypes())
202 |                     .Where(type => type.IsSubclassOf(typeof(EditorWindow)))
203 |                     .ToList();
204 | 
205 |                 var openWindows = new List<object>();
206 | 
207 |                 // Find currently open instances
208 |                 // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows
209 |                 EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll<EditorWindow>();
210 | 
211 |                 foreach (EditorWindow window in allWindows)
212 |                 {
213 |                     if (window == null)
214 |                         continue; // Skip potentially destroyed windows
215 | 
216 |                     try
217 |                     {
218 |                         openWindows.Add(
219 |                             new
220 |                             {
221 |                                 title = window.titleContent.text,
222 |                                 typeName = window.GetType().FullName,
223 |                                 isFocused = EditorWindow.focusedWindow == window,
224 |                                 position = new
225 |                                 {
226 |                                     x = window.position.x,
227 |                                     y = window.position.y,
228 |                                     width = window.position.width,
229 |                                     height = window.position.height,
230 |                                 },
231 |                                 instanceID = window.GetInstanceID(),
232 |                             }
233 |                         );
234 |                     }
235 |                     catch (Exception ex)
236 |                     {
237 |                         Debug.LogWarning(
238 |                             $"Could not get info for window {window.GetType().Name}: {ex.Message}"
239 |                         );
240 |                     }
241 |                 }
242 | 
243 |                 return Response.Success("Retrieved list of open editor windows.", openWindows);
244 |             }
245 |             catch (Exception e)
246 |             {
247 |                 return Response.Error($"Error getting editor windows: {e.Message}");
248 |             }
249 |         }
250 | 
251 |         private static object GetPrefabStageInfo()
252 |         {
253 |             try
254 |             {
255 |                 PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
256 |                 if (stage == null)
257 |                 {
258 |                     return Response.Success
259 |                     ("No prefab stage is currently open.", new { isOpen = false });
260 |                 }
261 | 
262 |                 return Response.Success(
263 |                     "Prefab stage info retrieved.",
264 |                     new
265 |                     {
266 |                         isOpen = true,
267 |                         assetPath = stage.assetPath,
268 |                         prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
269 |                         mode = stage.mode.ToString(),
270 |                         isDirty = stage.scene.isDirty
271 |                     }
272 |                 );
273 |             }
274 |             catch (Exception e)
275 |             {
276 |                 return Response.Error($"Error getting prefab stage info: {e.Message}");
277 |             }
278 |         }
279 | 
280 |         private static object GetActiveTool()
281 |         {
282 |             try
283 |             {
284 |                 Tool currentTool = UnityEditor.Tools.current;
285 |                 string toolName = currentTool.ToString(); // Enum to string
286 |                 bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active
287 |                 string activeToolName = customToolActive
288 |                     ? EditorTools.GetActiveToolName()
289 |                     : toolName; // Get custom name if needed
290 | 
291 |                 var toolInfo = new
292 |                 {
293 |                     activeTool = activeToolName,
294 |                     isCustom = customToolActive,
295 |                     pivotMode = UnityEditor.Tools.pivotMode.ToString(),
296 |                     pivotRotation = UnityEditor.Tools.pivotRotation.ToString(),
297 |                     handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity
298 |                     handlePosition = UnityEditor.Tools.handlePosition,
299 |                 };
300 | 
301 |                 return Response.Success("Retrieved active tool information.", toolInfo);
302 |             }
303 |             catch (Exception e)
304 |             {
305 |                 return Response.Error($"Error getting active tool: {e.Message}");
306 |             }
307 |         }
308 | 
309 |         private static object SetActiveTool(string toolName)
310 |         {
311 |             try
312 |             {
313 |                 Tool targetTool;
314 |                 if (Enum.TryParse<Tool>(toolName, true, out targetTool)) // Case-insensitive parse
315 |                 {
316 |                     // Check if it's a valid built-in tool
317 |                     if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool
318 |                     {
319 |                         UnityEditor.Tools.current = targetTool;
320 |                         return Response.Success($"Set active tool to '{targetTool}'.");
321 |                     }
322 |                     else
323 |                     {
324 |                         return Response.Error(
325 |                             $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid."
326 |                         );
327 |                     }
328 |                 }
329 |                 else
330 |                 {
331 |                     // Potentially try activating a custom tool by name here if needed
332 |                     // This often requires specific editor scripting knowledge for that tool.
333 |                     return Response.Error(
334 |                         $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)."
335 |                     );
336 |                 }
337 |             }
338 |             catch (Exception e)
339 |             {
340 |                 return Response.Error($"Error setting active tool: {e.Message}");
341 |             }
342 |         }
343 | 
344 |         private static object GetSelection()
345 |         {
346 |             try
347 |             {
348 |                 var selectionInfo = new
349 |                 {
350 |                     activeObject = Selection.activeObject?.name,
351 |                     activeGameObject = Selection.activeGameObject?.name,
352 |                     activeTransform = Selection.activeTransform?.name,
353 |                     activeInstanceID = Selection.activeInstanceID,
354 |                     count = Selection.count,
355 |                     objects = Selection
356 |                         .objects.Select(obj => new
357 |                         {
358 |                             name = obj?.name,
359 |                             type = obj?.GetType().FullName,
360 |                             instanceID = obj?.GetInstanceID(),
361 |                         })
362 |                         .ToList(),
363 |                     gameObjects = Selection
364 |                         .gameObjects.Select(go => new
365 |                         {
366 |                             name = go?.name,
367 |                             instanceID = go?.GetInstanceID(),
368 |                         })
369 |                         .ToList(),
370 |                     assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view
371 |                 };
372 | 
373 |                 return Response.Success("Retrieved current selection details.", selectionInfo);
374 |             }
375 |             catch (Exception e)
376 |             {
377 |                 return Response.Error($"Error getting selection: {e.Message}");
378 |             }
379 |         }
380 | 
381 |         // --- Tag Management Methods ---
382 | 
383 |         private static object AddTag(string tagName)
384 |         {
385 |             if (string.IsNullOrWhiteSpace(tagName))
386 |                 return Response.Error("Tag name cannot be empty or whitespace.");
387 | 
388 |             // Check if tag already exists
389 |             if (InternalEditorUtility.tags.Contains(tagName))
390 |             {
391 |                 return Response.Error($"Tag '{tagName}' already exists.");
392 |             }
393 | 
394 |             try
395 |             {
396 |                 // Add the tag using the internal utility
397 |                 InternalEditorUtility.AddTag(tagName);
398 |                 // Force save assets to ensure the change persists in the TagManager asset
399 |                 AssetDatabase.SaveAssets();
400 |                 return Response.Success($"Tag '{tagName}' added successfully.");
401 |             }
402 |             catch (Exception e)
403 |             {
404 |                 return Response.Error($"Failed to add tag '{tagName}': {e.Message}");
405 |             }
406 |         }
407 | 
408 |         private static object RemoveTag(string tagName)
409 |         {
410 |             if (string.IsNullOrWhiteSpace(tagName))
411 |                 return Response.Error("Tag name cannot be empty or whitespace.");
412 |             if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase))
413 |                 return Response.Error("Cannot remove the built-in 'Untagged' tag.");
414 | 
415 |             // Check if tag exists before attempting removal
416 |             if (!InternalEditorUtility.tags.Contains(tagName))
417 |             {
418 |                 return Response.Error($"Tag '{tagName}' does not exist.");
419 |             }
420 | 
421 |             try
422 |             {
423 |                 // Remove the tag using the internal utility
424 |                 InternalEditorUtility.RemoveTag(tagName);
425 |                 // Force save assets
426 |                 AssetDatabase.SaveAssets();
427 |                 return Response.Success($"Tag '{tagName}' removed successfully.");
428 |             }
429 |             catch (Exception e)
430 |             {
431 |                 // Catch potential issues if the tag is somehow in use or removal fails
432 |                 return Response.Error($"Failed to remove tag '{tagName}': {e.Message}");
433 |             }
434 |         }
435 | 
436 |         private static object GetTags()
437 |         {
438 |             try
439 |             {
440 |                 string[] tags = InternalEditorUtility.tags;
441 |                 return Response.Success("Retrieved current tags.", tags);
442 |             }
443 |             catch (Exception e)
444 |             {
445 |                 return Response.Error($"Failed to retrieve tags: {e.Message}");
446 |             }
447 |         }
448 | 
449 |         // --- Layer Management Methods ---
450 | 
451 |         private static object AddLayer(string layerName)
452 |         {
453 |             if (string.IsNullOrWhiteSpace(layerName))
454 |                 return Response.Error("Layer name cannot be empty or whitespace.");
455 | 
456 |             // Access the TagManager asset
457 |             SerializedObject tagManager = GetTagManager();
458 |             if (tagManager == null)
459 |                 return Response.Error("Could not access TagManager asset.");
460 | 
461 |             SerializedProperty layersProp = tagManager.FindProperty("layers");
462 |             if (layersProp == null || !layersProp.isArray)
463 |                 return Response.Error("Could not find 'layers' property in TagManager.");
464 | 
465 |             // Check if layer name already exists (case-insensitive check recommended)
466 |             for (int i = 0; i < TotalLayerCount; i++)
467 |             {
468 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
469 |                 if (
470 |                     layerSP != null
471 |                     && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
472 |                 )
473 |                 {
474 |                     return Response.Error($"Layer '{layerName}' already exists at index {i}.");
475 |                 }
476 |             }
477 | 
478 |             // Find the first empty user layer slot (indices 8 to 31)
479 |             int firstEmptyUserLayer = -1;
480 |             for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++)
481 |             {
482 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
483 |                 if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue))
484 |                 {
485 |                     firstEmptyUserLayer = i;
486 |                     break;
487 |                 }
488 |             }
489 | 
490 |             if (firstEmptyUserLayer == -1)
491 |             {
492 |                 return Response.Error("No empty User Layer slots available (8-31 are full).");
493 |             }
494 | 
495 |             // Assign the name to the found slot
496 |             try
497 |             {
498 |                 SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
499 |                     firstEmptyUserLayer
500 |                 );
501 |                 targetLayerSP.stringValue = layerName;
502 |                 // Apply the changes to the TagManager asset
503 |                 tagManager.ApplyModifiedProperties();
504 |                 // Save assets to make sure it's written to disk
505 |                 AssetDatabase.SaveAssets();
506 |                 return Response.Success(
507 |                     $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}."
508 |                 );
509 |             }
510 |             catch (Exception e)
511 |             {
512 |                 return Response.Error($"Failed to add layer '{layerName}': {e.Message}");
513 |             }
514 |         }
515 | 
516 |         private static object RemoveLayer(string layerName)
517 |         {
518 |             if (string.IsNullOrWhiteSpace(layerName))
519 |                 return Response.Error("Layer name cannot be empty or whitespace.");
520 | 
521 |             // Access the TagManager asset
522 |             SerializedObject tagManager = GetTagManager();
523 |             if (tagManager == null)
524 |                 return Response.Error("Could not access TagManager asset.");
525 | 
526 |             SerializedProperty layersProp = tagManager.FindProperty("layers");
527 |             if (layersProp == null || !layersProp.isArray)
528 |                 return Response.Error("Could not find 'layers' property in TagManager.");
529 | 
530 |             // Find the layer by name (must be user layer)
531 |             int layerIndexToRemove = -1;
532 |             for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers
533 |             {
534 |                 SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i);
535 |                 // Case-insensitive comparison is safer
536 |                 if (
537 |                     layerSP != null
538 |                     && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)
539 |                 )
540 |                 {
541 |                     layerIndexToRemove = i;
542 |                     break;
543 |                 }
544 |             }
545 | 
546 |             if (layerIndexToRemove == -1)
547 |             {
548 |                 return Response.Error($"User layer '{layerName}' not found.");
549 |             }
550 | 
551 |             // Clear the name for that index
552 |             try
553 |             {
554 |                 SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(
555 |                     layerIndexToRemove
556 |                 );
557 |                 targetLayerSP.stringValue = string.Empty; // Set to empty string to remove
558 |                 // Apply the changes
559 |                 tagManager.ApplyModifiedProperties();
560 |                 // Save assets
561 |                 AssetDatabase.SaveAssets();
562 |                 return Response.Success(
563 |                     $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully."
564 |                 );
565 |             }
566 |             catch (Exception e)
567 |             {
568 |                 return Response.Error($"Failed to remove layer '{layerName}': {e.Message}");
569 |             }
570 |         }
571 | 
572 |         private static object GetLayers()
573 |         {
574 |             try
575 |             {
576 |                 var layers = new Dictionary<int, string>();
577 |                 for (int i = 0; i < TotalLayerCount; i++)
578 |                 {
579 |                     string layerName = LayerMask.LayerToName(i);
580 |                     if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names
581 |                     {
582 |                         layers.Add(i, layerName);
583 |                     }
584 |                 }
585 |                 return Response.Success("Retrieved current named layers.", layers);
586 |             }
587 |             catch (Exception e)
588 |             {
589 |                 return Response.Error($"Failed to retrieve layers: {e.Message}");
590 |             }
591 |         }
592 | 
593 |         // --- Helper Methods ---
594 | 
595 |         /// <summary>
596 |         /// Gets the SerializedObject for the TagManager asset.
597 |         /// </summary>
598 |         private static SerializedObject GetTagManager()
599 |         {
600 |             try
601 |             {
602 |                 // Load the TagManager asset from the ProjectSettings folder
603 |                 UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath(
604 |                     "ProjectSettings/TagManager.asset"
605 |                 );
606 |                 if (tagManagerAssets == null || tagManagerAssets.Length == 0)
607 |                 {
608 |                     Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings.");
609 |                     return null;
610 |                 }
611 |                 // The first object in the asset file should be the TagManager
612 |                 return new SerializedObject(tagManagerAssets[0]);
613 |             }
614 |             catch (Exception e)
615 |             {
616 |                 Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}");
617 |                 return null;
618 |             }
619 |         }
620 | 
621 |         // --- Example Implementations for Settings ---
622 |         /*
623 |         private static object SetGameViewResolution(int width, int height) { ... }
624 |         private static object SetQualityLevel(JToken qualityLevelToken) { ... }
625 |         */
626 |     }
627 | 
628 |     // Helper class to get custom tool names (remains the same)
629 |     internal static class EditorTools
630 |     {
631 |         public static string GetActiveToolName()
632 |         {
633 |             // This is a placeholder. Real implementation depends on how custom tools
634 |             // are registered and tracked in the specific Unity project setup.
635 |             // It might involve checking static variables, calling methods on specific tool managers, etc.
636 |             if (UnityEditor.Tools.current == Tool.Custom)
637 |             {
638 |                 // Example: Check a known custom tool manager
639 |                 // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName;
640 |                 return "Unknown Custom Tool";
641 |             }
642 |             return UnityEditor.Tools.current.ToString();
643 |         }
644 |     }
645 | }
646 | 
```
Page 8/19FirstPrevNextLast