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