This is page 9 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 -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/MCPForUnityBridge.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.MenuItems; using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor { [InitializeOnLoad] public static partial class MCPForUnityBridge { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new(); // Single-writer outbox for framed responses private class Outbound { public byte[] Payload; public string Tag; public int? ReqId; } private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>()); private static CancellationTokenSource cts; private static Task listenerTask; private static int processingCommands = 0; private static bool initScheduled = false; private static bool ensureUpdateHooked = false; private static bool isStarting = false; private static double nextStartAt = 0.0f; private static double nextHeartbeatAt = 0.0f; private static int heartbeatSeq = 0; private static Dictionary< string, (string commandJson, TaskCompletionSource<string> tcs) > commandQueue = new(); private static int mainThreadId; private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients // IO diagnostics private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } // Debug helpers private static bool IsDebugEnabled() { try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } } private static void LogBreadcrumb(string stage) { if (IsDebugEnabled()) { McpLog.Info($"[{stage}]", always: false); } } public static bool IsRunning => isRunning; public static int GetCurrentPort() => currentUnityPort; public static bool IsAutoConnectMode() => isAutoConnectMode; /// <summary> /// Start with Auto-Connect mode - discovers new port and saves it /// </summary> public static void StartAutoConnect() { Stop(); // Stop current connection try { // Prefer stored project port and start using the robust Start() path (with retries/options) currentUnityPort = PortManager.GetPortWithFallback(); Start(); isAutoConnectMode = true; // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { Debug.LogError($"Auto-connect failed: {ex.Message}"); // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; } } public static bool FolderExists(string path) { if (string.IsNullOrEmpty(path)) { return false; } if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) { return true; } string fullPath = Path.Combine( Application.dataPath, path.StartsWith("Assets/") ? path[7..] : path ); return Directory.Exists(fullPath); } static MCPForUnityBridge() { // Record the main thread ID for safe thread checks try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } // Start single writer thread for framed responses try { var writerThread = new Thread(() => { foreach (var item in _outbox.GetConsumingEnumerable()) { try { long seq = Interlocked.Increment(ref _ioSeq); IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); var sw = System.Diagnostics.Stopwatch.StartNew(); // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, // writes are performed inline there. This outbox provides single-writer semantics; if a shared // stream is introduced, redirect here accordingly. // No-op: actual write happens in client loop using WriteFrameAsync sw.Stop(); IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); } catch (Exception ex) { IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); } } }) { IsBackground = true, Name = "MCP-Writer" }; writerThread.Start(); } catch { } // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } // Defer start until the editor is idle and not compiling ScheduleInitRetry(); // Add a safety net update hook in case delayCall is missed during reload churn if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; // Also coalesce play mode transitions into a deferred init EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); } /// <summary> /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. /// This prevents repeated restarts during script compilation that cause port hopping. /// </summary> private static void InitializeAfterCompilation() { initScheduled = false; // Play-mode friendly: allow starting in play mode; only defer while compiling if (IsCompiling()) { ScheduleInitRetry(); return; } if (!isRunning) { Start(); if (!isRunning) { // If a race prevented start, retry later ScheduleInitRetry(); } } } private static void ScheduleInitRetry() { if (initScheduled) { return; } initScheduled = true; // Debounce: start ~200ms after the last trigger nextStartAt = EditorApplication.timeSinceStartup + 0.20f; // Ensure the update pump is active if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } // Safety net: ensure the bridge starts shortly after domain reload when editor is idle private static void EnsureStartedOnEditorIdle() { // Do nothing while compiling if (IsCompiling()) { return; } // If already running, remove the hook if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; return; } // Debounced start: wait until the scheduled time if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) { return; } if (isStarting) { return; } isStarting = true; try { // Attempt start; if it succeeds, remove the hook to avoid overhead Start(); } finally { isStarting = false; } if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; } } // Helper to check compilation status across Unity versions private static bool IsCompiling() { if (EditorApplication.isCompiling) { return true; } try { System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (prop != null) { return (bool)prop.GetValue(null); } } catch { } return false; } public static void Start() { lock (startStopLock) { // Don't restart if already running on a working port if (isRunning && listener != null) { if (IsDebugEnabled()) { Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}"); } return; } Stop(); // Attempt fast bind with stored-port preference (sticky per-project) try { // Always consult PortManager first so we prefer the persisted project port currentUnityPort = PortManager.GetPortWithFallback(); // Breadcrumb: Start LogBreadcrumb("Start"); const int maxImmediateRetries = 3; const int retrySleepMs = 75; int attempt = 0; for (; ; ) { try { listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true ); #if UNITY_EDITOR_WIN try { listener.ExclusiveAddressUse = false; } catch { } #endif // Minimize TIME_WAIT by sending RST on close try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { // Ignore if not supported on platform } listener.Start(); break; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) { attempt++; Thread.Sleep(retrySleepMs); continue; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) { currentUnityPort = PortManager.GetPortWithFallback(); listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true ); #if UNITY_EDITOR_WIN try { listener.ExclusiveAddressUse = false; } catch { } #endif try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { } listener.Start(); break; } } isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); string serverVer = ReadInstalledServerVersionSafe(); Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); // Start background listener with cooperative cancellation cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); CommandRegistry.Initialize(); EditorApplication.update += ProcessCommands; // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { } // Write initial heartbeat immediately heartbeatSeq++; WriteHeartbeat(false, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; } catch (SocketException ex) { Debug.LogError($"Failed to start TCP listener: {ex.Message}"); } } } public static void Stop() { Task toWait = null; lock (startStopLock) { if (!isRunning) { return; } try { // Mark as stopping early to avoid accept logging during disposal isRunning = false; // Quiesce background listener quickly var cancel = cts; cts = null; try { cancel?.Cancel(); } catch { } try { listener?.Stop(); } catch { } listener = null; // Capture background task to wait briefly outside the lock toWait = listenerTask; listenerTask = null; } catch (Exception ex) { Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}"); } } // Proactively close all active client sockets to unblock any pending reads TcpClient[] toClose; lock (clientsLock) { toClose = activeClients.ToArray(); activeClients.Clear(); } foreach (var c in toClose) { try { c.Close(); } catch { } } // Give the background loop a short window to exit without blocking the editor if (toWait != null) { try { toWait.Wait(100); } catch { } } // Now unhook editor events safely try { EditorApplication.update -= ProcessCommands; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped."); } private static async Task ListenerLoopAsync(CancellationToken token) { while (isRunning && !token.IsCancellationRequested) { try { TcpClient client = await listener.AcceptTcpClientAsync(); // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true ); // Set longer receive timeout to prevent quick disconnections client.ReceiveTimeout = 60000; // 60 seconds // Fire and forget each client connection _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (ObjectDisposedException) { // Listener was disposed during stop/reload; exit quietly if (!isRunning || token.IsCancellationRequested) { break; } } catch (OperationCanceledException) { break; } catch (Exception ex) { if (isRunning && !token.IsCancellationRequested) { if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); } } } } private static async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream = client.GetStream()) { lock (clientsLock) { activeClients.Add(client); } try { // Framed I/O only; legacy mode removed try { if (IsDebugEnabled()) { var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}"); } } catch { } // Strict framing: always require FRAMING=1 and frame all I/O try { client.NoDelay = true; } catch { } try { string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); using var cts = new CancellationTokenSource(FrameIOTimeoutMs); #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); return; // abort this client } while (isRunning && !token.IsCancellationRequested) { try { // Strict framed mode only: enforced framed I/O for this connection string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); try { if (IsDebugEnabled()) { var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); } } catch { } string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); await WriteFrameAsync(stream, pingResponseBytes); continue; } lock (lockObj) { commandQueue[commandId] = (commandText, tcs); } // Wait for the handler to produce a response, but do not block indefinitely string response; try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); if (completed == tcs.Task) { // Got a result from the handler respCts.Cancel(); response = tcs.Task.Result; } else { // Timeout: return a structured error so the client can recover var timeoutResponse = new { status = "error", error = $"Command processing timed out after {FrameIOTimeoutMs} ms", }; response = JsonConvert.SerializeObject(timeoutResponse); } } catch (Exception ex) { var errorResponse = new { status = "error", error = ex.Message, }; response = JsonConvert.SerializeObject(errorResponse); } if (IsDebugEnabled()) { try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { } } // Crash-proof and self-reporting writer logs (direct write to this client's stream) long seq = System.Threading.Interlocked.Increment(ref _ioSeq); byte[] responseBytes; try { responseBytes = System.Text.Encoding.UTF8.GetBytes(response); IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); } catch (Exception ex) { IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); throw; } var swDirect = System.Diagnostics.Stopwatch.StartNew(); try { await WriteFrameAsync(stream, responseBytes); swDirect.Stop(); IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); } catch (Exception ex) { IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); throw; } } catch (Exception ex) { // Treat common disconnects/timeouts as benign; only surface hard errors string msg = ex.Message ?? string.Empty; bool isBenign = msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 || ex is System.IO.IOException; if (isBenign) { if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); } else { MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); } break; } } } finally { lock (clientsLock) { activeClients.Remove(client); } } } } // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) { byte[] buffer = new byte[count]; int offset = 0; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (offset < count) { int remaining = count - offset; int remainingTimeout = timeoutMs <= 0 ? Timeout.Infinite : timeoutMs - (int)stopwatch.ElapsedMilliseconds; // If a finite timeout is configured and already elapsed, fail immediately if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) { throw new System.IO.IOException("Read timed out"); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); if (remainingTimeout != Timeout.Infinite) { cts.CancelAfter(remainingTimeout); } try { #if NETSTANDARD2_1 || NET6_0_OR_GREATER int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); #else int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); #endif if (read == 0) { throw new System.IO.IOException("Connection closed before reading expected bytes"); } offset += read; } catch (OperationCanceledException) when (!cancel.IsCancellationRequested) { throw new System.IO.IOException("Read timed out"); } } return buffer; } private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) { using var cts = new CancellationTokenSource(FrameIOTimeoutMs); await WriteFrameAsync(stream, payload, cts.Token); } private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) { if (payload == null) { throw new System.ArgumentNullException(nameof(payload)); } if ((ulong)payload.LongLength > MaxFrameBytes) { throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); } byte[] header = new byte[8]; WriteUInt64BigEndian(header, (ulong)payload.LongLength); #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); #else await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); #endif } private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) { byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); ulong payloadLen = ReadUInt64BigEndian(header); if (payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } if (payloadLen == 0UL) throw new System.IO.IOException("Zero-length frames are not allowed"); if (payloadLen > int.MaxValue) { throw new System.IO.IOException("Frame too large for buffer"); } int count = (int)payloadLen; byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); return System.Text.Encoding.UTF8.GetString(payload); } private static ulong ReadUInt64BigEndian(byte[] buffer) { if (buffer == null || buffer.Length < 8) return 0UL; return ((ulong)buffer[0] << 56) | ((ulong)buffer[1] << 48) | ((ulong)buffer[2] << 40) | ((ulong)buffer[3] << 32) | ((ulong)buffer[4] << 24) | ((ulong)buffer[5] << 16) | ((ulong)buffer[6] << 8) | buffer[7]; } private static void WriteUInt64BigEndian(byte[] dest, ulong value) { if (dest == null || dest.Length < 8) { throw new System.ArgumentException("Destination buffer too small for UInt64"); } dest[0] = (byte)(value >> 56); dest[1] = (byte)(value >> 48); dest[2] = (byte)(value >> 40); dest[3] = (byte)(value >> 32); dest[4] = (byte)(value >> 24); dest[5] = (byte)(value >> 16); dest[6] = (byte)(value >> 8); dest[7] = (byte)(value); } private static void ProcessCommands() { if (!isRunning) return; if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard try { // Heartbeat without holding the queue lock double now = EditorApplication.timeSinceStartup; if (now >= nextHeartbeatAt) { WriteHeartbeat(false); nextHeartbeatAt = now + 0.5f; } // Snapshot under lock, then process outside to reduce contention List<(string id, string text, TaskCompletionSource<string> tcs)> work; lock (lockObj) { work = commandQueue .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) .ToList(); } foreach (var item in work) { string id = item.id; string commandText = item.text; TaskCompletionSource<string> tcs = item.tcs; try { // Special case handling if (string.IsNullOrEmpty(commandText)) { var emptyResponse = new { status = "error", error = "Empty command received", }; tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); // Remove quickly under lock lock (lockObj) { commandQueue.Remove(id); } continue; } // Trim the command text to remove any whitespace commandText = commandText.Trim(); // Non-JSON direct commands handling (like ping) if (commandText == "ping") { var pingResponse = new { status = "success", result = new { message = "pong" }, }; tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Check if the command is valid JSON before attempting to deserialize if (!IsValidJson(commandText)) { var invalidJsonResponse = new { status = "error", error = "Invalid JSON format", receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText, }; tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Normal JSON command processing Command command = JsonConvert.DeserializeObject<Command>(commandText); if (command == null) { var nullCommandResponse = new { status = "error", error = "Command deserialized to null", details = "The command was valid JSON but could not be deserialized to a Command object", }; tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); } else { string responseJson = ExecuteCommand(command); tcs.SetResult(responseJson); } } catch (Exception ex) { Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); var response = new { status = "error", error = ex.Message, commandType = "Unknown (error during processing)", receivedText = commandText?.Length > 50 ? commandText[..50] + "..." : commandText, }; string responseJson = JsonConvert.SerializeObject(response); tcs.SetResult(responseJson); } // Remove quickly under lock lock (lockObj) { commandQueue.Remove(id); } } } finally { Interlocked.Exchange(ref processingCommands, 0); } } // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. // Returns null on timeout or error; caller should provide a fallback error response. private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs) { if (func == null) return null; try { // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. if (mainThreadId == 0) { try { return func(); } catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } } // If we are already on the main thread, execute directly to avoid deadlocks try { if (Thread.CurrentThread.ManagedThreadId == mainThreadId) { return func(); } } catch { } object result = null; Exception captured = null; var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); EditorApplication.delayCall += () => { try { result = func(); } catch (Exception ex) { captured = ex; } finally { try { tcs.TrySetResult(true); } catch { } } }; // Wait for completion with timeout (Editor thread will pump delayCall) bool completed = tcs.Task.Wait(timeoutMs); if (!completed) { return null; // timeout } if (captured != null) { throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); } return result; } catch (Exception ex) { throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); } } // Helper method to check if a string is valid JSON private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) { return false; } text = text.Trim(); if ( (text.StartsWith("{") && text.EndsWith("}")) || // Object (text.StartsWith("[") && text.EndsWith("]")) ) // Array { try { JToken.Parse(text); return true; } catch { return false; } } return false; } private static string ExecuteCommand(Command command) { try { if (string.IsNullOrEmpty(command.type)) { var errorResponse = new { status = "error", error = "Command type cannot be empty", details = "A valid command type is required for processing", }; return JsonConvert.SerializeObject(errorResponse); } // Handle ping command for connection verification if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = new { status = "success", result = new { message = "pong" }, }; return JsonConvert.SerializeObject(pingResponse); } // Use JObject for parameters as the new handlers likely expect this JObject paramsObject = command.@params ?? new JObject(); object result = CommandRegistry.GetHandler(command.type)(paramsObject); // Standard success response format var response = new { status = "success", result }; return JsonConvert.SerializeObject(response); } catch (Exception ex) { // Log the detailed error in Unity for debugging Debug.LogError( $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}" ); // Standard error response format var response = new { status = "error", error = ex.Message, // Provide the specific error message command = command?.type ?? "Unknown", // Include the command type if available stackTrace = ex.StackTrace, // Include stack trace for detailed debugging paramsSummary = command?.@params != null ? GetParamsSummary(command.@params) : "No parameters", // Summarize parameters for context }; return JsonConvert.SerializeObject(response); } } private static object HandleManageScene(JObject paramsObject) { try { if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread"); var sw = System.Diagnostics.Stopwatch.StartNew(); var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); sw.Stop(); if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); return r ?? Response.Error("manage_scene returned null (timeout or error)"); } catch (Exception ex) { return Response.Error($"manage_scene dispatch error: {ex.Message}"); } } // Helper method to get a summary of parameters for error reporting private static string GetParamsSummary(JObject @params) { try { return @params == null || [email protected] ? "No parameters" : string.Join( ", ", @params .Properties() .Select(static p => $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" ) ); } catch { return "Could not summarize parameters"; } } // Heartbeat/status helpers private static void OnBeforeAssemblyReload() { // Stop cleanly before reload so sockets close and clients see 'reloading' try { Stop(); } catch { } // Avoid file I/O or heavy work here } private static void OnAfterAssemblyReload() { // Will be overwritten by Start(), but mark as alive quickly WriteHeartbeat(false, "idle"); LogBreadcrumb("Idle"); // Schedule a safe restart after reload to avoid races during compilation ScheduleInitRetry(); } private static void WriteHeartbeat(bool reloading, string reason = null) { try { // Allow override of status directory (useful in CI/containers) string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); if (string.IsNullOrWhiteSpace(dir)) { dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); var payload = new { unity_port = currentUnityPort, reloading, reason = reason ?? (reloading ? "reloading" : "ready"), seq = heartbeatSeq, project_path = Application.dataPath, last_heartbeat = DateTime.UtcNow.ToString("O") }; File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); } catch (Exception) { // Best-effort only } } private static string ReadInstalledServerVersionSafe() { try { string serverSrc = ServerInstaller.GetServerPath(); string verFile = Path.Combine(serverSrc, "server_version.txt"); if (File.Exists(verFile)) { string v = File.ReadAllText(verFile)?.Trim(); if (!string.IsNullOrEmpty(v)) return v; } } catch { } return "unknown"; } private static string ComputeProjectHash(string input) { try { using var sha1 = System.Security.Cryptography.SHA1.Create(); byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); byte[] hashBytes = sha1.ComputeHash(bytes); var sb = new System.Text.StringBuilder(); foreach (byte b in hashBytes) { sb.Append(b.ToString("x2")); } return sb.ToString()[..8]; } catch { return "default"; } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/MCPForUnityBridge.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor { /// <summary> /// Outbound message structure for the writer thread /// </summary> class Outbound { public byte[] Payload; public string Tag; public int? ReqId; } /// <summary> /// Queued command structure for main thread processing /// </summary> class QueuedCommand { public string CommandJson; public TaskCompletionSource<string> Tcs; public bool IsExecuting; } [InitializeOnLoad] public static partial class MCPForUnityBridge { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new(); private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>()); private static CancellationTokenSource cts; private static Task listenerTask; private static int processingCommands = 0; private static bool initScheduled = false; private static bool ensureUpdateHooked = false; private static bool isStarting = false; private static double nextStartAt = 0.0f; private static double nextHeartbeatAt = 0.0f; private static int heartbeatSeq = 0; private static Dictionary<string, QueuedCommand> commandQueue = new(); private static int mainThreadId; private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients // IO diagnostics private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } // Debug helpers private static bool IsDebugEnabled() { try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } } private static void LogBreadcrumb(string stage) { if (IsDebugEnabled()) { McpLog.Info($"[{stage}]", always: false); } } public static bool IsRunning => isRunning; public static int GetCurrentPort() => currentUnityPort; public static bool IsAutoConnectMode() => isAutoConnectMode; /// <summary> /// Start with Auto-Connect mode - discovers new port and saves it /// </summary> public static void StartAutoConnect() { Stop(); // Stop current connection try { // Prefer stored project port and start using the robust Start() path (with retries/options) currentUnityPort = PortManager.GetPortWithFallback(); Start(); isAutoConnectMode = true; // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { McpLog.Error($"Auto-connect failed: {ex.Message}"); // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; } } public static bool FolderExists(string path) { if (string.IsNullOrEmpty(path)) { return false; } if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) { return true; } string fullPath = Path.Combine( Application.dataPath, path.StartsWith("Assets/") ? path[7..] : path ); return Directory.Exists(fullPath); } static MCPForUnityBridge() { // Record the main thread ID for safe thread checks try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } // Start single writer thread for framed responses try { var writerThread = new Thread(() => { foreach (var item in _outbox.GetConsumingEnumerable()) { try { long seq = Interlocked.Increment(ref _ioSeq); IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); var sw = System.Diagnostics.Stopwatch.StartNew(); // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, // writes are performed inline there. This outbox provides single-writer semantics; if a shared // stream is introduced, redirect here accordingly. // No-op: actual write happens in client loop using WriteFrameAsync sw.Stop(); IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); } catch (Exception ex) { IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); } } }) { IsBackground = true, Name = "MCP-Writer" }; writerThread.Start(); } catch { } // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } // Defer start until the editor is idle and not compiling ScheduleInitRetry(); // Add a safety net update hook in case delayCall is missed during reload churn if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; // Also coalesce play mode transitions into a deferred init EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); } /// <summary> /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. /// This prevents repeated restarts during script compilation that cause port hopping. /// </summary> private static void InitializeAfterCompilation() { initScheduled = false; // Play-mode friendly: allow starting in play mode; only defer while compiling if (IsCompiling()) { ScheduleInitRetry(); return; } if (!isRunning) { Start(); if (!isRunning) { // If a race prevented start, retry later ScheduleInitRetry(); } } } private static void ScheduleInitRetry() { if (initScheduled) { return; } initScheduled = true; // Debounce: start ~200ms after the last trigger nextStartAt = EditorApplication.timeSinceStartup + 0.20f; // Ensure the update pump is active if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } // Safety net: ensure the bridge starts shortly after domain reload when editor is idle private static void EnsureStartedOnEditorIdle() { // Do nothing while compiling if (IsCompiling()) { return; } // If already running, remove the hook if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; return; } // Debounced start: wait until the scheduled time if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) { return; } if (isStarting) { return; } isStarting = true; try { // Attempt start; if it succeeds, remove the hook to avoid overhead Start(); } finally { isStarting = false; } if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; } } // Helper to check compilation status across Unity versions private static bool IsCompiling() { if (EditorApplication.isCompiling) { return true; } try { System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (prop != null) { return (bool)prop.GetValue(null); } } catch { } return false; } public static void Start() { lock (startStopLock) { // Don't restart if already running on a working port if (isRunning && listener != null) { if (IsDebugEnabled()) { McpLog.Info($"MCPForUnityBridge already running on port {currentUnityPort}"); } return; } Stop(); // Attempt fast bind with stored-port preference (sticky per-project) try { // Always consult PortManager first so we prefer the persisted project port currentUnityPort = PortManager.GetPortWithFallback(); // Breadcrumb: Start LogBreadcrumb("Start"); const int maxImmediateRetries = 3; const int retrySleepMs = 75; int attempt = 0; for (; ; ) { try { listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true ); #if UNITY_EDITOR_WIN try { listener.ExclusiveAddressUse = false; } catch { } #endif // Minimize TIME_WAIT by sending RST on close try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { // Ignore if not supported on platform } listener.Start(); break; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) { attempt++; Thread.Sleep(retrySleepMs); continue; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) { currentUnityPort = PortManager.GetPortWithFallback(); listener = new TcpListener(IPAddress.Loopback, currentUnityPort); listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true ); #if UNITY_EDITOR_WIN try { listener.ExclusiveAddressUse = false; } catch { } #endif try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { } listener.Start(); break; } } isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); string serverVer = ReadInstalledServerVersionSafe(); McpLog.Info($"MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); // Start background listener with cooperative cancellation cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); CommandRegistry.Initialize(); EditorApplication.update += ProcessCommands; // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } try { EditorApplication.quitting += Stop; } catch { } // Write initial heartbeat immediately heartbeatSeq++; WriteHeartbeat(false, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; } catch (SocketException ex) { McpLog.Error($"Failed to start TCP listener: {ex.Message}"); } } } public static void Stop() { Task toWait = null; lock (startStopLock) { if (!isRunning) { return; } try { // Mark as stopping early to avoid accept logging during disposal isRunning = false; // Quiesce background listener quickly var cancel = cts; cts = null; try { cancel?.Cancel(); } catch { } try { listener?.Stop(); } catch { } listener = null; // Capture background task to wait briefly outside the lock toWait = listenerTask; listenerTask = null; } catch (Exception ex) { McpLog.Error($"Error stopping MCPForUnityBridge: {ex.Message}"); } } // Proactively close all active client sockets to unblock any pending reads TcpClient[] toClose; lock (clientsLock) { toClose = activeClients.ToArray(); activeClients.Clear(); } foreach (var c in toClose) { try { c.Close(); } catch { } } // Give the background loop a short window to exit without blocking the editor if (toWait != null) { try { toWait.Wait(100); } catch { } } // Now unhook editor events safely try { EditorApplication.update -= ProcessCommands; } catch { } try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } try { EditorApplication.quitting -= Stop; } catch { } if (IsDebugEnabled()) McpLog.Info("MCPForUnityBridge stopped."); } private static async Task ListenerLoopAsync(CancellationToken token) { while (isRunning && !token.IsCancellationRequested) { try { TcpClient client = await listener.AcceptTcpClientAsync(); // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true ); // Set longer receive timeout to prevent quick disconnections client.ReceiveTimeout = 60000; // 60 seconds // Fire and forget each client connection _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (ObjectDisposedException) { // Listener was disposed during stop/reload; exit quietly if (!isRunning || token.IsCancellationRequested) { break; } } catch (OperationCanceledException) { break; } catch (Exception ex) { if (isRunning && !token.IsCancellationRequested) { if (IsDebugEnabled()) McpLog.Error($"Listener error: {ex.Message}"); } } } } private static async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream = client.GetStream()) { lock (clientsLock) { activeClients.Add(client); } try { // Framed I/O only; legacy mode removed try { if (IsDebugEnabled()) { var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; McpLog.Info($"Client connected {ep}"); } } catch { } // Strict framing: always require FRAMING=1 and frame all I/O try { client.NoDelay = true; } catch { } try { string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); using var cts = new CancellationTokenSource(FrameIOTimeoutMs); #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}"); return; // abort this client } while (isRunning && !token.IsCancellationRequested) { try { // Strict framed mode only: enforced framed I/O for this connection string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); try { if (IsDebugEnabled()) { var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; McpLog.Info($"recv framed: {preview}", always: false); } } catch { } string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); await WriteFrameAsync(stream, pingResponseBytes); continue; } lock (lockObj) { commandQueue[commandId] = new QueuedCommand { CommandJson = commandText, Tcs = tcs, IsExecuting = false }; } // Wait for the handler to produce a response, but do not block indefinitely string response; try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); if (completed == tcs.Task) { // Got a result from the handler respCts.Cancel(); response = tcs.Task.Result; } else { // Timeout: return a structured error so the client can recover var timeoutResponse = new { status = "error", error = $"Command processing timed out after {FrameIOTimeoutMs} ms", }; response = JsonConvert.SerializeObject(timeoutResponse); } } catch (Exception ex) { var errorResponse = new { status = "error", error = ex.Message, }; response = JsonConvert.SerializeObject(errorResponse); } if (IsDebugEnabled()) { try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } } // Crash-proof and self-reporting writer logs (direct write to this client's stream) long seq = System.Threading.Interlocked.Increment(ref _ioSeq); byte[] responseBytes; try { responseBytes = System.Text.Encoding.UTF8.GetBytes(response); IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); } catch (Exception ex) { IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); throw; } var swDirect = System.Diagnostics.Stopwatch.StartNew(); try { await WriteFrameAsync(stream, responseBytes); swDirect.Stop(); IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); } catch (Exception ex) { IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); throw; } } catch (Exception ex) { // Treat common disconnects/timeouts as benign; only surface hard errors string msg = ex.Message ?? string.Empty; bool isBenign = msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 || ex is System.IO.IOException; if (isBenign) { if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false); } else { McpLog.Error($"Client handler error: {msg}"); } break; } } } finally { lock (clientsLock) { activeClients.Remove(client); } } } } // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) { byte[] buffer = new byte[count]; int offset = 0; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (offset < count) { int remaining = count - offset; int remainingTimeout = timeoutMs <= 0 ? Timeout.Infinite : timeoutMs - (int)stopwatch.ElapsedMilliseconds; // If a finite timeout is configured and already elapsed, fail immediately if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) { throw new System.IO.IOException("Read timed out"); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); if (remainingTimeout != Timeout.Infinite) { cts.CancelAfter(remainingTimeout); } try { #if NETSTANDARD2_1 || NET6_0_OR_GREATER int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); #else int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); #endif if (read == 0) { throw new System.IO.IOException("Connection closed before reading expected bytes"); } offset += read; } catch (OperationCanceledException) when (!cancel.IsCancellationRequested) { throw new System.IO.IOException("Read timed out"); } } return buffer; } private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) { using var cts = new CancellationTokenSource(FrameIOTimeoutMs); await WriteFrameAsync(stream, payload, cts.Token); } private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) { if (payload == null) { throw new System.ArgumentNullException(nameof(payload)); } if ((ulong)payload.LongLength > MaxFrameBytes) { throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); } byte[] header = new byte[8]; WriteUInt64BigEndian(header, (ulong)payload.LongLength); #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); #else await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); #endif } private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) { byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); ulong payloadLen = ReadUInt64BigEndian(header); if (payloadLen > MaxFrameBytes) { throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); } if (payloadLen == 0UL) throw new System.IO.IOException("Zero-length frames are not allowed"); if (payloadLen > int.MaxValue) { throw new System.IO.IOException("Frame too large for buffer"); } int count = (int)payloadLen; byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); return System.Text.Encoding.UTF8.GetString(payload); } private static ulong ReadUInt64BigEndian(byte[] buffer) { if (buffer == null || buffer.Length < 8) return 0UL; return ((ulong)buffer[0] << 56) | ((ulong)buffer[1] << 48) | ((ulong)buffer[2] << 40) | ((ulong)buffer[3] << 32) | ((ulong)buffer[4] << 24) | ((ulong)buffer[5] << 16) | ((ulong)buffer[6] << 8) | buffer[7]; } private static void WriteUInt64BigEndian(byte[] dest, ulong value) { if (dest == null || dest.Length < 8) { throw new System.ArgumentException("Destination buffer too small for UInt64"); } dest[0] = (byte)(value >> 56); dest[1] = (byte)(value >> 48); dest[2] = (byte)(value >> 40); dest[3] = (byte)(value >> 32); dest[4] = (byte)(value >> 24); dest[5] = (byte)(value >> 16); dest[6] = (byte)(value >> 8); dest[7] = (byte)(value); } private static void ProcessCommands() { if (!isRunning) return; if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard try { // Heartbeat without holding the queue lock double now = EditorApplication.timeSinceStartup; if (now >= nextHeartbeatAt) { WriteHeartbeat(false); nextHeartbeatAt = now + 0.5f; } // Snapshot under lock, then process outside to reduce contention List<(string id, QueuedCommand command)> work; lock (lockObj) { work = new List<(string, QueuedCommand)>(commandQueue.Count); foreach (var kvp in commandQueue) { var queued = kvp.Value; if (queued.IsExecuting) continue; queued.IsExecuting = true; work.Add((kvp.Key, queued)); } } foreach (var item in work) { string id = item.id; QueuedCommand queuedCommand = item.command; string commandText = queuedCommand.CommandJson; TaskCompletionSource<string> tcs = queuedCommand.Tcs; try { // Special case handling if (string.IsNullOrEmpty(commandText)) { var emptyResponse = new { status = "error", error = "Empty command received", }; tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); // Remove quickly under lock lock (lockObj) { commandQueue.Remove(id); } continue; } // Trim the command text to remove any whitespace commandText = commandText.Trim(); // Non-JSON direct commands handling (like ping) if (commandText == "ping") { var pingResponse = new { status = "success", result = new { message = "pong" }, }; tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Check if the command is valid JSON before attempting to deserialize if (!IsValidJson(commandText)) { var invalidJsonResponse = new { status = "error", error = "Invalid JSON format", receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText, }; tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Normal JSON command processing Command command = JsonConvert.DeserializeObject<Command>(commandText); if (command == null) { var nullCommandResponse = new { status = "error", error = "Command deserialized to null", details = "The command was valid JSON but could not be deserialized to a Command object", }; tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); } else { // Use JObject for parameters as handlers expect this JObject paramsObject = command.@params ?? new JObject(); // Execute command (may be sync or async) object result = CommandRegistry.ExecuteCommand(command.type, paramsObject, tcs); // If result is null, it means async execution - TCS will be completed by the awaited task // In this case, DON'T remove from queue yet, DON'T complete TCS if (result == null) { // Async command - the task continuation will complete the TCS // Setup cleanup when TCS completes - schedule on next frame to avoid race conditions string asyncCommandId = id; _ = tcs.Task.ContinueWith(_ => { // Use EditorApplication.delayCall to schedule cleanup on main thread, next frame EditorApplication.delayCall += () => { lock (lockObj) { commandQueue.Remove(asyncCommandId); } }; }); continue; // Skip the queue removal below } // Synchronous result - complete TCS now var response = new { status = "success", result }; tcs.SetResult(JsonConvert.SerializeObject(response)); } } catch (Exception ex) { McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); var response = new { status = "error", error = ex.Message, commandType = "Unknown (error during processing)", receivedText = commandText?.Length > 50 ? commandText[..50] + "..." : commandText, }; string responseJson = JsonConvert.SerializeObject(response); tcs.SetResult(responseJson); } // Remove from queue (only for sync commands - async ones skip with 'continue' above) lock (lockObj) { commandQueue.Remove(id); } } } finally { Interlocked.Exchange(ref processingCommands, 0); } } // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. // Returns null on timeout or error; caller should provide a fallback error response. private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs) { if (func == null) return null; try { // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. if (mainThreadId == 0) { try { return func(); } catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } } // If we are already on the main thread, execute directly to avoid deadlocks try { if (Thread.CurrentThread.ManagedThreadId == mainThreadId) { return func(); } } catch { } object result = null; Exception captured = null; var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); EditorApplication.delayCall += () => { try { result = func(); } catch (Exception ex) { captured = ex; } finally { try { tcs.TrySetResult(true); } catch { } } }; // Wait for completion with timeout (Editor thread will pump delayCall) bool completed = tcs.Task.Wait(timeoutMs); if (!completed) { return null; // timeout } if (captured != null) { throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); } return result; } catch (Exception ex) { throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); } } // Helper method to check if a string is valid JSON private static bool IsValidJson(string text) { if (string.IsNullOrWhiteSpace(text)) { return false; } text = text.Trim(); if ( (text.StartsWith("{") && text.EndsWith("}")) || // Object (text.StartsWith("[") && text.EndsWith("]")) ) // Array { try { JToken.Parse(text); return true; } catch { return false; } } return false; } private static string ExecuteCommand(Command command) { try { if (string.IsNullOrEmpty(command.type)) { var errorResponse = new { status = "error", error = "Command type cannot be empty", details = "A valid command type is required for processing", }; return JsonConvert.SerializeObject(errorResponse); } // Handle ping command for connection verification if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = new { status = "success", result = new { message = "pong" }, }; return JsonConvert.SerializeObject(pingResponse); } // Use JObject for parameters as the new handlers likely expect this JObject paramsObject = command.@params ?? new JObject(); object result = CommandRegistry.GetHandler(command.type)(paramsObject); // Standard success response format var response = new { status = "success", result }; return JsonConvert.SerializeObject(response); } catch (Exception ex) { // Log the detailed error in Unity for debugging McpLog.Error($"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}"); // Standard error response format var response = new { status = "error", error = ex.Message, // Provide the specific error message command = command?.type ?? "Unknown", // Include the command type if available stackTrace = ex.StackTrace, // Include stack trace for detailed debugging paramsSummary = command?.@params != null ? GetParamsSummary(command.@params) : "No parameters", // Summarize parameters for context }; return JsonConvert.SerializeObject(response); } } private static object HandleManageScene(JObject paramsObject) { try { if (IsDebugEnabled()) McpLog.Info("[MCP] manage_scene: dispatching to main thread"); var sw = System.Diagnostics.Stopwatch.StartNew(); var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); sw.Stop(); if (IsDebugEnabled()) McpLog.Info($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); return r ?? Response.Error("manage_scene returned null (timeout or error)"); } catch (Exception ex) { return Response.Error($"manage_scene dispatch error: {ex.Message}"); } } // Helper method to get a summary of parameters for error reporting private static string GetParamsSummary(JObject @params) { try { return @params == null || [email protected] ? "No parameters" : string.Join( ", ", @params .Properties() .Select(static p => $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" ) ); } catch { return "Could not summarize parameters"; } } // Heartbeat/status helpers private static void OnBeforeAssemblyReload() { // Stop cleanly before reload so sockets close and clients see 'reloading' try { Stop(); } catch { } // Avoid file I/O or heavy work here } private static void OnAfterAssemblyReload() { // Will be overwritten by Start(), but mark as alive quickly WriteHeartbeat(false, "idle"); LogBreadcrumb("Idle"); // Schedule a safe restart after reload to avoid races during compilation ScheduleInitRetry(); } private static void WriteHeartbeat(bool reloading, string reason = null) { try { // Allow override of status directory (useful in CI/containers) string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); if (string.IsNullOrWhiteSpace(dir)) { dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); var payload = new { unity_port = currentUnityPort, reloading, reason = reason ?? (reloading ? "reloading" : "ready"), seq = heartbeatSeq, project_path = Application.dataPath, last_heartbeat = DateTime.UtcNow.ToString("O") }; File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); } catch (Exception) { // Best-effort only } } private static string ReadInstalledServerVersionSafe() { try { string serverSrc = ServerInstaller.GetServerPath(); string verFile = Path.Combine(serverSrc, "server_version.txt"); if (File.Exists(verFile)) { string v = File.ReadAllText(verFile)?.Trim(); if (!string.IsNullOrEmpty(v)) return v; } } catch { } return "unknown"; } private static string ComputeProjectHash(string input) { try { using var sha1 = System.Security.Cryptography.SHA1.Create(); byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); byte[] hashBytes = sha1.ComputeHash(bytes); var sb = new System.Text.StringBuilder(); foreach (byte b in hashBytes) { sb.Append(b.ToString("x2")); } return sb.ToString()[..8]; } catch { return "default"; } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageAsset.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class using static MCPForUnity.Editor.Tools.ManageGameObject; #if UNITY_6000_0_OR_NEWER using PhysicsMaterialType = UnityEngine.PhysicsMaterial; using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; #else using PhysicsMaterialType = UnityEngine.PhysicMaterial; using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; #endif namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles asset management operations within the Unity project. /// </summary> [McpForUnityTool("manage_asset")] public static class ManageAsset { // --- Main Handler --- // Define the list of valid actions private static readonly List<string> ValidActions = new List<string> { "import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components", }; public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Check if the action is valid before switching if (!ValidActions.Contains(action)) { string validActionsList = string.Join(", ", ValidActions); return Response.Error( $"Unknown action: '{action}'. Valid actions are: {validActionsList}" ); } // Common parameters string path = @params["path"]?.ToString(); try { switch (action) { case "import": // Note: Unity typically auto-imports. This might re-import or configure import settings. return ReimportAsset(path, @params["properties"] as JObject); case "create": return CreateAsset(@params); case "modify": return ModifyAsset(path, @params["properties"] as JObject); case "delete": return DeleteAsset(path); case "duplicate": return DuplicateAsset(path, @params["destination"]?.ToString()); case "move": // Often same as rename if within Assets/ case "rename": return MoveOrRenameAsset(path, @params["destination"]?.ToString()); case "search": return SearchAssets(@params); case "get_info": return GetAssetInfo( path, @params["generatePreview"]?.ToObject<bool>() ?? false ); case "create_folder": // Added specific action for clarity return CreateFolder(path); case "get_components": return GetComponentsFromAsset(path); default: // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. string validActionsListDefault = string.Join(", ", ValidActions); return Response.Error( $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" ); } } catch (Exception e) { Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); return Response.Error( $"Internal error processing action '{action}' on '{path}': {e.Message}" ); } } // --- Action Implementations --- private static object ReimportAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { // TODO: Apply importer properties before reimporting? // This is complex as it requires getting the AssetImporter, casting it, // applying properties via reflection or specific methods, saving, then reimporting. if (properties != null && properties.HasValues) { Debug.LogWarning( "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." ); // AssetImporter importer = AssetImporter.GetAtPath(fullPath); // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } } AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); } catch (Exception e) { return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); } } private static object CreateAsset(JObject @params) { string path = @params["path"]?.ToString(); string assetType = @params["assetType"]?.ToString(); JObject properties = @params["properties"] as JObject; if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create."); if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); // Ensure directory exists if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) { Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); AssetDatabase.Refresh(); // Make sure Unity knows about the new folder } if (AssetExists(fullPath)) return Response.Error($"Asset already exists at path: {fullPath}"); try { UnityEngine.Object newAsset = null; string lowerAssetType = assetType.ToLowerInvariant(); // Handle common asset types if (lowerAssetType == "folder") { return CreateFolder(path); // Use dedicated method } else if (lowerAssetType == "material") { // Prefer provided shader; fall back to common pipelines var requested = properties?["shader"]?.ToString(); Shader shader = (!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null) ?? Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("HDRP/Lit") ?? Shader.Find("Standard") ?? Shader.Find("Unlit/Color"); if (shader == null) return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); var mat = new Material(shader); if (properties != null) ApplyMaterialProperties(mat, properties); AssetDatabase.CreateAsset(mat, fullPath); newAsset = mat; } else if (lowerAssetType == "physicsmaterial") { PhysicsMaterialType pmat = new PhysicsMaterialType(); if (properties != null) ApplyPhysicsMaterialProperties(pmat, properties); AssetDatabase.CreateAsset(pmat, fullPath); newAsset = pmat; } else if (lowerAssetType == "scriptableobject") { string scriptClassName = properties?["scriptClass"]?.ToString(); if (string.IsNullOrEmpty(scriptClassName)) return Response.Error( "'scriptClass' property required when creating ScriptableObject asset." ); Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null; if ( scriptType == null || !typeof(ScriptableObject).IsAssignableFrom(scriptType) ) { var reason = scriptType == null ? (string.IsNullOrEmpty(error) ? "Type not found." : error) : "Type found but does not inherit from ScriptableObject."; return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); } ScriptableObject so = ScriptableObject.CreateInstance(scriptType); // TODO: Apply properties from JObject to the ScriptableObject instance? AssetDatabase.CreateAsset(so, fullPath); newAsset = so; } else if (lowerAssetType == "prefab") { // Creating prefabs usually involves saving an existing GameObject hierarchy. // A common pattern is to create an empty GameObject, configure it, and then save it. return Response.Error( "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." ); // Example (conceptual): // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); } // TODO: Add more asset types (Animation Controller, Scene, etc.) else { // Generic creation attempt (might fail or create empty files) // For some types, just creating the file might be enough if Unity imports it. // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it // newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath); return Response.Error( $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." ); } if ( newAsset == null && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) ) // Check if it wasn't a folder and asset wasn't created { return Response.Error( $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." ); } AssetDatabase.SaveAssets(); // AssetDatabase.Refresh(); // CreateAsset often handles refresh return Response.Success( $"Asset '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); } } private static object CreateFolder(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); if (AssetExists(fullPath)) { // Check if it's actually a folder already if (AssetDatabase.IsValidFolder(fullPath)) { return Response.Success( $"Folder already exists at path: {fullPath}", GetAssetData(fullPath) ); } else { return Response.Error( $"An asset (not a folder) already exists at path: {fullPath}" ); } } try { // Ensure parent exists if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) { // Recursively create parent folders if needed (AssetDatabase handles this internally) // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); } string guid = AssetDatabase.CreateFolder(parentDir, folderName); if (string.IsNullOrEmpty(guid)) { return Response.Error( $"Failed to create folder '{fullPath}'. Check logs and permissions." ); } // AssetDatabase.Refresh(); // CreateFolder usually handles refresh return Response.Success( $"Folder '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); } } private static object ModifyAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for modify."); if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>( fullPath ); if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); bool modified = false; // Flag to track if any changes were made // --- NEW: Handle GameObject / Prefab Component Modification --- if (asset is GameObject gameObject) { // Iterate through the properties JSON: keys are component names, values are properties objects for that component foreach (var prop in properties.Properties()) { string componentName = prop.Name; // e.g., "Collectible" // Check if the value associated with the component name is actually an object containing properties if ( prop.Value is JObject componentProperties && componentProperties.HasValues ) // e.g., {"bobSpeed": 2.0} { // Resolve component type via ComponentResolver, then fetch by Type Component targetComponent = null; bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); if (resolved) { targetComponent = gameObject.GetComponent(compType); } // Only warn about resolution failure if component also not found if (targetComponent == null && !resolved) { Debug.LogWarning( $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" ); } if (targetComponent != null) { // Apply the nested properties (e.g., bobSpeed) to the found component instance // Use |= to ensure 'modified' becomes true if any component is successfully modified modified |= ApplyObjectProperties( targetComponent, componentProperties ); } else { // Log a warning if a specified component couldn't be found Debug.LogWarning( $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." ); } } else { // Log a warning if the structure isn't {"ComponentName": {"prop": value}} // We could potentially try to apply this property directly to the GameObject here if needed, // but the primary goal is component modification. Debug.LogWarning( $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." ); } } // Note: 'modified' is now true if ANY component property was successfully changed. } // --- End NEW --- // --- Existing logic for other asset types (now as else-if) --- // Example: Modifying a Material else if (asset is Material material) { // Apply properties directly to the material. If this modifies, it sets modified=true. // Use |= in case the asset was already marked modified by previous logic (though unlikely here) modified |= ApplyMaterialProperties(material, properties); } // Example: Modifying a ScriptableObject else if (asset is ScriptableObject so) { // Apply properties directly to the ScriptableObject. modified |= ApplyObjectProperties(so, properties); // General helper } // Example: Modifying TextureImporter settings else if (asset is Texture) { AssetImporter importer = AssetImporter.GetAtPath(fullPath); if (importer is TextureImporter textureImporter) { bool importerModified = ApplyObjectProperties(textureImporter, properties); if (importerModified) { // Importer settings need saving and reimporting AssetDatabase.WriteImportSettingsIfDirty(fullPath); AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes modified = true; // Mark overall operation as modified } } else { Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); } } // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) else // Fallback for other asset types OR direct properties on non-GameObject assets { // This block handles non-GameObject/Material/ScriptableObject/Texture assets. // Attempts to apply properties directly to the asset itself. Debug.LogWarning( $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." ); modified |= ApplyObjectProperties(asset, properties); } // --- End Existing Logic --- // Check if any modification happened (either component or direct asset modification) if (modified) { // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. EditorUtility.SetDirty(asset); // Save all modified assets to disk. AssetDatabase.SaveAssets(); // Refresh might be needed in some edge cases, but SaveAssets usually covers it. // AssetDatabase.Refresh(); return Response.Success( $"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath) ); } else { // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. return Response.Success( $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath) ); // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); } } catch (Exception e) { // Log the detailed error internally Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); // Return a user-friendly error message return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); } } private static object DeleteAsset(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { bool success = AssetDatabase.DeleteAsset(fullPath); if (success) { // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh return Response.Success($"Asset '{fullPath}' deleted successfully."); } else { // This might happen if the file couldn't be deleted (e.g., locked) return Response.Error( $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." ); } } catch (Exception e) { return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); } } private static object DuplicateAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); string destPath; if (string.IsNullOrEmpty(destinationPath)) { // Generate a unique path if destination is not provided destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); } else { destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); } try { bool success = AssetDatabase.CopyAsset(sourcePath, destPath); if (success) { // AssetDatabase.Refresh(); return Response.Success( $"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath) ); } else { return Response.Error( $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); } } private static object MoveOrRenameAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for move/rename."); if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); if (AssetExists(destPath)) return Response.Error( $"An asset already exists at the destination path: {destPath}" ); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); try { // Validate will return an error string if failed, null if successful string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(error)) { return Response.Error( $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" ); } string guid = AssetDatabase.MoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success { // AssetDatabase.Refresh(); // MoveAsset usually handles refresh return Response.Success( $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath) ); } else { // This case might not be reachable if ValidateMoveAsset passes, but good to have return Response.Error( $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); } } private static object SearchAssets(JObject @params) { string searchPattern = @params["searchPattern"]?.ToString(); string filterType = @params["filterType"]?.ToString(); string pathScope = @params["path"]?.ToString(); // Use path as folder scope string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); int pageSize = @params["pageSize"]?.ToObject<int?>() ?? 50; // Default page size int pageNumber = @params["pageNumber"]?.ToObject<int?>() ?? 1; // Default page number (1-based) bool generatePreview = @params["generatePreview"]?.ToObject<bool>() ?? false; List<string> searchFilters = new List<string>(); if (!string.IsNullOrEmpty(searchPattern)) searchFilters.Add(searchPattern); if (!string.IsNullOrEmpty(filterType)) searchFilters.Add($"t:{filterType}"); string[] folderScope = null; if (!string.IsNullOrEmpty(pathScope)) { folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; if (!AssetDatabase.IsValidFolder(folderScope[0])) { // Maybe the user provided a file path instead of a folder? // We could search in the containing folder, or return an error. Debug.LogWarning( $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." ); folderScope = null; // Search everywhere if path isn't a folder } } DateTime? filterDateAfter = null; if (!string.IsNullOrEmpty(filterDateAfterStr)) { if ( DateTime.TryParse( filterDateAfterStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime parsedDate ) ) { filterDateAfter = parsedDate; } else { Debug.LogWarning( $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." ); } } try { string[] guids = AssetDatabase.FindAssets( string.Join(" ", searchFilters), folderScope ); List<object> results = new List<object>(); int totalFound = 0; foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(assetPath)) continue; // Apply date filter if present if (filterDateAfter.HasValue) { DateTime lastWriteTime = File.GetLastWriteTimeUtc( Path.Combine(Directory.GetCurrentDirectory(), assetPath) ); if (lastWriteTime <= filterDateAfter.Value) { continue; // Skip assets older than or equal to the filter date } } totalFound++; // Count matching assets before pagination results.Add(GetAssetData(assetPath, generatePreview)); } // Apply pagination int startIndex = (pageNumber - 1) * pageSize; var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); return Response.Success( $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new { totalAssets = totalFound, pageSize = pageSize, pageNumber = pageNumber, assets = pagedResults, } ); } catch (Exception e) { return Response.Error($"Error searching assets: {e.Message}"); } } private static object GetAssetInfo(string path, bool generatePreview) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { return Response.Success( "Asset info retrieved.", GetAssetData(fullPath, generatePreview) ); } catch (Exception e) { return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); } } /// <summary> /// Retrieves components attached to a GameObject asset (like a Prefab). /// </summary> /// <param name="path">The asset path of the GameObject or Prefab.</param> /// <returns>A response object containing a list of component type names or an error.</returns> private static object GetComponentsFromAsset(string path) { // 1. Validate input path if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_components."); // 2. Sanitize and check existence string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { // 3. Load the asset UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>( fullPath ); if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); // 4. Check if it's a GameObject (Prefabs load as GameObjects) GameObject gameObject = asset as GameObject; if (gameObject == null) { // Also check if it's *directly* a Component type (less common for primary assets) Component componentAsset = asset as Component; if (componentAsset != null) { // If the asset itself *is* a component, maybe return just its info? // This is an edge case. Let's stick to GameObjects for now. return Response.Error( $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." ); } return Response.Error( $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." ); } // 5. Get components Component[] components = gameObject.GetComponents<Component>(); // 6. Format component data List<object> componentList = components .Select(comp => new { typeName = comp.GetType().FullName, instanceID = comp.GetInstanceID(), // TODO: Add more component-specific details here if needed in the future? // Requires reflection or specific handling per component type. }) .ToList<object>(); // Explicit cast for clarity if needed // 7. Return success response return Response.Success( $"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList ); } catch (Exception e) { Debug.LogError( $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" ); return Response.Error( $"Error getting components for asset '{fullPath}': {e.Message}" ); } } // --- Internal Helpers --- /// <summary> /// Ensures the asset path starts with "Assets/". /// </summary> /// <summary> /// Checks if an asset exists at the given path (file or folder). /// </summary> private static bool AssetExists(string sanitizedPath) { // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. // Check if it's a known asset GUID. if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) { return true; } // AssetPathToGUID might not work for newly created folders not yet refreshed. // Check directory explicitly for folders. if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) { // Check if it's considered a *valid* folder by Unity return AssetDatabase.IsValidFolder(sanitizedPath); } // Check file existence for non-folder assets. if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) { return true; // Assume if file exists, it's an asset or will be imported } return false; // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); } /// <summary> /// Ensures the directory for a given asset path exists, creating it if necessary. /// </summary> private static void EnsureDirectoryExists(string directoryPath) { if (string.IsNullOrEmpty(directoryPath)) return; string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); if (!Directory.Exists(fullDirPath)) { Directory.CreateDirectory(fullDirPath); AssetDatabase.Refresh(); // Let Unity know about the new folder } } /// <summary> /// Applies properties from JObject to a Material. /// </summary> private static bool ApplyMaterialProperties(Material mat, JObject properties) { if (mat == null || properties == null) return false; bool modified = false; // Example: Set shader if (properties["shader"]?.Type == JTokenType.String) { Shader newShader = Shader.Find(properties["shader"].ToString()); if (newShader != null && mat.shader != newShader) { mat.shader = newShader; modified = true; } } // Example: Set color property if (properties["color"] is JObject colorProps) { string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color if (colorProps["value"] is JArray colArr && colArr.Count >= 3) { try { Color newColor = new Color( colArr[0].ToObject<float>(), colArr[1].ToObject<float>(), colArr[2].ToObject<float>(), colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) { mat.SetColor(propName, newColor); modified = true; } } catch (Exception ex) { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); } } } else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py { string propName = "_Color"; try { if (colorArr.Count >= 3) { Color newColor = new Color( colorArr[0].ToObject<float>(), colorArr[1].ToObject<float>(), colorArr[2].ToObject<float>(), colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) { mat.SetColor(propName, newColor); modified = true; } } } catch (Exception ex) { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); } } // Example: Set float property if (properties["float"] is JObject floatProps) { string propName = floatProps["name"]?.ToString(); if ( !string.IsNullOrEmpty(propName) && (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) ) { try { float newVal = floatProps["value"].ToObject<float>(); if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) { mat.SetFloat(propName, newVal); modified = true; } } catch (Exception ex) { Debug.LogWarning( $"Error parsing float property '{propName}': {ex.Message}" ); } } } // Example: Set texture property if (properties["texture"] is JObject texProps) { string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture string texPath = texProps["path"]?.ToString(); if (!string.IsNullOrEmpty(texPath)) { Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>( AssetPathUtility.SanitizeAssetPath(texPath) ); if ( newTex != null && mat.HasProperty(propName) && mat.GetTexture(propName) != newTex ) { mat.SetTexture(propName, newTex); modified = true; } else if (newTex == null) { Debug.LogWarning($"Texture not found at path: {texPath}"); } } } // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) return modified; } /// <summary> /// Applies properties from JObject to a PhysicsMaterial. /// </summary> private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) { if (pmat == null || properties == null) return false; bool modified = false; // Example: Set dynamic friction if (properties["dynamicFriction"]?.Type == JTokenType.Float) { float dynamicFriction = properties["dynamicFriction"].ToObject<float>(); pmat.dynamicFriction = dynamicFriction; modified = true; } // Example: Set static friction if (properties["staticFriction"]?.Type == JTokenType.Float) { float staticFriction = properties["staticFriction"].ToObject<float>(); pmat.staticFriction = staticFriction; modified = true; } // Example: Set bounciness if (properties["bounciness"]?.Type == JTokenType.Float) { float bounciness = properties["bounciness"].ToObject<float>(); pmat.bounciness = bounciness; modified = true; } List<String> averageList = new List<String> { "ave", "Ave", "average", "Average" }; List<String> multiplyList = new List<String> { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; List<String> minimumList = new List<String> { "min", "Min", "minimum", "Minimum" }; List<String> maximumList = new List<String> { "max", "Max", "maximum", "Maximum" }; // Example: Set friction combine if (properties["frictionCombine"]?.Type == JTokenType.String) { string frictionCombine = properties["frictionCombine"].ToString(); if (averageList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Average; else if (multiplyList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Multiply; else if (minimumList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Minimum; else if (maximumList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Maximum; modified = true; } // Example: Set bounce combine if (properties["bounceCombine"]?.Type == JTokenType.String) { string bounceCombine = properties["bounceCombine"].ToString(); if (averageList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Average; else if (multiplyList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Multiply; else if (minimumList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Minimum; else if (maximumList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Maximum; modified = true; } return modified; } /// <summary> /// Generic helper to set properties on any UnityEngine.Object using reflection. /// </summary> private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) { if (target == null || properties == null) return false; bool modified = false; Type type = target.GetType(); foreach (var prop in properties.Properties()) { string propName = prop.Name; JToken propValue = prop.Value; if (SetPropertyOrField(target, propName, propValue, type)) { modified = true; } } return modified; } /// <summary> /// Helper to set a property or field via reflection, handling basic types and Unity objects. /// </summary> private static bool SetPropertyOrField( object target, string memberName, JToken value, Type type = null ) { type = type ?? target.GetType(); System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase; try { System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); if (propInfo != null && propInfo.CanWrite) { object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); if ( convertedValue != null && !object.Equals(propInfo.GetValue(target), convertedValue) ) { propInfo.SetValue(target, convertedValue); return true; } } else { System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) { object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); if ( convertedValue != null && !object.Equals(fieldInfo.GetValue(target), convertedValue) ) { fieldInfo.SetValue(target, convertedValue); return true; } } } } catch (Exception ex) { Debug.LogWarning( $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" ); } return false; } /// <summary> /// Simple JToken to Type conversion for common Unity types and primitives. /// </summary> private static object ConvertJTokenToType(JToken token, Type targetType) { try { if (token == null || token.Type == JTokenType.Null) return null; if (targetType == typeof(string)) return token.ToObject<string>(); if (targetType == typeof(int)) return token.ToObject<int>(); if (targetType == typeof(float)) return token.ToObject<float>(); if (targetType == typeof(bool)) return token.ToObject<bool>(); if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) return new Vector2(arrV2[0].ToObject<float>(), arrV2[1].ToObject<float>()); if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) return new Vector3( arrV3[0].ToObject<float>(), arrV3[1].ToObject<float>(), arrV3[2].ToObject<float>() ); if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) return new Vector4( arrV4[0].ToObject<float>(), arrV4[1].ToObject<float>(), arrV4[2].ToObject<float>(), arrV4[3].ToObject<float>() ); if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) return new Quaternion( arrQ[0].ToObject<float>(), arrQ[1].ToObject<float>(), arrQ[2].ToObject<float>(), arrQ[3].ToObject<float>() ); if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA return new Color( arrC[0].ToObject<float>(), arrC[1].ToObject<float>(), arrC[2].ToObject<float>(), arrC.Count > 3 ? arrC[3].ToObject<float>() : 1.0f ); if (targetType.IsEnum) return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing // Handle loading Unity Objects (Materials, Textures, etc.) by path if ( typeof(UnityEngine.Object).IsAssignableFrom(targetType) && token.Type == JTokenType.String ) { string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( assetPath, targetType ); if (loadedAsset == null) { Debug.LogWarning( $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" ); } return loadedAsset; } // Fallback: Try direct conversion (might work for other simple value types) return token.ToObject(targetType); } catch (Exception ex) { Debug.LogWarning( $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" ); return null; } } // --- Data Serialization --- /// <summary> /// Creates a serializable representation of an asset. /// </summary> private static object GetAssetData(string path, bool generatePreview = false) { if (string.IsNullOrEmpty(path) || !AssetExists(path)) return null; string guid = AssetDatabase.AssetPathToGUID(path); Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path); string previewBase64 = null; int previewWidth = 0; int previewHeight = 0; if (generatePreview && asset != null) { Texture2D preview = AssetPreview.GetAssetPreview(asset); if (preview != null) { try { // Ensure texture is readable for EncodeToPNG // Creating a temporary readable copy is safer RenderTexture rt = null; Texture2D readablePreview = null; RenderTexture previous = RenderTexture.active; try { rt = RenderTexture.GetTemporary(preview.width, preview.height); Graphics.Blit(preview, rt); RenderTexture.active = rt; readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); readablePreview.Apply(); var pngData = readablePreview.EncodeToPNG(); if (pngData != null && pngData.Length > 0) { previewBase64 = Convert.ToBase64String(pngData); previewWidth = readablePreview.width; previewHeight = readablePreview.height; } } finally { RenderTexture.active = previous; if (rt != null) RenderTexture.ReleaseTemporary(rt); if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); } } catch (Exception ex) { Debug.LogWarning( $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." ); // Fallback: Try getting static preview if available? // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); } } else { Debug.LogWarning( $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" ); } } return new { path = path, guid = guid, assetType = assetType?.FullName ?? "Unknown", name = Path.GetFileNameWithoutExtension(path), fileName = Path.GetFileName(path), isFolder = AssetDatabase.IsValidFolder(path), instanceID = asset?.GetInstanceID() ?? 0, lastWriteTimeUtc = File.GetLastWriteTimeUtc( Path.Combine(Directory.GetCurrentDirectory(), path) ) .ToString("o"), // ISO 8601 // --- Preview Data --- previewBase64 = previewBase64, // PNG data as Base64 string previewWidth = previewWidth, previewHeight = previewHeight, // TODO: Add more metadata? Importer settings? Dependencies? }; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageAsset.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class using static MCPForUnity.Editor.Tools.ManageGameObject; #if UNITY_6000_0_OR_NEWER using PhysicsMaterialType = UnityEngine.PhysicsMaterial; using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; #else using PhysicsMaterialType = UnityEngine.PhysicMaterial; using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; #endif namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles asset management operations within the Unity project. /// </summary> [McpForUnityTool("manage_asset")] public static class ManageAsset { // --- Main Handler --- // Define the list of valid actions private static readonly List<string> ValidActions = new List<string> { "import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components", }; public static object HandleCommand(JObject @params) { string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Check if the action is valid before switching if (!ValidActions.Contains(action)) { string validActionsList = string.Join(", ", ValidActions); return Response.Error( $"Unknown action: '{action}'. Valid actions are: {validActionsList}" ); } // Common parameters string path = @params["path"]?.ToString(); try { switch (action) { case "import": // Note: Unity typically auto-imports. This might re-import or configure import settings. return ReimportAsset(path, @params["properties"] as JObject); case "create": return CreateAsset(@params); case "modify": return ModifyAsset(path, @params["properties"] as JObject); case "delete": return DeleteAsset(path); case "duplicate": return DuplicateAsset(path, @params["destination"]?.ToString()); case "move": // Often same as rename if within Assets/ case "rename": return MoveOrRenameAsset(path, @params["destination"]?.ToString()); case "search": return SearchAssets(@params); case "get_info": return GetAssetInfo( path, @params["generatePreview"]?.ToObject<bool>() ?? false ); case "create_folder": // Added specific action for clarity return CreateFolder(path); case "get_components": return GetComponentsFromAsset(path); default: // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. string validActionsListDefault = string.Join(", ", ValidActions); return Response.Error( $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" ); } } catch (Exception e) { Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); return Response.Error( $"Internal error processing action '{action}' on '{path}': {e.Message}" ); } } // --- Action Implementations --- private static object ReimportAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { // TODO: Apply importer properties before reimporting? // This is complex as it requires getting the AssetImporter, casting it, // applying properties via reflection or specific methods, saving, then reimporting. if (properties != null && properties.HasValues) { Debug.LogWarning( "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." ); // AssetImporter importer = AssetImporter.GetAtPath(fullPath); // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } } AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); } catch (Exception e) { return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); } } private static object CreateAsset(JObject @params) { string path = @params["path"]?.ToString(); string assetType = @params["assetType"]?.ToString(); JObject properties = @params["properties"] as JObject; if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create."); if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); // Ensure directory exists if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) { Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); AssetDatabase.Refresh(); // Make sure Unity knows about the new folder } if (AssetExists(fullPath)) return Response.Error($"Asset already exists at path: {fullPath}"); try { UnityEngine.Object newAsset = null; string lowerAssetType = assetType.ToLowerInvariant(); // Handle common asset types if (lowerAssetType == "folder") { return CreateFolder(path); // Use dedicated method } else if (lowerAssetType == "material") { // Prefer provided shader; fall back to common pipelines var requested = properties?["shader"]?.ToString(); Shader shader = (!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null) ?? Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("HDRP/Lit") ?? Shader.Find("Standard") ?? Shader.Find("Unlit/Color"); if (shader == null) return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); var mat = new Material(shader); if (properties != null) ApplyMaterialProperties(mat, properties); AssetDatabase.CreateAsset(mat, fullPath); newAsset = mat; } else if (lowerAssetType == "physicsmaterial") { PhysicsMaterialType pmat = new PhysicsMaterialType(); if (properties != null) ApplyPhysicsMaterialProperties(pmat, properties); AssetDatabase.CreateAsset(pmat, fullPath); newAsset = pmat; } else if (lowerAssetType == "scriptableobject") { string scriptClassName = properties?["scriptClass"]?.ToString(); if (string.IsNullOrEmpty(scriptClassName)) return Response.Error( "'scriptClass' property required when creating ScriptableObject asset." ); Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null; if ( scriptType == null || !typeof(ScriptableObject).IsAssignableFrom(scriptType) ) { var reason = scriptType == null ? (string.IsNullOrEmpty(error) ? "Type not found." : error) : "Type found but does not inherit from ScriptableObject."; return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); } ScriptableObject so = ScriptableObject.CreateInstance(scriptType); // TODO: Apply properties from JObject to the ScriptableObject instance? AssetDatabase.CreateAsset(so, fullPath); newAsset = so; } else if (lowerAssetType == "prefab") { // Creating prefabs usually involves saving an existing GameObject hierarchy. // A common pattern is to create an empty GameObject, configure it, and then save it. return Response.Error( "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." ); // Example (conceptual): // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); } // TODO: Add more asset types (Animation Controller, Scene, etc.) else { // Generic creation attempt (might fail or create empty files) // For some types, just creating the file might be enough if Unity imports it. // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it // newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath); return Response.Error( $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." ); } if ( newAsset == null && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) ) // Check if it wasn't a folder and asset wasn't created { return Response.Error( $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." ); } AssetDatabase.SaveAssets(); // AssetDatabase.Refresh(); // CreateAsset often handles refresh return Response.Success( $"Asset '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); } } private static object CreateFolder(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); if (AssetExists(fullPath)) { // Check if it's actually a folder already if (AssetDatabase.IsValidFolder(fullPath)) { return Response.Success( $"Folder already exists at path: {fullPath}", GetAssetData(fullPath) ); } else { return Response.Error( $"An asset (not a folder) already exists at path: {fullPath}" ); } } try { // Ensure parent exists if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) { // Recursively create parent folders if needed (AssetDatabase handles this internally) // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); } string guid = AssetDatabase.CreateFolder(parentDir, folderName); if (string.IsNullOrEmpty(guid)) { return Response.Error( $"Failed to create folder '{fullPath}'. Check logs and permissions." ); } // AssetDatabase.Refresh(); // CreateFolder usually handles refresh return Response.Success( $"Folder '{fullPath}' created successfully.", GetAssetData(fullPath) ); } catch (Exception e) { return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); } } private static object ModifyAsset(string path, JObject properties) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for modify."); if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>( fullPath ); if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); bool modified = false; // Flag to track if any changes were made // --- NEW: Handle GameObject / Prefab Component Modification --- if (asset is GameObject gameObject) { // Iterate through the properties JSON: keys are component names, values are properties objects for that component foreach (var prop in properties.Properties()) { string componentName = prop.Name; // e.g., "Collectible" // Check if the value associated with the component name is actually an object containing properties if ( prop.Value is JObject componentProperties && componentProperties.HasValues ) // e.g., {"bobSpeed": 2.0} { // Resolve component type via ComponentResolver, then fetch by Type Component targetComponent = null; bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); if (resolved) { targetComponent = gameObject.GetComponent(compType); } // Only warn about resolution failure if component also not found if (targetComponent == null && !resolved) { Debug.LogWarning( $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" ); } if (targetComponent != null) { // Apply the nested properties (e.g., bobSpeed) to the found component instance // Use |= to ensure 'modified' becomes true if any component is successfully modified modified |= ApplyObjectProperties( targetComponent, componentProperties ); } else { // Log a warning if a specified component couldn't be found Debug.LogWarning( $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." ); } } else { // Log a warning if the structure isn't {"ComponentName": {"prop": value}} // We could potentially try to apply this property directly to the GameObject here if needed, // but the primary goal is component modification. Debug.LogWarning( $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." ); } } // Note: 'modified' is now true if ANY component property was successfully changed. } // --- End NEW --- // --- Existing logic for other asset types (now as else-if) --- // Example: Modifying a Material else if (asset is Material material) { // Apply properties directly to the material. If this modifies, it sets modified=true. // Use |= in case the asset was already marked modified by previous logic (though unlikely here) modified |= ApplyMaterialProperties(material, properties); } // Example: Modifying a ScriptableObject else if (asset is ScriptableObject so) { // Apply properties directly to the ScriptableObject. modified |= ApplyObjectProperties(so, properties); // General helper } // Example: Modifying TextureImporter settings else if (asset is Texture) { AssetImporter importer = AssetImporter.GetAtPath(fullPath); if (importer is TextureImporter textureImporter) { bool importerModified = ApplyObjectProperties(textureImporter, properties); if (importerModified) { // Importer settings need saving and reimporting AssetDatabase.WriteImportSettingsIfDirty(fullPath); AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes modified = true; // Mark overall operation as modified } } else { Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); } } // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) else // Fallback for other asset types OR direct properties on non-GameObject assets { // This block handles non-GameObject/Material/ScriptableObject/Texture assets. // Attempts to apply properties directly to the asset itself. Debug.LogWarning( $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." ); modified |= ApplyObjectProperties(asset, properties); } // --- End Existing Logic --- // Check if any modification happened (either component or direct asset modification) if (modified) { // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. EditorUtility.SetDirty(asset); // Save all modified assets to disk. AssetDatabase.SaveAssets(); // Refresh might be needed in some edge cases, but SaveAssets usually covers it. // AssetDatabase.Refresh(); return Response.Success( $"Asset '{fullPath}' modified successfully.", GetAssetData(fullPath) ); } else { // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. return Response.Success( $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", GetAssetData(fullPath) ); // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); } } catch (Exception e) { // Log the detailed error internally Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); // Return a user-friendly error message return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); } } private static object DeleteAsset(string path) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { bool success = AssetDatabase.DeleteAsset(fullPath); if (success) { // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh return Response.Success($"Asset '{fullPath}' deleted successfully."); } else { // This might happen if the file couldn't be deleted (e.g., locked) return Response.Error( $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." ); } } catch (Exception e) { return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); } } private static object DuplicateAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); string destPath; if (string.IsNullOrEmpty(destinationPath)) { // Generate a unique path if destination is not provided destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); } else { destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); } try { bool success = AssetDatabase.CopyAsset(sourcePath, destPath); if (success) { // AssetDatabase.Refresh(); return Response.Success( $"Asset '{sourcePath}' duplicated to '{destPath}'.", GetAssetData(destPath) ); } else { return Response.Error( $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); } } private static object MoveOrRenameAsset(string path, string destinationPath) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for move/rename."); if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename."); string sourcePath = AssetPathUtility.SanitizeAssetPath(path); string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); if (AssetExists(destPath)) return Response.Error( $"An asset already exists at the destination path: {destPath}" ); // Ensure destination directory exists EnsureDirectoryExists(Path.GetDirectoryName(destPath)); try { // Validate will return an error string if failed, null if successful string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(error)) { return Response.Error( $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" ); } string guid = AssetDatabase.MoveAsset(sourcePath, destPath); if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success { // AssetDatabase.Refresh(); // MoveAsset usually handles refresh return Response.Success( $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", GetAssetData(destPath) ); } else { // This case might not be reachable if ValidateMoveAsset passes, but good to have return Response.Error( $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." ); } } catch (Exception e) { return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); } } private static object SearchAssets(JObject @params) { string searchPattern = @params["searchPattern"]?.ToString(); string filterType = @params["filterType"]?.ToString(); string pathScope = @params["path"]?.ToString(); // Use path as folder scope string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); int pageSize = @params["pageSize"]?.ToObject<int?>() ?? 50; // Default page size int pageNumber = @params["pageNumber"]?.ToObject<int?>() ?? 1; // Default page number (1-based) bool generatePreview = @params["generatePreview"]?.ToObject<bool>() ?? false; List<string> searchFilters = new List<string>(); if (!string.IsNullOrEmpty(searchPattern)) searchFilters.Add(searchPattern); if (!string.IsNullOrEmpty(filterType)) searchFilters.Add($"t:{filterType}"); string[] folderScope = null; if (!string.IsNullOrEmpty(pathScope)) { folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; if (!AssetDatabase.IsValidFolder(folderScope[0])) { // Maybe the user provided a file path instead of a folder? // We could search in the containing folder, or return an error. Debug.LogWarning( $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." ); folderScope = null; // Search everywhere if path isn't a folder } } DateTime? filterDateAfter = null; if (!string.IsNullOrEmpty(filterDateAfterStr)) { if ( DateTime.TryParse( filterDateAfterStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime parsedDate ) ) { filterDateAfter = parsedDate; } else { Debug.LogWarning( $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." ); } } try { string[] guids = AssetDatabase.FindAssets( string.Join(" ", searchFilters), folderScope ); List<object> results = new List<object>(); int totalFound = 0; foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(assetPath)) continue; // Apply date filter if present if (filterDateAfter.HasValue) { DateTime lastWriteTime = File.GetLastWriteTimeUtc( Path.Combine(Directory.GetCurrentDirectory(), assetPath) ); if (lastWriteTime <= filterDateAfter.Value) { continue; // Skip assets older than or equal to the filter date } } totalFound++; // Count matching assets before pagination results.Add(GetAssetData(assetPath, generatePreview)); } // Apply pagination int startIndex = (pageNumber - 1) * pageSize; var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); return Response.Success( $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", new { totalAssets = totalFound, pageSize = pageSize, pageNumber = pageNumber, assets = pagedResults, } ); } catch (Exception e) { return Response.Error($"Error searching assets: {e.Message}"); } } private static object GetAssetInfo(string path, bool generatePreview) { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info."); string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { return Response.Success( "Asset info retrieved.", GetAssetData(fullPath, generatePreview) ); } catch (Exception e) { return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); } } /// <summary> /// Retrieves components attached to a GameObject asset (like a Prefab). /// </summary> /// <param name="path">The asset path of the GameObject or Prefab.</param> /// <returns>A response object containing a list of component type names or an error.</returns> private static object GetComponentsFromAsset(string path) { // 1. Validate input path if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_components."); // 2. Sanitize and check existence string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); try { // 3. Load the asset UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>( fullPath ); if (asset == null) return Response.Error($"Failed to load asset at path: {fullPath}"); // 4. Check if it's a GameObject (Prefabs load as GameObjects) GameObject gameObject = asset as GameObject; if (gameObject == null) { // Also check if it's *directly* a Component type (less common for primary assets) Component componentAsset = asset as Component; if (componentAsset != null) { // If the asset itself *is* a component, maybe return just its info? // This is an edge case. Let's stick to GameObjects for now. return Response.Error( $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." ); } return Response.Error( $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." ); } // 5. Get components Component[] components = gameObject.GetComponents<Component>(); // 6. Format component data List<object> componentList = components .Select(comp => new { typeName = comp.GetType().FullName, instanceID = comp.GetInstanceID(), // TODO: Add more component-specific details here if needed in the future? // Requires reflection or specific handling per component type. }) .ToList<object>(); // Explicit cast for clarity if needed // 7. Return success response return Response.Success( $"Found {componentList.Count} component(s) on asset '{fullPath}'.", componentList ); } catch (Exception e) { Debug.LogError( $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" ); return Response.Error( $"Error getting components for asset '{fullPath}': {e.Message}" ); } } // --- Internal Helpers --- /// <summary> /// Ensures the asset path starts with "Assets/". /// </summary> /// <summary> /// Checks if an asset exists at the given path (file or folder). /// </summary> private static bool AssetExists(string sanitizedPath) { // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. // Check if it's a known asset GUID. if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) { return true; } // AssetPathToGUID might not work for newly created folders not yet refreshed. // Check directory explicitly for folders. if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) { // Check if it's considered a *valid* folder by Unity return AssetDatabase.IsValidFolder(sanitizedPath); } // Check file existence for non-folder assets. if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) { return true; // Assume if file exists, it's an asset or will be imported } return false; // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); } /// <summary> /// Ensures the directory for a given asset path exists, creating it if necessary. /// </summary> private static void EnsureDirectoryExists(string directoryPath) { if (string.IsNullOrEmpty(directoryPath)) return; string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); if (!Directory.Exists(fullDirPath)) { Directory.CreateDirectory(fullDirPath); AssetDatabase.Refresh(); // Let Unity know about the new folder } } /// <summary> /// Applies properties from JObject to a Material. /// </summary> private static bool ApplyMaterialProperties(Material mat, JObject properties) { if (mat == null || properties == null) return false; bool modified = false; // Example: Set shader if (properties["shader"]?.Type == JTokenType.String) { Shader newShader = Shader.Find(properties["shader"].ToString()); if (newShader != null && mat.shader != newShader) { mat.shader = newShader; modified = true; } } // Example: Set color property if (properties["color"] is JObject colorProps) { string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color if (colorProps["value"] is JArray colArr && colArr.Count >= 3) { try { Color newColor = new Color( colArr[0].ToObject<float>(), colArr[1].ToObject<float>(), colArr[2].ToObject<float>(), colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) { mat.SetColor(propName, newColor); modified = true; } } catch (Exception ex) { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); } } } else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py { string propName = "_Color"; try { if (colorArr.Count >= 3) { Color newColor = new Color( colorArr[0].ToObject<float>(), colorArr[1].ToObject<float>(), colorArr[2].ToObject<float>(), colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) { mat.SetColor(propName, newColor); modified = true; } } } catch (Exception ex) { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); } } // Example: Set float property if (properties["float"] is JObject floatProps) { string propName = floatProps["name"]?.ToString(); if ( !string.IsNullOrEmpty(propName) && (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) ) { try { float newVal = floatProps["value"].ToObject<float>(); if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) { mat.SetFloat(propName, newVal); modified = true; } } catch (Exception ex) { Debug.LogWarning( $"Error parsing float property '{propName}': {ex.Message}" ); } } } // Example: Set texture property if (properties["texture"] is JObject texProps) { string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture string texPath = texProps["path"]?.ToString(); if (!string.IsNullOrEmpty(texPath)) { Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>( AssetPathUtility.SanitizeAssetPath(texPath) ); if ( newTex != null && mat.HasProperty(propName) && mat.GetTexture(propName) != newTex ) { mat.SetTexture(propName, newTex); modified = true; } else if (newTex == null) { Debug.LogWarning($"Texture not found at path: {texPath}"); } } } // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) return modified; } /// <summary> /// Applies properties from JObject to a PhysicsMaterial. /// </summary> private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) { if (pmat == null || properties == null) return false; bool modified = false; // Example: Set dynamic friction if (properties["dynamicFriction"]?.Type == JTokenType.Float) { float dynamicFriction = properties["dynamicFriction"].ToObject<float>(); pmat.dynamicFriction = dynamicFriction; modified = true; } // Example: Set static friction if (properties["staticFriction"]?.Type == JTokenType.Float) { float staticFriction = properties["staticFriction"].ToObject<float>(); pmat.staticFriction = staticFriction; modified = true; } // Example: Set bounciness if (properties["bounciness"]?.Type == JTokenType.Float) { float bounciness = properties["bounciness"].ToObject<float>(); pmat.bounciness = bounciness; modified = true; } List<String> averageList = new List<String> { "ave", "Ave", "average", "Average" }; List<String> multiplyList = new List<String> { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; List<String> minimumList = new List<String> { "min", "Min", "minimum", "Minimum" }; List<String> maximumList = new List<String> { "max", "Max", "maximum", "Maximum" }; // Example: Set friction combine if (properties["frictionCombine"]?.Type == JTokenType.String) { string frictionCombine = properties["frictionCombine"].ToString(); if (averageList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Average; else if (multiplyList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Multiply; else if (minimumList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Minimum; else if (maximumList.Contains(frictionCombine)) pmat.frictionCombine = PhysicsMaterialCombine.Maximum; modified = true; } // Example: Set bounce combine if (properties["bounceCombine"]?.Type == JTokenType.String) { string bounceCombine = properties["bounceCombine"].ToString(); if (averageList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Average; else if (multiplyList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Multiply; else if (minimumList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Minimum; else if (maximumList.Contains(bounceCombine)) pmat.bounceCombine = PhysicsMaterialCombine.Maximum; modified = true; } return modified; } /// <summary> /// Generic helper to set properties on any UnityEngine.Object using reflection. /// </summary> private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) { if (target == null || properties == null) return false; bool modified = false; Type type = target.GetType(); foreach (var prop in properties.Properties()) { string propName = prop.Name; JToken propValue = prop.Value; if (SetPropertyOrField(target, propName, propValue, type)) { modified = true; } } return modified; } /// <summary> /// Helper to set a property or field via reflection, handling basic types and Unity objects. /// </summary> private static bool SetPropertyOrField( object target, string memberName, JToken value, Type type = null ) { type = type ?? target.GetType(); System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase; try { System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); if (propInfo != null && propInfo.CanWrite) { object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); if ( convertedValue != null && !object.Equals(propInfo.GetValue(target), convertedValue) ) { propInfo.SetValue(target, convertedValue); return true; } } else { System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) { object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); if ( convertedValue != null && !object.Equals(fieldInfo.GetValue(target), convertedValue) ) { fieldInfo.SetValue(target, convertedValue); return true; } } } } catch (Exception ex) { Debug.LogWarning( $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" ); } return false; } /// <summary> /// Simple JToken to Type conversion for common Unity types and primitives. /// </summary> private static object ConvertJTokenToType(JToken token, Type targetType) { try { if (token == null || token.Type == JTokenType.Null) return null; if (targetType == typeof(string)) return token.ToObject<string>(); if (targetType == typeof(int)) return token.ToObject<int>(); if (targetType == typeof(float)) return token.ToObject<float>(); if (targetType == typeof(bool)) return token.ToObject<bool>(); if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) return new Vector2(arrV2[0].ToObject<float>(), arrV2[1].ToObject<float>()); if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) return new Vector3( arrV3[0].ToObject<float>(), arrV3[1].ToObject<float>(), arrV3[2].ToObject<float>() ); if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) return new Vector4( arrV4[0].ToObject<float>(), arrV4[1].ToObject<float>(), arrV4[2].ToObject<float>(), arrV4[3].ToObject<float>() ); if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) return new Quaternion( arrQ[0].ToObject<float>(), arrQ[1].ToObject<float>(), arrQ[2].ToObject<float>(), arrQ[3].ToObject<float>() ); if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA return new Color( arrC[0].ToObject<float>(), arrC[1].ToObject<float>(), arrC[2].ToObject<float>(), arrC.Count > 3 ? arrC[3].ToObject<float>() : 1.0f ); if (targetType.IsEnum) return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing // Handle loading Unity Objects (Materials, Textures, etc.) by path if ( typeof(UnityEngine.Object).IsAssignableFrom(targetType) && token.Type == JTokenType.String ) { string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( assetPath, targetType ); if (loadedAsset == null) { Debug.LogWarning( $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" ); } return loadedAsset; } // Fallback: Try direct conversion (might work for other simple value types) return token.ToObject(targetType); } catch (Exception ex) { Debug.LogWarning( $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" ); return null; } } // --- Data Serialization --- /// <summary> /// Creates a serializable representation of an asset. /// </summary> private static object GetAssetData(string path, bool generatePreview = false) { if (string.IsNullOrEmpty(path) || !AssetExists(path)) return null; string guid = AssetDatabase.AssetPathToGUID(path); Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path); string previewBase64 = null; int previewWidth = 0; int previewHeight = 0; if (generatePreview && asset != null) { Texture2D preview = AssetPreview.GetAssetPreview(asset); if (preview != null) { try { // Ensure texture is readable for EncodeToPNG // Creating a temporary readable copy is safer RenderTexture rt = null; Texture2D readablePreview = null; RenderTexture previous = RenderTexture.active; try { rt = RenderTexture.GetTemporary(preview.width, preview.height); Graphics.Blit(preview, rt); RenderTexture.active = rt; readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); readablePreview.Apply(); var pngData = readablePreview.EncodeToPNG(); if (pngData != null && pngData.Length > 0) { previewBase64 = Convert.ToBase64String(pngData); previewWidth = readablePreview.width; previewHeight = readablePreview.height; } } finally { RenderTexture.active = previous; if (rt != null) RenderTexture.ReleaseTemporary(rt); if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); } } catch (Exception ex) { Debug.LogWarning( $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." ); // Fallback: Try getting static preview if available? // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); } } else { Debug.LogWarning( $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" ); } } return new { path = path, guid = guid, assetType = assetType?.FullName ?? "Unknown", name = Path.GetFileNameWithoutExtension(path), fileName = Path.GetFileName(path), isFolder = AssetDatabase.IsValidFolder(path), instanceID = asset?.GetInstanceID() ?? 0, lastWriteTimeUtc = File.GetLastWriteTimeUtc( Path.Combine(Directory.GetCurrentDirectory(), path) ) .ToString("o"), // ISO 8601 // --- Preview Data --- previewBase64 = previewBase64, // PNG data as Base64 string previewWidth = previewWidth, previewHeight = previewHeight, // TODO: Add more metadata? Importer settings? Dependencies? }; } } } ```