This is page 5 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/PathResolverService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using MCPForUnity.Editor.Helpers; 5 | using UnityEditor; 6 | using UnityEngine; 7 | 8 | namespace MCPForUnity.Editor.Services 9 | { 10 | /// <summary> 11 | /// Implementation of path resolver service with override support 12 | /// </summary> 13 | public class PathResolverService : IPathResolverService 14 | { 15 | private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; 16 | private const string UvPathOverrideKey = "MCPForUnity.UvPath"; 17 | private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath"; 18 | 19 | public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); 20 | public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, null)); 21 | public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(ClaudeCliPathOverrideKey, null)); 22 | 23 | public string GetMcpServerPath() 24 | { 25 | // Check for override first 26 | string overridePath = EditorPrefs.GetString(PythonDirOverrideKey, null); 27 | if (!string.IsNullOrEmpty(overridePath) && File.Exists(Path.Combine(overridePath, "server.py"))) 28 | { 29 | return overridePath; 30 | } 31 | 32 | // Fall back to automatic detection 33 | return McpPathResolver.FindPackagePythonDirectory(false); 34 | } 35 | 36 | public string GetUvPath() 37 | { 38 | // Check for override first 39 | string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null); 40 | if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) 41 | { 42 | return overridePath; 43 | } 44 | 45 | // Fall back to automatic detection 46 | try 47 | { 48 | return ServerInstaller.FindUvPath(); 49 | } 50 | catch 51 | { 52 | return null; 53 | } 54 | } 55 | 56 | public string GetClaudeCliPath() 57 | { 58 | // Check for override first 59 | string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); 60 | if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) 61 | { 62 | return overridePath; 63 | } 64 | 65 | // Fall back to automatic detection 66 | return ExecPath.ResolveClaude(); 67 | } 68 | 69 | public bool IsPythonDetected() 70 | { 71 | try 72 | { 73 | // Windows-specific Python detection 74 | if (Application.platform == RuntimePlatform.WindowsEditor) 75 | { 76 | // Common Windows Python installation paths 77 | string[] windowsCandidates = 78 | { 79 | @"C:\Python313\python.exe", 80 | @"C:\Python312\python.exe", 81 | @"C:\Python311\python.exe", 82 | @"C:\Python310\python.exe", 83 | @"C:\Python39\python.exe", 84 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), 85 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), 86 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), 87 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), 88 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), 89 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), 90 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), 91 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), 92 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), 93 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), 94 | }; 95 | 96 | foreach (string c in windowsCandidates) 97 | { 98 | if (File.Exists(c)) return true; 99 | } 100 | 101 | // Try 'where python' command (Windows equivalent of 'which') 102 | var psi = new ProcessStartInfo 103 | { 104 | FileName = "where", 105 | Arguments = "python", 106 | UseShellExecute = false, 107 | RedirectStandardOutput = true, 108 | RedirectStandardError = true, 109 | CreateNoWindow = true 110 | }; 111 | using (var p = Process.Start(psi)) 112 | { 113 | string outp = p.StandardOutput.ReadToEnd().Trim(); 114 | p.WaitForExit(2000); 115 | if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) 116 | { 117 | string[] lines = outp.Split('\n'); 118 | foreach (string line in lines) 119 | { 120 | string trimmed = line.Trim(); 121 | if (File.Exists(trimmed)) return true; 122 | } 123 | } 124 | } 125 | } 126 | else 127 | { 128 | // macOS/Linux detection 129 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 130 | string[] candidates = 131 | { 132 | "/opt/homebrew/bin/python3", 133 | "/usr/local/bin/python3", 134 | "/usr/bin/python3", 135 | "/opt/local/bin/python3", 136 | Path.Combine(home, ".local", "bin", "python3"), 137 | "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", 138 | "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", 139 | }; 140 | foreach (string c in candidates) 141 | { 142 | if (File.Exists(c)) return true; 143 | } 144 | 145 | // Try 'which python3' 146 | var psi = new ProcessStartInfo 147 | { 148 | FileName = "/usr/bin/which", 149 | Arguments = "python3", 150 | UseShellExecute = false, 151 | RedirectStandardOutput = true, 152 | RedirectStandardError = true, 153 | CreateNoWindow = true 154 | }; 155 | using (var p = Process.Start(psi)) 156 | { 157 | string outp = p.StandardOutput.ReadToEnd().Trim(); 158 | p.WaitForExit(2000); 159 | if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; 160 | } 161 | } 162 | } 163 | catch { } 164 | return false; 165 | } 166 | 167 | public bool IsUvDetected() 168 | { 169 | return !string.IsNullOrEmpty(GetUvPath()); 170 | } 171 | 172 | public bool IsClaudeCliDetected() 173 | { 174 | return !string.IsNullOrEmpty(GetClaudeCliPath()); 175 | } 176 | 177 | public void SetMcpServerOverride(string path) 178 | { 179 | if (string.IsNullOrEmpty(path)) 180 | { 181 | ClearMcpServerOverride(); 182 | return; 183 | } 184 | 185 | if (!File.Exists(Path.Combine(path, "server.py"))) 186 | { 187 | throw new ArgumentException("The selected folder does not contain server.py"); 188 | } 189 | 190 | EditorPrefs.SetString(PythonDirOverrideKey, path); 191 | } 192 | 193 | public void SetUvPathOverride(string path) 194 | { 195 | if (string.IsNullOrEmpty(path)) 196 | { 197 | ClearUvPathOverride(); 198 | return; 199 | } 200 | 201 | if (!File.Exists(path)) 202 | { 203 | throw new ArgumentException("The selected UV executable does not exist"); 204 | } 205 | 206 | EditorPrefs.SetString(UvPathOverrideKey, path); 207 | } 208 | 209 | public void SetClaudeCliPathOverride(string path) 210 | { 211 | if (string.IsNullOrEmpty(path)) 212 | { 213 | ClearClaudeCliPathOverride(); 214 | return; 215 | } 216 | 217 | if (!File.Exists(path)) 218 | { 219 | throw new ArgumentException("The selected Claude CLI executable does not exist"); 220 | } 221 | 222 | EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); 223 | // Also update the ExecPath helper for backwards compatibility 224 | ExecPath.SetClaudeCliPath(path); 225 | } 226 | 227 | public void ClearMcpServerOverride() 228 | { 229 | EditorPrefs.DeleteKey(PythonDirOverrideKey); 230 | } 231 | 232 | public void ClearUvPathOverride() 233 | { 234 | EditorPrefs.DeleteKey(UvPathOverrideKey); 235 | } 236 | 237 | public void ClearClaudeCliPathOverride() 238 | { 239 | EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); 240 | } 241 | } 242 | } 243 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using Newtonsoft.Json.Linq; 6 | using NUnit.Framework; 7 | using UnityEditor; 8 | using MCPForUnity.Editor.Helpers; 9 | using MCPForUnity.Editor.Models; 10 | 11 | namespace MCPForUnityTests.Editor.Helpers 12 | { 13 | public class WriteToConfigTests 14 | { 15 | private string _tempRoot; 16 | private string _fakeUvPath; 17 | private string _serverSrcDir; 18 | 19 | [SetUp] 20 | public void SetUp() 21 | { 22 | // Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo 23 | // restrictions when UseShellExecute=false for .cmd/.bat scripts. 24 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 25 | { 26 | Assert.Ignore("WriteToConfig tests are skipped on Windows (CI runs linux).\n" + 27 | "ValidateUvBinarySafe requires launching an actual exe on Windows."); 28 | } 29 | _tempRoot = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N")); 30 | Directory.CreateDirectory(_tempRoot); 31 | 32 | // Create a fake uv executable that prints a valid version string 33 | _fakeUvPath = Path.Combine(_tempRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.cmd" : "uv"); 34 | File.WriteAllText(_fakeUvPath, "#!/bin/sh\n\necho 'uv 9.9.9'\n"); 35 | TryChmodX(_fakeUvPath); 36 | 37 | // Create a fake server directory with server.py 38 | _serverSrcDir = Path.Combine(_tempRoot, "server-src"); 39 | Directory.CreateDirectory(_serverSrcDir); 40 | File.WriteAllText(Path.Combine(_serverSrcDir, "server.py"), "# dummy server\n"); 41 | 42 | // Point the editor to our server dir (so ResolveServerSrc() uses this) 43 | EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir); 44 | // Ensure no lock is enabled 45 | EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); 46 | // Disable auto-registration to avoid hitting user configs during tests 47 | EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false); 48 | } 49 | 50 | [TearDown] 51 | public void TearDown() 52 | { 53 | // Clean up editor preferences set during SetUp 54 | EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); 55 | EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); 56 | EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled"); 57 | 58 | // Remove temp files 59 | try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } 60 | } 61 | 62 | // --- Tests --- 63 | 64 | [Test] 65 | public void AddsEnvAndDisabledFalse_ForWindsurf() 66 | { 67 | var configPath = Path.Combine(_tempRoot, "windsurf.json"); 68 | WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); 69 | 70 | var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; 71 | InvokeWriteToConfig(configPath, client); 72 | 73 | var root = JObject.Parse(File.ReadAllText(configPath)); 74 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 75 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 76 | Assert.NotNull(unity["env"], "env should be present for all clients"); 77 | Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object"); 78 | Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing"); 79 | } 80 | 81 | [Test] 82 | public void AddsEnvAndDisabledFalse_ForKiro() 83 | { 84 | var configPath = Path.Combine(_tempRoot, "kiro.json"); 85 | WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); 86 | 87 | var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro }; 88 | InvokeWriteToConfig(configPath, client); 89 | 90 | var root = JObject.Parse(File.ReadAllText(configPath)); 91 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 92 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 93 | Assert.NotNull(unity["env"], "env should be present for all clients"); 94 | Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object"); 95 | Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing"); 96 | } 97 | 98 | [Test] 99 | public void DoesNotAddEnvOrDisabled_ForCursor() 100 | { 101 | var configPath = Path.Combine(_tempRoot, "cursor.json"); 102 | WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); 103 | 104 | var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; 105 | InvokeWriteToConfig(configPath, client); 106 | 107 | var root = JObject.Parse(File.ReadAllText(configPath)); 108 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 109 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 110 | Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients"); 111 | Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients"); 112 | } 113 | 114 | [Test] 115 | public void DoesNotAddEnvOrDisabled_ForVSCode() 116 | { 117 | var configPath = Path.Combine(_tempRoot, "vscode.json"); 118 | WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path"); 119 | 120 | var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; 121 | InvokeWriteToConfig(configPath, client); 122 | 123 | var root = JObject.Parse(File.ReadAllText(configPath)); 124 | var unity = (JObject)root.SelectToken("servers.unityMCP"); 125 | Assert.NotNull(unity, "Expected servers.unityMCP node"); 126 | Assert.IsNull(unity["env"], "env should not be added for VSCode client"); 127 | Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client"); 128 | Assert.AreEqual("stdio", (string)unity["type"], "VSCode entry should include type=stdio"); 129 | } 130 | 131 | [Test] 132 | public void PreservesExistingEnvAndDisabled() 133 | { 134 | var configPath = Path.Combine(_tempRoot, "preserve.json"); 135 | 136 | // Existing config with env and disabled=true should be preserved 137 | var json = new JObject 138 | { 139 | ["mcpServers"] = new JObject 140 | { 141 | ["unityMCP"] = new JObject 142 | { 143 | ["command"] = _fakeUvPath, 144 | ["args"] = new JArray("run", "--directory", "/old/path", "server.py"), 145 | ["env"] = new JObject { ["FOO"] = "bar" }, 146 | ["disabled"] = true 147 | } 148 | } 149 | }; 150 | File.WriteAllText(configPath, json.ToString()); 151 | 152 | var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; 153 | InvokeWriteToConfig(configPath, client); 154 | 155 | var root = JObject.Parse(File.ReadAllText(configPath)); 156 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 157 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 158 | Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved"); 159 | Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved"); 160 | } 161 | 162 | // --- Helpers --- 163 | 164 | private static void TryChmodX(string path) 165 | { 166 | try 167 | { 168 | var psi = new ProcessStartInfo 169 | { 170 | FileName = "/bin/chmod", 171 | Arguments = "+x \"" + path + "\"", 172 | UseShellExecute = false, 173 | RedirectStandardOutput = true, 174 | RedirectStandardError = true, 175 | CreateNoWindow = true 176 | }; 177 | using var p = Process.Start(psi); 178 | p?.WaitForExit(2000); 179 | } 180 | catch { /* best-effort on non-Unix */ } 181 | } 182 | 183 | private static void WriteInitialConfig(string configPath, bool isVSCode, string command, string directory) 184 | { 185 | Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); 186 | JObject root; 187 | if (isVSCode) 188 | { 189 | root = new JObject 190 | { 191 | ["servers"] = new JObject 192 | { 193 | ["unityMCP"] = new JObject 194 | { 195 | ["command"] = command, 196 | ["args"] = new JArray("run", "--directory", directory, "server.py"), 197 | ["type"] = "stdio" 198 | } 199 | } 200 | }; 201 | } 202 | else 203 | { 204 | root = new JObject 205 | { 206 | ["mcpServers"] = new JObject 207 | { 208 | ["unityMCP"] = new JObject 209 | { 210 | ["command"] = command, 211 | ["args"] = new JArray("run", "--directory", directory, "server.py") 212 | } 213 | } 214 | }; 215 | } 216 | File.WriteAllText(configPath, root.ToString()); 217 | } 218 | 219 | private static void InvokeWriteToConfig(string configPath, McpClient client) 220 | { 221 | var result = McpConfigurationHelper.WriteMcpConfiguration( 222 | pythonDir: string.Empty, 223 | configPath: configPath, 224 | mcpClient: client 225 | ); 226 | 227 | Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success"); 228 | } 229 | } 230 | } 231 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.Runtime.InteropServices; 2 | using UnityEditor; 3 | using UnityEngine; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnity.Editor.Windows 7 | { 8 | public class VSCodeManualSetupWindow : ManualConfigEditorWindow 9 | { 10 | public static void ShowWindow(string configPath, string configJson) 11 | { 12 | var window = GetWindow<VSCodeManualSetupWindow>("VSCode GitHub Copilot Setup"); 13 | window.configPath = configPath; 14 | window.configJson = configJson; 15 | window.minSize = new Vector2(550, 500); 16 | 17 | // Create a McpClient for VSCode 18 | window.mcpClient = new McpClient 19 | { 20 | name = "VSCode GitHub Copilot", 21 | mcpType = McpTypes.VSCode 22 | }; 23 | 24 | window.Show(); 25 | } 26 | 27 | protected override void OnGUI() 28 | { 29 | scrollPos = EditorGUILayout.BeginScrollView(scrollPos); 30 | 31 | // Header with improved styling 32 | EditorGUILayout.Space(10); 33 | Rect titleRect = EditorGUILayout.GetControlRect(false, 30); 34 | EditorGUI.DrawRect( 35 | new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), 36 | new Color(0.2f, 0.2f, 0.2f, 0.1f) 37 | ); 38 | GUI.Label( 39 | new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), 40 | "VSCode GitHub Copilot MCP Setup", 41 | EditorStyles.boldLabel 42 | ); 43 | EditorGUILayout.Space(10); 44 | 45 | // Instructions with improved styling 46 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 47 | 48 | Rect headerRect = EditorGUILayout.GetControlRect(false, 24); 49 | EditorGUI.DrawRect( 50 | new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), 51 | new Color(0.1f, 0.1f, 0.1f, 0.2f) 52 | ); 53 | GUI.Label( 54 | new Rect( 55 | headerRect.x + 8, 56 | headerRect.y + 4, 57 | headerRect.width - 16, 58 | headerRect.height 59 | ), 60 | "Setting up GitHub Copilot in VSCode with MCP for Unity", 61 | EditorStyles.boldLabel 62 | ); 63 | EditorGUILayout.Space(10); 64 | 65 | GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) 66 | { 67 | margin = new RectOffset(10, 10, 5, 5), 68 | }; 69 | 70 | EditorGUILayout.LabelField( 71 | "1. Prerequisites", 72 | EditorStyles.boldLabel 73 | ); 74 | EditorGUILayout.LabelField( 75 | "• Ensure you have VSCode installed", 76 | instructionStyle 77 | ); 78 | EditorGUILayout.LabelField( 79 | "• Ensure you have GitHub Copilot extension installed in VSCode", 80 | instructionStyle 81 | ); 82 | EditorGUILayout.LabelField( 83 | "• Ensure you have a valid GitHub Copilot subscription", 84 | instructionStyle 85 | ); 86 | EditorGUILayout.Space(5); 87 | 88 | EditorGUILayout.LabelField( 89 | "2. Steps to Configure", 90 | EditorStyles.boldLabel 91 | ); 92 | EditorGUILayout.LabelField( 93 | "a) Open or create your VSCode MCP config file (mcp.json) at the path below", 94 | instructionStyle 95 | ); 96 | EditorGUILayout.LabelField( 97 | "b) Paste the JSON shown below into mcp.json", 98 | instructionStyle 99 | ); 100 | EditorGUILayout.LabelField( 101 | "c) Save the file and restart VSCode", 102 | instructionStyle 103 | ); 104 | EditorGUILayout.Space(5); 105 | 106 | EditorGUILayout.LabelField( 107 | "3. VSCode mcp.json location:", 108 | EditorStyles.boldLabel 109 | ); 110 | 111 | // Path section with improved styling 112 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 113 | string displayPath; 114 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 115 | { 116 | displayPath = System.IO.Path.Combine( 117 | System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), 118 | "Code", 119 | "User", 120 | "mcp.json" 121 | ); 122 | } 123 | else 124 | { 125 | displayPath = System.IO.Path.Combine( 126 | System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), 127 | "Library", 128 | "Application Support", 129 | "Code", 130 | "User", 131 | "mcp.json" 132 | ); 133 | } 134 | 135 | // Store the path in the base class config path 136 | if (string.IsNullOrEmpty(configPath)) 137 | { 138 | configPath = displayPath; 139 | } 140 | 141 | // Prevent text overflow by allowing the text field to wrap 142 | GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; 143 | 144 | EditorGUILayout.TextField( 145 | displayPath, 146 | pathStyle, 147 | GUILayout.Height(EditorGUIUtility.singleLineHeight) 148 | ); 149 | 150 | // Copy button with improved styling 151 | EditorGUILayout.BeginHorizontal(); 152 | GUILayout.FlexibleSpace(); 153 | GUIStyle copyButtonStyle = new(GUI.skin.button) 154 | { 155 | padding = new RectOffset(15, 15, 5, 5), 156 | margin = new RectOffset(10, 10, 5, 5), 157 | }; 158 | 159 | if ( 160 | GUILayout.Button( 161 | "Copy Path", 162 | copyButtonStyle, 163 | GUILayout.Height(25), 164 | GUILayout.Width(100) 165 | ) 166 | ) 167 | { 168 | EditorGUIUtility.systemCopyBuffer = displayPath; 169 | pathCopied = true; 170 | copyFeedbackTimer = 2f; 171 | } 172 | 173 | if ( 174 | GUILayout.Button( 175 | "Open File", 176 | copyButtonStyle, 177 | GUILayout.Height(25), 178 | GUILayout.Width(100) 179 | ) 180 | ) 181 | { 182 | // Open the file using the system's default application 183 | System.Diagnostics.Process.Start( 184 | new System.Diagnostics.ProcessStartInfo 185 | { 186 | FileName = displayPath, 187 | UseShellExecute = true, 188 | } 189 | ); 190 | } 191 | 192 | if (pathCopied) 193 | { 194 | GUIStyle feedbackStyle = new(EditorStyles.label); 195 | feedbackStyle.normal.textColor = Color.green; 196 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 197 | } 198 | 199 | EditorGUILayout.EndHorizontal(); 200 | EditorGUILayout.EndVertical(); 201 | EditorGUILayout.Space(10); 202 | 203 | EditorGUILayout.LabelField( 204 | "4. Add this configuration to your mcp.json:", 205 | EditorStyles.boldLabel 206 | ); 207 | 208 | // JSON section with improved styling 209 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 210 | 211 | // Improved text area for JSON with syntax highlighting colors 212 | GUIStyle jsonStyle = new(EditorStyles.textArea) 213 | { 214 | font = EditorStyles.boldFont, 215 | wordWrap = true, 216 | }; 217 | jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue 218 | 219 | // Draw the JSON in a text area with a taller height for better readability 220 | EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); 221 | 222 | // Copy JSON button with improved styling 223 | EditorGUILayout.BeginHorizontal(); 224 | GUILayout.FlexibleSpace(); 225 | 226 | if ( 227 | GUILayout.Button( 228 | "Copy JSON", 229 | copyButtonStyle, 230 | GUILayout.Height(25), 231 | GUILayout.Width(100) 232 | ) 233 | ) 234 | { 235 | EditorGUIUtility.systemCopyBuffer = configJson; 236 | jsonCopied = true; 237 | copyFeedbackTimer = 2f; 238 | } 239 | 240 | if (jsonCopied) 241 | { 242 | GUIStyle feedbackStyle = new(EditorStyles.label); 243 | feedbackStyle.normal.textColor = Color.green; 244 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 245 | } 246 | 247 | EditorGUILayout.EndHorizontal(); 248 | EditorGUILayout.EndVertical(); 249 | 250 | EditorGUILayout.Space(10); 251 | EditorGUILayout.LabelField( 252 | "5. After configuration:", 253 | EditorStyles.boldLabel 254 | ); 255 | EditorGUILayout.LabelField( 256 | "• Restart VSCode", 257 | instructionStyle 258 | ); 259 | EditorGUILayout.LabelField( 260 | "• GitHub Copilot will now be able to interact with your Unity project through the MCP protocol", 261 | instructionStyle 262 | ); 263 | EditorGUILayout.LabelField( 264 | "• Remember to have the MCP for Unity Bridge running in Unity Editor", 265 | instructionStyle 266 | ); 267 | 268 | EditorGUILayout.EndVertical(); 269 | 270 | EditorGUILayout.Space(10); 271 | 272 | // Close button at the bottom 273 | EditorGUILayout.BeginHorizontal(); 274 | GUILayout.FlexibleSpace(); 275 | if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) 276 | { 277 | Close(); 278 | } 279 | GUILayout.FlexibleSpace(); 280 | EditorGUILayout.EndHorizontal(); 281 | 282 | EditorGUILayout.EndScrollView(); 283 | } 284 | 285 | protected override void Update() 286 | { 287 | // Call the base implementation which handles the copy feedback timer 288 | base.Update(); 289 | } 290 | } 291 | } 292 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.Runtime.InteropServices; 2 | using UnityEditor; 3 | using UnityEngine; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnity.Editor.Windows 7 | { 8 | public class VSCodeManualSetupWindow : ManualConfigEditorWindow 9 | { 10 | public static void ShowWindow(string configPath, string configJson) 11 | { 12 | var window = GetWindow<VSCodeManualSetupWindow>("VSCode GitHub Copilot Setup"); 13 | window.configPath = configPath; 14 | window.configJson = configJson; 15 | window.minSize = new Vector2(550, 500); 16 | 17 | // Create a McpClient for VSCode 18 | window.mcpClient = new McpClient 19 | { 20 | name = "VSCode GitHub Copilot", 21 | mcpType = McpTypes.VSCode 22 | }; 23 | 24 | window.Show(); 25 | } 26 | 27 | protected override void OnGUI() 28 | { 29 | scrollPos = EditorGUILayout.BeginScrollView(scrollPos); 30 | 31 | // Header with improved styling 32 | EditorGUILayout.Space(10); 33 | Rect titleRect = EditorGUILayout.GetControlRect(false, 30); 34 | EditorGUI.DrawRect( 35 | new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), 36 | new Color(0.2f, 0.2f, 0.2f, 0.1f) 37 | ); 38 | GUI.Label( 39 | new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), 40 | "VSCode GitHub Copilot MCP Setup", 41 | EditorStyles.boldLabel 42 | ); 43 | EditorGUILayout.Space(10); 44 | 45 | // Instructions with improved styling 46 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 47 | 48 | Rect headerRect = EditorGUILayout.GetControlRect(false, 24); 49 | EditorGUI.DrawRect( 50 | new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), 51 | new Color(0.1f, 0.1f, 0.1f, 0.2f) 52 | ); 53 | GUI.Label( 54 | new Rect( 55 | headerRect.x + 8, 56 | headerRect.y + 4, 57 | headerRect.width - 16, 58 | headerRect.height 59 | ), 60 | "Setting up GitHub Copilot in VSCode with MCP for Unity", 61 | EditorStyles.boldLabel 62 | ); 63 | EditorGUILayout.Space(10); 64 | 65 | GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) 66 | { 67 | margin = new RectOffset(10, 10, 5, 5), 68 | }; 69 | 70 | EditorGUILayout.LabelField( 71 | "1. Prerequisites", 72 | EditorStyles.boldLabel 73 | ); 74 | EditorGUILayout.LabelField( 75 | "• Ensure you have VSCode installed", 76 | instructionStyle 77 | ); 78 | EditorGUILayout.LabelField( 79 | "• Ensure you have GitHub Copilot extension installed in VSCode", 80 | instructionStyle 81 | ); 82 | EditorGUILayout.LabelField( 83 | "• Ensure you have a valid GitHub Copilot subscription", 84 | instructionStyle 85 | ); 86 | EditorGUILayout.Space(5); 87 | 88 | EditorGUILayout.LabelField( 89 | "2. Steps to Configure", 90 | EditorStyles.boldLabel 91 | ); 92 | EditorGUILayout.LabelField( 93 | "a) Open or create your VSCode MCP config file (mcp.json) at the path below", 94 | instructionStyle 95 | ); 96 | EditorGUILayout.LabelField( 97 | "b) Paste the JSON shown below into mcp.json", 98 | instructionStyle 99 | ); 100 | EditorGUILayout.LabelField( 101 | "c) Save the file and restart VSCode", 102 | instructionStyle 103 | ); 104 | EditorGUILayout.Space(5); 105 | 106 | EditorGUILayout.LabelField( 107 | "3. VSCode mcp.json location:", 108 | EditorStyles.boldLabel 109 | ); 110 | 111 | // Path section with improved styling 112 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 113 | string displayPath; 114 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 115 | { 116 | displayPath = System.IO.Path.Combine( 117 | System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), 118 | "Code", 119 | "User", 120 | "mcp.json" 121 | ); 122 | } 123 | else 124 | { 125 | displayPath = System.IO.Path.Combine( 126 | System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), 127 | "Library", 128 | "Application Support", 129 | "Code", 130 | "User", 131 | "mcp.json" 132 | ); 133 | } 134 | 135 | // Store the path in the base class config path 136 | if (string.IsNullOrEmpty(configPath)) 137 | { 138 | configPath = displayPath; 139 | } 140 | 141 | // Prevent text overflow by allowing the text field to wrap 142 | GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; 143 | 144 | EditorGUILayout.TextField( 145 | displayPath, 146 | pathStyle, 147 | GUILayout.Height(EditorGUIUtility.singleLineHeight) 148 | ); 149 | 150 | // Copy button with improved styling 151 | EditorGUILayout.BeginHorizontal(); 152 | GUILayout.FlexibleSpace(); 153 | GUIStyle copyButtonStyle = new(GUI.skin.button) 154 | { 155 | padding = new RectOffset(15, 15, 5, 5), 156 | margin = new RectOffset(10, 10, 5, 5), 157 | }; 158 | 159 | if ( 160 | GUILayout.Button( 161 | "Copy Path", 162 | copyButtonStyle, 163 | GUILayout.Height(25), 164 | GUILayout.Width(100) 165 | ) 166 | ) 167 | { 168 | EditorGUIUtility.systemCopyBuffer = displayPath; 169 | pathCopied = true; 170 | copyFeedbackTimer = 2f; 171 | } 172 | 173 | if ( 174 | GUILayout.Button( 175 | "Open File", 176 | copyButtonStyle, 177 | GUILayout.Height(25), 178 | GUILayout.Width(100) 179 | ) 180 | ) 181 | { 182 | // Open the file using the system's default application 183 | System.Diagnostics.Process.Start( 184 | new System.Diagnostics.ProcessStartInfo 185 | { 186 | FileName = displayPath, 187 | UseShellExecute = true, 188 | } 189 | ); 190 | } 191 | 192 | if (pathCopied) 193 | { 194 | GUIStyle feedbackStyle = new(EditorStyles.label); 195 | feedbackStyle.normal.textColor = Color.green; 196 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 197 | } 198 | 199 | EditorGUILayout.EndHorizontal(); 200 | EditorGUILayout.EndVertical(); 201 | EditorGUILayout.Space(10); 202 | 203 | EditorGUILayout.LabelField( 204 | "4. Add this configuration to your mcp.json:", 205 | EditorStyles.boldLabel 206 | ); 207 | 208 | // JSON section with improved styling 209 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 210 | 211 | // Improved text area for JSON with syntax highlighting colors 212 | GUIStyle jsonStyle = new(EditorStyles.textArea) 213 | { 214 | font = EditorStyles.boldFont, 215 | wordWrap = true, 216 | }; 217 | jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue 218 | 219 | // Draw the JSON in a text area with a taller height for better readability 220 | EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); 221 | 222 | // Copy JSON button with improved styling 223 | EditorGUILayout.BeginHorizontal(); 224 | GUILayout.FlexibleSpace(); 225 | 226 | if ( 227 | GUILayout.Button( 228 | "Copy JSON", 229 | copyButtonStyle, 230 | GUILayout.Height(25), 231 | GUILayout.Width(100) 232 | ) 233 | ) 234 | { 235 | EditorGUIUtility.systemCopyBuffer = configJson; 236 | jsonCopied = true; 237 | copyFeedbackTimer = 2f; 238 | } 239 | 240 | if (jsonCopied) 241 | { 242 | GUIStyle feedbackStyle = new(EditorStyles.label); 243 | feedbackStyle.normal.textColor = Color.green; 244 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 245 | } 246 | 247 | EditorGUILayout.EndHorizontal(); 248 | EditorGUILayout.EndVertical(); 249 | 250 | EditorGUILayout.Space(10); 251 | EditorGUILayout.LabelField( 252 | "5. After configuration:", 253 | EditorStyles.boldLabel 254 | ); 255 | EditorGUILayout.LabelField( 256 | "• Restart VSCode", 257 | instructionStyle 258 | ); 259 | EditorGUILayout.LabelField( 260 | "• GitHub Copilot will now be able to interact with your Unity project through the MCP protocol", 261 | instructionStyle 262 | ); 263 | EditorGUILayout.LabelField( 264 | "• Remember to have the MCP for Unity Bridge running in Unity Editor", 265 | instructionStyle 266 | ); 267 | 268 | EditorGUILayout.EndVertical(); 269 | 270 | EditorGUILayout.Space(10); 271 | 272 | // Close button at the bottom 273 | EditorGUILayout.BeginHorizontal(); 274 | GUILayout.FlexibleSpace(); 275 | if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) 276 | { 277 | Close(); 278 | } 279 | GUILayout.FlexibleSpace(); 280 | EditorGUILayout.EndHorizontal(); 281 | 282 | EditorGUILayout.EndScrollView(); 283 | } 284 | 285 | protected override void Update() 286 | { 287 | // Call the base implementation which handles the copy feedback timer 288 | base.Update(); 289 | } 290 | } 291 | } 292 | ``` -------------------------------------------------------------------------------- /docs/README-DEV.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP for Unity Development Tools 2 | 3 | | [English](README-DEV.md) | [简体中文](README-DEV-zh.md) | 4 | |---------------------------|------------------------------| 5 | 6 | Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development. 7 | 8 | ## 🚀 Available Development Features 9 | 10 | ### ✅ Development Deployment Scripts 11 | Quick deployment and testing tools for MCP for Unity core changes. 12 | 13 | ### 🔄 Coming Soon 14 | - **Development Mode Toggle**: Built-in Unity editor development features 15 | - **Hot Reload System**: Real-time code updates without Unity restarts 16 | - **Plugin Development Kit**: Tools for creating custom MCP for Unity extensions 17 | - **Automated Testing Suite**: Comprehensive testing framework for contributions 18 | - **Debug Dashboard**: Advanced debugging and monitoring tools 19 | 20 | --- 21 | 22 | ## Switching MCP package sources quickly 23 | 24 | Run this from the unity-mcp repo, not your game's root directory. Use `mcp_source.py` to quickly switch between different MCP for Unity package sources: 25 | 26 | **Usage:** 27 | ```bash 28 | python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3] 29 | ``` 30 | 31 | **Options:** 32 | - **1** Upstream main (CoplayDev/unity-mcp) 33 | - **2** Remote current branch (origin + branch) 34 | - **3** Local workspace (file: MCPForUnity) 35 | 36 | After switching, open Package Manager and Refresh to re-resolve packages. 37 | 38 | ## Development Deployment Scripts 39 | 40 | These deployment scripts help you quickly test changes to MCP for Unity core code. 41 | 42 | ## Scripts 43 | 44 | ### `deploy-dev.bat` 45 | Deploys your development code to the actual installation locations for testing. 46 | 47 | **What it does:** 48 | 1. Backs up original files to a timestamped folder 49 | 2. Copies Unity Bridge code to Unity's package cache 50 | 3. Copies Python Server code to the MCP installation folder 51 | 52 | **Usage:** 53 | 1. Run `deploy-dev.bat` 54 | 2. Enter Unity package cache path (example provided) 55 | 3. Enter server path (or use default: `%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`) 56 | 4. Enter backup location (or use default: `%USERPROFILE%\Desktop\unity-mcp-backup`) 57 | 58 | **Note:** Dev deploy skips `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`; reduces churn and avoids copying virtualenvs. 59 | 60 | ### `restore-dev.bat` 61 | Restores original files from backup. 62 | 63 | **What it does:** 64 | 1. Lists available backups with timestamps 65 | 2. Allows you to select which backup to restore 66 | 3. Restores both Unity Bridge and Python Server files 67 | 68 | ### `prune_tool_results.py` 69 | Compacts large `tool_result` blobs in conversation JSON into concise one-line summaries. 70 | 71 | **Usage:** 72 | ```bash 73 | python3 prune_tool_results.py < reports/claude-execution-output.json > reports/claude-execution-output.pruned.json 74 | ``` 75 | 76 | The script reads a conversation from `stdin` and writes the pruned version to `stdout`, making logs much easier to inspect or archive. 77 | 78 | These defaults dramatically cut token usage without affecting essential information. 79 | 80 | ## Finding Unity Package Cache Path 81 | 82 | Unity stores Git packages under a version-or-hash folder. Expect something like: 83 | ``` 84 | X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@<version-or-hash> 85 | ``` 86 | Example (hash): 87 | ``` 88 | X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e 89 | 90 | ``` 91 | 92 | To find it reliably: 93 | 1. Open Unity Package Manager 94 | 2. Select "MCP for Unity" package 95 | 3. Right click the package and choose "Show in Explorer" 96 | 4. That opens the exact cache folder Unity is using for your project 97 | 98 | Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. 99 | 100 | ## MCP Bridge Stress Test 101 | 102 | An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering real script reloads via immediate script edits (no menu calls required). 103 | 104 | ### Script 105 | - `tools/stress_mcp.py` 106 | 107 | ### What it does 108 | - Starts N TCP clients against the MCP for Unity bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`). 109 | - Sends lightweight framed `ping` keepalives to maintain concurrency. 110 | - In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with: 111 | - `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and 112 | - `precondition_sha256` computed from the current file contents to avoid drift. 113 | - Uses EOF insertion to avoid header/`using`-guard edits. 114 | 115 | ### Usage (local) 116 | ```bash 117 | # Recommended: use the included large script in the test project 118 | python3 tools/stress_mcp.py \ 119 | --duration 60 \ 120 | --clients 8 \ 121 | --unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" 122 | ``` 123 | 124 | Flags: 125 | - `--project` Unity project path (auto-detected to the included test project by default) 126 | - `--unity-file` C# file to edit (defaults to the long test script) 127 | - `--clients` number of concurrent clients (default 10) 128 | - `--duration` seconds to run (default 60) 129 | 130 | ### Expected outcome 131 | - No Unity Editor crashes during reload churn 132 | - Immediate reloads after each applied edit (no `Assets/Refresh` menu calls) 133 | - Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues 134 | - JSON summary printed at the end, e.g.: 135 | - `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}` 136 | 137 | ### Notes and troubleshooting 138 | - Immediate vs debounced: 139 | - The tool sets `options.refresh = "immediate"` so changes compile instantly. If you only need churn (not per-edit confirmation), switch to debounced to reduce mid-reload failures. 140 | - Precondition required: 141 | - `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA. 142 | - Edit location: 143 | - To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle. 144 | - Read API: 145 | - The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool. 146 | - Transient failures: 147 | - Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration. 148 | 149 | ### CI guidance 150 | - Keep this out of default PR CI due to Unity/editor requirements and runtime variability. 151 | - Optionally run it as a manual workflow or nightly job on a Unity-capable runner. 152 | 153 | ## CI Test Workflow (GitHub Actions) 154 | 155 | We provide a CI job to run a Natural Language Editing suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge. To run from your fork, you need the following GitHub "secrets": an `ANTHROPIC_API_KEY` and Unity credentials (usually `UNITY_EMAIL` + `UNITY_PASSWORD` or `UNITY_LICENSE` / `UNITY_SERIAL`.) These are redacted in logs so never visible. 156 | 157 | ***To run it*** 158 | - Trigger: In GitHun "Actions" for the repo, trigger `workflow dispatch` (`Claude NL/T Full Suite (Unity live)`). 159 | - Image: `UNITY_IMAGE` (UnityCI) pulled by tag; the job resolves a digest at runtime. Logs are sanitized. 160 | - Execution: single pass with immediate per‑test fragment emissions (strict single `<testcase>` per file). A placeholder guard fails fast if any fragment is a bare ID. Staging (`reports/_staging`) is promoted to `reports/` to reduce partial writes. 161 | - Reports: JUnit at `reports/junit-nl-suite.xml`, Markdown at `reports/junit-nl-suite.md`. 162 | - Publishing: JUnit is normalized to `reports/junit-for-actions.xml` and published; artifacts upload all files under `reports/`. 163 | 164 | ### Test target script 165 | - The repo includes a long, standalone C# script used to exercise larger edits and windows: 166 | - `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` 167 | Use this file locally and in CI to validate multi-edit batches, anchor inserts, and windowed reads on a sizable script. 168 | 169 | ### Adjust tests / prompts 170 | - Edit `.claude/prompts/nl-unity-suite-t.md` to modify the NL/T steps. Follow the conventions: emit one XML fragment per test under `reports/<TESTID>_results.xml`, each containing exactly one `<testcase>` with a `name` that begins with the test ID. No prologue/epilogue or code fences. 171 | - Keep edits minimal and reversible; include concise evidence. 172 | 173 | ### Run the suite 174 | 1) Push your branch, then manually run the workflow from the Actions tab. 175 | 2) The job writes reports into `reports/` and uploads artifacts. 176 | 3) The “JUnit Test Report” check summarizes results; open the Job Summary for full markdown. 177 | 178 | ### View results 179 | - Job Summary: inline markdown summary of the run on the Actions tab in GitHub 180 | - Check: “JUnit Test Report” on the PR/commit. 181 | - Artifacts: `claude-nl-suite-artifacts` includes XML and MD. 182 | 183 | ### MCP Connection Debugging 184 | - *Enable debug logs* in the MCP for Unity window (inside the Editor) to view connection status, auto-setup results, and MCP client paths. It shows: 185 | - bridge startup/port, client connections, strict framing negotiation, and parsed frames 186 | - auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and surfaced errors 187 | - In CI, the job tails Unity logs (redacted for serial/license/password/token) and prints socket/status JSON diagnostics if startup fails. 188 | ## Workflow 189 | 190 | 1. **Make changes** to your source code in this directory 191 | 2. **Deploy** using `deploy-dev.bat` 192 | 3. **Test** in Unity (restart Unity Editor first) 193 | 4. **Iterate** - repeat steps 1-3 as needed 194 | 5. **Restore** original files when done using `restore-dev.bat` 195 | 196 | ## Troubleshooting 197 | 198 | ### "Path not found" errors running the .bat file 199 | - Verify Unity package cache path is correct 200 | - Check that MCP for Unity package is actually installed 201 | - Ensure server is installed via MCP client 202 | 203 | ### "Permission denied" errors 204 | - Run cmd as Administrator 205 | - Close Unity Editor before deploying 206 | - Close any MCP clients before deploying 207 | 208 | ### "Backup not found" errors 209 | - Run `deploy-dev.bat` first to create initial backup 210 | - Check backup directory permissions 211 | - Verify backup directory path is correct 212 | 213 | ### Windows uv path issues 214 | - On Windows, when testing GUI clients, prefer the WinGet Links `uv.exe`; if multiple `uv.exe` exist, use "Choose `uv` Install Location" to pin the Links shim. ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using MCPForUnity.Editor.Helpers; 4 | using Newtonsoft.Json.Linq; 5 | using UnityEditor; 6 | using UnityEditor.SceneManagement; 7 | using UnityEngine; 8 | using UnityEngine.SceneManagement; 9 | 10 | namespace MCPForUnity.Editor.Tools.Prefabs 11 | { 12 | [McpForUnityTool("manage_prefabs")] 13 | public static class ManagePrefabs 14 | { 15 | private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; 16 | 17 | public static object HandleCommand(JObject @params) 18 | { 19 | if (@params == null) 20 | { 21 | return Response.Error("Parameters cannot be null."); 22 | } 23 | 24 | string action = @params["action"]?.ToString()?.ToLowerInvariant(); 25 | if (string.IsNullOrEmpty(action)) 26 | { 27 | return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); 28 | } 29 | 30 | try 31 | { 32 | switch (action) 33 | { 34 | case "open_stage": 35 | return OpenStage(@params); 36 | case "close_stage": 37 | return CloseStage(@params); 38 | case "save_open_stage": 39 | return SaveOpenStage(); 40 | case "create_from_gameobject": 41 | return CreatePrefabFromGameObject(@params); 42 | default: 43 | return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); 44 | } 45 | } 46 | catch (Exception e) 47 | { 48 | McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); 49 | return Response.Error($"Internal error: {e.Message}"); 50 | } 51 | } 52 | 53 | private static object OpenStage(JObject @params) 54 | { 55 | string prefabPath = @params["prefabPath"]?.ToString(); 56 | if (string.IsNullOrEmpty(prefabPath)) 57 | { 58 | return Response.Error("'prefabPath' parameter is required for open_stage."); 59 | } 60 | 61 | string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); 62 | GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath); 63 | if (prefabAsset == null) 64 | { 65 | return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); 66 | } 67 | 68 | string modeValue = @params["mode"]?.ToString(); 69 | if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) 70 | { 71 | return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); 72 | } 73 | 74 | PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); 75 | if (stage == null) 76 | { 77 | return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); 78 | } 79 | 80 | return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); 81 | } 82 | 83 | private static object CloseStage(JObject @params) 84 | { 85 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 86 | if (stage == null) 87 | { 88 | return Response.Success("No prefab stage was open."); 89 | } 90 | 91 | bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false; 92 | if (saveBeforeClose && stage.scene.isDirty) 93 | { 94 | SaveStagePrefab(stage); 95 | AssetDatabase.SaveAssets(); 96 | } 97 | 98 | StageUtility.GoToMainStage(); 99 | return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); 100 | } 101 | 102 | private static object SaveOpenStage() 103 | { 104 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 105 | if (stage == null) 106 | { 107 | return Response.Error("No prefab stage is currently open."); 108 | } 109 | 110 | SaveStagePrefab(stage); 111 | AssetDatabase.SaveAssets(); 112 | return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); 113 | } 114 | 115 | private static void SaveStagePrefab(PrefabStage stage) 116 | { 117 | if (stage?.prefabContentsRoot == null) 118 | { 119 | throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); 120 | } 121 | 122 | bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); 123 | if (!saved) 124 | { 125 | throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); 126 | } 127 | } 128 | 129 | private static object CreatePrefabFromGameObject(JObject @params) 130 | { 131 | string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); 132 | if (string.IsNullOrEmpty(targetName)) 133 | { 134 | return Response.Error("'target' parameter is required for create_from_gameobject."); 135 | } 136 | 137 | bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false; 138 | GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); 139 | if (sourceObject == null) 140 | { 141 | return Response.Error($"GameObject '{targetName}' not found in the active scene."); 142 | } 143 | 144 | if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) 145 | { 146 | return Response.Error( 147 | $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." 148 | ); 149 | } 150 | 151 | PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); 152 | if (status != PrefabInstanceStatus.NotAPrefab) 153 | { 154 | return Response.Error( 155 | $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." 156 | ); 157 | } 158 | 159 | string requestedPath = @params["prefabPath"]?.ToString(); 160 | if (string.IsNullOrWhiteSpace(requestedPath)) 161 | { 162 | return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); 163 | } 164 | 165 | string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); 166 | if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) 167 | { 168 | sanitizedPath += ".prefab"; 169 | } 170 | 171 | bool allowOverwrite = @params["allowOverwrite"]?.ToObject<bool>() ?? false; 172 | string finalPath = sanitizedPath; 173 | 174 | if (!allowOverwrite && AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null) 175 | { 176 | finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); 177 | } 178 | 179 | EnsureAssetDirectoryExists(finalPath); 180 | 181 | try 182 | { 183 | GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( 184 | sourceObject, 185 | finalPath, 186 | InteractionMode.AutomatedAction 187 | ); 188 | 189 | if (connectedInstance == null) 190 | { 191 | return Response.Error($"Failed to save prefab asset at '{finalPath}'."); 192 | } 193 | 194 | Selection.activeGameObject = connectedInstance; 195 | 196 | return Response.Success( 197 | $"Prefab created at '{finalPath}' and instance linked.", 198 | new 199 | { 200 | prefabPath = finalPath, 201 | instanceId = connectedInstance.GetInstanceID() 202 | } 203 | ); 204 | } 205 | catch (Exception e) 206 | { 207 | return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); 208 | } 209 | } 210 | 211 | private static void EnsureAssetDirectoryExists(string assetPath) 212 | { 213 | string directory = Path.GetDirectoryName(assetPath); 214 | if (string.IsNullOrEmpty(directory)) 215 | { 216 | return; 217 | } 218 | 219 | string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); 220 | if (!Directory.Exists(fullDirectory)) 221 | { 222 | Directory.CreateDirectory(fullDirectory); 223 | AssetDatabase.Refresh(); 224 | } 225 | } 226 | 227 | private static GameObject FindSceneObjectByName(string name, bool includeInactive) 228 | { 229 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 230 | if (stage?.prefabContentsRoot != null) 231 | { 232 | foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive)) 233 | { 234 | if (transform.name == name) 235 | { 236 | return transform.gameObject; 237 | } 238 | } 239 | } 240 | 241 | Scene activeScene = SceneManager.GetActiveScene(); 242 | foreach (GameObject root in activeScene.GetRootGameObjects()) 243 | { 244 | foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive)) 245 | { 246 | GameObject candidate = transform.gameObject; 247 | if (candidate.name == name) 248 | { 249 | return candidate; 250 | } 251 | } 252 | } 253 | 254 | return null; 255 | } 256 | 257 | private static object SerializeStage(PrefabStage stage) 258 | { 259 | if (stage == null) 260 | { 261 | return new { isOpen = false }; 262 | } 263 | 264 | return new 265 | { 266 | isOpen = true, 267 | assetPath = stage.assetPath, 268 | prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, 269 | mode = stage.mode.ToString(), 270 | isDirty = stage.scene.isDirty 271 | }; 272 | } 273 | 274 | } 275 | } 276 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using MCPForUnity.Editor.Helpers; 4 | using Newtonsoft.Json.Linq; 5 | using UnityEditor; 6 | using UnityEditor.SceneManagement; 7 | using UnityEngine; 8 | using UnityEngine.SceneManagement; 9 | 10 | namespace MCPForUnity.Editor.Tools.Prefabs 11 | { 12 | [McpForUnityTool("manage_prefabs")] 13 | public static class ManagePrefabs 14 | { 15 | private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; 16 | 17 | public static object HandleCommand(JObject @params) 18 | { 19 | if (@params == null) 20 | { 21 | return Response.Error("Parameters cannot be null."); 22 | } 23 | 24 | string action = @params["action"]?.ToString()?.ToLowerInvariant(); 25 | if (string.IsNullOrEmpty(action)) 26 | { 27 | return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); 28 | } 29 | 30 | try 31 | { 32 | switch (action) 33 | { 34 | case "open_stage": 35 | return OpenStage(@params); 36 | case "close_stage": 37 | return CloseStage(@params); 38 | case "save_open_stage": 39 | return SaveOpenStage(); 40 | case "create_from_gameobject": 41 | return CreatePrefabFromGameObject(@params); 42 | default: 43 | return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); 44 | } 45 | } 46 | catch (Exception e) 47 | { 48 | McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); 49 | return Response.Error($"Internal error: {e.Message}"); 50 | } 51 | } 52 | 53 | private static object OpenStage(JObject @params) 54 | { 55 | string prefabPath = @params["prefabPath"]?.ToString(); 56 | if (string.IsNullOrEmpty(prefabPath)) 57 | { 58 | return Response.Error("'prefabPath' parameter is required for open_stage."); 59 | } 60 | 61 | string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); 62 | GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath); 63 | if (prefabAsset == null) 64 | { 65 | return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); 66 | } 67 | 68 | string modeValue = @params["mode"]?.ToString(); 69 | if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) 70 | { 71 | return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); 72 | } 73 | 74 | PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); 75 | if (stage == null) 76 | { 77 | return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); 78 | } 79 | 80 | return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); 81 | } 82 | 83 | private static object CloseStage(JObject @params) 84 | { 85 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 86 | if (stage == null) 87 | { 88 | return Response.Success("No prefab stage was open."); 89 | } 90 | 91 | bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false; 92 | if (saveBeforeClose && stage.scene.isDirty) 93 | { 94 | SaveStagePrefab(stage); 95 | AssetDatabase.SaveAssets(); 96 | } 97 | 98 | StageUtility.GoToMainStage(); 99 | return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); 100 | } 101 | 102 | private static object SaveOpenStage() 103 | { 104 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 105 | if (stage == null) 106 | { 107 | return Response.Error("No prefab stage is currently open."); 108 | } 109 | 110 | SaveStagePrefab(stage); 111 | AssetDatabase.SaveAssets(); 112 | return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); 113 | } 114 | 115 | private static void SaveStagePrefab(PrefabStage stage) 116 | { 117 | if (stage?.prefabContentsRoot == null) 118 | { 119 | throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); 120 | } 121 | 122 | bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); 123 | if (!saved) 124 | { 125 | throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); 126 | } 127 | } 128 | 129 | private static object CreatePrefabFromGameObject(JObject @params) 130 | { 131 | string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); 132 | if (string.IsNullOrEmpty(targetName)) 133 | { 134 | return Response.Error("'target' parameter is required for create_from_gameobject."); 135 | } 136 | 137 | bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false; 138 | GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); 139 | if (sourceObject == null) 140 | { 141 | return Response.Error($"GameObject '{targetName}' not found in the active scene."); 142 | } 143 | 144 | if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) 145 | { 146 | return Response.Error( 147 | $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." 148 | ); 149 | } 150 | 151 | PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); 152 | if (status != PrefabInstanceStatus.NotAPrefab) 153 | { 154 | return Response.Error( 155 | $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." 156 | ); 157 | } 158 | 159 | string requestedPath = @params["prefabPath"]?.ToString(); 160 | if (string.IsNullOrWhiteSpace(requestedPath)) 161 | { 162 | return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); 163 | } 164 | 165 | string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); 166 | if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) 167 | { 168 | sanitizedPath += ".prefab"; 169 | } 170 | 171 | bool allowOverwrite = @params["allowOverwrite"]?.ToObject<bool>() ?? false; 172 | string finalPath = sanitizedPath; 173 | 174 | if (!allowOverwrite && AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null) 175 | { 176 | finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); 177 | } 178 | 179 | EnsureAssetDirectoryExists(finalPath); 180 | 181 | try 182 | { 183 | GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( 184 | sourceObject, 185 | finalPath, 186 | InteractionMode.AutomatedAction 187 | ); 188 | 189 | if (connectedInstance == null) 190 | { 191 | return Response.Error($"Failed to save prefab asset at '{finalPath}'."); 192 | } 193 | 194 | Selection.activeGameObject = connectedInstance; 195 | 196 | return Response.Success( 197 | $"Prefab created at '{finalPath}' and instance linked.", 198 | new 199 | { 200 | prefabPath = finalPath, 201 | instanceId = connectedInstance.GetInstanceID() 202 | } 203 | ); 204 | } 205 | catch (Exception e) 206 | { 207 | return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); 208 | } 209 | } 210 | 211 | private static void EnsureAssetDirectoryExists(string assetPath) 212 | { 213 | string directory = Path.GetDirectoryName(assetPath); 214 | if (string.IsNullOrEmpty(directory)) 215 | { 216 | return; 217 | } 218 | 219 | string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); 220 | if (!Directory.Exists(fullDirectory)) 221 | { 222 | Directory.CreateDirectory(fullDirectory); 223 | AssetDatabase.Refresh(); 224 | } 225 | } 226 | 227 | private static GameObject FindSceneObjectByName(string name, bool includeInactive) 228 | { 229 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 230 | if (stage?.prefabContentsRoot != null) 231 | { 232 | foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive)) 233 | { 234 | if (transform.name == name) 235 | { 236 | return transform.gameObject; 237 | } 238 | } 239 | } 240 | 241 | Scene activeScene = SceneManager.GetActiveScene(); 242 | foreach (GameObject root in activeScene.GetRootGameObjects()) 243 | { 244 | foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive)) 245 | { 246 | GameObject candidate = transform.gameObject; 247 | if (candidate.name == name) 248 | { 249 | return candidate; 250 | } 251 | } 252 | } 253 | 254 | return null; 255 | } 256 | 257 | private static object SerializeStage(PrefabStage stage) 258 | { 259 | if (stage == null) 260 | { 261 | return new { isOpen = false }; 262 | } 263 | 264 | return new 265 | { 266 | isOpen = true, 267 | assetPath = stage.assetPath, 268 | prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, 269 | mode = stage.mode.ToString(), 270 | isDirty = stage.scene.isDirty 271 | }; 272 | } 273 | 274 | } 275 | } 276 | ``` -------------------------------------------------------------------------------- /docs/v6_NEW_UI_CHANGES.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP for Unity v6 - New Editor Window 2 | 3 | > **UI Toolkit-based window with service-oriented architecture** 4 | 5 |  6 | *Dark theme* 7 | 8 |  9 | *Light theme* 10 | 11 | --- 12 | 13 | ## Overview 14 | 15 | The new MCP Editor Window is a complete rebuild using **UI Toolkit (UXML/USS)** with a **service-oriented architecture**. The design philosophy emphasizes **explicit over implicit** behavior, making the system more predictable, testable, and maintainable. 16 | 17 | **Quick Access:** `Cmd/Ctrl+Shift+M` or `Window > MCP For Unity > Open MCP Window` 18 | 19 | **Key Improvements:** 20 | - 🎨 Modern UI that doesn't hide info as the window size changes 21 | - 🏗️ Service layer separates business logic from UI 22 | - 🔧 Explicit path overrides for troubleshooting 23 | - 📦 Asset Store support with server download capability 24 | - ⚡ Keyboard shortcut for quick access 25 | 26 | --- 27 | 28 | ## Key Differences at a Glance 29 | 30 | | Feature | Old Window | New Window | Notes | 31 | |---------|-----------|------------|-------| 32 | | **Architecture** | Monolithic | Service-based | Better testability & reusability | 33 | | **UI Framework** | IMGUI | UI Toolkit (UXML/USS) | Modern, responsive, themeable | 34 | | **Auto-Setup** | ✅ Automatic | ❌ Manual | Users have explicit control | 35 | | **Path Overrides** | ⚠️ Python only | ✅ Python + UV + Claude CLI | Advanced troubleshooting | 36 | | **Bridge Health** | ⚠️ Hidden | ✅ Visible with test button | Separate from connection status | 37 | | **Configure All** | ❌ None | ✅ Batch with summary | Configure all clients at once | 38 | | **Manual Config** | ✅ Popup windows | ✅ Inline foldout | Less window clutter | 39 | | **Server Download** | ❌ None | ✅ Asset Store support | Download server from GitHub | 40 | | **Keyboard Shortcut** | ❌ None | ✅ Cmd/Ctrl+Shift+M | Quick access | 41 | 42 | ## What's New 43 | 44 | ### UI Enhancements 45 | - **Advanced Settings Foldout** - Collapsible section for path overrides (MCP server, UV, Claude CLI) 46 | - **Visual Path Validation** - Green/red indicators show whether override paths are valid 47 | - **Bridge Health Indicator** - Separate from connection status, shows handshake and ping/pong results 48 | - **Manual Connection Test Button** - Verify bridge health on demand without reconnecting 49 | - **Inline Manual Configuration** - Copy config path and JSON without opening separate windows 50 | 51 | ### Functional Improvements 52 | - **Configure All Detected Clients** - One-click batch configuration with summary dialog 53 | - **Keyboard Shortcut** - `Cmd/Ctrl+Shift+M` opens the window quickly 54 | 55 | ### Asset Store Support 56 | - **Server Download Button** - Asset Store users can download the server from GitHub releases 57 | - **Dynamic UI** - Shows appropriate button based on installation type 58 | 59 |  60 | *Asset Store version showing the "Download & Install Server" button* 61 | 62 | --- 63 | 64 | ## Features Not Supported (By Design) 65 | 66 | The new window intentionally removes implicit behaviors and complex edge-case handling to provide a cleaner, more predictable UX. 67 | 68 | ### ❌ Auto-Setup on First Run 69 | - **Old:** Automatically configured clients on first window open 70 | - **Why Removed:** Users should explicitly choose which clients to configure 71 | - **Alternative:** Use "Configure All Detected Clients" button 72 | 73 | ### ❌ Python Detection Warning 74 | - **Old:** Warning banner if Python not detected on system 75 | - **Why Removed:** Setup Wizard handles dependency checks, we also can't flood a bunch of error and warning logs when submitting to the Asset Store 76 | - **Alternative:** Run Setup Wizard via `Window > MCP For Unity > Setup Wizard` 77 | 78 | ### ❌ Separate Manual Setup Windows 79 | - **Old:** `VSCodeManualSetupWindow`, `ManualConfigEditorWindow` popup dialogs 80 | - **Why Removed:** Looks neater, less visual clutter 81 | - **Alternative:** Inline "Manual Configuration" foldout with copy buttons 82 | 83 | ### ❌ Server Installation Status Panel 84 | - **Old:** Dedicated panel showing server install status with color indicators 85 | - **Why Removed:** Simplified to focus on active configuration and the connection status, we now have a setup wizard for this 86 | - **Alternative:** Server path override in Advanced Settings + Rebuild button 87 | 88 | --- 89 | 90 | ## Service Locator Architecture 91 | 92 | The new window uses a **service locator pattern** to access business logic without tight coupling. This provides flexibility for testing and future dependency injection migration. 93 | 94 | ### MCPServiceLocator 95 | 96 | **Purpose:** Central access point for MCP services 97 | 98 | **Usage:** 99 | ```csharp 100 | // Access bridge service 101 | MCPServiceLocator.Bridge.Start(); 102 | 103 | // Access client configuration service 104 | MCPServiceLocator.Client.ConfigureAllDetectedClients(); 105 | 106 | // Access path resolver service 107 | string mcpServerPath = MCPServiceLocator.Paths.GetMcpServerPath(); 108 | ``` 109 | 110 | **Benefits:** 111 | - No constructor dependencies (easy to use anywhere) 112 | - Lazy initialization (services created only when needed) 113 | - Testable (supports custom implementations via `Register()`) 114 | 115 | --- 116 | 117 | ### IBridgeControlService 118 | 119 | **Purpose:** Manages MCP for Unity Bridge lifecycle and health verification 120 | 121 | **Key Methods:** 122 | - `Start()` / `Stop()` - Bridge lifecycle management 123 | - `Verify(port)` - Health check with handshake + ping/pong validation 124 | - `IsRunning` - Current bridge status 125 | - `CurrentPort` - Active port number 126 | 127 | **Implementation:** `BridgeControlService` 128 | 129 | **Usage Example:** 130 | ```csharp 131 | var bridge = MCPServiceLocator.Bridge; 132 | bridge.Start(); 133 | 134 | var result = bridge.Verify(bridge.CurrentPort); 135 | if (result.Success && result.PingSucceeded) 136 | { 137 | Debug.Log("Bridge is healthy"); 138 | } 139 | ``` 140 | 141 | --- 142 | 143 | ### IClientConfigurationService 144 | 145 | **Purpose:** Handles MCP client configuration and registration 146 | 147 | **Key Methods:** 148 | - `ConfigureClient(client)` - Configure a single client 149 | - `ConfigureAllDetectedClients()` - Batch configure with summary 150 | - `CheckClientStatus(client)` - Verify client status + auto-rewrite paths 151 | - `RegisterClaudeCode()` / `UnregisterClaudeCode()` - Claude Code management 152 | - `GenerateConfigJson(client)` - Get JSON for manual configuration 153 | 154 | **Implementation:** `ClientConfigurationService` 155 | 156 | **Usage Example:** 157 | ```csharp 158 | var clientService = MCPServiceLocator.Client; 159 | var summary = clientService.ConfigureAllDetectedClients(); 160 | Debug.Log($"Configured: {summary.SuccessCount}, Failed: {summary.FailureCount}"); 161 | ``` 162 | 163 | --- 164 | 165 | ### IPathResolverService 166 | 167 | **Purpose:** Resolves paths to required tools with override support 168 | 169 | **Key Methods:** 170 | - `GetMcpServerPath()` - MCP server directory 171 | - `GetUvPath()` - UV executable path 172 | - `GetClaudeCliPath()` - Claude CLI path 173 | - `SetMcpServerOverride(path)` / `ClearMcpServerOverride()` - Manage MCP server overrides 174 | - `SetUvPathOverride(path)` / `ClearUvPathOverride()` - Manage UV overrides 175 | - `SetClaudeCliPathOverride(path)` / `ClearClaudeCliPathOverride()` - Manage Claude CLI overrides 176 | - `IsPythonDetected()` / `IsUvDetected()` - Detection checks 177 | 178 | **Implementation:** `PathResolverService` 179 | 180 | **Usage Example:** 181 | ```csharp 182 | var paths = MCPServiceLocator.Paths; 183 | 184 | // Check if UV is detected 185 | if (!paths.IsUvDetected()) 186 | { 187 | Debug.LogWarning("UV not found"); 188 | } 189 | 190 | // Set an override 191 | paths.SetUvPathOverride("/custom/path/to/uv"); 192 | ``` 193 | 194 | ## Technical Details 195 | 196 | ### Files Created 197 | 198 | **Services:** 199 | ```text 200 | MCPForUnity/Editor/Services/ 201 | ├── IBridgeControlService.cs # Bridge lifecycle interface 202 | ├── BridgeControlService.cs # Bridge lifecycle implementation 203 | ├── IClientConfigurationService.cs # Client config interface 204 | ├── ClientConfigurationService.cs # Client config implementation 205 | ├── IPathResolverService.cs # Path resolution interface 206 | ├── PathResolverService.cs # Path resolution implementation 207 | └── MCPServiceLocator.cs # Service locator pattern 208 | ``` 209 | 210 | **Helpers:** 211 | ```text 212 | MCPForUnity/Editor/Helpers/ 213 | └── AssetPathUtility.cs # Package path detection & package.json parsing 214 | ``` 215 | 216 | **UI:** 217 | ```text 218 | MCPForUnity/Editor/Windows/ 219 | ├── MCPForUnityEditorWindowNew.cs # Main window (~850 lines) 220 | ├── MCPForUnityEditorWindowNew.uxml # UI Toolkit layout 221 | └── MCPForUnityEditorWindowNew.uss # UI Toolkit styles 222 | ``` 223 | 224 | **CI/CD:** 225 | ```text 226 | .github/workflows/ 227 | └── bump-version.yml # Server upload to releases 228 | ``` 229 | 230 | ### Key Files Modified 231 | 232 | - `ServerInstaller.cs` - Added download/install logic for Asset Store 233 | - `SetupWizard.cs` - Integration with new service locator 234 | - `PackageDetector.cs` - Uses `AssetPathUtility` for version detection 235 | 236 | --- 237 | 238 | ## Migration Notes 239 | 240 | ### For Users 241 | 242 | **Immediate Changes (v6.x):** 243 | - Both old and new windows are available 244 | - New window accessible via `Cmd/Ctrl+Shift+M` or menu 245 | - Settings and overrides are shared between windows (same EditorPrefs keys) 246 | - Services can be used by both windows 247 | 248 | **Upcoming Changes (v8.x):** 249 | - ⚠️ **Old window will be removed in v8.0** 250 | - All users will automatically use the new window 251 | - EditorPrefs keys remain the same (no migration needed) 252 | - Custom scripts using old window APIs will need updates 253 | 254 | ### For Developers 255 | 256 | **Using the Services:** 257 | ```csharp 258 | // Accessing services from any editor script 259 | var bridge = MCPServiceLocator.Bridge; 260 | var client = MCPServiceLocator.Client; 261 | var paths = MCPServiceLocator.Paths; 262 | 263 | // Services are lazily initialized on first access 264 | // No need to check for null 265 | ``` 266 | 267 | **Testing with Custom Implementations:** 268 | ```csharp 269 | // In test setup 270 | var mockBridge = new MockBridgeService(); 271 | MCPServiceLocator.Register(mockBridge); 272 | 273 | // Services are now testable without Unity dependencies 274 | ``` 275 | 276 | **Reusing Service Logic:** 277 | The service layer is designed to be reused by other parts of the codebase. For example: 278 | - Build scripts can use `IClientConfigurationService` to auto-configure clients 279 | - CI/CD can use `IBridgeControlService` to verify bridge health 280 | - Tools can use `IPathResolverService` for consistent path resolution 281 | 282 | **Notes:** 283 | - A lot of Helpers will gradually be moved to the service layer 284 | - Why not Dependency Injection? This change had a lot of changes, so we didn't want to add too much complexity to the codebase in one go 285 | 286 | --- 287 | 288 | ## Pull Request Reference 289 | 290 | **PR #313:** [feat: New UI with service architecture](https://github.com/CoplayDev/unity-mcp/pull/313) 291 | 292 | **Key Commits:** 293 | - Service layer implementation 294 | - UI Toolkit window rebuild 295 | - Asset Store server download support 296 | - CI/CD server upload automation 297 | 298 | --- 299 | 300 | **Last Updated:** 2025-10-10 301 | **Unity Versions:** Unity 2021.3+ through Unity 6.x 302 | **Architecture:** Service Locator + UI Toolkit 303 | **Status:** Active (Old window deprecated in v8.0) 304 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs: -------------------------------------------------------------------------------- ```csharp 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | using System; 4 | using UnityEngine; 5 | #if UNITY_EDITOR 6 | using UnityEditor; // Required for AssetDatabase and EditorUtility 7 | #endif 8 | 9 | namespace MCPForUnity.Runtime.Serialization 10 | { 11 | public class Vector3Converter : JsonConverter<Vector3> 12 | { 13 | public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) 14 | { 15 | writer.WriteStartObject(); 16 | writer.WritePropertyName("x"); 17 | writer.WriteValue(value.x); 18 | writer.WritePropertyName("y"); 19 | writer.WriteValue(value.y); 20 | writer.WritePropertyName("z"); 21 | writer.WriteValue(value.z); 22 | writer.WriteEndObject(); 23 | } 24 | 25 | public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) 26 | { 27 | JObject jo = JObject.Load(reader); 28 | return new Vector3( 29 | (float)jo["x"], 30 | (float)jo["y"], 31 | (float)jo["z"] 32 | ); 33 | } 34 | } 35 | 36 | public class Vector2Converter : JsonConverter<Vector2> 37 | { 38 | public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) 39 | { 40 | writer.WriteStartObject(); 41 | writer.WritePropertyName("x"); 42 | writer.WriteValue(value.x); 43 | writer.WritePropertyName("y"); 44 | writer.WriteValue(value.y); 45 | writer.WriteEndObject(); 46 | } 47 | 48 | public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) 49 | { 50 | JObject jo = JObject.Load(reader); 51 | return new Vector2( 52 | (float)jo["x"], 53 | (float)jo["y"] 54 | ); 55 | } 56 | } 57 | 58 | public class QuaternionConverter : JsonConverter<Quaternion> 59 | { 60 | public override void WriteJson(JsonWriter writer, Quaternion value, JsonSerializer serializer) 61 | { 62 | writer.WriteStartObject(); 63 | writer.WritePropertyName("x"); 64 | writer.WriteValue(value.x); 65 | writer.WritePropertyName("y"); 66 | writer.WriteValue(value.y); 67 | writer.WritePropertyName("z"); 68 | writer.WriteValue(value.z); 69 | writer.WritePropertyName("w"); 70 | writer.WriteValue(value.w); 71 | writer.WriteEndObject(); 72 | } 73 | 74 | public override Quaternion ReadJson(JsonReader reader, Type objectType, Quaternion existingValue, bool hasExistingValue, JsonSerializer serializer) 75 | { 76 | JObject jo = JObject.Load(reader); 77 | return new Quaternion( 78 | (float)jo["x"], 79 | (float)jo["y"], 80 | (float)jo["z"], 81 | (float)jo["w"] 82 | ); 83 | } 84 | } 85 | 86 | public class ColorConverter : JsonConverter<Color> 87 | { 88 | public override void WriteJson(JsonWriter writer, Color value, JsonSerializer serializer) 89 | { 90 | writer.WriteStartObject(); 91 | writer.WritePropertyName("r"); 92 | writer.WriteValue(value.r); 93 | writer.WritePropertyName("g"); 94 | writer.WriteValue(value.g); 95 | writer.WritePropertyName("b"); 96 | writer.WriteValue(value.b); 97 | writer.WritePropertyName("a"); 98 | writer.WriteValue(value.a); 99 | writer.WriteEndObject(); 100 | } 101 | 102 | public override Color ReadJson(JsonReader reader, Type objectType, Color existingValue, bool hasExistingValue, JsonSerializer serializer) 103 | { 104 | JObject jo = JObject.Load(reader); 105 | return new Color( 106 | (float)jo["r"], 107 | (float)jo["g"], 108 | (float)jo["b"], 109 | (float)jo["a"] 110 | ); 111 | } 112 | } 113 | 114 | public class RectConverter : JsonConverter<Rect> 115 | { 116 | public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) 117 | { 118 | writer.WriteStartObject(); 119 | writer.WritePropertyName("x"); 120 | writer.WriteValue(value.x); 121 | writer.WritePropertyName("y"); 122 | writer.WriteValue(value.y); 123 | writer.WritePropertyName("width"); 124 | writer.WriteValue(value.width); 125 | writer.WritePropertyName("height"); 126 | writer.WriteValue(value.height); 127 | writer.WriteEndObject(); 128 | } 129 | 130 | public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingValue, bool hasExistingValue, JsonSerializer serializer) 131 | { 132 | JObject jo = JObject.Load(reader); 133 | return new Rect( 134 | (float)jo["x"], 135 | (float)jo["y"], 136 | (float)jo["width"], 137 | (float)jo["height"] 138 | ); 139 | } 140 | } 141 | 142 | public class BoundsConverter : JsonConverter<Bounds> 143 | { 144 | public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) 145 | { 146 | writer.WriteStartObject(); 147 | writer.WritePropertyName("center"); 148 | serializer.Serialize(writer, value.center); // Use serializer to handle nested Vector3 149 | writer.WritePropertyName("size"); 150 | serializer.Serialize(writer, value.size); // Use serializer to handle nested Vector3 151 | writer.WriteEndObject(); 152 | } 153 | 154 | public override Bounds ReadJson(JsonReader reader, Type objectType, Bounds existingValue, bool hasExistingValue, JsonSerializer serializer) 155 | { 156 | JObject jo = JObject.Load(reader); 157 | Vector3 center = jo["center"].ToObject<Vector3>(serializer); // Use serializer to handle nested Vector3 158 | Vector3 size = jo["size"].ToObject<Vector3>(serializer); // Use serializer to handle nested Vector3 159 | return new Bounds(center, size); 160 | } 161 | } 162 | 163 | // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) 164 | public class UnityEngineObjectConverter : JsonConverter<UnityEngine.Object> 165 | { 166 | public override bool CanRead => true; // We need to implement ReadJson 167 | public override bool CanWrite => true; 168 | 169 | public override void WriteJson(JsonWriter writer, UnityEngine.Object value, JsonSerializer serializer) 170 | { 171 | if (value == null) 172 | { 173 | writer.WriteNull(); 174 | return; 175 | } 176 | 177 | #if UNITY_EDITOR // AssetDatabase and EditorUtility are Editor-only 178 | if (UnityEditor.AssetDatabase.Contains(value)) 179 | { 180 | // It's an asset (Material, Texture, Prefab, etc.) 181 | string path = UnityEditor.AssetDatabase.GetAssetPath(value); 182 | if (!string.IsNullOrEmpty(path)) 183 | { 184 | writer.WriteValue(path); 185 | } 186 | else 187 | { 188 | // Asset exists but path couldn't be found? Write minimal info. 189 | writer.WriteStartObject(); 190 | writer.WritePropertyName("name"); 191 | writer.WriteValue(value.name); 192 | writer.WritePropertyName("instanceID"); 193 | writer.WriteValue(value.GetInstanceID()); 194 | writer.WritePropertyName("isAssetWithoutPath"); 195 | writer.WriteValue(true); 196 | writer.WriteEndObject(); 197 | } 198 | } 199 | else 200 | { 201 | // It's a scene object (GameObject, Component, etc.) 202 | writer.WriteStartObject(); 203 | writer.WritePropertyName("name"); 204 | writer.WriteValue(value.name); 205 | writer.WritePropertyName("instanceID"); 206 | writer.WriteValue(value.GetInstanceID()); 207 | writer.WriteEndObject(); 208 | } 209 | #else 210 | // Runtime fallback: Write basic info without AssetDatabase 211 | writer.WriteStartObject(); 212 | writer.WritePropertyName("name"); 213 | writer.WriteValue(value.name); 214 | writer.WritePropertyName("instanceID"); 215 | writer.WriteValue(value.GetInstanceID()); 216 | writer.WritePropertyName("warning"); 217 | writer.WriteValue("UnityEngineObjectConverter running in non-Editor mode, asset path unavailable."); 218 | writer.WriteEndObject(); 219 | #endif 220 | } 221 | 222 | public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, UnityEngine.Object existingValue, bool hasExistingValue, JsonSerializer serializer) 223 | { 224 | if (reader.TokenType == JsonToken.Null) 225 | { 226 | return null; 227 | } 228 | 229 | #if UNITY_EDITOR 230 | if (reader.TokenType == JsonToken.String) 231 | { 232 | // Assume it's an asset path 233 | string path = reader.Value.ToString(); 234 | return UnityEditor.AssetDatabase.LoadAssetAtPath(path, objectType); 235 | } 236 | 237 | if (reader.TokenType == JsonToken.StartObject) 238 | { 239 | JObject jo = JObject.Load(reader); 240 | if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) 241 | { 242 | int instanceId = idToken.ToObject<int>(); 243 | UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); 244 | if (obj != null && objectType.IsAssignableFrom(obj.GetType())) 245 | { 246 | return obj; 247 | } 248 | } 249 | // Could potentially try finding by name as a fallback if ID lookup fails/isn't present 250 | // but that's less reliable. 251 | } 252 | #else 253 | // Runtime deserialization is tricky without AssetDatabase/EditorUtility 254 | // Maybe log a warning and return null or existingValue? 255 | Debug.LogWarning("UnityEngineObjectConverter cannot deserialize complex objects in non-Editor mode."); 256 | // Skip the token to avoid breaking the reader 257 | if (reader.TokenType == JsonToken.StartObject) JObject.Load(reader); 258 | else if (reader.TokenType == JsonToken.String) reader.ReadAsString(); 259 | // Return null or existing value, depending on desired behavior 260 | return existingValue; 261 | #endif 262 | 263 | throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); 264 | } 265 | } 266 | } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs: -------------------------------------------------------------------------------- ```csharp 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | using System; 4 | using UnityEngine; 5 | #if UNITY_EDITOR 6 | using UnityEditor; // Required for AssetDatabase and EditorUtility 7 | #endif 8 | 9 | namespace MCPForUnity.Runtime.Serialization 10 | { 11 | public class Vector3Converter : JsonConverter<Vector3> 12 | { 13 | public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) 14 | { 15 | writer.WriteStartObject(); 16 | writer.WritePropertyName("x"); 17 | writer.WriteValue(value.x); 18 | writer.WritePropertyName("y"); 19 | writer.WriteValue(value.y); 20 | writer.WritePropertyName("z"); 21 | writer.WriteValue(value.z); 22 | writer.WriteEndObject(); 23 | } 24 | 25 | public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) 26 | { 27 | JObject jo = JObject.Load(reader); 28 | return new Vector3( 29 | (float)jo["x"], 30 | (float)jo["y"], 31 | (float)jo["z"] 32 | ); 33 | } 34 | } 35 | 36 | public class Vector2Converter : JsonConverter<Vector2> 37 | { 38 | public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) 39 | { 40 | writer.WriteStartObject(); 41 | writer.WritePropertyName("x"); 42 | writer.WriteValue(value.x); 43 | writer.WritePropertyName("y"); 44 | writer.WriteValue(value.y); 45 | writer.WriteEndObject(); 46 | } 47 | 48 | public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) 49 | { 50 | JObject jo = JObject.Load(reader); 51 | return new Vector2( 52 | (float)jo["x"], 53 | (float)jo["y"] 54 | ); 55 | } 56 | } 57 | 58 | public class QuaternionConverter : JsonConverter<Quaternion> 59 | { 60 | public override void WriteJson(JsonWriter writer, Quaternion value, JsonSerializer serializer) 61 | { 62 | writer.WriteStartObject(); 63 | writer.WritePropertyName("x"); 64 | writer.WriteValue(value.x); 65 | writer.WritePropertyName("y"); 66 | writer.WriteValue(value.y); 67 | writer.WritePropertyName("z"); 68 | writer.WriteValue(value.z); 69 | writer.WritePropertyName("w"); 70 | writer.WriteValue(value.w); 71 | writer.WriteEndObject(); 72 | } 73 | 74 | public override Quaternion ReadJson(JsonReader reader, Type objectType, Quaternion existingValue, bool hasExistingValue, JsonSerializer serializer) 75 | { 76 | JObject jo = JObject.Load(reader); 77 | return new Quaternion( 78 | (float)jo["x"], 79 | (float)jo["y"], 80 | (float)jo["z"], 81 | (float)jo["w"] 82 | ); 83 | } 84 | } 85 | 86 | public class ColorConverter : JsonConverter<Color> 87 | { 88 | public override void WriteJson(JsonWriter writer, Color value, JsonSerializer serializer) 89 | { 90 | writer.WriteStartObject(); 91 | writer.WritePropertyName("r"); 92 | writer.WriteValue(value.r); 93 | writer.WritePropertyName("g"); 94 | writer.WriteValue(value.g); 95 | writer.WritePropertyName("b"); 96 | writer.WriteValue(value.b); 97 | writer.WritePropertyName("a"); 98 | writer.WriteValue(value.a); 99 | writer.WriteEndObject(); 100 | } 101 | 102 | public override Color ReadJson(JsonReader reader, Type objectType, Color existingValue, bool hasExistingValue, JsonSerializer serializer) 103 | { 104 | JObject jo = JObject.Load(reader); 105 | return new Color( 106 | (float)jo["r"], 107 | (float)jo["g"], 108 | (float)jo["b"], 109 | (float)jo["a"] 110 | ); 111 | } 112 | } 113 | 114 | public class RectConverter : JsonConverter<Rect> 115 | { 116 | public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) 117 | { 118 | writer.WriteStartObject(); 119 | writer.WritePropertyName("x"); 120 | writer.WriteValue(value.x); 121 | writer.WritePropertyName("y"); 122 | writer.WriteValue(value.y); 123 | writer.WritePropertyName("width"); 124 | writer.WriteValue(value.width); 125 | writer.WritePropertyName("height"); 126 | writer.WriteValue(value.height); 127 | writer.WriteEndObject(); 128 | } 129 | 130 | public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingValue, bool hasExistingValue, JsonSerializer serializer) 131 | { 132 | JObject jo = JObject.Load(reader); 133 | return new Rect( 134 | (float)jo["x"], 135 | (float)jo["y"], 136 | (float)jo["width"], 137 | (float)jo["height"] 138 | ); 139 | } 140 | } 141 | 142 | public class BoundsConverter : JsonConverter<Bounds> 143 | { 144 | public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) 145 | { 146 | writer.WriteStartObject(); 147 | writer.WritePropertyName("center"); 148 | serializer.Serialize(writer, value.center); // Use serializer to handle nested Vector3 149 | writer.WritePropertyName("size"); 150 | serializer.Serialize(writer, value.size); // Use serializer to handle nested Vector3 151 | writer.WriteEndObject(); 152 | } 153 | 154 | public override Bounds ReadJson(JsonReader reader, Type objectType, Bounds existingValue, bool hasExistingValue, JsonSerializer serializer) 155 | { 156 | JObject jo = JObject.Load(reader); 157 | Vector3 center = jo["center"].ToObject<Vector3>(serializer); // Use serializer to handle nested Vector3 158 | Vector3 size = jo["size"].ToObject<Vector3>(serializer); // Use serializer to handle nested Vector3 159 | return new Bounds(center, size); 160 | } 161 | } 162 | 163 | // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) 164 | public class UnityEngineObjectConverter : JsonConverter<UnityEngine.Object> 165 | { 166 | public override bool CanRead => true; // We need to implement ReadJson 167 | public override bool CanWrite => true; 168 | 169 | public override void WriteJson(JsonWriter writer, UnityEngine.Object value, JsonSerializer serializer) 170 | { 171 | if (value == null) 172 | { 173 | writer.WriteNull(); 174 | return; 175 | } 176 | 177 | #if UNITY_EDITOR // AssetDatabase and EditorUtility are Editor-only 178 | if (UnityEditor.AssetDatabase.Contains(value)) 179 | { 180 | // It's an asset (Material, Texture, Prefab, etc.) 181 | string path = UnityEditor.AssetDatabase.GetAssetPath(value); 182 | if (!string.IsNullOrEmpty(path)) 183 | { 184 | writer.WriteValue(path); 185 | } 186 | else 187 | { 188 | // Asset exists but path couldn't be found? Write minimal info. 189 | writer.WriteStartObject(); 190 | writer.WritePropertyName("name"); 191 | writer.WriteValue(value.name); 192 | writer.WritePropertyName("instanceID"); 193 | writer.WriteValue(value.GetInstanceID()); 194 | writer.WritePropertyName("isAssetWithoutPath"); 195 | writer.WriteValue(true); 196 | writer.WriteEndObject(); 197 | } 198 | } 199 | else 200 | { 201 | // It's a scene object (GameObject, Component, etc.) 202 | writer.WriteStartObject(); 203 | writer.WritePropertyName("name"); 204 | writer.WriteValue(value.name); 205 | writer.WritePropertyName("instanceID"); 206 | writer.WriteValue(value.GetInstanceID()); 207 | writer.WriteEndObject(); 208 | } 209 | #else 210 | // Runtime fallback: Write basic info without AssetDatabase 211 | writer.WriteStartObject(); 212 | writer.WritePropertyName("name"); 213 | writer.WriteValue(value.name); 214 | writer.WritePropertyName("instanceID"); 215 | writer.WriteValue(value.GetInstanceID()); 216 | writer.WritePropertyName("warning"); 217 | writer.WriteValue("UnityEngineObjectConverter running in non-Editor mode, asset path unavailable."); 218 | writer.WriteEndObject(); 219 | #endif 220 | } 221 | 222 | public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, UnityEngine.Object existingValue, bool hasExistingValue, JsonSerializer serializer) 223 | { 224 | if (reader.TokenType == JsonToken.Null) 225 | { 226 | return null; 227 | } 228 | 229 | #if UNITY_EDITOR 230 | if (reader.TokenType == JsonToken.String) 231 | { 232 | // Assume it's an asset path 233 | string path = reader.Value.ToString(); 234 | return UnityEditor.AssetDatabase.LoadAssetAtPath(path, objectType); 235 | } 236 | 237 | if (reader.TokenType == JsonToken.StartObject) 238 | { 239 | JObject jo = JObject.Load(reader); 240 | if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) 241 | { 242 | int instanceId = idToken.ToObject<int>(); 243 | UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); 244 | if (obj != null && objectType.IsAssignableFrom(obj.GetType())) 245 | { 246 | return obj; 247 | } 248 | } 249 | // Could potentially try finding by name as a fallback if ID lookup fails/isn't present 250 | // but that's less reliable. 251 | } 252 | #else 253 | // Runtime deserialization is tricky without AssetDatabase/EditorUtility 254 | // Maybe log a warning and return null or existingValue? 255 | Debug.LogWarning("UnityEngineObjectConverter cannot deserialize complex objects in non-Editor mode."); 256 | // Skip the token to avoid breaking the reader 257 | if (reader.TokenType == JsonToken.StartObject) JObject.Load(reader); 258 | else if (reader.TokenType == JsonToken.String) reader.ReadAsString(); 259 | // Return null or existing value, depending on desired behavior 260 | return existingValue; 261 | #endif 262 | 263 | throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); 264 | } 265 | } 266 | } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.Runtime.InteropServices; 2 | using UnityEditor; 3 | using UnityEngine; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnity.Editor.Windows 7 | { 8 | // Editor window to display manual configuration instructions 9 | public class ManualConfigEditorWindow : EditorWindow 10 | { 11 | protected string configPath; 12 | protected string configJson; 13 | protected Vector2 scrollPos; 14 | protected bool pathCopied = false; 15 | protected bool jsonCopied = false; 16 | protected float copyFeedbackTimer = 0; 17 | protected McpClient mcpClient; 18 | 19 | public static void ShowWindow(string configPath, string configJson, McpClient mcpClient) 20 | { 21 | var window = GetWindow<ManualConfigEditorWindow>("Manual Configuration"); 22 | window.configPath = configPath; 23 | window.configJson = configJson; 24 | window.mcpClient = mcpClient; 25 | window.minSize = new Vector2(500, 400); 26 | window.Show(); 27 | } 28 | 29 | protected virtual void OnGUI() 30 | { 31 | scrollPos = EditorGUILayout.BeginScrollView(scrollPos); 32 | 33 | // Header with improved styling 34 | EditorGUILayout.Space(10); 35 | Rect titleRect = EditorGUILayout.GetControlRect(false, 30); 36 | EditorGUI.DrawRect( 37 | new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), 38 | new Color(0.2f, 0.2f, 0.2f, 0.1f) 39 | ); 40 | GUI.Label( 41 | new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), 42 | (mcpClient?.name ?? "Unknown") + " Manual Configuration", 43 | EditorStyles.boldLabel 44 | ); 45 | EditorGUILayout.Space(10); 46 | 47 | // Instructions with improved styling 48 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 49 | 50 | Rect headerRect = EditorGUILayout.GetControlRect(false, 24); 51 | EditorGUI.DrawRect( 52 | new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), 53 | new Color(0.1f, 0.1f, 0.1f, 0.2f) 54 | ); 55 | GUI.Label( 56 | new Rect( 57 | headerRect.x + 8, 58 | headerRect.y + 4, 59 | headerRect.width - 16, 60 | headerRect.height 61 | ), 62 | "The automatic configuration failed. Please follow these steps:", 63 | EditorStyles.boldLabel 64 | ); 65 | EditorGUILayout.Space(10); 66 | 67 | GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) 68 | { 69 | margin = new RectOffset(10, 10, 5, 5), 70 | }; 71 | 72 | EditorGUILayout.LabelField( 73 | "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:", 74 | instructionStyle 75 | ); 76 | if (mcpClient?.mcpType == McpTypes.ClaudeDesktop) 77 | { 78 | EditorGUILayout.LabelField( 79 | " a) Going to Settings > Developer > Edit Config", 80 | instructionStyle 81 | ); 82 | } 83 | else if (mcpClient?.mcpType == McpTypes.Cursor) 84 | { 85 | EditorGUILayout.LabelField( 86 | " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", 87 | instructionStyle 88 | ); 89 | } 90 | else if (mcpClient?.mcpType == McpTypes.Windsurf) 91 | { 92 | EditorGUILayout.LabelField( 93 | " a) Going to File > Preferences > Windsurf Settings > MCP > Manage MCPs -> View raw config", 94 | instructionStyle 95 | ); 96 | } 97 | else if (mcpClient?.mcpType == McpTypes.Kiro) 98 | { 99 | EditorGUILayout.LabelField( 100 | " a) Going to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config", 101 | instructionStyle 102 | ); 103 | } 104 | else if (mcpClient?.mcpType == McpTypes.Codex) 105 | { 106 | EditorGUILayout.LabelField( 107 | " a) Running `codex config edit` in a terminal", 108 | instructionStyle 109 | ); 110 | } 111 | EditorGUILayout.LabelField(" OR", instructionStyle); 112 | EditorGUILayout.LabelField( 113 | " b) Opening the configuration file at:", 114 | instructionStyle 115 | ); 116 | 117 | // Path section with improved styling 118 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 119 | string displayPath; 120 | if (mcpClient != null) 121 | { 122 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 123 | { 124 | displayPath = mcpClient.windowsConfigPath; 125 | } 126 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 127 | { 128 | displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) 129 | 130 | ? configPath 131 | 132 | : mcpClient.macConfigPath; 133 | } 134 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 135 | { 136 | displayPath = mcpClient.linuxConfigPath; 137 | } 138 | else 139 | { 140 | displayPath = configPath; 141 | } 142 | } 143 | else 144 | { 145 | displayPath = configPath; 146 | } 147 | 148 | // Prevent text overflow by allowing the text field to wrap 149 | GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; 150 | 151 | EditorGUILayout.TextField( 152 | displayPath, 153 | pathStyle, 154 | GUILayout.Height(EditorGUIUtility.singleLineHeight) 155 | ); 156 | 157 | // Copy button with improved styling 158 | EditorGUILayout.BeginHorizontal(); 159 | GUILayout.FlexibleSpace(); 160 | GUIStyle copyButtonStyle = new(GUI.skin.button) 161 | { 162 | padding = new RectOffset(15, 15, 5, 5), 163 | margin = new RectOffset(10, 10, 5, 5), 164 | }; 165 | 166 | if ( 167 | GUILayout.Button( 168 | "Copy Path", 169 | copyButtonStyle, 170 | GUILayout.Height(25), 171 | GUILayout.Width(100) 172 | ) 173 | ) 174 | { 175 | EditorGUIUtility.systemCopyBuffer = displayPath; 176 | pathCopied = true; 177 | copyFeedbackTimer = 2f; 178 | } 179 | 180 | if ( 181 | GUILayout.Button( 182 | "Open File", 183 | copyButtonStyle, 184 | GUILayout.Height(25), 185 | GUILayout.Width(100) 186 | ) 187 | ) 188 | { 189 | // Open the file using the system's default application 190 | System.Diagnostics.Process.Start( 191 | new System.Diagnostics.ProcessStartInfo 192 | { 193 | FileName = displayPath, 194 | UseShellExecute = true, 195 | } 196 | ); 197 | } 198 | 199 | if (pathCopied) 200 | { 201 | GUIStyle feedbackStyle = new(EditorStyles.label); 202 | feedbackStyle.normal.textColor = Color.green; 203 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 204 | } 205 | 206 | EditorGUILayout.EndHorizontal(); 207 | EditorGUILayout.EndVertical(); 208 | 209 | EditorGUILayout.Space(10); 210 | 211 | string configLabel = mcpClient?.mcpType == McpTypes.Codex 212 | ? "2. Paste the following TOML configuration:" 213 | : "2. Paste the following JSON configuration:"; 214 | EditorGUILayout.LabelField(configLabel, instructionStyle); 215 | 216 | // JSON section with improved styling 217 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 218 | 219 | // Improved text area for JSON with syntax highlighting colors 220 | GUIStyle jsonStyle = new(EditorStyles.textArea) 221 | { 222 | font = EditorStyles.boldFont, 223 | wordWrap = true, 224 | }; 225 | jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue 226 | 227 | // Draw the JSON in a text area with a taller height for better readability 228 | EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); 229 | 230 | // Copy JSON button with improved styling 231 | EditorGUILayout.BeginHorizontal(); 232 | GUILayout.FlexibleSpace(); 233 | 234 | if ( 235 | GUILayout.Button( 236 | "Copy JSON", 237 | copyButtonStyle, 238 | GUILayout.Height(25), 239 | GUILayout.Width(100) 240 | ) 241 | ) 242 | { 243 | EditorGUIUtility.systemCopyBuffer = configJson; 244 | jsonCopied = true; 245 | copyFeedbackTimer = 2f; 246 | } 247 | 248 | if (jsonCopied) 249 | { 250 | GUIStyle feedbackStyle = new(EditorStyles.label); 251 | feedbackStyle.normal.textColor = Color.green; 252 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 253 | } 254 | 255 | EditorGUILayout.EndHorizontal(); 256 | EditorGUILayout.EndVertical(); 257 | 258 | EditorGUILayout.Space(10); 259 | EditorGUILayout.LabelField( 260 | "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"), 261 | instructionStyle 262 | ); 263 | 264 | EditorGUILayout.EndVertical(); 265 | 266 | EditorGUILayout.Space(10); 267 | 268 | // Close button at the bottom 269 | EditorGUILayout.BeginHorizontal(); 270 | GUILayout.FlexibleSpace(); 271 | if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) 272 | { 273 | Close(); 274 | } 275 | GUILayout.FlexibleSpace(); 276 | EditorGUILayout.EndHorizontal(); 277 | 278 | EditorGUILayout.EndScrollView(); 279 | } 280 | 281 | protected virtual void Update() 282 | { 283 | // Handle the feedback message timer 284 | if (copyFeedbackTimer > 0) 285 | { 286 | copyFeedbackTimer -= Time.deltaTime; 287 | if (copyFeedbackTimer <= 0) 288 | { 289 | pathCopied = false; 290 | jsonCopied = false; 291 | Repaint(); 292 | } 293 | } 294 | } 295 | } 296 | } 297 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.Runtime.InteropServices; 2 | using UnityEditor; 3 | using UnityEngine; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnity.Editor.Windows 7 | { 8 | // Editor window to display manual configuration instructions 9 | public class ManualConfigEditorWindow : EditorWindow 10 | { 11 | protected string configPath; 12 | protected string configJson; 13 | protected Vector2 scrollPos; 14 | protected bool pathCopied = false; 15 | protected bool jsonCopied = false; 16 | protected float copyFeedbackTimer = 0; 17 | protected McpClient mcpClient; 18 | 19 | public static void ShowWindow(string configPath, string configJson, McpClient mcpClient) 20 | { 21 | var window = GetWindow<ManualConfigEditorWindow>("Manual Configuration"); 22 | window.configPath = configPath; 23 | window.configJson = configJson; 24 | window.mcpClient = mcpClient; 25 | window.minSize = new Vector2(500, 400); 26 | window.Show(); 27 | } 28 | 29 | protected virtual void OnGUI() 30 | { 31 | scrollPos = EditorGUILayout.BeginScrollView(scrollPos); 32 | 33 | // Header with improved styling 34 | EditorGUILayout.Space(10); 35 | Rect titleRect = EditorGUILayout.GetControlRect(false, 30); 36 | EditorGUI.DrawRect( 37 | new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), 38 | new Color(0.2f, 0.2f, 0.2f, 0.1f) 39 | ); 40 | GUI.Label( 41 | new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), 42 | (mcpClient?.name ?? "Unknown") + " Manual Configuration", 43 | EditorStyles.boldLabel 44 | ); 45 | EditorGUILayout.Space(10); 46 | 47 | // Instructions with improved styling 48 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 49 | 50 | Rect headerRect = EditorGUILayout.GetControlRect(false, 24); 51 | EditorGUI.DrawRect( 52 | new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), 53 | new Color(0.1f, 0.1f, 0.1f, 0.2f) 54 | ); 55 | GUI.Label( 56 | new Rect( 57 | headerRect.x + 8, 58 | headerRect.y + 4, 59 | headerRect.width - 16, 60 | headerRect.height 61 | ), 62 | "The automatic configuration failed. Please follow these steps:", 63 | EditorStyles.boldLabel 64 | ); 65 | EditorGUILayout.Space(10); 66 | 67 | GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) 68 | { 69 | margin = new RectOffset(10, 10, 5, 5), 70 | }; 71 | 72 | EditorGUILayout.LabelField( 73 | "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:", 74 | instructionStyle 75 | ); 76 | if (mcpClient?.mcpType == McpTypes.ClaudeDesktop) 77 | { 78 | EditorGUILayout.LabelField( 79 | " a) Going to Settings > Developer > Edit Config", 80 | instructionStyle 81 | ); 82 | } 83 | else if (mcpClient?.mcpType == McpTypes.Cursor) 84 | { 85 | EditorGUILayout.LabelField( 86 | " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", 87 | instructionStyle 88 | ); 89 | } 90 | else if (mcpClient?.mcpType == McpTypes.Windsurf) 91 | { 92 | EditorGUILayout.LabelField( 93 | " a) Going to File > Preferences > Windsurf Settings > MCP > Manage MCPs -> View raw config", 94 | instructionStyle 95 | ); 96 | } 97 | else if (mcpClient?.mcpType == McpTypes.Kiro) 98 | { 99 | EditorGUILayout.LabelField( 100 | " a) Going to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config", 101 | instructionStyle 102 | ); 103 | } 104 | else if (mcpClient?.mcpType == McpTypes.Codex) 105 | { 106 | EditorGUILayout.LabelField( 107 | " a) Running `codex config edit` in a terminal", 108 | instructionStyle 109 | ); 110 | } 111 | EditorGUILayout.LabelField(" OR", instructionStyle); 112 | EditorGUILayout.LabelField( 113 | " b) Opening the configuration file at:", 114 | instructionStyle 115 | ); 116 | 117 | // Path section with improved styling 118 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 119 | string displayPath; 120 | if (mcpClient != null) 121 | { 122 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 123 | { 124 | displayPath = mcpClient.windowsConfigPath; 125 | } 126 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 127 | { 128 | displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) 129 | 130 | ? configPath 131 | 132 | : mcpClient.macConfigPath; 133 | } 134 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 135 | { 136 | displayPath = mcpClient.linuxConfigPath; 137 | } 138 | else 139 | { 140 | displayPath = configPath; 141 | } 142 | } 143 | else 144 | { 145 | displayPath = configPath; 146 | } 147 | 148 | // Prevent text overflow by allowing the text field to wrap 149 | GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; 150 | 151 | EditorGUILayout.TextField( 152 | displayPath, 153 | pathStyle, 154 | GUILayout.Height(EditorGUIUtility.singleLineHeight) 155 | ); 156 | 157 | // Copy button with improved styling 158 | EditorGUILayout.BeginHorizontal(); 159 | GUILayout.FlexibleSpace(); 160 | GUIStyle copyButtonStyle = new(GUI.skin.button) 161 | { 162 | padding = new RectOffset(15, 15, 5, 5), 163 | margin = new RectOffset(10, 10, 5, 5), 164 | }; 165 | 166 | if ( 167 | GUILayout.Button( 168 | "Copy Path", 169 | copyButtonStyle, 170 | GUILayout.Height(25), 171 | GUILayout.Width(100) 172 | ) 173 | ) 174 | { 175 | EditorGUIUtility.systemCopyBuffer = displayPath; 176 | pathCopied = true; 177 | copyFeedbackTimer = 2f; 178 | } 179 | 180 | if ( 181 | GUILayout.Button( 182 | "Open File", 183 | copyButtonStyle, 184 | GUILayout.Height(25), 185 | GUILayout.Width(100) 186 | ) 187 | ) 188 | { 189 | // Open the file using the system's default application 190 | System.Diagnostics.Process.Start( 191 | new System.Diagnostics.ProcessStartInfo 192 | { 193 | FileName = displayPath, 194 | UseShellExecute = true, 195 | } 196 | ); 197 | } 198 | 199 | if (pathCopied) 200 | { 201 | GUIStyle feedbackStyle = new(EditorStyles.label); 202 | feedbackStyle.normal.textColor = Color.green; 203 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 204 | } 205 | 206 | EditorGUILayout.EndHorizontal(); 207 | EditorGUILayout.EndVertical(); 208 | 209 | EditorGUILayout.Space(10); 210 | 211 | string configLabel = mcpClient?.mcpType == McpTypes.Codex 212 | ? "2. Paste the following TOML configuration:" 213 | : "2. Paste the following JSON configuration:"; 214 | EditorGUILayout.LabelField(configLabel, instructionStyle); 215 | 216 | // JSON section with improved styling 217 | EditorGUILayout.BeginVertical(EditorStyles.helpBox); 218 | 219 | // Improved text area for JSON with syntax highlighting colors 220 | GUIStyle jsonStyle = new(EditorStyles.textArea) 221 | { 222 | font = EditorStyles.boldFont, 223 | wordWrap = true, 224 | }; 225 | jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue 226 | 227 | // Draw the JSON in a text area with a taller height for better readability 228 | EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); 229 | 230 | // Copy JSON button with improved styling 231 | EditorGUILayout.BeginHorizontal(); 232 | GUILayout.FlexibleSpace(); 233 | 234 | if ( 235 | GUILayout.Button( 236 | "Copy JSON", 237 | copyButtonStyle, 238 | GUILayout.Height(25), 239 | GUILayout.Width(100) 240 | ) 241 | ) 242 | { 243 | EditorGUIUtility.systemCopyBuffer = configJson; 244 | jsonCopied = true; 245 | copyFeedbackTimer = 2f; 246 | } 247 | 248 | if (jsonCopied) 249 | { 250 | GUIStyle feedbackStyle = new(EditorStyles.label); 251 | feedbackStyle.normal.textColor = Color.green; 252 | EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); 253 | } 254 | 255 | EditorGUILayout.EndHorizontal(); 256 | EditorGUILayout.EndVertical(); 257 | 258 | EditorGUILayout.Space(10); 259 | EditorGUILayout.LabelField( 260 | "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"), 261 | instructionStyle 262 | ); 263 | 264 | EditorGUILayout.EndVertical(); 265 | 266 | EditorGUILayout.Space(10); 267 | 268 | // Close button at the bottom 269 | EditorGUILayout.BeginHorizontal(); 270 | GUILayout.FlexibleSpace(); 271 | if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) 272 | { 273 | Close(); 274 | } 275 | GUILayout.FlexibleSpace(); 276 | EditorGUILayout.EndHorizontal(); 277 | 278 | EditorGUILayout.EndScrollView(); 279 | } 280 | 281 | protected virtual void Update() 282 | { 283 | // Handle the feedback message timer 284 | if (copyFeedbackTimer > 0) 285 | { 286 | copyFeedbackTimer -= Time.deltaTime; 287 | if (copyFeedbackTimer <= 0) 288 | { 289 | pathCopied = false; 290 | jsonCopied = false; 291 | Repaint(); 292 | } 293 | } 294 | } 295 | } 296 | } 297 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using UnityEditor; 8 | using UnityEngine; 9 | using MCPForUnity.Editor.Dependencies; 10 | using MCPForUnity.Editor.Helpers; 11 | using MCPForUnity.Editor.Models; 12 | 13 | namespace MCPForUnity.Editor.Helpers 14 | { 15 | /// <summary> 16 | /// Shared helper for MCP client configuration management with sophisticated 17 | /// logic for preserving existing configs and handling different client types 18 | /// </summary> 19 | public static class McpConfigurationHelper 20 | { 21 | private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; 22 | 23 | /// <summary> 24 | /// Writes MCP configuration to the specified path using sophisticated logic 25 | /// that preserves existing configuration and only writes when necessary 26 | /// </summary> 27 | public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) 28 | { 29 | // 0) Respect explicit lock (hidden pref or UI toggle) 30 | try 31 | { 32 | if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) 33 | return "Skipped (locked)"; 34 | } 35 | catch { } 36 | 37 | JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; 38 | 39 | // Read existing config if it exists 40 | string existingJson = "{}"; 41 | if (File.Exists(configPath)) 42 | { 43 | try 44 | { 45 | existingJson = File.ReadAllText(configPath); 46 | } 47 | catch (Exception e) 48 | { 49 | Debug.LogWarning($"Error reading existing config: {e.Message}."); 50 | } 51 | } 52 | 53 | // Parse the existing JSON while preserving all properties 54 | dynamic existingConfig; 55 | try 56 | { 57 | if (string.IsNullOrWhiteSpace(existingJson)) 58 | { 59 | existingConfig = new JObject(); 60 | } 61 | else 62 | { 63 | existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); 64 | } 65 | } 66 | catch 67 | { 68 | // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object 69 | if (!string.IsNullOrWhiteSpace(existingJson)) 70 | { 71 | Debug.LogWarning("UnityMCP: Configuration file could not be parsed; rewriting server block."); 72 | } 73 | existingConfig = new JObject(); 74 | } 75 | 76 | // Determine existing entry references (command/args) 77 | string existingCommand = null; 78 | string[] existingArgs = null; 79 | bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); 80 | try 81 | { 82 | if (isVSCode) 83 | { 84 | existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); 85 | existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>(); 86 | } 87 | else 88 | { 89 | existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); 90 | existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>(); 91 | } 92 | } 93 | catch { } 94 | 95 | // 1) Start from existing, only fill gaps (prefer trusted resolver) 96 | string uvPath = ServerInstaller.FindUvPath(); 97 | // Optionally trust existingCommand if it looks like uv/uv.exe 98 | try 99 | { 100 | var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); 101 | if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) 102 | { 103 | uvPath = existingCommand; 104 | } 105 | } 106 | catch { } 107 | if (uvPath == null) return "UV package manager not found. Please install UV first."; 108 | string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); 109 | 110 | // 2) Canonical args order 111 | var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; 112 | 113 | // 3) Only write if changed 114 | bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) 115 | || !ArgsEqual(existingArgs, newArgs); 116 | if (!changed) 117 | { 118 | return "Configured successfully"; // nothing to do 119 | } 120 | 121 | // 4) Ensure containers exist and write back minimal changes 122 | JObject existingRoot; 123 | if (existingConfig is JObject eo) 124 | existingRoot = eo; 125 | else 126 | existingRoot = JObject.FromObject(existingConfig); 127 | 128 | existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); 129 | 130 | string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); 131 | 132 | McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); 133 | 134 | try 135 | { 136 | if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); 137 | EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); 138 | } 139 | catch { } 140 | 141 | return "Configured successfully"; 142 | } 143 | 144 | /// <summary> 145 | /// Configures a Codex client with sophisticated TOML handling 146 | /// </summary> 147 | public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) 148 | { 149 | try 150 | { 151 | if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) 152 | return "Skipped (locked)"; 153 | } 154 | catch { } 155 | 156 | string existingToml = string.Empty; 157 | if (File.Exists(configPath)) 158 | { 159 | try 160 | { 161 | existingToml = File.ReadAllText(configPath); 162 | } 163 | catch (Exception e) 164 | { 165 | Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); 166 | existingToml = string.Empty; 167 | } 168 | } 169 | 170 | string existingCommand = null; 171 | string[] existingArgs = null; 172 | if (!string.IsNullOrWhiteSpace(existingToml)) 173 | { 174 | CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); 175 | } 176 | 177 | string uvPath = ServerInstaller.FindUvPath(); 178 | try 179 | { 180 | var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); 181 | if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) 182 | { 183 | uvPath = existingCommand; 184 | } 185 | } 186 | catch { } 187 | 188 | if (uvPath == null) 189 | { 190 | return "UV package manager not found. Please install UV first."; 191 | } 192 | 193 | string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); 194 | var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; 195 | 196 | bool changed = true; 197 | if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null) 198 | { 199 | changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) 200 | || !ArgsEqual(existingArgs, newArgs); 201 | } 202 | 203 | if (!changed) 204 | { 205 | return "Configured successfully"; 206 | } 207 | 208 | string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath, serverSrc); 209 | 210 | McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml); 211 | 212 | try 213 | { 214 | if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); 215 | EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); 216 | } 217 | catch { } 218 | 219 | return "Configured successfully"; 220 | } 221 | 222 | /// <summary> 223 | /// Validates UV binary by running --version command 224 | /// </summary> 225 | private static bool IsValidUvBinary(string path) 226 | { 227 | try 228 | { 229 | if (!File.Exists(path)) return false; 230 | var psi = new System.Diagnostics.ProcessStartInfo 231 | { 232 | FileName = path, 233 | Arguments = "--version", 234 | UseShellExecute = false, 235 | RedirectStandardOutput = true, 236 | RedirectStandardError = true, 237 | CreateNoWindow = true 238 | }; 239 | using var p = System.Diagnostics.Process.Start(psi); 240 | if (p == null) return false; 241 | if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } 242 | if (p.ExitCode != 0) return false; 243 | string output = p.StandardOutput.ReadToEnd().Trim(); 244 | return output.StartsWith("uv "); 245 | } 246 | catch { return false; } 247 | } 248 | 249 | /// <summary> 250 | /// Compares two string arrays for equality 251 | /// </summary> 252 | private static bool ArgsEqual(string[] a, string[] b) 253 | { 254 | if (a == null || b == null) return a == b; 255 | if (a.Length != b.Length) return false; 256 | for (int i = 0; i < a.Length; i++) 257 | { 258 | if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; 259 | } 260 | return true; 261 | } 262 | 263 | /// <summary> 264 | /// Gets the appropriate config file path for the given MCP client based on OS 265 | /// </summary> 266 | public static string GetClientConfigPath(McpClient mcpClient) 267 | { 268 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 269 | { 270 | return mcpClient.windowsConfigPath; 271 | } 272 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 273 | { 274 | return string.IsNullOrEmpty(mcpClient.macConfigPath) 275 | ? mcpClient.linuxConfigPath 276 | : mcpClient.macConfigPath; 277 | } 278 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 279 | { 280 | return mcpClient.linuxConfigPath; 281 | } 282 | else 283 | { 284 | return mcpClient.linuxConfigPath; // fallback 285 | } 286 | } 287 | 288 | /// <summary> 289 | /// Creates the directory for the config file if it doesn't exist 290 | /// </summary> 291 | public static void EnsureConfigDirectoryExists(string configPath) 292 | { 293 | Directory.CreateDirectory(Path.GetDirectoryName(configPath)); 294 | } 295 | } 296 | } 297 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PackageUpdateServiceTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using NUnit.Framework; 3 | using UnityEditor; 4 | using MCPForUnity.Editor.Services; 5 | 6 | namespace MCPForUnityTests.Editor.Services 7 | { 8 | public class PackageUpdateServiceTests 9 | { 10 | private PackageUpdateService _service; 11 | private const string TestLastCheckDateKey = "MCPForUnity.LastUpdateCheck"; 12 | private const string TestCachedVersionKey = "MCPForUnity.LatestKnownVersion"; 13 | 14 | [SetUp] 15 | public void SetUp() 16 | { 17 | _service = new PackageUpdateService(); 18 | 19 | // Clean up any existing test data 20 | CleanupEditorPrefs(); 21 | } 22 | 23 | [TearDown] 24 | public void TearDown() 25 | { 26 | // Clean up test data 27 | CleanupEditorPrefs(); 28 | } 29 | 30 | private void CleanupEditorPrefs() 31 | { 32 | if (EditorPrefs.HasKey(TestLastCheckDateKey)) 33 | { 34 | EditorPrefs.DeleteKey(TestLastCheckDateKey); 35 | } 36 | if (EditorPrefs.HasKey(TestCachedVersionKey)) 37 | { 38 | EditorPrefs.DeleteKey(TestCachedVersionKey); 39 | } 40 | } 41 | 42 | [Test] 43 | public void IsNewerVersion_ReturnsTrue_WhenMajorVersionIsNewer() 44 | { 45 | bool result = _service.IsNewerVersion("2.0.0", "1.0.0"); 46 | Assert.IsTrue(result, "2.0.0 should be newer than 1.0.0"); 47 | } 48 | 49 | [Test] 50 | public void IsNewerVersion_ReturnsTrue_WhenMinorVersionIsNewer() 51 | { 52 | bool result = _service.IsNewerVersion("1.2.0", "1.1.0"); 53 | Assert.IsTrue(result, "1.2.0 should be newer than 1.1.0"); 54 | } 55 | 56 | [Test] 57 | public void IsNewerVersion_ReturnsTrue_WhenPatchVersionIsNewer() 58 | { 59 | bool result = _service.IsNewerVersion("1.0.2", "1.0.1"); 60 | Assert.IsTrue(result, "1.0.2 should be newer than 1.0.1"); 61 | } 62 | 63 | [Test] 64 | public void IsNewerVersion_ReturnsFalse_WhenVersionsAreEqual() 65 | { 66 | bool result = _service.IsNewerVersion("1.0.0", "1.0.0"); 67 | Assert.IsFalse(result, "Same versions should return false"); 68 | } 69 | 70 | [Test] 71 | public void IsNewerVersion_ReturnsFalse_WhenVersionIsOlder() 72 | { 73 | bool result = _service.IsNewerVersion("1.0.0", "2.0.0"); 74 | Assert.IsFalse(result, "1.0.0 should not be newer than 2.0.0"); 75 | } 76 | 77 | [Test] 78 | public void IsNewerVersion_HandlesVersionPrefix_v() 79 | { 80 | bool result = _service.IsNewerVersion("v2.0.0", "v1.0.0"); 81 | Assert.IsTrue(result, "Should handle 'v' prefix correctly"); 82 | } 83 | 84 | [Test] 85 | public void IsNewerVersion_HandlesVersionPrefix_V() 86 | { 87 | bool result = _service.IsNewerVersion("V2.0.0", "V1.0.0"); 88 | Assert.IsTrue(result, "Should handle 'V' prefix correctly"); 89 | } 90 | 91 | [Test] 92 | public void IsNewerVersion_HandlesMixedPrefixes() 93 | { 94 | bool result = _service.IsNewerVersion("v2.0.0", "1.0.0"); 95 | Assert.IsTrue(result, "Should handle mixed prefixes correctly"); 96 | } 97 | 98 | [Test] 99 | public void IsNewerVersion_ComparesCorrectly_WhenMajorDiffers() 100 | { 101 | bool result1 = _service.IsNewerVersion("10.0.0", "9.0.0"); 102 | bool result2 = _service.IsNewerVersion("2.0.0", "10.0.0"); 103 | 104 | Assert.IsTrue(result1, "10.0.0 should be newer than 9.0.0"); 105 | Assert.IsFalse(result2, "2.0.0 should not be newer than 10.0.0"); 106 | } 107 | 108 | [Test] 109 | public void IsNewerVersion_ReturnsFalse_OnInvalidVersionFormat() 110 | { 111 | // Service should handle errors gracefully 112 | bool result = _service.IsNewerVersion("invalid", "1.0.0"); 113 | Assert.IsFalse(result, "Should return false for invalid version format"); 114 | } 115 | 116 | [Test] 117 | public void CheckForUpdate_ReturnsCachedVersion_WhenCacheIsValid() 118 | { 119 | // Arrange: Set up valid cache 120 | string today = DateTime.Now.ToString("yyyy-MM-dd"); 121 | string cachedVersion = "5.5.5"; 122 | EditorPrefs.SetString(TestLastCheckDateKey, today); 123 | EditorPrefs.SetString(TestCachedVersionKey, cachedVersion); 124 | 125 | // Act 126 | var result = _service.CheckForUpdate("5.0.0"); 127 | 128 | // Assert 129 | Assert.IsTrue(result.CheckSucceeded, "Check should succeed with valid cache"); 130 | Assert.AreEqual(cachedVersion, result.LatestVersion, "Should return cached version"); 131 | Assert.IsTrue(result.UpdateAvailable, "Update should be available (5.5.5 > 5.0.0)"); 132 | } 133 | 134 | [Test] 135 | public void CheckForUpdate_DetectsUpdateAvailable_WhenNewerVersionCached() 136 | { 137 | // Arrange 138 | string today = DateTime.Now.ToString("yyyy-MM-dd"); 139 | EditorPrefs.SetString(TestLastCheckDateKey, today); 140 | EditorPrefs.SetString(TestCachedVersionKey, "6.0.0"); 141 | 142 | // Act 143 | var result = _service.CheckForUpdate("5.0.0"); 144 | 145 | // Assert 146 | Assert.IsTrue(result.UpdateAvailable, "Should detect update is available"); 147 | Assert.AreEqual("6.0.0", result.LatestVersion); 148 | } 149 | 150 | [Test] 151 | public void CheckForUpdate_DetectsNoUpdate_WhenVersionsMatch() 152 | { 153 | // Arrange 154 | string today = DateTime.Now.ToString("yyyy-MM-dd"); 155 | EditorPrefs.SetString(TestLastCheckDateKey, today); 156 | EditorPrefs.SetString(TestCachedVersionKey, "5.0.0"); 157 | 158 | // Act 159 | var result = _service.CheckForUpdate("5.0.0"); 160 | 161 | // Assert 162 | Assert.IsFalse(result.UpdateAvailable, "Should detect no update needed"); 163 | Assert.AreEqual("5.0.0", result.LatestVersion); 164 | } 165 | 166 | [Test] 167 | public void CheckForUpdate_DetectsNoUpdate_WhenCurrentVersionIsNewer() 168 | { 169 | // Arrange 170 | string today = DateTime.Now.ToString("yyyy-MM-dd"); 171 | EditorPrefs.SetString(TestLastCheckDateKey, today); 172 | EditorPrefs.SetString(TestCachedVersionKey, "5.0.0"); 173 | 174 | // Act 175 | var result = _service.CheckForUpdate("6.0.0"); 176 | 177 | // Assert 178 | Assert.IsFalse(result.UpdateAvailable, "Should detect no update when current is newer"); 179 | Assert.AreEqual("5.0.0", result.LatestVersion); 180 | } 181 | 182 | [Test] 183 | public void CheckForUpdate_IgnoresExpiredCache_AndAttemptsFreshFetch() 184 | { 185 | // Arrange: Set cache from yesterday (expired) 186 | string yesterday = DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd"); 187 | string cachedVersion = "4.0.0"; 188 | EditorPrefs.SetString(TestLastCheckDateKey, yesterday); 189 | EditorPrefs.SetString(TestCachedVersionKey, cachedVersion); 190 | 191 | // Act 192 | var result = _service.CheckForUpdate("5.0.0"); 193 | 194 | // Assert 195 | Assert.IsNotNull(result, "Should return a result"); 196 | 197 | // If the check succeeded (network available), verify it didn't use the expired cache 198 | if (result.CheckSucceeded) 199 | { 200 | Assert.AreNotEqual(cachedVersion, result.LatestVersion, 201 | "Should not return expired cached version when fresh fetch succeeds"); 202 | Assert.IsNotNull(result.LatestVersion, "Should have fetched a new version"); 203 | } 204 | else 205 | { 206 | // If offline, check should fail (not succeed with cached data) 207 | Assert.IsFalse(result.UpdateAvailable, 208 | "Should not report update available when fetch fails and cache is expired"); 209 | } 210 | } 211 | 212 | [Test] 213 | public void CheckForUpdate_ReturnsAssetStoreMessage_ForNonGitInstallations() 214 | { 215 | // Note: This test verifies the service behavior when IsGitInstallation() returns false. 216 | // Since the actual result depends on package installation method, we create a mock 217 | // implementation to test this specific code path. 218 | 219 | var mockService = new MockAssetStorePackageUpdateService(); 220 | 221 | // Act 222 | var result = mockService.CheckForUpdate("5.0.0"); 223 | 224 | // Assert 225 | Assert.IsFalse(result.CheckSucceeded, "Check should not succeed for Asset Store installs"); 226 | Assert.IsFalse(result.UpdateAvailable, "No update should be reported for Asset Store installs"); 227 | Assert.AreEqual("Asset Store installations are updated via Unity Asset Store", result.Message, 228 | "Should return Asset Store update message"); 229 | Assert.IsNull(result.LatestVersion, "Latest version should be null for Asset Store installs"); 230 | } 231 | 232 | [Test] 233 | public void ClearCache_RemovesAllCachedData() 234 | { 235 | // Arrange: Set up cache 236 | EditorPrefs.SetString(TestLastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd")); 237 | EditorPrefs.SetString(TestCachedVersionKey, "5.0.0"); 238 | 239 | // Verify cache exists 240 | Assert.IsTrue(EditorPrefs.HasKey(TestLastCheckDateKey), "Cache should exist before clearing"); 241 | Assert.IsTrue(EditorPrefs.HasKey(TestCachedVersionKey), "Cache should exist before clearing"); 242 | 243 | // Act 244 | _service.ClearCache(); 245 | 246 | // Assert 247 | Assert.IsFalse(EditorPrefs.HasKey(TestLastCheckDateKey), "Date cache should be cleared"); 248 | Assert.IsFalse(EditorPrefs.HasKey(TestCachedVersionKey), "Version cache should be cleared"); 249 | } 250 | 251 | [Test] 252 | public void ClearCache_DoesNotThrow_WhenNoCacheExists() 253 | { 254 | // Ensure no cache exists 255 | CleanupEditorPrefs(); 256 | 257 | // Act & Assert - should not throw 258 | Assert.DoesNotThrow(() => _service.ClearCache(), "Should not throw when clearing non-existent cache"); 259 | } 260 | } 261 | 262 | /// <summary> 263 | /// Mock implementation of IPackageUpdateService that simulates Asset Store installation behavior 264 | /// </summary> 265 | internal class MockAssetStorePackageUpdateService : IPackageUpdateService 266 | { 267 | public UpdateCheckResult CheckForUpdate(string currentVersion) 268 | { 269 | // Simulate Asset Store installation (IsGitInstallation returns false) 270 | return new UpdateCheckResult 271 | { 272 | CheckSucceeded = false, 273 | UpdateAvailable = false, 274 | Message = "Asset Store installations are updated via Unity Asset Store" 275 | }; 276 | } 277 | 278 | public bool IsNewerVersion(string version1, string version2) 279 | { 280 | // Not used in the Asset Store test, but required by interface 281 | return false; 282 | } 283 | 284 | public bool IsGitInstallation() 285 | { 286 | // Simulate non-Git installation (Asset Store) 287 | return false; 288 | } 289 | 290 | public void ClearCache() 291 | { 292 | // Not used in the Asset Store test, but required by interface 293 | } 294 | } 295 | } 296 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using UnityEditor; 8 | using UnityEngine; 9 | using MCPForUnity.Editor.Dependencies; 10 | using MCPForUnity.Editor.Helpers; 11 | using MCPForUnity.Editor.Models; 12 | 13 | namespace MCPForUnity.Editor.Helpers 14 | { 15 | /// <summary> 16 | /// Shared helper for MCP client configuration management with sophisticated 17 | /// logic for preserving existing configs and handling different client types 18 | /// </summary> 19 | public static class McpConfigurationHelper 20 | { 21 | private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; 22 | 23 | /// <summary> 24 | /// Writes MCP configuration to the specified path using sophisticated logic 25 | /// that preserves existing configuration and only writes when necessary 26 | /// </summary> 27 | public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) 28 | { 29 | // 0) Respect explicit lock (hidden pref or UI toggle) 30 | try 31 | { 32 | if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) 33 | return "Skipped (locked)"; 34 | } 35 | catch { } 36 | 37 | JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; 38 | 39 | // Read existing config if it exists 40 | string existingJson = "{}"; 41 | if (File.Exists(configPath)) 42 | { 43 | try 44 | { 45 | existingJson = File.ReadAllText(configPath); 46 | } 47 | catch (Exception e) 48 | { 49 | Debug.LogWarning($"Error reading existing config: {e.Message}."); 50 | } 51 | } 52 | 53 | // Parse the existing JSON while preserving all properties 54 | dynamic existingConfig; 55 | try 56 | { 57 | if (string.IsNullOrWhiteSpace(existingJson)) 58 | { 59 | existingConfig = new JObject(); 60 | } 61 | else 62 | { 63 | existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); 64 | } 65 | } 66 | catch 67 | { 68 | // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object 69 | if (!string.IsNullOrWhiteSpace(existingJson)) 70 | { 71 | Debug.LogWarning("UnityMCP: Configuration file could not be parsed; rewriting server block."); 72 | } 73 | existingConfig = new JObject(); 74 | } 75 | 76 | // Determine existing entry references (command/args) 77 | string existingCommand = null; 78 | string[] existingArgs = null; 79 | bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); 80 | try 81 | { 82 | if (isVSCode) 83 | { 84 | existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); 85 | existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject<string[]>(); 86 | } 87 | else 88 | { 89 | existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); 90 | existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject<string[]>(); 91 | } 92 | } 93 | catch { } 94 | 95 | // 1) Start from existing, only fill gaps (prefer trusted resolver) 96 | string uvPath = ServerInstaller.FindUvPath(); 97 | // Optionally trust existingCommand if it looks like uv/uv.exe 98 | try 99 | { 100 | var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); 101 | if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) 102 | { 103 | uvPath = existingCommand; 104 | } 105 | } 106 | catch { } 107 | if (uvPath == null) return "UV package manager not found. Please install UV first."; 108 | string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); 109 | 110 | // 2) Canonical args order 111 | var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; 112 | 113 | // 3) Only write if changed 114 | bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) 115 | || !ArgsEqual(existingArgs, newArgs); 116 | if (!changed) 117 | { 118 | return "Configured successfully"; // nothing to do 119 | } 120 | 121 | // 4) Ensure containers exist and write back minimal changes 122 | JObject existingRoot; 123 | if (existingConfig is JObject eo) 124 | existingRoot = eo; 125 | else 126 | existingRoot = JObject.FromObject(existingConfig); 127 | 128 | existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); 129 | 130 | string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); 131 | 132 | McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); 133 | 134 | try 135 | { 136 | if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); 137 | EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); 138 | } 139 | catch { } 140 | 141 | return "Configured successfully"; 142 | } 143 | 144 | /// <summary> 145 | /// Configures a Codex client with sophisticated TOML handling 146 | /// </summary> 147 | public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) 148 | { 149 | try 150 | { 151 | if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) 152 | return "Skipped (locked)"; 153 | } 154 | catch { } 155 | 156 | string existingToml = string.Empty; 157 | if (File.Exists(configPath)) 158 | { 159 | try 160 | { 161 | existingToml = File.ReadAllText(configPath); 162 | } 163 | catch (Exception e) 164 | { 165 | Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); 166 | existingToml = string.Empty; 167 | } 168 | } 169 | 170 | string existingCommand = null; 171 | string[] existingArgs = null; 172 | if (!string.IsNullOrWhiteSpace(existingToml)) 173 | { 174 | CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); 175 | } 176 | 177 | string uvPath = ServerInstaller.FindUvPath(); 178 | try 179 | { 180 | var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); 181 | if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(existingCommand)) 182 | { 183 | uvPath = existingCommand; 184 | } 185 | } 186 | catch { } 187 | 188 | if (uvPath == null) 189 | { 190 | return "UV package manager not found. Please install UV first."; 191 | } 192 | 193 | string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); 194 | var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; 195 | 196 | bool changed = true; 197 | if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null) 198 | { 199 | changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) 200 | || !ArgsEqual(existingArgs, newArgs); 201 | } 202 | 203 | if (!changed) 204 | { 205 | return "Configured successfully"; 206 | } 207 | 208 | string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc); 209 | string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock); 210 | 211 | McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml); 212 | 213 | try 214 | { 215 | if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); 216 | EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); 217 | } 218 | catch { } 219 | 220 | return "Configured successfully"; 221 | } 222 | 223 | /// <summary> 224 | /// Validates UV binary by running --version command 225 | /// </summary> 226 | private static bool IsValidUvBinary(string path) 227 | { 228 | try 229 | { 230 | if (!File.Exists(path)) return false; 231 | var psi = new System.Diagnostics.ProcessStartInfo 232 | { 233 | FileName = path, 234 | Arguments = "--version", 235 | UseShellExecute = false, 236 | RedirectStandardOutput = true, 237 | RedirectStandardError = true, 238 | CreateNoWindow = true 239 | }; 240 | using var p = System.Diagnostics.Process.Start(psi); 241 | if (p == null) return false; 242 | if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } 243 | if (p.ExitCode != 0) return false; 244 | string output = p.StandardOutput.ReadToEnd().Trim(); 245 | return output.StartsWith("uv "); 246 | } 247 | catch { return false; } 248 | } 249 | 250 | /// <summary> 251 | /// Compares two string arrays for equality 252 | /// </summary> 253 | private static bool ArgsEqual(string[] a, string[] b) 254 | { 255 | if (a == null || b == null) return a == b; 256 | if (a.Length != b.Length) return false; 257 | for (int i = 0; i < a.Length; i++) 258 | { 259 | if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; 260 | } 261 | return true; 262 | } 263 | 264 | /// <summary> 265 | /// Gets the appropriate config file path for the given MCP client based on OS 266 | /// </summary> 267 | public static string GetClientConfigPath(McpClient mcpClient) 268 | { 269 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 270 | { 271 | return mcpClient.windowsConfigPath; 272 | } 273 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 274 | { 275 | return string.IsNullOrEmpty(mcpClient.macConfigPath) 276 | ? mcpClient.linuxConfigPath 277 | : mcpClient.macConfigPath; 278 | } 279 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 280 | { 281 | return mcpClient.linuxConfigPath; 282 | } 283 | else 284 | { 285 | return mcpClient.linuxConfigPath; // fallback 286 | } 287 | } 288 | 289 | /// <summary> 290 | /// Creates the directory for the config file if it doesn't exist 291 | /// </summary> 292 | public static void EnsureConfigDirectoryExists(string configPath) 293 | { 294 | Directory.CreateDirectory(Path.GetDirectoryName(configPath)); 295 | } 296 | } 297 | } 298 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ExecPath.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Runtime.InteropServices; 7 | using UnityEditor; 8 | 9 | namespace MCPForUnity.Editor.Helpers 10 | { 11 | internal static class ExecPath 12 | { 13 | private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; 14 | 15 | // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. 16 | internal static string ResolveClaude() 17 | { 18 | try 19 | { 20 | string pref = EditorPrefs.GetString(PrefClaude, string.Empty); 21 | if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; 22 | } 23 | catch { } 24 | 25 | string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); 26 | if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; 27 | 28 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 29 | { 30 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 31 | string[] candidates = 32 | { 33 | "/opt/homebrew/bin/claude", 34 | "/usr/local/bin/claude", 35 | Path.Combine(home, ".local", "bin", "claude"), 36 | }; 37 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 38 | // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude 39 | string nvmClaude = ResolveClaudeFromNvm(home); 40 | if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; 41 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 42 | return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); 43 | #else 44 | return null; 45 | #endif 46 | } 47 | 48 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 49 | { 50 | #if UNITY_EDITOR_WIN 51 | // Common npm global locations 52 | string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; 53 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; 54 | string[] candidates = 55 | { 56 | // Prefer .cmd (most reliable from non-interactive processes) 57 | Path.Combine(appData, "npm", "claude.cmd"), 58 | Path.Combine(localAppData, "npm", "claude.cmd"), 59 | // Fall back to PowerShell shim if only .ps1 is present 60 | Path.Combine(appData, "npm", "claude.ps1"), 61 | Path.Combine(localAppData, "npm", "claude.ps1"), 62 | }; 63 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 64 | string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); 65 | if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; 66 | #endif 67 | return null; 68 | } 69 | 70 | // Linux 71 | { 72 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 73 | string[] candidates = 74 | { 75 | "/usr/local/bin/claude", 76 | "/usr/bin/claude", 77 | Path.Combine(home, ".local", "bin", "claude"), 78 | }; 79 | foreach (string c in candidates) { if (File.Exists(c)) return c; } 80 | // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude 81 | string nvmClaude = ResolveClaudeFromNvm(home); 82 | if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; 83 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 84 | return Which("claude", "/usr/local/bin:/usr/bin:/bin"); 85 | #else 86 | return null; 87 | #endif 88 | } 89 | } 90 | 91 | // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version 92 | private static string ResolveClaudeFromNvm(string home) 93 | { 94 | try 95 | { 96 | if (string.IsNullOrEmpty(home)) return null; 97 | string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); 98 | if (!Directory.Exists(nvmNodeDir)) return null; 99 | 100 | string bestPath = null; 101 | Version bestVersion = null; 102 | foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) 103 | { 104 | string name = Path.GetFileName(versionDir); 105 | if (string.IsNullOrEmpty(name)) continue; 106 | if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) 107 | { 108 | // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 109 | string versionStr = name.Substring(1); 110 | int dashIndex = versionStr.IndexOf('-'); 111 | if (dashIndex > 0) 112 | { 113 | versionStr = versionStr.Substring(0, dashIndex); 114 | } 115 | if (Version.TryParse(versionStr, out Version parsed)) 116 | { 117 | string candidate = Path.Combine(versionDir, "bin", "claude"); 118 | if (File.Exists(candidate)) 119 | { 120 | if (bestVersion == null || parsed > bestVersion) 121 | { 122 | bestVersion = parsed; 123 | bestPath = candidate; 124 | } 125 | } 126 | } 127 | } 128 | } 129 | return bestPath; 130 | } 131 | catch { return null; } 132 | } 133 | 134 | // Explicitly set the Claude CLI absolute path override in EditorPrefs 135 | internal static void SetClaudeCliPath(string absolutePath) 136 | { 137 | try 138 | { 139 | if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) 140 | { 141 | EditorPrefs.SetString(PrefClaude, absolutePath); 142 | } 143 | } 144 | catch { } 145 | } 146 | 147 | // Clear any previously set Claude CLI override path 148 | internal static void ClearClaudeCliPath() 149 | { 150 | try 151 | { 152 | if (EditorPrefs.HasKey(PrefClaude)) 153 | { 154 | EditorPrefs.DeleteKey(PrefClaude); 155 | } 156 | } 157 | catch { } 158 | } 159 | 160 | // Use existing UV resolver; returns absolute path or null. 161 | internal static string ResolveUv() 162 | { 163 | return ServerInstaller.FindUvPath(); 164 | } 165 | 166 | internal static bool TryRun( 167 | string file, 168 | string args, 169 | string workingDir, 170 | out string stdout, 171 | out string stderr, 172 | int timeoutMs = 15000, 173 | string extraPathPrepend = null) 174 | { 175 | stdout = string.Empty; 176 | stderr = string.Empty; 177 | try 178 | { 179 | // Handle PowerShell scripts on Windows by invoking through powershell.exe 180 | bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && 181 | file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); 182 | 183 | var psi = new ProcessStartInfo 184 | { 185 | FileName = isPs1 ? "powershell.exe" : file, 186 | Arguments = isPs1 187 | ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() 188 | : args, 189 | WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, 190 | UseShellExecute = false, 191 | RedirectStandardOutput = true, 192 | RedirectStandardError = true, 193 | CreateNoWindow = true, 194 | }; 195 | if (!string.IsNullOrEmpty(extraPathPrepend)) 196 | { 197 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 198 | psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) 199 | ? extraPathPrepend 200 | : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); 201 | } 202 | 203 | using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; 204 | 205 | var so = new StringBuilder(); 206 | var se = new StringBuilder(); 207 | process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; 208 | process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; 209 | 210 | if (!process.Start()) return false; 211 | 212 | process.BeginOutputReadLine(); 213 | process.BeginErrorReadLine(); 214 | 215 | if (!process.WaitForExit(timeoutMs)) 216 | { 217 | try { process.Kill(); } catch { } 218 | return false; 219 | } 220 | 221 | // Ensure async buffers are flushed 222 | process.WaitForExit(); 223 | 224 | stdout = so.ToString(); 225 | stderr = se.ToString(); 226 | return process.ExitCode == 0; 227 | } 228 | catch 229 | { 230 | return false; 231 | } 232 | } 233 | 234 | #if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX 235 | private static string Which(string exe, string prependPath) 236 | { 237 | try 238 | { 239 | var psi = new ProcessStartInfo("/usr/bin/which", exe) 240 | { 241 | UseShellExecute = false, 242 | RedirectStandardOutput = true, 243 | CreateNoWindow = true, 244 | }; 245 | string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; 246 | psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); 247 | using var p = Process.Start(psi); 248 | string output = p?.StandardOutput.ReadToEnd().Trim(); 249 | p?.WaitForExit(1500); 250 | return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; 251 | } 252 | catch { return null; } 253 | } 254 | #endif 255 | 256 | #if UNITY_EDITOR_WIN 257 | private static string Where(string exe) 258 | { 259 | try 260 | { 261 | var psi = new ProcessStartInfo("where", exe) 262 | { 263 | UseShellExecute = false, 264 | RedirectStandardOutput = true, 265 | CreateNoWindow = true, 266 | }; 267 | using var p = Process.Start(psi); 268 | string first = p?.StandardOutput.ReadToEnd() 269 | .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) 270 | .FirstOrDefault(); 271 | p?.WaitForExit(1500); 272 | return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; 273 | } 274 | catch { return null; } 275 | } 276 | #endif 277 | } 278 | } 279 | ```