This is page 11 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/Windows/MCPForUnityEditorWindow.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Net.Sockets; using System.Net; using System.IO; using System.Linq; using System.Runtime.InteropServices; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Windows { public class MCPForUnityEditorWindow : EditorWindow { private bool isUnityBridgeRunning = false; private Vector2 scrollPosition; private string pythonServerInstallationStatus = "Not Installed"; private Color pythonServerInstallationStatusColor = Color.red; private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) private readonly McpClients mcpClients = new(); private bool autoRegisterEnabled; private bool lastClientRegisteredOk; private bool lastBridgeVerifiedOk; private string pythonDirOverride = null; private bool debugLogsEnabled; // Script validation settings private int validationLevelIndex = 1; // Default to Standard private readonly string[] validationLevelOptions = new string[] { "Basic - Only syntax checks", "Standard - Syntax + Unity practices", "Comprehensive - All checks + semantic analysis", "Strict - Full semantic validation (requires Roslyn)" }; // UI state private int selectedClientIndex = 0; [MenuItem("Window/MCP For Unity")] public static void ShowWindow() { GetWindow<MCPForUnityEditorWindow>("MCP For Unity"); } private void OnEnable() { UpdatePythonServerInstallationStatus(); // Refresh bridge status isUnityBridgeRunning = MCPForUnityBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); if (debugLogsEnabled) { LogDebugPrefsState(); } foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); } // Load validation level setting LoadValidationLevelSetting(); // Show one-time migration dialog ShowMigrationDialogIfNeeded(); // First-run auto-setup only if Claude CLI is available if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { AutoFirstRunSetup(); } } private void OnFocus() { // Refresh bridge running state on focus in case initialization completed after domain reload isUnityBridgeRunning = MCPForUnityBridge.IsRunning; if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; CheckMcpConfiguration(selectedClient); } Repaint(); } private Color GetStatusColor(McpStatus status) { // Return appropriate color based on the status enum return status switch { McpStatus.Configured => Color.green, McpStatus.Running => Color.green, McpStatus.Connected => Color.green, McpStatus.IncorrectPath => Color.yellow, McpStatus.CommunicationError => Color.yellow, McpStatus.NoResponse => Color.yellow, _ => Color.red, // Default to red for error states or not configured }; } private void UpdatePythonServerInstallationStatus() { try { string installedPath = ServerInstaller.GetServerPath(); bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); if (installedOk) { pythonServerInstallationStatus = "Installed"; pythonServerInstallationStatusColor = Color.green; return; } // Fall back to embedded/dev source via our existing resolution logic string embeddedPath = FindPackagePythonDirectory(); bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); if (embeddedOk) { pythonServerInstallationStatus = "Installed (Embedded)"; pythonServerInstallationStatusColor = Color.green; } else { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; } } catch { pythonServerInstallationStatus = "Not Installed"; pythonServerInstallationStatusColor = Color.red; } } private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) { float offsetX = (statusRect.width - size) / 2; float offsetY = (statusRect.height - size) / 2; Rect dotRect = new(statusRect.x + offsetX, statusRect.y + offsetY, size, size); Vector3 center = new( dotRect.x + (dotRect.width / 2), dotRect.y + (dotRect.height / 2), 0 ); float radius = size / 2; // Draw the main dot Handles.color = statusColor; Handles.DrawSolidDisc(center, Vector3.forward, radius); // Draw the border Color borderColor = new( statusColor.r * 0.7f, statusColor.g * 0.7f, statusColor.b * 0.7f ); Handles.color = borderColor; Handles.DrawWireDisc(center, Vector3.forward, radius); } private void OnGUI() { scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); // Migration warning banner (non-dismissible) DrawMigrationWarningBanner(); // Header DrawHeader(); // Compute equal column widths for uniform layout float horizontalSpacing = 2f; float outerPadding = 20f; // approximate padding // Make columns a bit less wide for a tighter layout float computed = (position.width - outerPadding - horizontalSpacing) / 2f; float colWidth = Mathf.Clamp(computed, 220f, 340f); // Use fixed heights per row so paired panels match exactly float topPanelHeight = 190f; float bottomPanelHeight = 230f; // Top row: Server Status (left) and Unity Bridge (right) EditorGUILayout.BeginHorizontal(); { EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); DrawServerStatusSection(); EditorGUILayout.EndVertical(); EditorGUILayout.Space(horizontalSpacing); EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); DrawBridgeSection(); EditorGUILayout.EndVertical(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(10); // Second row: MCP Client Configuration (left) and Script Validation (right) EditorGUILayout.BeginHorizontal(); { EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); DrawUnifiedClientConfiguration(); EditorGUILayout.EndVertical(); EditorGUILayout.Space(horizontalSpacing); EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); DrawValidationSection(); EditorGUILayout.EndVertical(); } EditorGUILayout.EndHorizontal(); // Minimal bottom padding EditorGUILayout.Space(2); EditorGUILayout.EndScrollView(); } private void DrawHeader() { EditorGUILayout.Space(15); Rect titleRect = EditorGUILayout.GetControlRect(false, 40); EditorGUI.DrawRect(titleRect, new Color(0.2f, 0.2f, 0.2f, 0.1f)); GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 16, alignment = TextAnchor.MiddleLeft }; GUI.Label( new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), "MCP For Unity", titleStyle ); // Place the Show Debug Logs toggle on the same header row, right-aligned float toggleWidth = 160f; Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); if (newDebug != debugLogsEnabled) { debugLogsEnabled = newDebug; EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); if (debugLogsEnabled) { LogDebugPrefsState(); } } EditorGUILayout.Space(15); } private void LogDebugPrefsState() { try { string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); // Version-scoped detection key string embeddedVer = ReadEmbeddedVersionOrFallback(); string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; bool detectLogged = SafeGetPrefBool(detectKey); // Project-scoped auto-register key string projectPath = Application.dataPath ?? string.Empty; string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; bool autoRegistered = SafeGetPrefBool(autoKey); MCPForUnity.Editor.Helpers.McpLog.Info( "MCP Debug Prefs:\n" + $" DebugLogs: {debugLogsEnabled}\n" + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + $" UvPath: '{uvPathPref}'\n" + $" ServerSrc: '{serverSrcPref}'\n" + $" UseEmbeddedServer: {useEmbedded}\n" + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", always: false ); } catch (Exception ex) { UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); } } private static string SafeGetPrefString(string key) { try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } } private static bool SafeGetPrefBool(string key) { try { return EditorPrefs.GetBool(key, false); } catch { return false; } } private static string ReadEmbeddedVersionOrFallback() { try { if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) { var p = Path.Combine(embeddedSrc, "server_version.txt"); if (File.Exists(p)) { var s = File.ReadAllText(p)?.Trim(); if (!string.IsNullOrEmpty(s)) return s; } } } catch { } return "unknown"; } private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Server Status", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); GUIStyle statusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(pythonServerInstallationStatus, statusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(5); EditorGUILayout.BeginHorizontal(); bool isAutoMode = MCPForUnityBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); int currentUnityPort = MCPForUnityBridge.GetCurrentPort(); GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); EditorGUILayout.Space(5); /// Auto-Setup button below ports string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) { RunSetupNow(); } EditorGUILayout.Space(4); // Rebuild MCP Server button with tooltip tag using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); GUIContent repairLabel = new GUIContent( "Rebuild MCP Server", "Deletes the installed server and re-copies it from the package. Use this to update the server after making source code changes or if the installation is corrupted." ); if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) { bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RebuildMcpServer(); if (ok) { EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); UpdatePythonServerInstallationStatus(); } else { EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); } } } // (Removed descriptive tool tag under the Repair button) // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); // Python detection warning with link if (!IsPythonDetected()) { GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; EditorGUILayout.LabelField("<color=#cc3333><b>Warning:</b></color> No Python installation found.", warnStyle); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Open Install Instructions", GUILayout.Width(200))) { Application.OpenURL("https://www.python.org/downloads/"); } } EditorGUILayout.Space(4); } // Troubleshooting helpers if (pythonServerInstallationStatusColor != Color.green) { using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) { string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) { pythonDirOverride = picked; EditorPrefs.SetString("MCPForUnity.PythonDirOverride", pythonDirOverride); UpdatePythonServerInstallationStatus(); } else if (!string.IsNullOrEmpty(picked)) { EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); } } if (GUILayout.Button("Verify again", GUILayout.Width(120))) { UpdatePythonServerInstallationStatus(); } } } EditorGUILayout.EndVertical(); } private void DrawBridgeSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); // Always reflect the live state each repaint to avoid stale UI after recompiles isUnityBridgeRunning = MCPForUnityBridge.IsRunning; GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Unity Bridge", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); Color bridgeColor = isUnityBridgeRunning ? Color.green : Color.red; Rect bridgeStatusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(bridgeStatusRect, bridgeColor, 16); GUIStyle bridgeStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(isUnityBridgeRunning ? "Running" : "Stopped", bridgeStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) { ToggleUnityBridge(); } EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); } private void DrawValidationSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Script Validation", sectionTitleStyle); EditorGUILayout.Space(8); EditorGUI.BeginChangeCheck(); validationLevelIndex = EditorGUILayout.Popup("Validation Level", validationLevelIndex, validationLevelOptions, GUILayout.Height(20)); if (EditorGUI.EndChangeCheck()) { SaveValidationLevelSetting(); } EditorGUILayout.Space(8); string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); EditorGUILayout.Space(4); // (Show Debug Logs toggle moved to header) EditorGUILayout.Space(2); EditorGUILayout.EndVertical(); } private void DrawUnifiedClientConfiguration() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); // (Auto-connect toggle removed per design) // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); EditorGUI.BeginChangeCheck(); selectedClientIndex = EditorGUILayout.Popup("Select Client", selectedClientIndex, clientNames, GUILayout.Height(20)); if (EditorGUI.EndChangeCheck()) { selectedClientIndex = Mathf.Clamp(selectedClientIndex, 0, mcpClients.clients.Count - 1); } EditorGUILayout.Space(10); if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; DrawClientConfigurationCompact(selectedClient); } EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); } private void AutoFirstRunSetup() { try { // Project-scoped one-time flag string projectPath = Application.dataPath ?? string.Empty; string key = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; if (EditorPrefs.GetBool(key, false)) { return; } // Attempt client registration using discovered Python server dir pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) { bool anyRegistered = false; foreach (McpClient client in mcpClients.clients) { try { if (client.mcpType == McpTypes.ClaudeCode) { // Only attempt if Claude CLI is present if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) { RegisterWithClaudeCode(pythonDir); anyRegistered = true; } } else { CheckMcpConfiguration(client); bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; } } } catch (Exception ex) { MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || CodexConfigHelper.IsCodexConfigured(pythonDir) || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port if (!MCPForUnityBridge.IsRunning) { try { MCPForUnityBridge.StartAutoConnect(); isUnityBridgeRunning = MCPForUnityBridge.IsRunning; Repaint(); } catch (Exception ex) { MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); } } // Verify bridge with a quick ping lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); EditorPrefs.SetBool(key, true); } catch (Exception e) { MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); } } private static string ComputeSha1(string input) { try { using SHA1 sha1 = SHA1.Create(); byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); byte[] hash = sha1.ComputeHash(bytes); StringBuilder sb = new StringBuilder(hash.Length * 2); foreach (byte b in hash) { sb.Append(b.ToString("x2")); } return sb.ToString(); } catch { return ""; } } private void RunSetupNow() { // Force a one-shot setup regardless of first-run flag try { pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) { EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); return; } bool anyRegistered = false; foreach (McpClient client in mcpClients.clients) { try { if (client.mcpType == McpTypes.ClaudeCode) { if (!IsClaudeConfigured()) { RegisterWithClaudeCode(pythonDir); anyRegistered = true; } } else { CheckMcpConfiguration(client); bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; } } } catch (Exception ex) { UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || CodexConfigHelper.IsCodexConfigured(pythonDir) || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); isUnityBridgeRunning = MCPForUnityBridge.IsRunning; // Verify lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); Repaint(); } catch (Exception e) { EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); } } private static bool IsCursorConfigured(string pythonDir) { try { string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"); if (!File.Exists(configPath)) return false; string json = File.ReadAllText(configPath); dynamic cfg = JsonConvert.DeserializeObject(json); var servers = cfg?.mcpServers; if (servers == null) return false; var unity = servers.unityMCP ?? servers.UnityMCP; if (unity == null) return false; var args = unity.args; if (args == null) return false; // Prefer exact extraction of the --directory value and compare normalized paths string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args) .Select(x => x?.ToString() ?? string.Empty) .ToArray(); string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); if (string.IsNullOrEmpty(dir)) return false; return McpConfigFileHelper.PathsEqual(dir, pythonDir); } catch { return false; } } private static bool IsClaudeConfigured() { try { string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) return false; // Only prepend PATH on Unix string pathPrepend = null; if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin"; } if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) { return false; } return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } private static bool VerifyBridgePing(int port) { // Use strict framed protocol to match bridge (FRAMING=1) const int ConnectTimeoutMs = 1000; const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout try { using TcpClient client = new TcpClient(); var connectTask = client.ConnectAsync(IPAddress.Loopback, port); if (!connectTask.Wait(ConnectTimeoutMs)) return false; using NetworkStream stream = client.GetStream(); try { client.NoDelay = true; } catch { } // 1) Read handshake line (ASCII, newline-terminated) string handshake = ReadLineAscii(stream, 2000); if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) { UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); return false; } // 2) Send framed "ping" byte[] payload = Encoding.UTF8.GetBytes("ping"); WriteFrame(stream, payload, FrameTimeoutMs); // 3) Read framed response and check for pong string response = ReadFrameUtf8(stream, FrameTimeoutMs); bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; if (!ok) { UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); } return ok; } catch (Exception ex) { UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); return false; } } // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); byte[] header = new byte[8]; ulong len = (ulong)payload.LongLength; header[0] = (byte)(len >> 56); header[1] = (byte)(len >> 48); header[2] = (byte)(len >> 40); header[3] = (byte)(len >> 32); header[4] = (byte)(len >> 24); header[5] = (byte)(len >> 16); header[6] = (byte)(len >> 8); header[7] = (byte)(len); stream.WriteTimeout = timeoutMs; stream.Write(header, 0, header.Length); stream.Write(payload, 0, payload.Length); } private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) { byte[] header = ReadExact(stream, 8, timeoutMs); ulong len = ((ulong)header[0] << 56) | ((ulong)header[1] << 48) | ((ulong)header[2] << 40) | ((ulong)header[3] << 32) | ((ulong)header[4] << 24) | ((ulong)header[5] << 16) | ((ulong)header[6] << 8) | header[7]; if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); if (len > int.MaxValue) throw new IOException("Frame too large"); byte[] payload = ReadExact(stream, (int)len, timeoutMs); return Encoding.UTF8.GetString(payload); } private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) { byte[] buffer = new byte[count]; int offset = 0; stream.ReadTimeout = timeoutMs; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); offset += read; } return buffer; } private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) { stream.ReadTimeout = timeoutMs; using var ms = new MemoryStream(); byte[] one = new byte[1]; while (ms.Length < maxLen) { int n = stream.Read(one, 0, 1); if (n <= 0) break; if (one[0] == (byte)'\n') break; ms.WriteByte(one[0]); } return Encoding.ASCII.GetString(ms.ToArray()); } private void DrawClientConfigurationCompact(McpClient mcpClient) { // Special pre-check for Claude Code: if CLI missing, reflect in status UI if (mcpClient.mcpType == McpTypes.ClaudeCode) { string claudeCheck = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudeCheck)) { mcpClient.configStatus = "Claude Not Found"; mcpClient.status = McpStatus.NotConfigured; } } // Pre-check for clients that require uv (all except Claude Code) bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; bool uvMissingEarly = false; if (uvRequired) { string uvPathEarly = FindUvPath(); if (string.IsNullOrEmpty(uvPathEarly)) { uvMissingEarly = true; mcpClient.configStatus = "uv Not Found"; mcpClient.status = McpStatus.NotConfigured; } } // Status display EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); Color statusColor = GetStatusColor(mcpClient.status); DrawStatusDot(statusRect, statusColor, 16); GUIStyle clientStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold }; EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); // When Claude CLI is missing, show a clear install hint directly below status if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) { GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange EditorGUILayout.BeginHorizontal(); GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); Vector2 textSize = installHintStyle.CalcSize(installText); EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.Space(10); // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls if (uvRequired && uvMissingEarly) { GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) { fontSize = 12, fontStyle = FontStyle.Bold, wordWrap = false }; installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); EditorGUILayout.BeginHorizontal(); GUIContent installText2 = new GUIContent("Make sure uv is installed!"); Vector2 sz = installHintStyle2.CalcSize(installText2); EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; GUILayout.Space(6); if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) { string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); if (!string.IsNullOrEmpty(picked)) { EditorPrefs.SetString("MCPForUnity.UvPath", picked); ConfigureMcpClient(mcpClient); Repaint(); } } EditorGUILayout.EndHorizontal(); return; } // Action buttons in horizontal layout EditorGUILayout.BeginHorizontal(); if (mcpClient.mcpType == McpTypes.VSCode) { if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) { ConfigureMcpClient(mcpClient); } } else if (mcpClient.mcpType == McpTypes.ClaudeCode) { bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); if (claudeAvailable) { bool isConfigured = mcpClient.status == McpStatus.Configured; string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; if (GUILayout.Button(buttonText, GUILayout.Height(32))) { if (isConfigured) { UnregisterWithClaudeCode(); } else { string pythonDir = FindPackagePythonDirectory(); RegisterWithClaudeCode(pythonDir); } } // Hide the picker once a valid binary is available EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; string resolvedClaude = ExecPath.ResolveClaude(); EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); } // CLI picker row (only when not found) EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); if (!claudeAvailable) { // Only show the picker button in not-found state (no redundant "not found" label) if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) { string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); if (!string.IsNullOrEmpty(picked)) { ExecPath.SetClaudeCliPath(picked); // Auto-register after setting a valid path string pythonDir = FindPackagePythonDirectory(); RegisterWithClaudeCode(pythonDir); Repaint(); } } } EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); } else { if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) { ConfigureMcpClient(mcpClient); } } if (mcpClient.mcpType != McpTypes.ClaudeCode) { if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) { string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath; if (mcpClient.mcpType == McpTypes.VSCode) { string pythonDir = FindPackagePythonDirectory(); string uvPath = FindUvPath(); if (uvPath == null) { UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); return; } // VSCode now reads from mcp.json with a top-level "servers" block var vscodeConfig = new { servers = new { unityMCP = new { command = uvPath, args = new[] { "run", "--directory", pythonDir, "server.py" } } } }; JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); } else { ShowManualInstructionsWindow(configPath, mcpClient); } } } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8); // Quick info (hide when Claude is not found to avoid confusion) bool hideConfigInfo = (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); if (!hideConfigInfo) { GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 10 }; EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); } } private void ToggleUnityBridge() { if (isUnityBridgeRunning) { MCPForUnityBridge.Stop(); } else { MCPForUnityBridge.Start(); } // Reflect the actual state post-operation (avoid optimistic toggle) isUnityBridgeRunning = MCPForUnityBridge.IsRunning; Repaint(); } // New method to show manual instructions without changing status private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) { // Get the Python directory path using Package Manager API string pythonDir = FindPackagePythonDirectory(); // Build manual JSON centrally using the shared builder string uvPathForManual = FindUvPath(); if (uvPathForManual == null) { UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); return; } string manualConfig = mcpClient?.mcpType == McpTypes.Codex ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); } private string FindPackagePythonDirectory() { // Use shared helper for consistent path resolution across both windows return McpPathResolver.FindPackagePythonDirectory(debugLogsEnabled); } private string ConfigureMcpClient(McpClient mcpClient) { try { // Use shared helper for consistent config path resolution string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); // Create directory if it doesn't exist McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); // Find the server.py file location using shared helper string pythonDir = FindPackagePythonDirectory(); if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { ShowManualInstructionsWindow(configPath, mcpClient); return "Manual Configuration Required"; } string result = mcpClient.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); // Update the client status after successful configuration if (result == "Configured successfully") { mcpClient.SetStatus(McpStatus.Configured); } return result; } catch (Exception e) { // Determine the config file path based on OS for error message string configPath = ""; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { configPath = mcpClient.windowsConfigPath; } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ) { configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; } ShowManualInstructionsWindow(configPath, mcpClient); UnityEngine.Debug.LogError( $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); return $"Failed to configure {mcpClient.name}"; } } private void LoadValidationLevelSetting() { string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); validationLevelIndex = savedLevel.ToLower() switch { "basic" => 0, "standard" => 1, "comprehensive" => 2, "strict" => 3, _ => 1 // Default to Standard }; } private void SaveValidationLevelSetting() { string levelString = validationLevelIndex switch { 0 => "basic", 1 => "standard", 2 => "comprehensive", 3 => "strict", _ => "standard" }; EditorPrefs.SetString("MCPForUnity_ScriptValidationLevel", levelString); } private string GetValidationLevelDescription(int index) { return index switch { 0 => "Only basic syntax checks (braces, quotes, comments)", 1 => "Syntax checks + Unity best practices and warnings", 2 => "All checks + semantic analysis and performance warnings", 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", _ => "Standard validation" }; } private void CheckMcpConfiguration(McpClient mcpClient) { try { // Special handling for Claude Code if (mcpClient.mcpType == McpTypes.ClaudeCode) { CheckClaudeCodeConfiguration(mcpClient); return; } // Use shared helper for consistent config path resolution string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); if (!File.Exists(configPath)) { mcpClient.SetStatus(McpStatus.NotConfigured); return; } string configJson = File.ReadAllText(configPath); // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode string pythonDir = FindPackagePythonDirectory(); // Use switch statement to handle different client types, extracting common logic string[] args = null; bool configExists = false; switch (mcpClient.mcpType) { case McpTypes.VSCode: dynamic config = JsonConvert.DeserializeObject(configJson); // New schema: top-level servers if (config?.servers?.unityMCP != null) { args = config.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } // Back-compat: legacy mcp.servers else if (config?.mcp?.servers?.unityMCP != null) { args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); configExists = true; } break; case McpTypes.Codex: if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) { args = codexArgs; configExists = true; } break; default: // Standard MCP configuration check for Claude Desktop, Cursor, etc. McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { args = standardConfig.mcpServers.unityMCP.args; configExists = true; } break; } // Common logic for checking configuration status if (configExists) { string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); } else { // Attempt auto-rewrite once if the package path changed try { string rewriteResult = mcpClient.mcpType == McpTypes.Codex ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); if (rewriteResult == "Configured successfully") { if (debugLogsEnabled) { MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); } mcpClient.SetStatus(McpStatus.Configured); } else { mcpClient.SetStatus(McpStatus.IncorrectPath); } } catch (Exception ex) { mcpClient.SetStatus(McpStatus.IncorrectPath); if (debugLogsEnabled) { UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); } } } } else { mcpClient.SetStatus(McpStatus.MissingConfig); } } catch (Exception e) { mcpClient.SetStatus(McpStatus.Error, e.Message); } } private void RegisterWithClaudeCode(string pythonDir) { // Resolve claude and uv; then run register command string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) { UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); return; } string uvPath = ExecPath.ResolveUv() ?? "uv"; // Prefer embedded/dev path when available string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; string projectDir = Path.GetDirectoryName(Application.dataPath); // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is string pathPrepend = null; if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) { pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : "/usr/local/bin:/usr/bin:/bin"; } if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { string combined = ($"{stdout}\n{stderr}") ?? string.Empty; if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) { // Treat as success if Claude reports existing registration var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); Repaint(); UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP for Unity already registered with Claude Code."); } else { UnityEngine.Debug.LogError($"MCP for Unity: Failed to start Claude CLI.\n{stderr}\n{stdout}"); } return; } // Update status var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); Repaint(); UnityEngine.Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Registered with Claude Code."); } private void UnregisterWithClaudeCode() { string claudePath = ExecPath.ResolveClaude(); if (string.IsNullOrEmpty(claudePath)) { UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); return; } string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = Application.platform == RuntimePlatform.OSXEditor ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : null; // On Windows, don't modify PATH - use system PATH as-is // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get <name>` string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; List<string> existingNames = new List<string>(); foreach (var candidate in candidateNamesForGet) { if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) { // Success exit code indicates the server exists existingNames.Add(candidate); } } if (existingNames.Count == 0) { // Nothing to unregister – set status and bail early var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { claudeClient.SetStatus(McpStatus.NotConfigured); UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); Repaint(); } return; } // Try different possible server names string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; bool success = false; foreach (string serverName in possibleNames) { if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) { success = true; UnityEngine.Debug.Log($"MCP for Unity: Successfully removed MCP server: {serverName}"); break; } else if (!string.IsNullOrEmpty(stderr) && !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) { // If it's not a "not found" error, log it and stop trying UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); break; } } if (success) { var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { // Optimistically flip to NotConfigured; then verify claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); UnityEngine.Debug.Log("MCP for Unity: MCP server successfully unregistered from Claude Code."); } else { // If no servers were found to remove, they're already unregistered // Force status to NotConfigured and update the UI UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); if (claudeClient != null) { claudeClient.SetStatus(McpStatus.NotConfigured); CheckClaudeCodeConfiguration(claudeClient); } Repaint(); } } // Removed unused ParseTextOutput private string FindUvPath() { try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } } // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead // Removed unused FindClaudeCommand private void CheckClaudeCodeConfiguration(McpClient mcpClient) { try { // Get the Unity project directory to check project-specific config string unityProjectDir = Application.dataPath; string projectDir = Path.GetDirectoryName(unityProjectDir); // Read the global Claude config file (honor macConfigPath on macOS) string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) configPath = mcpClient.windowsConfigPath; else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; else configPath = mcpClient.linuxConfigPath; if (debugLogsEnabled) { MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); } if (!File.Exists(configPath)) { UnityEngine.Debug.LogWarning($"Claude config file not found at: {configPath}"); mcpClient.SetStatus(McpStatus.NotConfigured); return; } string configJson = File.ReadAllText(configPath); dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); // Check for "UnityMCP" server in the mcpServers section (current format) if (claudeConfig?.mcpServers != null) { var servers = claudeConfig.mcpServers; if (servers.UnityMCP != null || servers.unityMCP != null) { // Found MCP for Unity configured mcpClient.SetStatus(McpStatus.Configured); return; } } // Also check if there's a project-specific configuration for this Unity project (legacy format) if (claudeConfig?.projects != null) { // Look for the project path in the config foreach (var project in claudeConfig.projects) { string projectPath = project.Name; // Normalize paths for comparison (handle forward/back slash differences) string normalizedProjectPath = Path.GetFullPath(projectPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string normalizedProjectDir = Path.GetFullPath(projectDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (string.Equals(normalizedProjectPath, normalizedProjectDir, StringComparison.OrdinalIgnoreCase) && project.Value?.mcpServers != null) { // Check for "UnityMCP" (case variations) var servers = project.Value.mcpServers; if (servers.UnityMCP != null || servers.unityMCP != null) { // Found MCP for Unity configured for this project mcpClient.SetStatus(McpStatus.Configured); return; } } } } // No configuration found for this project mcpClient.SetStatus(McpStatus.NotConfigured); } catch (Exception e) { UnityEngine.Debug.LogWarning($"Error checking Claude Code config: {e.Message}"); mcpClient.SetStatus(McpStatus.Error, e.Message); } } private void ShowMigrationDialogIfNeeded() { const string dialogShownKey = "MCPForUnity.LegacyMigrationDialogShown"; if (EditorPrefs.GetBool(dialogShownKey, false)) { return; // Already shown } int result = EditorUtility.DisplayDialogComplex( "Migration Required", "This is the legacy UnityMcpBridge package.\n\n" + "Please migrate to the new MCPForUnity package to receive updates and support.\n\n" + "Migration takes just a few minutes.", "View Migration Guide", "Remind Me Later", "I'll Migrate Later" ); if (result == 0) // View Migration Guide { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md"); EditorPrefs.SetBool(dialogShownKey, true); } else if (result == 2) // I'll Migrate Later { EditorPrefs.SetBool(dialogShownKey, true); } // result == 1 (Remind Me Later) - don't set the flag, show again next time } private void DrawMigrationWarningBanner() { // Warning banner - not dismissible, always visible EditorGUILayout.Space(5); Rect bannerRect = EditorGUILayout.GetControlRect(false, 50); EditorGUI.DrawRect(bannerRect, new Color(1f, 0.6f, 0f, 0.3f)); // Orange background GUIStyle warningStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 13, alignment = TextAnchor.MiddleLeft, richText = true }; // Use Unicode warning triangle (same as used elsewhere in codebase at line 647, 652) string warningText = "\u26A0 <color=#FF8C00>LEGACY PACKAGE:</color> Please migrate to MCPForUnity for updates and support."; Rect textRect = new Rect(bannerRect.x + 15, bannerRect.y + 8, bannerRect.width - 180, bannerRect.height - 16); GUI.Label(textRect, warningText, warningStyle); // Button on the right Rect buttonRect = new Rect(bannerRect.xMax - 160, bannerRect.y + 10, 145, 30); if (GUI.Button(buttonRect, "View Migration Guide")) { Application.OpenURL("https://github.com/CoplayDev/unity-mcp/blob/main/docs/v5_MIGRATION.md"); } EditorGUILayout.Space(5); } private bool IsPythonDetected() { try { // Windows-specific Python detection if (Application.platform == RuntimePlatform.WindowsEditor) { // Common Windows Python installation paths string[] windowsCandidates = { @"C:\Python313\python.exe", @"C:\Python312\python.exe", @"C:\Python311\python.exe", @"C:\Python310\python.exe", @"C:\Python39\python.exe", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), }; foreach (string c in windowsCandidates) { if (File.Exists(c)) return true; } // Try 'where python' command (Windows equivalent of 'which') var psi = new ProcessStartInfo { FileName = "where", Arguments = "python", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var p = Process.Start(psi); string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) { string[] lines = outp.Split('\n'); foreach (string line in lines) { string trimmed = line.Trim(); if (File.Exists(trimmed)) return true; } } } else { // macOS/Linux detection (existing code) string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] candidates = { "/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3", "/opt/local/bin/python3", Path.Combine(home, ".local", "bin", "python3"), "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", }; foreach (string c in candidates) { if (File.Exists(c)) return true; } // Try 'which python3' var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = "python3", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var p = Process.Start(psi); string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; } } catch { } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageGameObject.cs: -------------------------------------------------------------------------------- ```csharp #nullable disable using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json; // Added for JsonSerializationException using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.Compilation; // For CompilationPipeline using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; using UnityEngine.SceneManagement; using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Runtime.Serialization; namespace MCPForUnity.Editor.Tools { /// <summary> /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// </summary> [McpForUnityTool("manage_gameobject")] public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings { Converters = new List<JsonConverter> { new Vector3Converter(), new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), new BoundsConverter(), new UnityEngineObjectConverter() } }); // --- Main Handler --- public static object HandleCommand(JObject @params) { if (@params == null) { return Response.Error("Parameters cannot be null."); } string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Parameters used by various actions JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) string searchMethod = @params["searchMethod"]?.ToString().ToLower(); // Get common parameters (consolidated) string name = @params["name"]?.ToString(); string tag = @params["tag"]?.ToString(); string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; // --- Add parameter for controlling non-public field inclusion --- bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject<bool>() ?? true; // Default to true // --- End add parameter --- // --- Prefab Redirection Check --- string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; if ( !string.IsNullOrEmpty(targetPath) && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) if (action == "modify" || action == "set_component_property") { Debug.Log( $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." ); // Prepare params for ManageAsset.ModifyAsset JObject assetParams = new JObject(); assetParams["action"] = "modify"; // ManageAsset uses "modify" assetParams["path"] = targetPath; // Extract properties. // For 'set_component_property', combine componentName and componentProperties. // For 'modify', directly use componentProperties. JObject properties = null; if (action == "set_component_property") { string compName = @params["componentName"]?.ToString(); JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting if (string.IsNullOrEmpty(compName)) return Response.Error( "Missing 'componentName' for 'set_component_property' on prefab." ); if (compProps == null) return Response.Error( $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." ); properties = new JObject(); properties[compName] = compProps; } else // action == "modify" { properties = @params["componentProperties"] as JObject; if (properties == null) return Response.Error( "Missing 'componentProperties' for 'modify' action on prefab." ); } assetParams["properties"] = properties; // Call ManageAsset handler return ManageAsset.HandleCommand(assetParams); } else if ( action == "delete" || action == "add_component" || action == "remove_component" || action == "get_components" ) // Added get_components here too { // Explicitly block other modifications on the prefab asset itself via manage_gameobject return Response.Error( $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." ); } // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. // No specific handling needed here, the code below will run. } // --- End Prefab Redirection Check --- try { switch (action) { case "create": return CreateGameObject(@params); case "modify": return ModifyGameObject(@params, targetToken, searchMethod); case "delete": return DeleteGameObject(targetToken, searchMethod); case "find": return FindGameObjects(@params, targetToken, searchMethod); case "get_components": string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string if (getCompTarget == null) return Response.Error( "'target' parameter required for get_components." ); // Pass the includeNonPublicSerialized flag here return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); case "get_component": string getSingleCompTarget = targetToken?.ToString(); if (getSingleCompTarget == null) return Response.Error( "'target' parameter required for get_component." ); string componentName = @params["componentName"]?.ToString(); if (string.IsNullOrEmpty(componentName)) return Response.Error( "'componentName' parameter required for get_component." ); return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized); case "add_component": return AddComponentToTarget(@params, targetToken, searchMethod); case "remove_component": return RemoveComponentFromTarget(@params, targetToken, searchMethod); case "set_component_property": return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); default: return Response.Error($"Unknown action: '{action}'."); } } catch (Exception e) { Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); return Response.Error($"Internal error processing action '{action}': {e.Message}"); } } // --- Action Implementations --- private static object CreateGameObject(JObject @params) { string name = @params["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { return Response.Error("'name' parameter is required for 'create' action."); } // Get prefab creation parameters bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject<bool>() ?? false; string prefabPath = @params["prefabPath"]?.ToString(); string tag = @params["tag"]?.ToString(); // Get tag for creation string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check GameObject newGo = null; // Initialize as null // --- Try Instantiating Prefab First --- string originalPrefabPath = prefabPath; // Keep original for messages if (!string.IsNullOrEmpty(prefabPath)) { // If no extension, search for the prefab by name if ( !prefabPath.Contains("/") && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { string prefabNameOnly = prefabPath; Debug.Log( $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" ); string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); if (guids.Length == 0) { return Response.Error( $"Prefab named '{prefabNameOnly}' not found anywhere in the project." ); } else if (guids.Length > 1) { string foundPaths = string.Join( ", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) ); return Response.Error( $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." ); } else // Exactly one found { prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path Debug.Log( $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" ); } } else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. Debug.LogWarning( $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." ); prefabPath += ".prefab"; // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. } // The logic above now handles finding or assuming the .prefab extension. GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath); if (prefabAsset != null) { try { // Instantiate the prefab, initially place it at the root // Parent will be set later if specified newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; if (newGo == null) { // This might happen if the asset exists but isn't a valid GameObject prefab somehow Debug.LogError( $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." ); return Response.Error( $"Failed to instantiate prefab at '{prefabPath}'." ); } // Name the instance based on the 'name' parameter, not the prefab's default name if (!string.IsNullOrEmpty(name)) { newGo.name = name; } // Register Undo for prefab instantiation Undo.RegisterCreatedObjectUndo( newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" ); Debug.Log( $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." ); } catch (Exception e) { return Response.Error( $"Error instantiating prefab '{prefabPath}': {e.Message}" ); } } else { // Only return error if prefabPath was specified but not found. // If prefabPath was empty/null, we proceed to create primitive/empty. Debug.LogWarning( $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." ); // Do not return error here, allow fallback to primitive/empty creation } } // --- Fallback: Create Primitive or Empty GameObject --- bool createdNewObject = false; // Flag to track if we created (not instantiated) if (newGo == null) // Only proceed if prefab instantiation didn't happen { if (!string.IsNullOrEmpty(primitiveType)) { try { PrimitiveType type = (PrimitiveType) Enum.Parse(typeof(PrimitiveType), primitiveType, true); newGo = GameObject.CreatePrimitive(type); // Set name *after* creation for primitives if (!string.IsNullOrEmpty(name)) { newGo.name = name; } else { UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak return Response.Error( "'name' parameter is required when creating a primitive." ); } createdNewObject = true; } catch (ArgumentException) { return Response.Error( $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" ); } catch (Exception e) { return Response.Error( $"Failed to create primitive '{primitiveType}': {e.Message}" ); } } else // Create empty GameObject { if (string.IsNullOrEmpty(name)) { return Response.Error( "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." ); } newGo = new GameObject(name); createdNewObject = true; } // Record creation for Undo *only* if we created a new object if (createdNewObject) { Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); } } // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- if (newGo == null) { // Should theoretically not happen if logic above is correct, but safety check. return Response.Error("Failed to create or instantiate the GameObject."); } // Record potential changes to the existing prefab instance or the new GO // Record transform separately in case parent changes affect it Undo.RecordObject(newGo.transform, "Set GameObject Transform"); Undo.RecordObject(newGo, "Set GameObject Properties"); // Set Parent JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding if (parentGo == null) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object return Response.Error($"Parent specified ('{parentToken}') but not found."); } newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true } // Set Transform Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue) newGo.transform.localPosition = position.Value; if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; if (scale.HasValue) newGo.transform.localScale = scale.Value; // Set Tag (added for create action) if (!string.IsNullOrEmpty(tag)) { // Similar logic as in ModifyGameObject for setting/creating tags string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { newGo.tag = tagToSet; } catch (UnityException ex) { if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." ); try { InternalEditorUtility.AddTag(tagToSet); newGo.tag = tagToSet; // Retry Debug.Log( $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." ); } } else { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." ); } } } // Set Layer (new for create action) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId != -1) { newGo.layer = layerId; } else { Debug.LogWarning( $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." ); } } // Add Components if (@params["componentsToAdd"] is JArray componentsToAddArray) { foreach (var compToken in componentsToAddArray) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) { typeName = compToken.ToString(); } else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(newGo, typeName, properties); if (addResult != null) // Check if AddComponentInternal returned an error object { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return addResult; // Return the error response } } else { Debug.LogWarning( $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" ); } } } // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true GameObject finalInstance = newGo; // Use this for selection and return data if (createdNewObject && saveAsPrefab) { string finalPrefabPath = prefabPath; // Use a separate variable for saving path // This check should now happen *before* attempting to save if (string.IsNullOrEmpty(finalPrefabPath)) { // Clean up the created object before returning error UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." ); } // Ensure the *saving* path ends with .prefab if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { Debug.Log( $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" ); finalPrefabPath += ".prefab"; } try { // Ensure directory exists using the final saving path string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); if ( !string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath) ) { System.IO.Directory.CreateDirectory(directoryPath); AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder Debug.Log( $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" ); } // Use SaveAsPrefabAssetAndConnect with the final saving path finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( newGo, finalPrefabPath, InteractionMode.UserAction ); if (finalInstance == null) { // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." ); } Debug.Log( $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." ); // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect } catch (Exception e) { // Clean up the instance if prefab saving fails UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } // Select the instance in the scene (either prefab instance or newly created/saved one) Selection.activeGameObject = finalInstance; // Determine appropriate success message using the potentially updated or original path string messagePrefabPath = finalInstance == null ? originalPrefabPath : AssetDatabase.GetAssetPath( PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance ); string successMessage; if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab { successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; } else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab { successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; } else // Created new primitive or empty GO, didn't save as prefab { successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; } // Use the new serializer helper //return Response.Success(successMessage, GetGameObjectData(finalInstance)); return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } private static object ModifyGameObject( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } // Record state for Undo *before* modifications Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); Undo.RecordObject(targetGo, "Modify GameObject Properties"); bool modified = false; // Rename (using consolidated 'name' parameter) string name = @params["name"]?.ToString(); if (!string.IsNullOrEmpty(name) && targetGo.name != name) { targetGo.name = name; modified = true; } // Change Parent (using consolidated 'parent' parameter) JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Check for hierarchy loops if ( newParentGo == null && !( parentToken.Type == JTokenType.Null || ( parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()) ) ) ) { return Response.Error($"New parent ('{parentToken}') not found."); } if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) { return Response.Error( $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." ); } if (targetGo.transform.parent != (newParentGo?.transform)) { targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true modified = true; } } // Set Active State bool? setActive = @params["setActive"]?.ToObject<bool?>(); if (setActive.HasValue && targetGo.activeSelf != setActive.Value) { targetGo.SetActive(setActive.Value); modified = true; } // Change Tag (using consolidated 'tag' parameter) string tag = @params["tag"]?.ToString(); // Only attempt to change tag if a non-null tag is provided and it's different from the current one. // Allow setting an empty string to remove the tag (Unity uses "Untagged"). if (tag != null && targetGo.tag != tag) { // Ensure the tag is not empty, if empty, it means "Untagged" implicitly string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { targetGo.tag = tagToSet; modified = true; } catch (UnityException ex) { // Check if the error is specifically because the tag doesn't exist if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." ); try { // Attempt to create the tag using internal utility InternalEditorUtility.AddTag(tagToSet); // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. // yield return null; // Cannot yield here, editor script limitation // Retry setting the tag immediately after creation targetGo.tag = tagToSet; modified = true; Debug.Log( $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { // Handle failure during tag creation or the second assignment attempt Debug.LogError( $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" ); return Response.Error( $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." ); } } else { // If the exception was for a different reason, return the original error return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } } // Change Layer (using consolidated 'layer' parameter) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1 && layerName != "Default") { return Response.Error( $"Invalid layer specified: '{layerName}'. Use a valid layer name." ); } if (layerId != -1 && targetGo.layer != layerId) { targetGo.layer = layerId; modified = true; } } // Transform Modifications Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue && targetGo.transform.localPosition != position.Value) { targetGo.transform.localPosition = position.Value; modified = true; } if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) { targetGo.transform.localEulerAngles = rotation.Value; modified = true; } if (scale.HasValue && targetGo.transform.localScale != scale.Value) { targetGo.transform.localScale = scale.Value; modified = true; } // --- Component Modifications --- // Note: These might need more specific Undo recording per component // Remove Components if (@params["componentsToRemove"] is JArray componentsToRemoveArray) { foreach (var compToken in componentsToRemoveArray) { // ... (parsing logic as in CreateGameObject) ... string typeName = compToken.ToString(); if (!string.IsNullOrEmpty(typeName)) { var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error if removal failed modified = true; } } } // Add Components (similar to create) if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) { foreach (var compToken in componentsToAddArrayModify) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; modified = true; } } } // Set Component Properties var componentErrors = new List<object>(); if (@params["componentProperties"] is JObject componentPropertiesObj) { foreach (var prop in componentPropertiesObj.Properties()) { string compName = prop.Name; JObject propertiesToSet = prop.Value as JObject; if (propertiesToSet != null) { var setResult = SetComponentPropertiesInternal( targetGo, compName, propertiesToSet ); if (setResult != null) { componentErrors.Add(setResult); } else { modified = true; } } } } // Return component errors if any occurred (after processing all components) if (componentErrors.Count > 0) { // Aggregate flattened error strings to make tests/API assertions simpler var aggregatedErrors = new System.Collections.Generic.List<string>(); foreach (var errorObj in componentErrors) { try { var dataProp = errorObj?.GetType().GetProperty("data"); var dataVal = dataProp?.GetValue(errorObj); if (dataVal != null) { var errorsProp = dataVal.GetType().GetProperty("errors"); var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; if (errorsEnum != null) { foreach (var item in errorsEnum) { var s = item?.ToString(); if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); } } } } catch { } } return Response.Error( $"One or more component property operations failed on '{targetGo.name}'.", new { componentErrors = componentErrors, errors = aggregatedErrors } ); } if (!modified) { // Use the new serializer helper // return Response.Success( // $"No modifications applied to GameObject '{targetGo.name}'.", // GetGameObjectData(targetGo)); return Response.Success( $"No modifications applied to GameObject '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } EditorUtility.SetDirty(targetGo); // Mark scene as dirty // Use the new serializer helper return Response.Success( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // return Response.Success( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); } private static object DeleteGameObject(JToken targetToken, string searchMethod) { // Find potentially multiple objects if name/tag search is used without find_all=false implicitly List<GameObject> targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety if (targets.Count == 0) { return Response.Error( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } List<object> deletedObjects = new List<object>(); foreach (var targetGo in targets) { if (targetGo != null) { string goName = targetGo.name; int goId = targetGo.GetInstanceID(); // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(targetGo); deletedObjects.Add(new { name = goName, instanceID = goId }); } } if (deletedObjects.Count > 0) { string message = targets.Count == 1 ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; return Response.Success(message, deletedObjects); } else { // Should not happen if targets.Count > 0 initially, but defensive check return Response.Error("Failed to delete target GameObject(s)."); } } private static object FindGameObjects( JObject @params, JToken targetToken, string searchMethod ) { bool findAll = @params["findAll"]?.ToObject<bool>() ?? false; List<GameObject> foundObjects = FindObjectsInternal( targetToken, searchMethod, findAll, @params ); if (foundObjects.Count == 0) { return Response.Success("No matching GameObjects found.", new List<object>()); } // Use the new serializer helper //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); return Response.Success($"Found {results.Count} GameObject(s).", results); } private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } try { // --- Get components, immediately copy to list, and null original array --- Component[] originalComponents = targetGo.GetComponents<Component>(); List<Component> componentsToIterate = new List<Component>(originalComponents ?? Array.Empty<Component>()); // Copy immediately, handle null case int componentCount = componentsToIterate.Count; originalComponents = null; // Null the original reference // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); // --- End Copy and Null --- var componentData = new List<object>(); for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY { Component c = componentsToIterate[i]; // Use the copy if (c == null) { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); continue; // Safety check } // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); try { var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); if (data != null) // Ensure GetComponentData didn't return null { componentData.Insert(0, data); // Insert at beginning to maintain original order in final list } // else // { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); // } } catch (Exception ex) { Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); // Optionally add placeholder data or just skip componentData.Insert(0, new JObject( // Insert error marker at beginning new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), new JProperty("instanceID", c.GetInstanceID()), new JProperty("error", ex.Message) )); } } // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); // Cleanup the list we created componentsToIterate.Clear(); componentsToIterate = null; return Response.Success( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData // List was built in original order ); } catch (Exception e) { return Response.Error( $"Error getting components from '{targetGo.name}': {e.Message}" ); } } private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } try { // Try to find the component by name Component targetComponent = targetGo.GetComponent(componentName); // If not found directly, try to find by type name (handle namespaces) if (targetComponent == null) { Component[] allComponents = targetGo.GetComponents<Component>(); foreach (Component comp in allComponents) { if (comp != null) { string typeName = comp.GetType().Name; string fullTypeName = comp.GetType().FullName; if (typeName == componentName || fullTypeName == componentName) { targetComponent = comp; break; } } } } if (targetComponent == null) { return Response.Error( $"Component '{componentName}' not found on GameObject '{targetGo.name}'." ); } var componentData = Helpers.GameObjectSerializer.GetComponentData(targetComponent, includeNonPublicSerialized); if (componentData == null) { return Response.Error( $"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'." ); } return Response.Success( $"Retrieved component '{componentName}' from '{targetGo.name}'.", componentData ); } catch (Exception e) { return Response.Error( $"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}" ); } } private static object AddComponentToTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; JObject properties = null; // Allow adding component specified directly or via componentsToAdd array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name } else if ( @params["componentsToAdd"] is JArray componentsToAddArray && componentsToAddArray.Count > 0 ) { var compToken = componentsToAddArray.First; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToAdd') is required." ); } var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' added to '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // Return updated GO data } private static object RemoveComponentFromTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; // Allow removing component specified directly or via componentsToRemove array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); } else if ( @params["componentsToRemove"] is JArray componentsToRemoveArray && componentsToRemoveArray.Count > 0 ) { typeName = componentsToRemoveArray.First?.ToString(); } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToRemove') is required." ); } var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } private static object SetComponentPropertyOnTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string compName = @params["componentName"]?.ToString(); JObject propertiesToSet = null; if (!string.IsNullOrEmpty(compName)) { // Properties might be directly under componentProperties or nested under the component name if (@params["componentProperties"] is JObject compProps) { propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure } } else { return Response.Error("'componentName' parameter is required."); } if (propertiesToSet == null || !propertiesToSet.HasValues) { return Response.Error( "'componentProperties' dictionary for the specified component is required and cannot be empty." ); } var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); if (setResult != null) return setResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } // --- Internal Helpers --- /// <summary> /// Parses a JArray like [x, y, z] into a Vector3. /// </summary> private static Vector3? ParseVector3(JArray array) { if (array != null && array.Count == 3) { try { return new Vector3( array[0].ToObject<float>(), array[1].ToObject<float>(), array[2].ToObject<float>() ); } catch (Exception ex) { Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); } } return null; } /// <summary> /// Finds a single GameObject based on token (ID, name, path) and search method. /// </summary> private static GameObject FindObjectInternal( JToken targetToken, string searchMethod, JObject findParams = null ) { // If find_all is not explicitly false, we still want only one for most single-target operations. bool findAll = findParams?["findAll"]?.ToObject<bool>() ?? false; // If a specific target ID is given, always find just that one. if ( targetToken?.Type == JTokenType.Integer || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) ) { findAll = false; } List<GameObject> results = FindObjectsInternal( targetToken, searchMethod, findAll, findParams ); return results.Count > 0 ? results[0] : null; } /// <summary> /// Core logic for finding GameObjects based on various criteria. /// </summary> private static List<GameObject> FindObjectsInternal( JToken targetToken, string searchMethod, bool findAll, JObject findParams = null ) { List<GameObject> results = new List<GameObject>(); string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself bool searchInChildren = findParams?["searchInChildren"]?.ToObject<bool>() ?? false; bool searchInactive = findParams?["searchInactive"]?.ToObject<bool>() ?? false; // Default search method if not specified if (string.IsNullOrEmpty(searchMethod)) { if (targetToken?.Type == JTokenType.Integer) searchMethod = "by_id"; else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) searchMethod = "by_path"; else searchMethod = "by_name"; // Default fallback } GameObject rootSearchObject = null; // If searching in children, find the initial target first if (searchInChildren && targetToken != null) { rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search if (rootSearchObject == null) { Debug.LogWarning( $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." ); return results; // Return empty if root not found } } switch (searchMethod) { case "by_id": if (int.TryParse(searchTerm, out int instanceId)) { // EditorUtility.InstanceIDToObject is slow, iterate manually if possible // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; var allObjects = GetAllSceneObjects(searchInactive); // More efficient GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceID() == instanceId ); if (obj != null) results.Add(obj); } break; case "by_name": var searchPoolName = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); break; case "by_path": // Path is relative to scene root or rootSearchObject Transform foundTransform = rootSearchObject ? rootSearchObject.transform.Find(searchTerm) : GameObject.Find(searchTerm)?.transform; if (foundTransform != null) results.Add(foundTransform.gameObject); break; case "by_tag": var searchPoolTag = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); break; case "by_layer": var searchPoolLayer = rootSearchObject ? rootSearchObject .GetComponentsInChildren<Transform>(searchInactive) .Select(t => t.gameObject) : GetAllSceneObjects(searchInactive); if (int.TryParse(searchTerm, out int layerIndex)) { results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); } else { int namedLayer = LayerMask.NameToLayer(searchTerm); if (namedLayer != -1) results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); } break; case "by_component": Type componentType = FindType(searchTerm); if (componentType != null) { // Determine FindObjectsInactive based on the searchInactive flag FindObjectsInactive findInactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state var searchPoolComp = rootSearchObject ? rootSearchObject .GetComponentsInChildren(componentType, searchInactive) .Select(c => (c as Component).gameObject) : UnityEngine .Object.FindObjectsByType( componentType, findInactive, FindObjectsSortMode.None ) .Select(c => (c as Component).gameObject); results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid } else { Debug.LogWarning( $"[ManageGameObject.Find] Component type not found: {searchTerm}" ); } break; case "by_id_or_name_or_path": // Helper method used internally if (int.TryParse(searchTerm, out int id)) { var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceID() == id ); if (objById != null) { results.Add(objById); break; } } GameObject objByPath = GameObject.Find(searchTerm); if (objByPath != null) { results.Add(objByPath); break; } var allObjectsName = GetAllSceneObjects(true); results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); break; default: Debug.LogWarning( $"[ManageGameObject.Find] Unknown search method: {searchMethod}" ); break; } // If only one result is needed, return just the first one found. if (!findAll && results.Count > 1) { return new List<GameObject> { results[0] }; } return results.Distinct().ToList(); // Ensure uniqueness } // Helper to get all scene objects efficiently private static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive) { // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType<GameObject>() var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); var allObjects = new List<GameObject>(); foreach (var root in rootObjects) { allObjects.AddRange( root.GetComponentsInChildren<Transform>(includeInactive) .Select(t => t.gameObject) ); } return allObjects; } /// <summary> /// Adds a component by type name and optionally sets properties. /// Returns null on success, or an error response object on failure. /// </summary> private static object AddComponentInternal( GameObject targetGo, string typeName, JObject properties ) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error( $"Component type '{typeName}' not found or is not a valid Component." ); } if (!typeof(Component).IsAssignableFrom(componentType)) { return Response.Error($"Type '{typeName}' is not a Component."); } // Prevent adding Transform again if (componentType == typeof(Transform)) { return Response.Error("Cannot add another Transform component."); } // Check for 2D/3D physics component conflicts bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); if (isAdding2DPhysics) { // Check if the GameObject already has any 3D Rigidbody or Collider if ( targetGo.GetComponent<Rigidbody>() != null || targetGo.GetComponent<Collider>() != null ) { return Response.Error( $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." ); } } else if (isAdding3DPhysics) { // Check if the GameObject already has any 2D Rigidbody or Collider if ( targetGo.GetComponent<Rigidbody2D>() != null || targetGo.GetComponent<Collider2D>() != null ) { return Response.Error( $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." ); } } try { // Use Undo.AddComponent for undo support Component newComponent = Undo.AddComponent(targetGo, componentType); if (newComponent == null) { return Response.Error( $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." ); } // Set default values for specific component types if (newComponent is Light light) { // Default newly added lights to directional light.type = LightType.Directional; } // Set properties if provided if (properties != null) { var setResult = SetComponentPropertiesInternal( targetGo, typeName, properties, newComponent ); // Pass the new component instance if (setResult != null) { // If setting properties failed, maybe remove the added component? Undo.DestroyObjectImmediate(newComponent); return setResult; // Return the error from setting properties } } return null; // Success } catch (Exception e) { return Response.Error( $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" ); } } /// <summary> /// Removes a component by type name. /// Returns null on success, or an error response object on failure. /// </summary> private static object RemoveComponentInternal(GameObject targetGo, string typeName) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error($"Component type '{typeName}' not found for removal."); } // Prevent removing essential components if (componentType == typeof(Transform)) { return Response.Error("Cannot remove the Transform component."); } Component componentToRemove = targetGo.GetComponent(componentType); if (componentToRemove == null) { return Response.Error( $"Component '{typeName}' not found on '{targetGo.name}' to remove." ); } try { // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(componentToRemove); return null; // Success } catch (Exception e) { return Response.Error( $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" ); } } /// <summary> /// Sets properties on a component. /// Returns null on success, or an error response object on failure. /// </summary> private static object SetComponentPropertiesInternal( GameObject targetGo, string compName, JObject propertiesToSet, Component targetComponentInstance = null ) { Component targetComponent = targetComponentInstance; if (targetComponent == null) { if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) { targetComponent = targetGo.GetComponent(compType); } else { targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup } } if (targetComponent == null) { return Response.Error( $"Component '{compName}' not found on '{targetGo.name}' to set properties." ); } Undo.RecordObject(targetComponent, "Set Component Properties"); var failures = new List<string>(); foreach (var prop in propertiesToSet.Properties()) { string propName = prop.Name; JToken propValue = prop.Value; try { bool setResult = SetProperty(targetComponent, propName, propValue); if (!setResult) { var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); var msg = suggestions.Any() ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; Debug.LogWarning($"[ManageGameObject] {msg}"); failures.Add(msg); } } catch (Exception e) { Debug.LogError( $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" ); failures.Add($"Error setting '{propName}': {e.Message}"); } } EditorUtility.SetDirty(targetComponent); return failures.Count == 0 ? null : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); } /// <summary> /// Helper to set a property or field via reflection, handling basic types. /// </summary> private static bool SetProperty(object target, string memberName, JToken value) { Type type = target.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Use shared serializer to avoid per-call allocation var inputSerializer = InputSerializer; try { // Handle special case for materials with dot notation (material.property) // Examples: material.color, sharedMaterial.color, materials[0].color if (memberName.Contains('.') || memberName.Contains('[')) { // Pass the inputSerializer down for nested conversions return SetNestedProperty(target, memberName, value, inputSerializer); } PropertyInfo propInfo = type.GetProperty(memberName, flags); if (propInfo != null && propInfo.CanWrite) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { propInfo.SetValue(target, convertedValue); return true; } else { Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) // Check if !IsLiteral? { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { fieldInfo.SetValue(target, convertedValue); return true; } else { Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { // Try NonPublic [SerializeField] fields var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); if (npField != null && npField.GetCustomAttribute<SerializeField>() != null) { object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { npField.SetValue(target, convertedValue); return true; } } } } } catch (Exception ex) { Debug.LogError( $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}" ); } return false; } /// <summary> /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") /// </summary> // Pass the input serializer for conversions //Using the serializer helper private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) { try { // Split the path into parts (handling both dot notation and array indexing) string[] pathParts = SplitPropertyPath(path); if (pathParts.Length == 0) return false; object currentObject = target; Type currentType = currentObject.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Traverse the path until we reach the final property for (int i = 0; i < pathParts.Length - 1; i++) { string part = pathParts[i]; bool isArray = false; int arrayIndex = -1; // Check if this part contains array indexing if (part.Contains("[")) { int startBracket = part.IndexOf('['); int endBracket = part.IndexOf(']'); if (startBracket > 0 && endBracket > startBracket) { string indexStr = part.Substring( startBracket + 1, endBracket - startBracket - 1 ); if (int.TryParse(indexStr, out arrayIndex)) { isArray = true; part = part.Substring(0, startBracket); } } } // Get the property/field PropertyInfo propInfo = currentType.GetProperty(part, flags); FieldInfo fieldInfo = null; if (propInfo == null) { fieldInfo = currentType.GetField(part, flags); if (fieldInfo == null) { Debug.LogWarning( $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" ); return false; } } // Get the value currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); //Need to stop if current property is null if (currentObject == null) { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." ); return false; } // If this part was an array or list, access the specific index if (isArray) { if (currentObject is Material[]) { var materials = currentObject as Material[]; if (arrayIndex < 0 || arrayIndex >= materials.Length) { Debug.LogWarning( $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" ); return false; } currentObject = materials[arrayIndex]; } else if (currentObject is System.Collections.IList) { var list = currentObject as System.Collections.IList; if (arrayIndex < 0 || arrayIndex >= list.Count) { Debug.LogWarning( $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" ); return false; } currentObject = list[arrayIndex]; } else { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." ); return false; } } currentType = currentObject.GetType(); } // Set the final property string finalPart = pathParts[pathParts.Length - 1]; // Special handling for Material properties (shader properties) if (currentObject is Material material && finalPart.StartsWith("_")) { // Use the serializer to convert the JToken value first if (value is JArray jArray) { // Try converting to known types that SetColor/SetVector accept if (jArray.Count == 4) { try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } try { Vector4 vec = value.ToObject<Vector4>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } else if (jArray.Count == 3) { try { Color color = value.ToObject<Color>(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color } else if (jArray.Count == 2) { try { Vector2 vec = value.ToObject<Vector2>(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } } else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) { try { material.SetFloat(finalPart, value.ToObject<float>(inputSerializer)); return true; } catch { } } else if (value.Type == JTokenType.Boolean) { try { material.SetFloat(finalPart, value.ToObject<bool>(inputSerializer) ? 1f : 0f); return true; } catch { } } else if (value.Type == JTokenType.String) { // Try converting to Texture using the serializer/converter try { Texture texture = value.ToObject<Texture>(inputSerializer); if (texture != null) { material.SetTexture(finalPart, texture); return true; } } catch { } } Debug.LogWarning( $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" ); return false; } // For standard properties (not shader specific) PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); if (finalPropInfo != null && finalPropInfo.CanWrite) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalPropInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); if (finalFieldInfo != null) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalFieldInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { Debug.LogWarning( $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" ); } } } catch (Exception ex) { Debug.LogError( $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" ); } return false; } /// <summary> /// Split a property path into parts, handling both dot notation and array indexers /// </summary> private static string[] SplitPropertyPath(string path) { // Handle complex paths with both dots and array indexers List<string> parts = new List<string>(); int startIndex = 0; bool inBrackets = false; for (int i = 0; i < path.Length; i++) { char c = path[i]; if (c == '[') { inBrackets = true; } else if (c == ']') { inBrackets = false; } else if (c == '.' && !inBrackets) { // Found a dot separator outside of brackets parts.Add(path.Substring(startIndex, i - startIndex)); startIndex = i + 1; } } if (startIndex < path.Length) { parts.Add(path.Substring(startIndex)); } return parts.ToArray(); } /// <summary> /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. /// </summary> // Pass the input serializer private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) { if (token == null || token.Type == JTokenType.Null) { if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) { Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); return Activator.CreateInstance(targetType); } return null; } try { // Use the provided serializer instance which includes our custom converters return token.ToObject(targetType, inputSerializer); } catch (JsonSerializationException jsonEx) { Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); // Optionally re-throw or return null/default // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; throw; // Re-throw to indicate failure higher up } catch (ArgumentException argEx) { Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); throw; } catch (Exception ex) { Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); throw; } // If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here. // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; } // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- // Keep them temporarily for reference or if specific fallback logic is ever needed. private static Vector3 ParseJTokenToVector3(JToken token) { // ... (implementation - likely replaced by Vector3Converter) ... // Consider removing these if the serializer handles them reliably. if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) { return new Vector3(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["z"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 3) { return new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); return Vector3.zero; } private static Vector2 ParseJTokenToVector2(JToken token) { // ... (implementation - likely replaced by Vector2Converter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) { return new Vector2(obj["x"].ToObject<float>(), obj["y"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 2) { return new Vector2(arr[0].ToObject<float>(), arr[1].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); return Vector2.zero; } private static Quaternion ParseJTokenToQuaternion(JToken token) { // ... (implementation - likely replaced by QuaternionConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) { return new Quaternion(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["z"].ToObject<float>(), obj["w"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Quaternion(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); return Quaternion.identity; } private static Color ParseJTokenToColor(JToken token) { // ... (implementation - likely replaced by ColorConverter) ... if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) { return new Color(obj["r"].ToObject<float>(), obj["g"].ToObject<float>(), obj["b"].ToObject<float>(), obj["a"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Color(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); return Color.white; } private static Rect ParseJTokenToRect(JToken token) { // ... (implementation - likely replaced by RectConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) { return new Rect(obj["x"].ToObject<float>(), obj["y"].ToObject<float>(), obj["width"].ToObject<float>(), obj["height"].ToObject<float>()); } if (token is JArray arr && arr.Count >= 4) { return new Rect(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>(), arr[3].ToObject<float>()); } Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); return Rect.zero; } private static Bounds ParseJTokenToBounds(JToken token) { // ... (implementation - likely replaced by BoundsConverter) ... if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) { // Requires Vector3 conversion, which should ideally use the serializer too Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject<Vector3>(inputSerializer) Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject<Vector3>(inputSerializer) return new Bounds(center, size); } // Array fallback for Bounds is less intuitive, maybe remove? // if (token is JArray arr && arr.Count >= 6) // { // return new Bounds(new Vector3(arr[0].ToObject<float>(), arr[1].ToObject<float>(), arr[2].ToObject<float>()), new Vector3(arr[3].ToObject<float>(), arr[4].ToObject<float>(), arr[5].ToObject<float>())); // } Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); return new Bounds(Vector3.zero, Vector3.zero); } // --- End Redundant Parse Helpers --- /// <summary> /// Finds a specific UnityEngine.Object based on a find instruction JObject. /// Primarily used by UnityEngineObjectConverter during deserialization. /// </summary> // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) { string findTerm = instruction["find"]?.ToString(); string method = instruction["method"]?.ToString()?.ToLower(); string componentName = instruction["component"]?.ToString(); // Specific component to get if (string.IsNullOrEmpty(findTerm)) { Debug.LogWarning("Find instruction missing 'find' term."); return null; } // Use a flexible default search method if none provided string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first if (typeof(Material).IsAssignableFrom(targetType) || typeof(Texture).IsAssignableFrom(targetType) || typeof(ScriptableObject).IsAssignableFrom(targetType) || targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. typeof(AudioClip).IsAssignableFrom(targetType) || typeof(AnimationClip).IsAssignableFrom(targetType) || typeof(Font).IsAssignableFrom(targetType) || typeof(Shader).IsAssignableFrom(targetType) || typeof(ComputeShader).IsAssignableFrom(targetType) || typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check { // Try loading directly by path/GUID first UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); if (asset != null) return asset; asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(findTerm); // Try generic if type specific failed if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; // If direct path failed, try finding by name/type using FindAssets string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name string[] guids = AssetDatabase.FindAssets(searchFilter); if (guids.Length == 1) { asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); if (asset != null) return asset; } else if (guids.Length > 1) { Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); // Optionally return the first one? Or null? Returning null is safer. return null; } // If still not found, fall through to scene search (though unlikely for assets) } // --- Scene Object Search --- // Find the GameObject using the internal finder GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); if (foundGo == null) { // Don't warn yet, could still be an asset not found above // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); return null; } // Now, get the target object/component from the found GameObject if (targetType == typeof(GameObject)) { return foundGo; // We were looking for a GameObject } else if (typeof(Component).IsAssignableFrom(targetType)) { Type componentToGetType = targetType; if (!string.IsNullOrEmpty(componentName)) { Type specificCompType = FindType(componentName); if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) { componentToGetType = specificCompType; } else { Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); } } Component foundComp = foundGo.GetComponent(componentToGetType); if (foundComp == null) { Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); } return foundComp; } else { Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); return null; } } /// <summary> /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. /// Searches already-loaded assemblies, prioritizing runtime script assemblies. /// </summary> private static Type FindType(string typeName) { if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) { return resolvedType; } // Log the resolver error if type wasn't found if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"[FindType] {error}"); } return null; } } /// <summary> /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. /// Prioritizes runtime (Player) assemblies over Editor assemblies. /// </summary> internal static class ComponentResolver { private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal); private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal); /// <summary> /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. /// Never uses Assembly.LoadFrom. /// </summary> public static bool TryResolve(string nameOrFullName, out Type type, out string error) { error = string.Empty; type = null!; // Handle null/empty input if (string.IsNullOrWhiteSpace(nameOrFullName)) { error = "Component name cannot be null or empty"; return false; } // 1) Exact cache hits if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; type = Type.GetType(nameOrFullName, throwOnError: false); if (IsValidComponent(type)) { Cache(type); return true; } // 2) Search loaded assemblies (prefer Player assemblies) var candidates = FindCandidates(nameOrFullName); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #if UNITY_EDITOR // 3) Last resort: Editor-only TypeCache (fast index) var tc = TypeCache.GetTypesDerivedFrom<Component>() .Where(t => NamesMatch(t, nameOrFullName)); candidates = PreferPlayer(tc).ToList(); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #endif error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; type = null!; return false; } private static bool NamesMatch(Type t, string q) => t.Name.Equals(q, StringComparison.Ordinal) || (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); private static bool IsValidComponent(Type t) => t != null && typeof(Component).IsAssignableFrom(t); private static void Cache(Type t) { if (t.FullName != null) CacheByFqn[t.FullName] = t; CacheByName[t.Name] = t; } private static List<Type> FindCandidates(string query) { bool isShort = !query.Contains('.'); var loaded = AppDomain.CurrentDomain.GetAssemblies(); #if UNITY_EDITOR // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) var playerAsmNames = new HashSet<string>( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); IEnumerable<System.Reflection.Assembly> playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); IEnumerable<System.Reflection.Assembly> editorAsms = loaded.Except(playerAsms); #else IEnumerable<System.Reflection.Assembly> playerAsms = loaded; IEnumerable<System.Reflection.Assembly> editorAsms = Array.Empty<System.Reflection.Assembly>(); #endif static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly a) { try { return a.GetTypes(); } catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } } Func<Type, bool> match = isShort ? (t => t.Name.Equals(query, StringComparison.Ordinal)) : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); var fromPlayer = playerAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var fromEditor = editorAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var list = new List<Type>(fromPlayer); if (list.Count == 0) list.AddRange(fromEditor); return list; } #if UNITY_EDITOR private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> seq) { var player = new HashSet<string>( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); } #endif private static string Ambiguity(string query, IEnumerable<Type> cands) { var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + "\nProvide a fully qualified type name to disambiguate."; } /// <summary> /// Gets all accessible property and field names from a component type. /// </summary> public static List<string> GetAllComponentProperties(Type componentType) { if (componentType == null) return new List<string>(); var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite) .Select(p => p.Name); var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) .Where(f => !f.IsInitOnly && !f.IsLiteral) .Select(f => f.Name); // Also include SerializeField private fields (common in Unity) var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) .Where(f => f.GetCustomAttribute<SerializeField>() != null) .Select(f => f.Name); return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); } /// <summary> /// Uses AI to suggest the most likely property matches for a user's input. /// </summary> public static List<string> GetAIPropertySuggestions(string userInput, List<string> availableProperties) { if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) return new List<string>(); // Simple caching to avoid repeated AI calls for the same input var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) return cached; try { var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + $"User requested: \"{userInput}\"\n" + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + $"Find 1-3 most likely matches considering:\n" + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + $"- camelCase vs PascalCase vs spaces\n" + $"- Similar meaning/semantics\n" + $"- Common Unity naming patterns\n\n" + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + $"If confidence is low (<70%), return empty string.\n\n" + $"Examples:\n" + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + $"- \"Health Points\" → \"healthPoints, hp\"\n" + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; // For now, we'll use a simple rule-based approach that mimics AI behavior // This can be replaced with actual AI calls later var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); PropertySuggestionCache[cacheKey] = suggestions; return suggestions; } catch (Exception ex) { Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); return new List<string>(); } } private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new(); /// <summary> /// Rule-based suggestions that mimic AI behavior for property matching. /// This provides immediate value while we could add real AI integration later. /// </summary> private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties) { var suggestions = new List<string>(); var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); foreach (var property in availableProperties) { var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); // Exact match after cleaning if (cleanedProperty == cleanedInput) { suggestions.Add(property); continue; } // Check if property contains all words from input var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) { suggestions.Add(property); continue; } // Levenshtein distance for close matches if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) { suggestions.Add(property); } } // Prioritize exact matches, then by similarity return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) .Take(3) .ToList(); } /// <summary> /// Calculates Levenshtein distance between two strings for similarity matching. /// </summary> private static int LevenshteinDistance(string s1, string s2) { if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; if (string.IsNullOrEmpty(s2)) return s1.Length; var matrix = new int[s1.Length + 1, s2.Length + 1]; for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; for (int i = 1; i <= s1.Length; i++) { for (int j = 1; j <= s2.Length; j++) { int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; matrix[i, j] = Math.Min(Math.Min( matrix[i - 1, j] + 1, // deletion matrix[i, j - 1] + 1), // insertion matrix[i - 1, j - 1] + cost); // substitution } } return matrix[s1.Length, s2.Length]; } // Removed duplicate ParseVector3 - using the one at line 1114 // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. // They are now in Helpers.GameObjectSerializer } } ```