This is page 4 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 -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using NUnit.Framework; 3 | using UnityEngine; 4 | using MCPForUnity.Editor.Tools; 5 | using static MCPForUnity.Editor.Tools.ManageGameObject; 6 | 7 | namespace MCPForUnityTests.Editor.Tools 8 | { 9 | public class ComponentResolverTests 10 | { 11 | [Test] 12 | public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() 13 | { 14 | bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); 15 | 16 | Assert.IsTrue(result, "Should resolve Transform component"); 17 | Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); 18 | Assert.IsEmpty(error, "Should have no error message"); 19 | } 20 | 21 | [Test] 22 | public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() 23 | { 24 | bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); 25 | 26 | Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); 27 | Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); 28 | Assert.IsEmpty(error, "Should have no error message"); 29 | } 30 | 31 | [Test] 32 | public void TryResolve_ReturnsTrue_ForCustomComponentShortName() 33 | { 34 | bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); 35 | 36 | Assert.IsTrue(result, "Should resolve CustomComponent"); 37 | Assert.IsNotNull(type, "Should return valid type"); 38 | Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); 39 | Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); 40 | Assert.IsEmpty(error, "Should have no error message"); 41 | } 42 | 43 | [Test] 44 | public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() 45 | { 46 | bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); 47 | 48 | Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); 49 | Assert.IsNotNull(type, "Should return valid type"); 50 | Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); 51 | Assert.AreEqual("TestNamespace.CustomComponent", type.FullName, "Should have correct full name"); 52 | Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); 53 | Assert.IsEmpty(error, "Should have no error message"); 54 | } 55 | 56 | [Test] 57 | public void TryResolve_ReturnsFalse_ForNonExistentComponent() 58 | { 59 | bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); 60 | 61 | Assert.IsFalse(result, "Should not resolve non-existent component"); 62 | Assert.IsNull(type, "Should return null type"); 63 | Assert.IsNotEmpty(error, "Should have error message"); 64 | Assert.That(error, Does.Contain("not found"), "Error should mention component not found"); 65 | } 66 | 67 | [Test] 68 | public void TryResolve_ReturnsFalse_ForEmptyString() 69 | { 70 | bool result = ComponentResolver.TryResolve("", out Type type, out string error); 71 | 72 | Assert.IsFalse(result, "Should not resolve empty string"); 73 | Assert.IsNull(type, "Should return null type"); 74 | Assert.IsNotEmpty(error, "Should have error message"); 75 | } 76 | 77 | [Test] 78 | public void TryResolve_ReturnsFalse_ForNullString() 79 | { 80 | bool result = ComponentResolver.TryResolve(null, out Type type, out string error); 81 | 82 | Assert.IsFalse(result, "Should not resolve null string"); 83 | Assert.IsNull(type, "Should return null type"); 84 | Assert.IsNotEmpty(error, "Should have error message"); 85 | Assert.That(error, Does.Contain("null or empty"), "Error should mention null or empty"); 86 | } 87 | 88 | [Test] 89 | public void TryResolve_CachesResolvedTypes() 90 | { 91 | // First call 92 | bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); 93 | 94 | // Second call should use cache 95 | bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); 96 | 97 | Assert.IsTrue(result1, "First call should succeed"); 98 | Assert.IsTrue(result2, "Second call should succeed"); 99 | Assert.AreSame(type1, type2, "Should return same type instance (cached)"); 100 | Assert.IsEmpty(error1, "First call should have no error"); 101 | Assert.IsEmpty(error2, "Second call should have no error"); 102 | } 103 | 104 | [Test] 105 | public void TryResolve_PrefersPlayerAssemblies() 106 | { 107 | // Test that custom user scripts (in Player assemblies) are found 108 | bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); 109 | 110 | Assert.IsTrue(result, "Should resolve user script from Player assembly"); 111 | Assert.IsNotNull(type, "Should return valid type"); 112 | 113 | // Verify it's not from an Editor assembly by checking the assembly name 114 | string assemblyName = type.Assembly.GetName().Name; 115 | Assert.That(assemblyName, Does.Not.Contain("Editor"), 116 | "User script should come from Player assembly, not Editor assembly"); 117 | 118 | // Verify it's from the TestAsmdef assembly (which is a Player assembly) 119 | Assert.AreEqual("TestAsmdef", assemblyName, 120 | "CustomComponent should be resolved from TestAsmdef assembly"); 121 | } 122 | 123 | [Test] 124 | public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() 125 | { 126 | // This test would need duplicate component names to be meaningful 127 | // For now, test with a built-in component that should not have duplicates 128 | bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); 129 | 130 | Assert.IsTrue(result, "Transform should resolve uniquely"); 131 | Assert.AreEqual(typeof(Transform), type, "Should return correct type"); 132 | Assert.IsEmpty(error, "Should have no ambiguity error"); 133 | } 134 | 135 | [Test] 136 | public void ResolvedType_IsValidComponent() 137 | { 138 | bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); 139 | 140 | Assert.IsTrue(result, "Should resolve Rigidbody"); 141 | Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); 142 | Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || 143 | typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); 144 | } 145 | } 146 | } 147 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/CodexConfigHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using MCPForUnity.External.Tommy; 6 | 7 | namespace MCPForUnity.Editor.Helpers 8 | { 9 | /// <summary> 10 | /// Codex CLI specific configuration helpers. Handles TOML snippet 11 | /// generation and lightweight parsing so Codex can join the auto-setup 12 | /// flow alongside JSON-based clients. 13 | /// </summary> 14 | public static class CodexConfigHelper 15 | { 16 | public static bool IsCodexConfigured(string pythonDir) 17 | { 18 | try 19 | { 20 | string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 21 | if (string.IsNullOrEmpty(basePath)) return false; 22 | 23 | string configPath = Path.Combine(basePath, ".codex", "config.toml"); 24 | if (!File.Exists(configPath)) return false; 25 | 26 | string toml = File.ReadAllText(configPath); 27 | if (!TryParseCodexServer(toml, out _, out var args)) return false; 28 | 29 | string dir = McpConfigFileHelper.ExtractDirectoryArg(args); 30 | if (string.IsNullOrEmpty(dir)) return false; 31 | 32 | return McpConfigFileHelper.PathsEqual(dir, pythonDir); 33 | } 34 | catch 35 | { 36 | return false; 37 | } 38 | } 39 | 40 | public static string BuildCodexServerBlock(string uvPath, string serverSrc) 41 | { 42 | var table = new TomlTable(); 43 | var mcpServers = new TomlTable(); 44 | 45 | mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); 46 | table["mcp_servers"] = mcpServers; 47 | 48 | using var writer = new StringWriter(); 49 | table.WriteTo(writer); 50 | return writer.ToString(); 51 | } 52 | 53 | public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc) 54 | { 55 | // Parse existing TOML or create new root table 56 | var root = TryParseToml(existingToml) ?? new TomlTable(); 57 | 58 | // Ensure mcp_servers table exists 59 | if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable)) 60 | { 61 | root["mcp_servers"] = new TomlTable(); 62 | } 63 | var mcpServers = root["mcp_servers"] as TomlTable; 64 | 65 | // Create or update unityMCP table 66 | mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); 67 | 68 | // Serialize back to TOML 69 | using var writer = new StringWriter(); 70 | root.WriteTo(writer); 71 | return writer.ToString(); 72 | } 73 | 74 | public static bool TryParseCodexServer(string toml, out string command, out string[] args) 75 | { 76 | command = null; 77 | args = null; 78 | 79 | var root = TryParseToml(toml); 80 | if (root == null) return false; 81 | 82 | if (!TryGetTable(root, "mcp_servers", out var servers) 83 | && !TryGetTable(root, "mcpServers", out servers)) 84 | { 85 | return false; 86 | } 87 | 88 | if (!TryGetTable(servers, "unityMCP", out var unity)) 89 | { 90 | return false; 91 | } 92 | 93 | command = GetTomlString(unity, "command"); 94 | args = GetTomlStringArray(unity, "args"); 95 | 96 | return !string.IsNullOrEmpty(command) && args != null; 97 | } 98 | 99 | /// <summary> 100 | /// Safely parses TOML string, returning null on failure 101 | /// </summary> 102 | private static TomlTable TryParseToml(string toml) 103 | { 104 | if (string.IsNullOrWhiteSpace(toml)) return null; 105 | 106 | try 107 | { 108 | using var reader = new StringReader(toml); 109 | return TOML.Parse(reader); 110 | } 111 | catch (TomlParseException) 112 | { 113 | return null; 114 | } 115 | catch (TomlSyntaxException) 116 | { 117 | return null; 118 | } 119 | catch (FormatException) 120 | { 121 | return null; 122 | } 123 | } 124 | 125 | /// <summary> 126 | /// Creates a TomlTable for the unityMCP server configuration 127 | /// </summary> 128 | private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc) 129 | { 130 | var unityMCP = new TomlTable(); 131 | unityMCP["command"] = new TomlString { Value = uvPath }; 132 | 133 | var argsArray = new TomlArray(); 134 | argsArray.Add(new TomlString { Value = "run" }); 135 | argsArray.Add(new TomlString { Value = "--directory" }); 136 | argsArray.Add(new TomlString { Value = serverSrc }); 137 | argsArray.Add(new TomlString { Value = "server.py" }); 138 | unityMCP["args"] = argsArray; 139 | 140 | return unityMCP; 141 | } 142 | 143 | private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) 144 | { 145 | table = null; 146 | if (parent == null) return false; 147 | 148 | if (parent.TryGetNode(key, out var node)) 149 | { 150 | if (node is TomlTable tbl) 151 | { 152 | table = tbl; 153 | return true; 154 | } 155 | 156 | if (node is TomlArray array) 157 | { 158 | var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault(); 159 | if (firstTable != null) 160 | { 161 | table = firstTable; 162 | return true; 163 | } 164 | } 165 | } 166 | 167 | return false; 168 | } 169 | 170 | private static string GetTomlString(TomlTable table, string key) 171 | { 172 | if (table != null && table.TryGetNode(key, out var node)) 173 | { 174 | if (node is TomlString str) return str.Value; 175 | if (node.HasValue) return node.ToString(); 176 | } 177 | return null; 178 | } 179 | 180 | private static string[] GetTomlStringArray(TomlTable table, string key) 181 | { 182 | if (table == null) return null; 183 | if (!table.TryGetNode(key, out var node)) return null; 184 | 185 | if (node is TomlArray array) 186 | { 187 | List<string> values = new List<string>(); 188 | foreach (TomlNode element in array.Children) 189 | { 190 | if (element is TomlString str) 191 | { 192 | values.Add(str.Value); 193 | } 194 | else if (element.HasValue) 195 | { 196 | values.Add(element.ToString()); 197 | } 198 | } 199 | 200 | return values.Count > 0 ? values.ToArray() : Array.Empty<string>(); 201 | } 202 | 203 | if (node is TomlString single) 204 | { 205 | return new[] { single.Value }; 206 | } 207 | 208 | return null; 209 | } 210 | } 211 | } 212 | ``` -------------------------------------------------------------------------------- /tests/test_transport_framing.py: -------------------------------------------------------------------------------- ```python 1 | from unity_connection import UnityConnection 2 | import sys 3 | import json 4 | import struct 5 | import socket 6 | import threading 7 | import time 8 | import select 9 | from pathlib import Path 10 | 11 | import pytest 12 | 13 | # locate server src dynamically to avoid hardcoded layout assumptions 14 | ROOT = Path(__file__).resolve().parents[1] 15 | candidates = [ 16 | ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", 17 | ROOT / "UnityMcpServer~" / "src", 18 | ] 19 | SRC = next((p for p in candidates if p.exists()), None) 20 | if SRC is None: 21 | searched = "\n".join(str(p) for p in candidates) 22 | pytest.skip( 23 | "MCP for Unity server source not found. Tried:\n" + searched, 24 | allow_module_level=True, 25 | ) 26 | sys.path.insert(0, str(SRC)) 27 | 28 | 29 | def start_dummy_server(greeting: bytes, respond_ping: bool = False): 30 | """Start a minimal TCP server for handshake tests.""" 31 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 32 | sock.bind(("127.0.0.1", 0)) 33 | sock.listen(1) 34 | port = sock.getsockname()[1] 35 | ready = threading.Event() 36 | 37 | def _run(): 38 | ready.set() 39 | conn, _ = sock.accept() 40 | conn.settimeout(1.0) 41 | if greeting: 42 | conn.sendall(greeting) 43 | if respond_ping: 44 | try: 45 | # Read exactly n bytes helper 46 | def _read_exact(n: int) -> bytes: 47 | buf = b"" 48 | while len(buf) < n: 49 | chunk = conn.recv(n - len(buf)) 50 | if not chunk: 51 | break 52 | buf += chunk 53 | return buf 54 | 55 | header = _read_exact(8) 56 | if len(header) == 8: 57 | length = struct.unpack(">Q", header)[0] 58 | payload = _read_exact(length) 59 | if payload == b'{"type":"ping"}': 60 | resp = b'{"type":"pong"}' 61 | conn.sendall(struct.pack(">Q", len(resp)) + resp) 62 | except Exception: 63 | pass 64 | time.sleep(0.1) 65 | try: 66 | conn.close() 67 | except Exception: 68 | pass 69 | finally: 70 | sock.close() 71 | 72 | threading.Thread(target=_run, daemon=True).start() 73 | ready.wait() 74 | return port 75 | 76 | 77 | def start_handshake_enforcing_server(): 78 | """Server that drops connection if client sends data before handshake.""" 79 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 80 | sock.bind(("127.0.0.1", 0)) 81 | sock.listen(1) 82 | port = sock.getsockname()[1] 83 | ready = threading.Event() 84 | 85 | def _run(): 86 | ready.set() 87 | conn, _ = sock.accept() 88 | # If client sends any data before greeting, disconnect (poll briefly) 89 | try: 90 | conn.setblocking(False) 91 | deadline = time.time() + 0.15 # short, reduces race with legitimate clients 92 | while time.time() < deadline: 93 | r, _, _ = select.select([conn], [], [], 0.01) 94 | if r: 95 | try: 96 | peek = conn.recv(1, socket.MSG_PEEK) 97 | except BlockingIOError: 98 | peek = b"" 99 | except Exception: 100 | peek = b"\x00" 101 | if peek: 102 | conn.close() 103 | sock.close() 104 | return 105 | # No pre-handshake data observed; send greeting 106 | conn.setblocking(True) 107 | conn.sendall(b"MCP/0.1 FRAMING=1\n") 108 | time.sleep(0.1) 109 | finally: 110 | try: 111 | conn.close() 112 | finally: 113 | sock.close() 114 | 115 | threading.Thread(target=_run, daemon=True).start() 116 | ready.wait() 117 | return port 118 | 119 | 120 | def test_handshake_requires_framing(): 121 | port = start_dummy_server(b"MCP/0.1\n") 122 | conn = UnityConnection(host="127.0.0.1", port=port) 123 | assert conn.connect() is False 124 | assert conn.sock is None 125 | 126 | 127 | def test_small_frame_ping_pong(): 128 | port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True) 129 | conn = UnityConnection(host="127.0.0.1", port=port) 130 | try: 131 | assert conn.connect() is True 132 | assert conn.use_framing is True 133 | payload = b'{"type":"ping"}' 134 | conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) 135 | resp = conn.receive_full_response(conn.sock) 136 | assert json.loads(resp.decode("utf-8"))["type"] == "pong" 137 | finally: 138 | conn.disconnect() 139 | 140 | 141 | def test_unframed_data_disconnect(): 142 | port = start_handshake_enforcing_server() 143 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 144 | sock.connect(("127.0.0.1", port)) 145 | sock.settimeout(1.0) 146 | sock.sendall(b"BAD") 147 | time.sleep(0.4) 148 | try: 149 | data = sock.recv(1024) 150 | assert data == b"" 151 | except (ConnectionResetError, ConnectionAbortedError): 152 | # Some platforms raise instead of returning empty bytes when the 153 | # server closes the connection after detecting pre-handshake data. 154 | pass 155 | finally: 156 | sock.close() 157 | 158 | 159 | def test_zero_length_payload_heartbeat(): 160 | # Server that sends handshake and a zero-length heartbeat frame followed by a pong payload 161 | import socket 162 | import struct 163 | import threading 164 | import time 165 | 166 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 167 | sock.bind(("127.0.0.1", 0)) 168 | sock.listen(1) 169 | port = sock.getsockname()[1] 170 | ready = threading.Event() 171 | 172 | def _run(): 173 | ready.set() 174 | conn, _ = sock.accept() 175 | try: 176 | conn.sendall(b"MCP/0.1 FRAMING=1\n") 177 | time.sleep(0.02) 178 | # Heartbeat frame (length=0) 179 | conn.sendall(struct.pack(">Q", 0)) 180 | time.sleep(0.02) 181 | # Real payload frame 182 | payload = b'{"type":"pong"}' 183 | conn.sendall(struct.pack(">Q", len(payload)) + payload) 184 | time.sleep(0.02) 185 | finally: 186 | try: 187 | conn.close() 188 | except Exception: 189 | pass 190 | sock.close() 191 | 192 | threading.Thread(target=_run, daemon=True).start() 193 | ready.wait() 194 | 195 | conn = UnityConnection(host="127.0.0.1", port=port) 196 | try: 197 | assert conn.connect() is True 198 | # Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen) 199 | resp = conn.receive_full_response(conn.sock) 200 | assert resp in (b'{"type":"pong"}', b"") 201 | finally: 202 | conn.disconnect() 203 | 204 | 205 | @pytest.mark.skip(reason="TODO: oversized payload should disconnect") 206 | def test_oversized_payload_rejected(): 207 | pass 208 | 209 | 210 | @pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect") 211 | def test_partial_frame_timeout(): 212 | pass 213 | 214 | 215 | @pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations") 216 | def test_parallel_invocations_no_interleaving(): 217 | pass 218 | 219 | 220 | @pytest.mark.skip(reason="TODO: reconnection after drop mid-command") 221 | def test_reconnect_mid_command(): 222 | pass 223 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// Windows-specific dependency detection 12 | /// </summary> 13 | public class WindowsPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "Windows"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths 29 | var candidates = new[] 30 | { 31 | "python.exe", 32 | "python3.exe", 33 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 34 | "Programs", "Python", "Python313", "python.exe"), 35 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 36 | "Programs", "Python", "Python312", "python.exe"), 37 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 38 | "Programs", "Python", "Python311", "python.exe"), 39 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), 40 | "Python313", "python.exe"), 41 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), 42 | "Python312", "python.exe") 43 | }; 44 | 45 | foreach (var candidate in candidates) 46 | { 47 | if (TryValidatePython(candidate, out string version, out string fullPath)) 48 | { 49 | status.IsAvailable = true; 50 | status.Version = version; 51 | status.Path = fullPath; 52 | status.Details = $"Found Python {version} at {fullPath}"; 53 | return status; 54 | } 55 | } 56 | 57 | // Try PATH resolution using 'where' command 58 | if (TryFindInPath("python.exe", out string pathResult) || 59 | TryFindInPath("python3.exe", out pathResult)) 60 | { 61 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 62 | { 63 | status.IsAvailable = true; 64 | status.Version = version; 65 | status.Path = fullPath; 66 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 67 | return status; 68 | } 69 | } 70 | 71 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 72 | status.Details = "Checked common installation paths and PATH environment variable."; 73 | } 74 | catch (Exception ex) 75 | { 76 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 77 | } 78 | 79 | return status; 80 | } 81 | 82 | public override string GetPythonInstallUrl() 83 | { 84 | return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; 85 | } 86 | 87 | public override string GetUVInstallUrl() 88 | { 89 | return "https://docs.astral.sh/uv/getting-started/installation/#windows"; 90 | } 91 | 92 | public override string GetInstallationRecommendations() 93 | { 94 | return @"Windows Installation Recommendations: 95 | 96 | 1. Python: Install from Microsoft Store or python.org 97 | - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' 98 | - Direct download: https://python.org/downloads/windows/ 99 | 100 | 2. UV Package Manager: Install via PowerShell 101 | - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" 102 | - Or download from: https://github.com/astral-sh/uv/releases 103 | 104 | 3. MCP Server: Will be installed automatically by Unity MCP Bridge"; 105 | } 106 | 107 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 108 | { 109 | version = null; 110 | fullPath = null; 111 | 112 | try 113 | { 114 | var psi = new ProcessStartInfo 115 | { 116 | FileName = pythonPath, 117 | Arguments = "--version", 118 | UseShellExecute = false, 119 | RedirectStandardOutput = true, 120 | RedirectStandardError = true, 121 | CreateNoWindow = true 122 | }; 123 | 124 | using var process = Process.Start(psi); 125 | if (process == null) return false; 126 | 127 | string output = process.StandardOutput.ReadToEnd().Trim(); 128 | process.WaitForExit(5000); 129 | 130 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 131 | { 132 | version = output.Substring(7); // Remove "Python " prefix 133 | fullPath = pythonPath; 134 | 135 | // Validate minimum version (Python 4+ or Python 3.10+) 136 | if (TryParseVersion(version, out var major, out var minor)) 137 | { 138 | return major > 3 || (major >= 3 && minor >= 10); 139 | } 140 | } 141 | } 142 | catch 143 | { 144 | // Ignore validation errors 145 | } 146 | 147 | return false; 148 | } 149 | 150 | private bool TryFindInPath(string executable, out string fullPath) 151 | { 152 | fullPath = null; 153 | 154 | try 155 | { 156 | var psi = new ProcessStartInfo 157 | { 158 | FileName = "where", 159 | Arguments = executable, 160 | UseShellExecute = false, 161 | RedirectStandardOutput = true, 162 | RedirectStandardError = true, 163 | CreateNoWindow = true 164 | }; 165 | 166 | using var process = Process.Start(psi); 167 | if (process == null) return false; 168 | 169 | string output = process.StandardOutput.ReadToEnd().Trim(); 170 | process.WaitForExit(3000); 171 | 172 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) 173 | { 174 | // Take the first result 175 | var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); 176 | if (lines.Length > 0) 177 | { 178 | fullPath = lines[0].Trim(); 179 | return File.Exists(fullPath); 180 | } 181 | } 182 | } 183 | catch 184 | { 185 | // Ignore errors 186 | } 187 | 188 | return false; 189 | } 190 | } 191 | } 192 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// Windows-specific dependency detection 12 | /// </summary> 13 | public class WindowsPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "Windows"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths 29 | var candidates = new[] 30 | { 31 | "python.exe", 32 | "python3.exe", 33 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 34 | "Programs", "Python", "Python313", "python.exe"), 35 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 36 | "Programs", "Python", "Python312", "python.exe"), 37 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 38 | "Programs", "Python", "Python311", "python.exe"), 39 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), 40 | "Python313", "python.exe"), 41 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), 42 | "Python312", "python.exe") 43 | }; 44 | 45 | foreach (var candidate in candidates) 46 | { 47 | if (TryValidatePython(candidate, out string version, out string fullPath)) 48 | { 49 | status.IsAvailable = true; 50 | status.Version = version; 51 | status.Path = fullPath; 52 | status.Details = $"Found Python {version} at {fullPath}"; 53 | return status; 54 | } 55 | } 56 | 57 | // Try PATH resolution using 'where' command 58 | if (TryFindInPath("python.exe", out string pathResult) || 59 | TryFindInPath("python3.exe", out pathResult)) 60 | { 61 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 62 | { 63 | status.IsAvailable = true; 64 | status.Version = version; 65 | status.Path = fullPath; 66 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 67 | return status; 68 | } 69 | } 70 | 71 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 72 | status.Details = "Checked common installation paths and PATH environment variable."; 73 | } 74 | catch (Exception ex) 75 | { 76 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 77 | } 78 | 79 | return status; 80 | } 81 | 82 | public override string GetPythonInstallUrl() 83 | { 84 | return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; 85 | } 86 | 87 | public override string GetUVInstallUrl() 88 | { 89 | return "https://docs.astral.sh/uv/getting-started/installation/#windows"; 90 | } 91 | 92 | public override string GetInstallationRecommendations() 93 | { 94 | return @"Windows Installation Recommendations: 95 | 96 | 1. Python: Install from Microsoft Store or python.org 97 | - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' 98 | - Direct download: https://python.org/downloads/windows/ 99 | 100 | 2. UV Package Manager: Install via PowerShell 101 | - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" 102 | - Or download from: https://github.com/astral-sh/uv/releases 103 | 104 | 3. MCP Server: Will be installed automatically by MCP for Unity Bridge"; 105 | } 106 | 107 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 108 | { 109 | version = null; 110 | fullPath = null; 111 | 112 | try 113 | { 114 | var psi = new ProcessStartInfo 115 | { 116 | FileName = pythonPath, 117 | Arguments = "--version", 118 | UseShellExecute = false, 119 | RedirectStandardOutput = true, 120 | RedirectStandardError = true, 121 | CreateNoWindow = true 122 | }; 123 | 124 | using var process = Process.Start(psi); 125 | if (process == null) return false; 126 | 127 | string output = process.StandardOutput.ReadToEnd().Trim(); 128 | process.WaitForExit(5000); 129 | 130 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 131 | { 132 | version = output.Substring(7); // Remove "Python " prefix 133 | fullPath = pythonPath; 134 | 135 | // Validate minimum version (Python 4+ or Python 3.10+) 136 | if (TryParseVersion(version, out var major, out var minor)) 137 | { 138 | return major > 3 || (major >= 3 && minor >= 10); 139 | } 140 | } 141 | } 142 | catch 143 | { 144 | // Ignore validation errors 145 | } 146 | 147 | return false; 148 | } 149 | 150 | private bool TryFindInPath(string executable, out string fullPath) 151 | { 152 | fullPath = null; 153 | 154 | try 155 | { 156 | var psi = new ProcessStartInfo 157 | { 158 | FileName = "where", 159 | Arguments = executable, 160 | UseShellExecute = false, 161 | RedirectStandardOutput = true, 162 | RedirectStandardError = true, 163 | CreateNoWindow = true 164 | }; 165 | 166 | using var process = Process.Start(psi); 167 | if (process == null) return false; 168 | 169 | string output = process.StandardOutput.ReadToEnd().Trim(); 170 | process.WaitForExit(3000); 171 | 172 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) 173 | { 174 | // Take the first result 175 | var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); 176 | if (lines.Length > 0) 177 | { 178 | fullPath = lines[0].Trim(); 179 | return File.Exists(fullPath); 180 | } 181 | } 182 | } 183 | catch 184 | { 185 | // Ignore errors 186 | } 187 | 188 | return false; 189 | } 190 | } 191 | } 192 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using NUnit.Framework; 3 | using UnityEngine; 4 | using Newtonsoft.Json.Linq; 5 | using MCPForUnity.Editor.Tools; 6 | using System.Reflection; 7 | 8 | namespace MCPForUnityTests.Editor.Tools 9 | { 10 | /// <summary> 11 | /// In-memory tests for ManageScript validation logic. 12 | /// These tests focus on the validation methods directly without creating files. 13 | /// </summary> 14 | public class ManageScriptValidationTests 15 | { 16 | [Test] 17 | public void HandleCommand_NullParams_ReturnsError() 18 | { 19 | var result = ManageScript.HandleCommand(null); 20 | Assert.IsNotNull(result, "Should handle null parameters gracefully"); 21 | } 22 | 23 | [Test] 24 | public void HandleCommand_InvalidAction_ReturnsError() 25 | { 26 | var paramsObj = new JObject 27 | { 28 | ["action"] = "invalid_action", 29 | ["name"] = "TestScript", 30 | ["path"] = "Assets/Scripts" 31 | }; 32 | 33 | var result = ManageScript.HandleCommand(paramsObj); 34 | Assert.IsNotNull(result, "Should return error result for invalid action"); 35 | } 36 | 37 | [Test] 38 | public void CheckBalancedDelimiters_ValidCode_ReturnsTrue() 39 | { 40 | string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}"; 41 | 42 | bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected); 43 | Assert.IsTrue(result, "Valid C# code should pass balance check"); 44 | } 45 | 46 | [Test] 47 | public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse() 48 | { 49 | string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace"; 50 | 51 | bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected); 52 | Assert.IsFalse(result, "Unbalanced code should fail balance check"); 53 | } 54 | 55 | [Test] 56 | public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue() 57 | { 58 | string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}"; 59 | 60 | bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected); 61 | Assert.IsTrue(result, "Code with braces in strings should pass balance check"); 62 | } 63 | 64 | [Test] 65 | public void CheckScopedBalance_ValidCode_ReturnsTrue() 66 | { 67 | string validCode = "{ Debug.Log(\"test\"); }"; 68 | 69 | bool result = CallCheckScopedBalance(validCode, 0, validCode.Length); 70 | Assert.IsTrue(result, "Valid scoped code should pass balance check"); 71 | } 72 | 73 | [Test] 74 | public void CheckScopedBalance_ShouldTolerateOuterContext_ReturnsTrue() 75 | { 76 | // This simulates a snippet extracted from a larger context 77 | string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context"; 78 | 79 | bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length); 80 | 81 | // Scoped balance should tolerate some imbalance from outer context 82 | Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance"); 83 | } 84 | 85 | [Test] 86 | public void TicTacToe3D_ValidationScenario_DoesNotCrash() 87 | { 88 | // Test the scenario that was causing issues without file I/O 89 | string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}"; 90 | 91 | // Test that the validation methods don't crash on this code 92 | bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected); 93 | bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length); 94 | 95 | Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation"); 96 | Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation"); 97 | } 98 | 99 | // Helper methods to access private ManageScript methods via reflection 100 | private bool CallCheckBalancedDelimiters(string contents, out int line, out char expected) 101 | { 102 | line = 0; 103 | expected = ' '; 104 | 105 | try 106 | { 107 | var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters", 108 | BindingFlags.NonPublic | BindingFlags.Static); 109 | 110 | if (method != null) 111 | { 112 | var parameters = new object[] { contents, line, expected }; 113 | var result = (bool)method.Invoke(null, parameters); 114 | line = (int)parameters[1]; 115 | expected = (char)parameters[2]; 116 | return result; 117 | } 118 | } 119 | catch (Exception ex) 120 | { 121 | Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}"); 122 | } 123 | 124 | // Fallback: basic structural check 125 | return BasicBalanceCheck(contents); 126 | } 127 | 128 | private bool CallCheckScopedBalance(string text, int start, int end) 129 | { 130 | try 131 | { 132 | var method = typeof(ManageScript).GetMethod("CheckScopedBalance", 133 | BindingFlags.NonPublic | BindingFlags.Static); 134 | 135 | if (method != null) 136 | { 137 | return (bool)method.Invoke(null, new object[] { text, start, end }); 138 | } 139 | } 140 | catch (Exception ex) 141 | { 142 | Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}"); 143 | } 144 | 145 | return true; // Default to passing if we can't test the actual method 146 | } 147 | 148 | private bool BasicBalanceCheck(string contents) 149 | { 150 | // Simple fallback balance check 151 | int braceCount = 0; 152 | bool inString = false; 153 | bool escaped = false; 154 | 155 | for (int i = 0; i < contents.Length; i++) 156 | { 157 | char c = contents[i]; 158 | 159 | if (escaped) 160 | { 161 | escaped = false; 162 | continue; 163 | } 164 | 165 | if (inString) 166 | { 167 | if (c == '\\') escaped = true; 168 | else if (c == '"') inString = false; 169 | continue; 170 | } 171 | 172 | if (c == '"') inString = true; 173 | else if (c == '{') braceCount++; 174 | else if (c == '}') braceCount--; 175 | 176 | if (braceCount < 0) return false; 177 | } 178 | 179 | return braceCount == 0; 180 | } 181 | } 182 | } 183 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Data/McpClients.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnity.Editor.Data 7 | { 8 | public class McpClients 9 | { 10 | public List<McpClient> clients = new() 11 | { 12 | // 1) Cursor 13 | new() 14 | { 15 | name = "Cursor", 16 | windowsConfigPath = Path.Combine( 17 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 18 | ".cursor", 19 | "mcp.json" 20 | ), 21 | macConfigPath = Path.Combine( 22 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 23 | ".cursor", 24 | "mcp.json" 25 | ), 26 | linuxConfigPath = Path.Combine( 27 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 28 | ".cursor", 29 | "mcp.json" 30 | ), 31 | mcpType = McpTypes.Cursor, 32 | configStatus = "Not Configured", 33 | }, 34 | // 2) Claude Code 35 | new() 36 | { 37 | name = "Claude Code", 38 | windowsConfigPath = Path.Combine( 39 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 40 | ".claude.json" 41 | ), 42 | macConfigPath = Path.Combine( 43 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 44 | ".claude.json" 45 | ), 46 | linuxConfigPath = Path.Combine( 47 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 48 | ".claude.json" 49 | ), 50 | mcpType = McpTypes.ClaudeCode, 51 | configStatus = "Not Configured", 52 | }, 53 | // 3) Windsurf 54 | new() 55 | { 56 | name = "Windsurf", 57 | windowsConfigPath = Path.Combine( 58 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 59 | ".codeium", 60 | "windsurf", 61 | "mcp_config.json" 62 | ), 63 | macConfigPath = Path.Combine( 64 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 65 | ".codeium", 66 | "windsurf", 67 | "mcp_config.json" 68 | ), 69 | linuxConfigPath = Path.Combine( 70 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 71 | ".codeium", 72 | "windsurf", 73 | "mcp_config.json" 74 | ), 75 | mcpType = McpTypes.Windsurf, 76 | configStatus = "Not Configured", 77 | }, 78 | // 4) Claude Desktop 79 | new() 80 | { 81 | name = "Claude Desktop", 82 | windowsConfigPath = Path.Combine( 83 | Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), 84 | "Claude", 85 | "claude_desktop_config.json" 86 | ), 87 | 88 | macConfigPath = Path.Combine( 89 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 90 | "Library", 91 | "Application Support", 92 | "Claude", 93 | "claude_desktop_config.json" 94 | ), 95 | linuxConfigPath = Path.Combine( 96 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 97 | ".config", 98 | "Claude", 99 | "claude_desktop_config.json" 100 | ), 101 | 102 | mcpType = McpTypes.ClaudeDesktop, 103 | configStatus = "Not Configured", 104 | }, 105 | // 5) VSCode GitHub Copilot 106 | new() 107 | { 108 | name = "VSCode GitHub Copilot", 109 | // Windows path is canonical under %AppData%\Code\User 110 | windowsConfigPath = Path.Combine( 111 | Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), 112 | "Code", 113 | "User", 114 | "mcp.json" 115 | ), 116 | // macOS: ~/Library/Application Support/Code/User/mcp.json 117 | macConfigPath = Path.Combine( 118 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 119 | "Library", 120 | "Application Support", 121 | "Code", 122 | "User", 123 | "mcp.json" 124 | ), 125 | // Linux: ~/.config/Code/User/mcp.json 126 | linuxConfigPath = Path.Combine( 127 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 128 | ".config", 129 | "Code", 130 | "User", 131 | "mcp.json" 132 | ), 133 | mcpType = McpTypes.VSCode, 134 | configStatus = "Not Configured", 135 | }, 136 | // 3) Kiro 137 | new() 138 | { 139 | name = "Kiro", 140 | windowsConfigPath = Path.Combine( 141 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 142 | ".kiro", 143 | "settings", 144 | "mcp.json" 145 | ), 146 | macConfigPath = Path.Combine( 147 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 148 | ".kiro", 149 | "settings", 150 | "mcp.json" 151 | ), 152 | linuxConfigPath = Path.Combine( 153 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 154 | ".kiro", 155 | "settings", 156 | "mcp.json" 157 | ), 158 | mcpType = McpTypes.Kiro, 159 | configStatus = "Not Configured", 160 | }, 161 | // 4) Codex CLI 162 | new() 163 | { 164 | name = "Codex CLI", 165 | windowsConfigPath = Path.Combine( 166 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 167 | ".codex", 168 | "config.toml" 169 | ), 170 | macConfigPath = Path.Combine( 171 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 172 | ".codex", 173 | "config.toml" 174 | ), 175 | linuxConfigPath = Path.Combine( 176 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 177 | ".codex", 178 | "config.toml" 179 | ), 180 | mcpType = McpTypes.Codex, 181 | configStatus = "Not Configured", 182 | }, 183 | }; 184 | 185 | // Initialize status enums after construction 186 | public McpClients() 187 | { 188 | foreach (var client in clients) 189 | { 190 | if (client.configStatus == "Not Configured") 191 | { 192 | client.status = McpStatus.NotConfigured; 193 | } 194 | } 195 | } 196 | } 197 | } 198 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using NUnit.Framework; 4 | using UnityEngine; 5 | using MCPForUnity.Editor.Tools; 6 | using static MCPForUnity.Editor.Tools.ManageGameObject; 7 | 8 | namespace MCPForUnityTests.Editor.Tools 9 | { 10 | public class AIPropertyMatchingTests 11 | { 12 | private List<string> sampleProperties; 13 | 14 | [SetUp] 15 | public void SetUp() 16 | { 17 | sampleProperties = new List<string> 18 | { 19 | "maxReachDistance", 20 | "maxHorizontalDistance", 21 | "maxVerticalDistance", 22 | "moveSpeed", 23 | "healthPoints", 24 | "playerName", 25 | "isEnabled", 26 | "mass", 27 | "velocity", 28 | "transform" 29 | }; 30 | } 31 | 32 | [Test] 33 | public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() 34 | { 35 | var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); 36 | 37 | Assert.IsNotEmpty(properties, "Transform should have properties"); 38 | Assert.Contains("position", properties, "Transform should have position property"); 39 | Assert.Contains("rotation", properties, "Transform should have rotation property"); 40 | Assert.Contains("localScale", properties, "Transform should have localScale property"); 41 | } 42 | 43 | [Test] 44 | public void GetAllComponentProperties_ReturnsEmpty_ForNullType() 45 | { 46 | var properties = ComponentResolver.GetAllComponentProperties(null); 47 | 48 | Assert.IsEmpty(properties, "Null type should return empty list"); 49 | } 50 | 51 | [Test] 52 | public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() 53 | { 54 | var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties); 55 | 56 | Assert.IsEmpty(suggestions, "Null input should return no suggestions"); 57 | } 58 | 59 | [Test] 60 | public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() 61 | { 62 | var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties); 63 | 64 | Assert.IsEmpty(suggestions, "Empty input should return no suggestions"); 65 | } 66 | 67 | [Test] 68 | public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() 69 | { 70 | var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>()); 71 | 72 | Assert.IsEmpty(suggestions, "Empty property list should return no suggestions"); 73 | } 74 | 75 | [Test] 76 | public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() 77 | { 78 | var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties); 79 | 80 | Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces"); 81 | Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match"); 82 | } 83 | 84 | [Test] 85 | public void GetAIPropertySuggestions_FindsMultipleWordMatches() 86 | { 87 | var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties); 88 | 89 | Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance"); 90 | Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); 91 | Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance"); 92 | } 93 | 94 | [Test] 95 | public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() 96 | { 97 | var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S 98 | 99 | Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital"); 100 | } 101 | 102 | [Test] 103 | public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms() 104 | { 105 | var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties); 106 | 107 | // Note: Current algorithm might not find "mass" but should handle it gracefully 108 | Assert.IsNotNull(suggestions, "Should return valid suggestions list"); 109 | } 110 | 111 | [Test] 112 | public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber() 113 | { 114 | // Test with input that might match many properties 115 | var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties); 116 | 117 | Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer"); 118 | } 119 | 120 | [Test] 121 | public void GetAIPropertySuggestions_CachesResults() 122 | { 123 | var input = "Max Reach Distance"; 124 | 125 | // First call 126 | var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); 127 | 128 | // Second call should use cache (tested indirectly by ensuring consistency) 129 | var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); 130 | 131 | Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent"); 132 | CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical"); 133 | } 134 | 135 | [Test] 136 | public void GetAIPropertySuggestions_HandlesUnityNamingConventions() 137 | { 138 | var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" }; 139 | 140 | var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties); 141 | var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties); 142 | var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties); 143 | 144 | Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention"); 145 | Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention"); 146 | Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention"); 147 | } 148 | 149 | [Test] 150 | public void GetAIPropertySuggestions_PrioritizesExactMatches() 151 | { 152 | var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" }; 153 | var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties); 154 | 155 | Assert.IsNotEmpty(suggestions, "Should find suggestions"); 156 | Assert.Contains("speed", suggestions, "Exact match should be included in results"); 157 | // Note: Implementation may or may not prioritize exact matches first 158 | } 159 | 160 | [Test] 161 | public void GetAIPropertySuggestions_HandlesCaseInsensitive() 162 | { 163 | var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties); 164 | var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties); 165 | 166 | Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input"); 167 | Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input"); 168 | } 169 | } 170 | } 171 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Data/McpClients.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Models; 6 | 7 | namespace MCPForUnity.Editor.Data 8 | { 9 | public class McpClients 10 | { 11 | public List<McpClient> clients = new() 12 | { 13 | // 1) Cursor 14 | new() 15 | { 16 | name = "Cursor", 17 | windowsConfigPath = Path.Combine( 18 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 19 | ".cursor", 20 | "mcp.json" 21 | ), 22 | macConfigPath = Path.Combine( 23 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 24 | ".cursor", 25 | "mcp.json" 26 | ), 27 | linuxConfigPath = Path.Combine( 28 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 29 | ".cursor", 30 | "mcp.json" 31 | ), 32 | mcpType = McpTypes.Cursor, 33 | configStatus = "Not Configured", 34 | }, 35 | // 2) Claude Code 36 | new() 37 | { 38 | name = "Claude Code", 39 | windowsConfigPath = Path.Combine( 40 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 41 | ".claude.json" 42 | ), 43 | macConfigPath = Path.Combine( 44 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 45 | ".claude.json" 46 | ), 47 | linuxConfigPath = Path.Combine( 48 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 49 | ".claude.json" 50 | ), 51 | mcpType = McpTypes.ClaudeCode, 52 | configStatus = "Not Configured", 53 | }, 54 | // 3) Windsurf 55 | new() 56 | { 57 | name = "Windsurf", 58 | windowsConfigPath = Path.Combine( 59 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 60 | ".codeium", 61 | "windsurf", 62 | "mcp_config.json" 63 | ), 64 | macConfigPath = Path.Combine( 65 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 66 | ".codeium", 67 | "windsurf", 68 | "mcp_config.json" 69 | ), 70 | linuxConfigPath = Path.Combine( 71 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 72 | ".codeium", 73 | "windsurf", 74 | "mcp_config.json" 75 | ), 76 | mcpType = McpTypes.Windsurf, 77 | configStatus = "Not Configured", 78 | }, 79 | // 4) Claude Desktop 80 | new() 81 | { 82 | name = "Claude Desktop", 83 | windowsConfigPath = Path.Combine( 84 | Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), 85 | "Claude", 86 | "claude_desktop_config.json" 87 | ), 88 | 89 | macConfigPath = Path.Combine( 90 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 91 | "Library", 92 | "Application Support", 93 | "Claude", 94 | "claude_desktop_config.json" 95 | ), 96 | linuxConfigPath = Path.Combine( 97 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 98 | ".config", 99 | "Claude", 100 | "claude_desktop_config.json" 101 | ), 102 | 103 | mcpType = McpTypes.ClaudeDesktop, 104 | configStatus = "Not Configured", 105 | }, 106 | // 5) VSCode GitHub Copilot 107 | new() 108 | { 109 | name = "VSCode GitHub Copilot", 110 | // Windows path is canonical under %AppData%\Code\User 111 | windowsConfigPath = Path.Combine( 112 | Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), 113 | "Code", 114 | "User", 115 | "mcp.json" 116 | ), 117 | // macOS: ~/Library/Application Support/Code/User/mcp.json 118 | macConfigPath = Path.Combine( 119 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 120 | "Library", 121 | "Application Support", 122 | "Code", 123 | "User", 124 | "mcp.json" 125 | ), 126 | // Linux: ~/.config/Code/User/mcp.json 127 | linuxConfigPath = Path.Combine( 128 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 129 | ".config", 130 | "Code", 131 | "User", 132 | "mcp.json" 133 | ), 134 | mcpType = McpTypes.VSCode, 135 | configStatus = "Not Configured", 136 | }, 137 | // 3) Kiro 138 | new() 139 | { 140 | name = "Kiro", 141 | windowsConfigPath = Path.Combine( 142 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 143 | ".kiro", 144 | "settings", 145 | "mcp.json" 146 | ), 147 | macConfigPath = Path.Combine( 148 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 149 | ".kiro", 150 | "settings", 151 | "mcp.json" 152 | ), 153 | linuxConfigPath = Path.Combine( 154 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 155 | ".kiro", 156 | "settings", 157 | "mcp.json" 158 | ), 159 | mcpType = McpTypes.Kiro, 160 | configStatus = "Not Configured", 161 | }, 162 | // 4) Codex CLI 163 | new() 164 | { 165 | name = "Codex CLI", 166 | windowsConfigPath = Path.Combine( 167 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 168 | ".codex", 169 | "config.toml" 170 | ), 171 | macConfigPath = Path.Combine( 172 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 173 | ".codex", 174 | "config.toml" 175 | ), 176 | linuxConfigPath = Path.Combine( 177 | Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), 178 | ".codex", 179 | "config.toml" 180 | ), 181 | mcpType = McpTypes.Codex, 182 | configStatus = "Not Configured", 183 | }, 184 | }; 185 | 186 | // Initialize status enums after construction 187 | public McpClients() 188 | { 189 | foreach (var client in clients) 190 | { 191 | if (client.configStatus == "Not Configured") 192 | { 193 | client.status = McpStatus.NotConfigured; 194 | } 195 | } 196 | } 197 | } 198 | } 199 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/server.py: -------------------------------------------------------------------------------- ```python 1 | from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType 2 | from mcp.server.fastmcp import FastMCP 3 | import logging 4 | from logging.handlers import RotatingFileHandler 5 | import os 6 | from contextlib import asynccontextmanager 7 | from typing import AsyncIterator, Dict, Any 8 | from config import config 9 | from tools import register_all_tools 10 | from resources import register_all_resources 11 | from unity_connection import get_unity_connection, UnityConnection 12 | import time 13 | 14 | # Configure logging using settings from config 15 | logging.basicConfig( 16 | level=getattr(logging, config.log_level), 17 | format=config.log_format, 18 | stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio 19 | force=True # Ensure our handler replaces any prior stdout handlers 20 | ) 21 | logger = logging.getLogger("mcp-for-unity-server") 22 | 23 | # Also write logs to a rotating file so logs are available when launched via stdio 24 | try: 25 | import os as _os 26 | _log_dir = _os.path.join(_os.path.expanduser( 27 | "~/Library/Application Support/UnityMCP"), "Logs") 28 | _os.makedirs(_log_dir, exist_ok=True) 29 | _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") 30 | _fh = RotatingFileHandler( 31 | _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") 32 | _fh.setFormatter(logging.Formatter(config.log_format)) 33 | _fh.setLevel(getattr(logging, config.log_level)) 34 | logger.addHandler(_fh) 35 | # Also route telemetry logger to the same rotating file and normal level 36 | try: 37 | tlog = logging.getLogger("unity-mcp-telemetry") 38 | tlog.setLevel(getattr(logging, config.log_level)) 39 | tlog.addHandler(_fh) 40 | except Exception: 41 | # Never let logging setup break startup 42 | pass 43 | except Exception: 44 | # Never let logging setup break startup 45 | pass 46 | # Quieten noisy third-party loggers to avoid clutter during stdio handshake 47 | for noisy in ("httpx", "urllib3"): 48 | try: 49 | logging.getLogger(noisy).setLevel( 50 | max(logging.WARNING, getattr(logging, config.log_level))) 51 | except Exception: 52 | pass 53 | 54 | # Import telemetry only after logging is configured to ensure its logs use stderr and proper levels 55 | # Ensure a slightly higher telemetry timeout unless explicitly overridden by env 56 | try: 57 | 58 | # Ensure generous timeout unless explicitly overridden by env 59 | if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): 60 | os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" 61 | except Exception: 62 | pass 63 | 64 | # Global connection state 65 | _unity_connection: UnityConnection = None 66 | 67 | 68 | @asynccontextmanager 69 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 70 | """Handle server startup and shutdown.""" 71 | global _unity_connection 72 | logger.info("MCP for Unity Server starting up") 73 | 74 | # Record server startup telemetry 75 | start_time = time.time() 76 | start_clk = time.perf_counter() 77 | try: 78 | from pathlib import Path 79 | ver_path = Path(__file__).parent / "server_version.txt" 80 | server_version = ver_path.read_text(encoding="utf-8").strip() 81 | except Exception: 82 | server_version = "unknown" 83 | # Defer initial telemetry by 1s to avoid stdio handshake interference 84 | import threading 85 | 86 | def _emit_startup(): 87 | try: 88 | record_telemetry(RecordType.STARTUP, { 89 | "server_version": server_version, 90 | "startup_time": start_time, 91 | }) 92 | record_milestone(MilestoneType.FIRST_STARTUP) 93 | except Exception: 94 | logger.debug("Deferred startup telemetry failed", exc_info=True) 95 | threading.Timer(1.0, _emit_startup).start() 96 | 97 | try: 98 | skip_connect = os.environ.get( 99 | "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") 100 | if skip_connect: 101 | logger.info( 102 | "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") 103 | else: 104 | _unity_connection = get_unity_connection() 105 | logger.info("Connected to Unity on startup") 106 | 107 | # Record successful Unity connection (deferred) 108 | import threading as _t 109 | _t.Timer(1.0, lambda: record_telemetry( 110 | RecordType.UNITY_CONNECTION, 111 | { 112 | "status": "connected", 113 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 114 | } 115 | )).start() 116 | 117 | except ConnectionError as e: 118 | logger.warning("Could not connect to Unity on startup: %s", e) 119 | _unity_connection = None 120 | 121 | # Record connection failure (deferred) 122 | import threading as _t 123 | _err_msg = str(e)[:200] 124 | _t.Timer(1.0, lambda: record_telemetry( 125 | RecordType.UNITY_CONNECTION, 126 | { 127 | "status": "failed", 128 | "error": _err_msg, 129 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 130 | } 131 | )).start() 132 | except Exception as e: 133 | logger.warning( 134 | "Unexpected error connecting to Unity on startup: %s", e) 135 | _unity_connection = None 136 | import threading as _t 137 | _err_msg = str(e)[:200] 138 | _t.Timer(1.0, lambda: record_telemetry( 139 | RecordType.UNITY_CONNECTION, 140 | { 141 | "status": "failed", 142 | "error": _err_msg, 143 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 144 | } 145 | )).start() 146 | 147 | try: 148 | # Yield the connection object so it can be attached to the context 149 | # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) 150 | yield {"bridge": _unity_connection} 151 | finally: 152 | if _unity_connection: 153 | _unity_connection.disconnect() 154 | _unity_connection = None 155 | logger.info("MCP for Unity Server shut down") 156 | 157 | # Initialize MCP server 158 | mcp = FastMCP( 159 | name="mcp-for-unity-server", 160 | lifespan=server_lifespan 161 | ) 162 | 163 | # Register all tools 164 | register_all_tools(mcp) 165 | 166 | # Register all resources 167 | register_all_resources(mcp) 168 | 169 | 170 | @mcp.prompt() 171 | def asset_creation_strategy() -> str: 172 | """Guide for discovering and using MCP for Unity tools effectively.""" 173 | return ( 174 | "Available MCP for Unity Server Tools:\n\n" 175 | "- `manage_editor`: Controls editor state and queries info.\n" 176 | "- `execute_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" 177 | "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" 178 | "- `manage_scene`: Manages scenes.\n" 179 | "- `manage_gameobject`: Manages GameObjects in the scene.\n" 180 | "- `manage_script`: Manages C# script files.\n" 181 | "- `manage_asset`: Manages prefabs and assets.\n" 182 | "- `manage_shader`: Manages shaders.\n\n" 183 | "Tips:\n" 184 | "- Create prefabs for reusable GameObjects.\n" 185 | "- Always include a camera and main light in your scenes.\n" 186 | "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" 187 | "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" 188 | "- Use `execute_menu_item` for interacting with Unity systems and third party tools like a user would.\n" 189 | ) 190 | 191 | 192 | # Run the server 193 | if __name__ == "__main__": 194 | mcp.run(transport='stdio') 195 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// Linux-specific dependency detection 12 | /// </summary> 13 | public class LinuxPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "Linux"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths on Linux 29 | var candidates = new[] 30 | { 31 | "python3", 32 | "python", 33 | "/usr/bin/python3", 34 | "/usr/local/bin/python3", 35 | "/opt/python/bin/python3", 36 | "/snap/bin/python3" 37 | }; 38 | 39 | foreach (var candidate in candidates) 40 | { 41 | if (TryValidatePython(candidate, out string version, out string fullPath)) 42 | { 43 | status.IsAvailable = true; 44 | status.Version = version; 45 | status.Path = fullPath; 46 | status.Details = $"Found Python {version} at {fullPath}"; 47 | return status; 48 | } 49 | } 50 | 51 | // Try PATH resolution using 'which' command 52 | if (TryFindInPath("python3", out string pathResult) || 53 | TryFindInPath("python", out pathResult)) 54 | { 55 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 56 | { 57 | status.IsAvailable = true; 58 | status.Version = version; 59 | status.Path = fullPath; 60 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 61 | return status; 62 | } 63 | } 64 | 65 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 66 | status.Details = "Checked common installation paths including system, snap, and user-local locations."; 67 | } 68 | catch (Exception ex) 69 | { 70 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 71 | } 72 | 73 | return status; 74 | } 75 | 76 | public override string GetPythonInstallUrl() 77 | { 78 | return "https://www.python.org/downloads/source/"; 79 | } 80 | 81 | public override string GetUVInstallUrl() 82 | { 83 | return "https://docs.astral.sh/uv/getting-started/installation/#linux"; 84 | } 85 | 86 | public override string GetInstallationRecommendations() 87 | { 88 | return @"Linux Installation Recommendations: 89 | 90 | 1. Python: Install via package manager or pyenv 91 | - Ubuntu/Debian: sudo apt install python3 python3-pip 92 | - Fedora/RHEL: sudo dnf install python3 python3-pip 93 | - Arch: sudo pacman -S python python-pip 94 | - Or use pyenv: https://github.com/pyenv/pyenv 95 | 96 | 2. UV Package Manager: Install via curl 97 | - Run: curl -LsSf https://astral.sh/uv/install.sh | sh 98 | - Or download from: https://github.com/astral-sh/uv/releases 99 | 100 | 3. MCP Server: Will be installed automatically by MCP for Unity 101 | 102 | Note: Make sure ~/.local/bin is in your PATH for user-local installations."; 103 | } 104 | 105 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 106 | { 107 | version = null; 108 | fullPath = null; 109 | 110 | try 111 | { 112 | var psi = new ProcessStartInfo 113 | { 114 | FileName = pythonPath, 115 | Arguments = "--version", 116 | UseShellExecute = false, 117 | RedirectStandardOutput = true, 118 | RedirectStandardError = true, 119 | CreateNoWindow = true 120 | }; 121 | 122 | // Set PATH to include common locations 123 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 124 | var pathAdditions = new[] 125 | { 126 | "/usr/local/bin", 127 | "/usr/bin", 128 | "/bin", 129 | "/snap/bin", 130 | Path.Combine(homeDir, ".local", "bin") 131 | }; 132 | 133 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 134 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 135 | 136 | using var process = Process.Start(psi); 137 | if (process == null) return false; 138 | 139 | string output = process.StandardOutput.ReadToEnd().Trim(); 140 | process.WaitForExit(5000); 141 | 142 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 143 | { 144 | version = output.Substring(7); // Remove "Python " prefix 145 | fullPath = pythonPath; 146 | 147 | // Validate minimum version (Python 4+ or Python 3.10+) 148 | if (TryParseVersion(version, out var major, out var minor)) 149 | { 150 | return major > 3 || (major >= 3 && minor >= 10); 151 | } 152 | } 153 | } 154 | catch 155 | { 156 | // Ignore validation errors 157 | } 158 | 159 | return false; 160 | } 161 | 162 | private bool TryFindInPath(string executable, out string fullPath) 163 | { 164 | fullPath = null; 165 | 166 | try 167 | { 168 | var psi = new ProcessStartInfo 169 | { 170 | FileName = "/usr/bin/which", 171 | Arguments = executable, 172 | UseShellExecute = false, 173 | RedirectStandardOutput = true, 174 | RedirectStandardError = true, 175 | CreateNoWindow = true 176 | }; 177 | 178 | // Enhance PATH for Unity's GUI environment 179 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 180 | var pathAdditions = new[] 181 | { 182 | "/usr/local/bin", 183 | "/usr/bin", 184 | "/bin", 185 | "/snap/bin", 186 | Path.Combine(homeDir, ".local", "bin") 187 | }; 188 | 189 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 190 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 191 | 192 | using var process = Process.Start(psi); 193 | if (process == null) return false; 194 | 195 | string output = process.StandardOutput.ReadToEnd().Trim(); 196 | process.WaitForExit(3000); 197 | 198 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) 199 | { 200 | fullPath = output; 201 | return true; 202 | } 203 | } 204 | catch 205 | { 206 | // Ignore errors 207 | } 208 | 209 | return false; 210 | } 211 | } 212 | } 213 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// Linux-specific dependency detection 12 | /// </summary> 13 | public class LinuxPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "Linux"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths on Linux 29 | var candidates = new[] 30 | { 31 | "python3", 32 | "python", 33 | "/usr/bin/python3", 34 | "/usr/local/bin/python3", 35 | "/opt/python/bin/python3", 36 | "/snap/bin/python3" 37 | }; 38 | 39 | foreach (var candidate in candidates) 40 | { 41 | if (TryValidatePython(candidate, out string version, out string fullPath)) 42 | { 43 | status.IsAvailable = true; 44 | status.Version = version; 45 | status.Path = fullPath; 46 | status.Details = $"Found Python {version} at {fullPath}"; 47 | return status; 48 | } 49 | } 50 | 51 | // Try PATH resolution using 'which' command 52 | if (TryFindInPath("python3", out string pathResult) || 53 | TryFindInPath("python", out pathResult)) 54 | { 55 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 56 | { 57 | status.IsAvailable = true; 58 | status.Version = version; 59 | status.Path = fullPath; 60 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 61 | return status; 62 | } 63 | } 64 | 65 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 66 | status.Details = "Checked common installation paths including system, snap, and user-local locations."; 67 | } 68 | catch (Exception ex) 69 | { 70 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 71 | } 72 | 73 | return status; 74 | } 75 | 76 | public override string GetPythonInstallUrl() 77 | { 78 | return "https://www.python.org/downloads/source/"; 79 | } 80 | 81 | public override string GetUVInstallUrl() 82 | { 83 | return "https://docs.astral.sh/uv/getting-started/installation/#linux"; 84 | } 85 | 86 | public override string GetInstallationRecommendations() 87 | { 88 | return @"Linux Installation Recommendations: 89 | 90 | 1. Python: Install via package manager or pyenv 91 | - Ubuntu/Debian: sudo apt install python3 python3-pip 92 | - Fedora/RHEL: sudo dnf install python3 python3-pip 93 | - Arch: sudo pacman -S python python-pip 94 | - Or use pyenv: https://github.com/pyenv/pyenv 95 | 96 | 2. UV Package Manager: Install via curl 97 | - Run: curl -LsSf https://astral.sh/uv/install.sh | sh 98 | - Or download from: https://github.com/astral-sh/uv/releases 99 | 100 | 3. MCP Server: Will be installed automatically by Unity MCP Bridge 101 | 102 | Note: Make sure ~/.local/bin is in your PATH for user-local installations."; 103 | } 104 | 105 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 106 | { 107 | version = null; 108 | fullPath = null; 109 | 110 | try 111 | { 112 | var psi = new ProcessStartInfo 113 | { 114 | FileName = pythonPath, 115 | Arguments = "--version", 116 | UseShellExecute = false, 117 | RedirectStandardOutput = true, 118 | RedirectStandardError = true, 119 | CreateNoWindow = true 120 | }; 121 | 122 | // Set PATH to include common locations 123 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 124 | var pathAdditions = new[] 125 | { 126 | "/usr/local/bin", 127 | "/usr/bin", 128 | "/bin", 129 | "/snap/bin", 130 | Path.Combine(homeDir, ".local", "bin") 131 | }; 132 | 133 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 134 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 135 | 136 | using var process = Process.Start(psi); 137 | if (process == null) return false; 138 | 139 | string output = process.StandardOutput.ReadToEnd().Trim(); 140 | process.WaitForExit(5000); 141 | 142 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 143 | { 144 | version = output.Substring(7); // Remove "Python " prefix 145 | fullPath = pythonPath; 146 | 147 | // Validate minimum version (Python 4+ or Python 3.10+) 148 | if (TryParseVersion(version, out var major, out var minor)) 149 | { 150 | return major > 3 || (major >= 3 && minor >= 10); 151 | } 152 | } 153 | } 154 | catch 155 | { 156 | // Ignore validation errors 157 | } 158 | 159 | return false; 160 | } 161 | 162 | private bool TryFindInPath(string executable, out string fullPath) 163 | { 164 | fullPath = null; 165 | 166 | try 167 | { 168 | var psi = new ProcessStartInfo 169 | { 170 | FileName = "/usr/bin/which", 171 | Arguments = executable, 172 | UseShellExecute = false, 173 | RedirectStandardOutput = true, 174 | RedirectStandardError = true, 175 | CreateNoWindow = true 176 | }; 177 | 178 | // Enhance PATH for Unity's GUI environment 179 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 180 | var pathAdditions = new[] 181 | { 182 | "/usr/local/bin", 183 | "/usr/bin", 184 | "/bin", 185 | "/snap/bin", 186 | Path.Combine(homeDir, ".local", "bin") 187 | }; 188 | 189 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 190 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 191 | 192 | using var process = Process.Start(psi); 193 | if (process == null) return false; 194 | 195 | string output = process.StandardOutput.ReadToEnd().Trim(); 196 | process.WaitForExit(3000); 197 | 198 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) 199 | { 200 | fullPath = output; 201 | return true; 202 | } 203 | } 204 | catch 205 | { 206 | // Ignore errors 207 | } 208 | 209 | return false; 210 | } 211 | } 212 | } 213 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/TelemetryHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using UnityEngine; 5 | 6 | namespace MCPForUnity.Editor.Helpers 7 | { 8 | /// <summary> 9 | /// Unity Bridge telemetry helper for collecting usage analytics 10 | /// Following privacy-first approach with easy opt-out mechanisms 11 | /// </summary> 12 | public static class TelemetryHelper 13 | { 14 | private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; 15 | private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; 16 | private static Action<Dictionary<string, object>> s_sender; 17 | 18 | /// <summary> 19 | /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) 20 | /// </summary> 21 | public static bool IsEnabled 22 | { 23 | get 24 | { 25 | // Check environment variables first 26 | var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); 27 | if (!string.IsNullOrEmpty(envDisable) && 28 | (envDisable.ToLower() == "true" || envDisable == "1")) 29 | { 30 | return false; 31 | } 32 | 33 | var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); 34 | if (!string.IsNullOrEmpty(unityMcpDisable) && 35 | (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) 36 | { 37 | return false; 38 | } 39 | 40 | // Honor protocol-wide opt-out as well 41 | var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); 42 | if (!string.IsNullOrEmpty(mcpDisable) && 43 | (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) 44 | { 45 | return false; 46 | } 47 | 48 | // Check EditorPrefs 49 | return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); 50 | } 51 | } 52 | 53 | /// <summary> 54 | /// Get or generate customer UUID for anonymous tracking 55 | /// </summary> 56 | public static string GetCustomerUUID() 57 | { 58 | var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); 59 | if (string.IsNullOrEmpty(uuid)) 60 | { 61 | uuid = System.Guid.NewGuid().ToString(); 62 | UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); 63 | } 64 | return uuid; 65 | } 66 | 67 | /// <summary> 68 | /// Disable telemetry (stored in EditorPrefs) 69 | /// </summary> 70 | public static void DisableTelemetry() 71 | { 72 | UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); 73 | } 74 | 75 | /// <summary> 76 | /// Enable telemetry (stored in EditorPrefs) 77 | /// </summary> 78 | public static void EnableTelemetry() 79 | { 80 | UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); 81 | } 82 | 83 | /// <summary> 84 | /// Send telemetry data to MCP server for processing 85 | /// This is a lightweight bridge - the actual telemetry logic is in the MCP server 86 | /// </summary> 87 | public static void RecordEvent(string eventType, Dictionary<string, object> data = null) 88 | { 89 | if (!IsEnabled) 90 | return; 91 | 92 | try 93 | { 94 | var telemetryData = new Dictionary<string, object> 95 | { 96 | ["event_type"] = eventType, 97 | ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), 98 | ["customer_uuid"] = GetCustomerUUID(), 99 | ["unity_version"] = Application.unityVersion, 100 | ["platform"] = Application.platform.ToString(), 101 | ["source"] = "unity_bridge" 102 | }; 103 | 104 | if (data != null) 105 | { 106 | telemetryData["data"] = data; 107 | } 108 | 109 | // Send to MCP server via existing bridge communication 110 | // The MCP server will handle actual telemetry transmission 111 | SendTelemetryToMcpServer(telemetryData); 112 | } 113 | catch (Exception e) 114 | { 115 | // Never let telemetry errors interfere with functionality 116 | if (IsDebugEnabled()) 117 | { 118 | McpLog.Warn($"Telemetry error (non-blocking): {e.Message}"); 119 | } 120 | } 121 | } 122 | 123 | /// <summary> 124 | /// Allows the bridge to register a concrete sender for telemetry payloads. 125 | /// </summary> 126 | public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender) 127 | { 128 | Interlocked.Exchange(ref s_sender, sender); 129 | } 130 | 131 | public static void UnregisterTelemetrySender() 132 | { 133 | Interlocked.Exchange(ref s_sender, null); 134 | } 135 | 136 | /// <summary> 137 | /// Record bridge startup event 138 | /// </summary> 139 | public static void RecordBridgeStartup() 140 | { 141 | RecordEvent("bridge_startup", new Dictionary<string, object> 142 | { 143 | ["bridge_version"] = "3.0.2", 144 | ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() 145 | }); 146 | } 147 | 148 | /// <summary> 149 | /// Record bridge connection event 150 | /// </summary> 151 | public static void RecordBridgeConnection(bool success, string error = null) 152 | { 153 | var data = new Dictionary<string, object> 154 | { 155 | ["success"] = success 156 | }; 157 | 158 | if (!string.IsNullOrEmpty(error)) 159 | { 160 | data["error"] = error.Substring(0, Math.Min(200, error.Length)); 161 | } 162 | 163 | RecordEvent("bridge_connection", data); 164 | } 165 | 166 | /// <summary> 167 | /// Record tool execution from Unity side 168 | /// </summary> 169 | public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) 170 | { 171 | var data = new Dictionary<string, object> 172 | { 173 | ["tool_name"] = toolName, 174 | ["success"] = success, 175 | ["duration_ms"] = Math.Round(durationMs, 2) 176 | }; 177 | 178 | if (!string.IsNullOrEmpty(error)) 179 | { 180 | data["error"] = error.Substring(0, Math.Min(200, error.Length)); 181 | } 182 | 183 | RecordEvent("tool_execution_unity", data); 184 | } 185 | 186 | private static void SendTelemetryToMcpServer(Dictionary<string, object> telemetryData) 187 | { 188 | var sender = Volatile.Read(ref s_sender); 189 | if (sender != null) 190 | { 191 | try 192 | { 193 | sender(telemetryData); 194 | return; 195 | } 196 | catch (Exception e) 197 | { 198 | if (IsDebugEnabled()) 199 | { 200 | McpLog.Warn($"Telemetry sender error (non-blocking): {e.Message}"); 201 | } 202 | } 203 | } 204 | 205 | // Fallback: log when debug is enabled 206 | if (IsDebugEnabled()) 207 | { 208 | McpLog.Info($"Telemetry: {telemetryData["event_type"]}"); 209 | } 210 | } 211 | 212 | private static bool IsDebugEnabled() 213 | { 214 | try 215 | { 216 | return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); 217 | } 218 | catch 219 | { 220 | return false; 221 | } 222 | } 223 | } 224 | } 225 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/server.py: -------------------------------------------------------------------------------- ```python 1 | from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType 2 | from mcp.server.fastmcp import FastMCP 3 | import logging 4 | from logging.handlers import RotatingFileHandler 5 | import os 6 | from contextlib import asynccontextmanager 7 | from typing import AsyncIterator, Dict, Any 8 | from config import config 9 | from tools import register_all_tools 10 | from unity_connection import get_unity_connection, UnityConnection 11 | import time 12 | 13 | # Configure logging using settings from config 14 | logging.basicConfig( 15 | level=getattr(logging, config.log_level), 16 | format=config.log_format, 17 | stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio 18 | force=True # Ensure our handler replaces any prior stdout handlers 19 | ) 20 | logger = logging.getLogger("mcp-for-unity-server") 21 | 22 | # Also write logs to a rotating file so logs are available when launched via stdio 23 | try: 24 | import os as _os 25 | _log_dir = _os.path.join(_os.path.expanduser( 26 | "~/Library/Application Support/UnityMCP"), "Logs") 27 | _os.makedirs(_log_dir, exist_ok=True) 28 | _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") 29 | _fh = RotatingFileHandler( 30 | _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") 31 | _fh.setFormatter(logging.Formatter(config.log_format)) 32 | _fh.setLevel(getattr(logging, config.log_level)) 33 | logger.addHandler(_fh) 34 | # Also route telemetry logger to the same rotating file and normal level 35 | try: 36 | tlog = logging.getLogger("unity-mcp-telemetry") 37 | tlog.setLevel(getattr(logging, config.log_level)) 38 | tlog.addHandler(_fh) 39 | except Exception: 40 | # Never let logging setup break startup 41 | pass 42 | except Exception: 43 | # Never let logging setup break startup 44 | pass 45 | # Quieten noisy third-party loggers to avoid clutter during stdio handshake 46 | for noisy in ("httpx", "urllib3"): 47 | try: 48 | logging.getLogger(noisy).setLevel( 49 | max(logging.WARNING, getattr(logging, config.log_level))) 50 | except Exception: 51 | pass 52 | 53 | # Import telemetry only after logging is configured to ensure its logs use stderr and proper levels 54 | # Ensure a slightly higher telemetry timeout unless explicitly overridden by env 55 | try: 56 | 57 | # Ensure generous timeout unless explicitly overridden by env 58 | if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): 59 | os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" 60 | except Exception: 61 | pass 62 | 63 | # Global connection state 64 | _unity_connection: UnityConnection = None 65 | 66 | 67 | @asynccontextmanager 68 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 69 | """Handle server startup and shutdown.""" 70 | global _unity_connection 71 | logger.info("MCP for Unity Server starting up") 72 | 73 | # Record server startup telemetry 74 | start_time = time.time() 75 | start_clk = time.perf_counter() 76 | try: 77 | from pathlib import Path 78 | ver_path = Path(__file__).parent / "server_version.txt" 79 | server_version = ver_path.read_text(encoding="utf-8").strip() 80 | except Exception: 81 | server_version = "unknown" 82 | # Defer initial telemetry by 1s to avoid stdio handshake interference 83 | import threading 84 | 85 | def _emit_startup(): 86 | try: 87 | record_telemetry(RecordType.STARTUP, { 88 | "server_version": server_version, 89 | "startup_time": start_time, 90 | }) 91 | record_milestone(MilestoneType.FIRST_STARTUP) 92 | except Exception: 93 | logger.debug("Deferred startup telemetry failed", exc_info=True) 94 | threading.Timer(1.0, _emit_startup).start() 95 | 96 | try: 97 | skip_connect = os.environ.get( 98 | "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") 99 | if skip_connect: 100 | logger.info( 101 | "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") 102 | else: 103 | _unity_connection = get_unity_connection() 104 | logger.info("Connected to Unity on startup") 105 | 106 | # Record successful Unity connection (deferred) 107 | import threading as _t 108 | _t.Timer(1.0, lambda: record_telemetry( 109 | RecordType.UNITY_CONNECTION, 110 | { 111 | "status": "connected", 112 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 113 | } 114 | )).start() 115 | 116 | except ConnectionError as e: 117 | logger.warning("Could not connect to Unity on startup: %s", e) 118 | _unity_connection = None 119 | 120 | # Record connection failure (deferred) 121 | import threading as _t 122 | _err_msg = str(e)[:200] 123 | _t.Timer(1.0, lambda: record_telemetry( 124 | RecordType.UNITY_CONNECTION, 125 | { 126 | "status": "failed", 127 | "error": _err_msg, 128 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 129 | } 130 | )).start() 131 | except Exception as e: 132 | logger.warning( 133 | "Unexpected error connecting to Unity on startup: %s", e) 134 | _unity_connection = None 135 | import threading as _t 136 | _err_msg = str(e)[:200] 137 | _t.Timer(1.0, lambda: record_telemetry( 138 | RecordType.UNITY_CONNECTION, 139 | { 140 | "status": "failed", 141 | "error": _err_msg, 142 | "connection_time_ms": (time.perf_counter() - start_clk) * 1000, 143 | } 144 | )).start() 145 | 146 | try: 147 | # Yield the connection object so it can be attached to the context 148 | # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) 149 | yield {"bridge": _unity_connection} 150 | finally: 151 | if _unity_connection: 152 | _unity_connection.disconnect() 153 | _unity_connection = None 154 | logger.info("MCP for Unity Server shut down") 155 | 156 | # Initialize MCP server 157 | mcp = FastMCP( 158 | name="mcp-for-unity-server", 159 | lifespan=server_lifespan 160 | ) 161 | 162 | # Register all tools 163 | register_all_tools(mcp) 164 | 165 | # Asset Creation Strategy 166 | 167 | 168 | @mcp.prompt() 169 | def asset_creation_strategy() -> str: 170 | """Guide for discovering and using MCP for Unity tools effectively.""" 171 | return ( 172 | "Available MCP for Unity Server Tools:\n\n" 173 | "- `manage_editor`: Controls editor state and queries info.\n" 174 | "- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" 175 | "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" 176 | "- `manage_scene`: Manages scenes.\n" 177 | "- `manage_gameobject`: Manages GameObjects in the scene.\n" 178 | "- `manage_script`: Manages C# script files.\n" 179 | "- `manage_asset`: Manages prefabs and assets.\n" 180 | "- `manage_shader`: Manages shaders.\n\n" 181 | "Tips:\n" 182 | "- Create prefabs for reusable GameObjects.\n" 183 | "- Always include a camera and main light in your scenes.\n" 184 | "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" 185 | "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" 186 | "- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n" 187 | "- List menu items before using them if you are unsure of the menu path.\n" 188 | "- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n" 189 | ) 190 | 191 | 192 | # Run the server 193 | if __name__ == "__main__": 194 | mcp.run(transport='stdio') 195 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using UnityEngine; 5 | 6 | namespace MCPForUnity.Editor.Helpers 7 | { 8 | /// <summary> 9 | /// Unity Bridge telemetry helper for collecting usage analytics 10 | /// Following privacy-first approach with easy opt-out mechanisms 11 | /// </summary> 12 | public static class TelemetryHelper 13 | { 14 | private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; 15 | private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; 16 | private static Action<Dictionary<string, object>> s_sender; 17 | 18 | /// <summary> 19 | /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) 20 | /// </summary> 21 | public static bool IsEnabled 22 | { 23 | get 24 | { 25 | // Check environment variables first 26 | var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); 27 | if (!string.IsNullOrEmpty(envDisable) && 28 | (envDisable.ToLower() == "true" || envDisable == "1")) 29 | { 30 | return false; 31 | } 32 | 33 | var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); 34 | if (!string.IsNullOrEmpty(unityMcpDisable) && 35 | (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) 36 | { 37 | return false; 38 | } 39 | 40 | // Honor protocol-wide opt-out as well 41 | var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); 42 | if (!string.IsNullOrEmpty(mcpDisable) && 43 | (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) 44 | { 45 | return false; 46 | } 47 | 48 | // Check EditorPrefs 49 | return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); 50 | } 51 | } 52 | 53 | /// <summary> 54 | /// Get or generate customer UUID for anonymous tracking 55 | /// </summary> 56 | public static string GetCustomerUUID() 57 | { 58 | var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); 59 | if (string.IsNullOrEmpty(uuid)) 60 | { 61 | uuid = System.Guid.NewGuid().ToString(); 62 | UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); 63 | } 64 | return uuid; 65 | } 66 | 67 | /// <summary> 68 | /// Disable telemetry (stored in EditorPrefs) 69 | /// </summary> 70 | public static void DisableTelemetry() 71 | { 72 | UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); 73 | } 74 | 75 | /// <summary> 76 | /// Enable telemetry (stored in EditorPrefs) 77 | /// </summary> 78 | public static void EnableTelemetry() 79 | { 80 | UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); 81 | } 82 | 83 | /// <summary> 84 | /// Send telemetry data to Python server for processing 85 | /// This is a lightweight bridge - the actual telemetry logic is in Python 86 | /// </summary> 87 | public static void RecordEvent(string eventType, Dictionary<string, object> data = null) 88 | { 89 | if (!IsEnabled) 90 | return; 91 | 92 | try 93 | { 94 | var telemetryData = new Dictionary<string, object> 95 | { 96 | ["event_type"] = eventType, 97 | ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), 98 | ["customer_uuid"] = GetCustomerUUID(), 99 | ["unity_version"] = Application.unityVersion, 100 | ["platform"] = Application.platform.ToString(), 101 | ["source"] = "unity_bridge" 102 | }; 103 | 104 | if (data != null) 105 | { 106 | telemetryData["data"] = data; 107 | } 108 | 109 | // Send to Python server via existing bridge communication 110 | // The Python server will handle actual telemetry transmission 111 | SendTelemetryToPythonServer(telemetryData); 112 | } 113 | catch (Exception e) 114 | { 115 | // Never let telemetry errors interfere with functionality 116 | if (IsDebugEnabled()) 117 | { 118 | Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}"); 119 | } 120 | } 121 | } 122 | 123 | /// <summary> 124 | /// Allows the bridge to register a concrete sender for telemetry payloads. 125 | /// </summary> 126 | public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender) 127 | { 128 | Interlocked.Exchange(ref s_sender, sender); 129 | } 130 | 131 | public static void UnregisterTelemetrySender() 132 | { 133 | Interlocked.Exchange(ref s_sender, null); 134 | } 135 | 136 | /// <summary> 137 | /// Record bridge startup event 138 | /// </summary> 139 | public static void RecordBridgeStartup() 140 | { 141 | RecordEvent("bridge_startup", new Dictionary<string, object> 142 | { 143 | ["bridge_version"] = "3.0.2", 144 | ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() 145 | }); 146 | } 147 | 148 | /// <summary> 149 | /// Record bridge connection event 150 | /// </summary> 151 | public static void RecordBridgeConnection(bool success, string error = null) 152 | { 153 | var data = new Dictionary<string, object> 154 | { 155 | ["success"] = success 156 | }; 157 | 158 | if (!string.IsNullOrEmpty(error)) 159 | { 160 | data["error"] = error.Substring(0, Math.Min(200, error.Length)); 161 | } 162 | 163 | RecordEvent("bridge_connection", data); 164 | } 165 | 166 | /// <summary> 167 | /// Record tool execution from Unity side 168 | /// </summary> 169 | public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) 170 | { 171 | var data = new Dictionary<string, object> 172 | { 173 | ["tool_name"] = toolName, 174 | ["success"] = success, 175 | ["duration_ms"] = Math.Round(durationMs, 2) 176 | }; 177 | 178 | if (!string.IsNullOrEmpty(error)) 179 | { 180 | data["error"] = error.Substring(0, Math.Min(200, error.Length)); 181 | } 182 | 183 | RecordEvent("tool_execution_unity", data); 184 | } 185 | 186 | private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData) 187 | { 188 | var sender = Volatile.Read(ref s_sender); 189 | if (sender != null) 190 | { 191 | try 192 | { 193 | sender(telemetryData); 194 | return; 195 | } 196 | catch (Exception e) 197 | { 198 | if (IsDebugEnabled()) 199 | { 200 | Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}"); 201 | } 202 | } 203 | } 204 | 205 | // Fallback: log when debug is enabled 206 | if (IsDebugEnabled()) 207 | { 208 | Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}"); 209 | } 210 | } 211 | 212 | private static bool IsDebugEnabled() 213 | { 214 | try 215 | { 216 | return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); 217 | } 218 | catch 219 | { 220 | return false; 221 | } 222 | } 223 | } 224 | } 225 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// macOS-specific dependency detection 12 | /// </summary> 13 | public class MacOSPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "macOS"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths on macOS 29 | var candidates = new[] 30 | { 31 | "python3", 32 | "python", 33 | "/usr/bin/python3", 34 | "/usr/local/bin/python3", 35 | "/opt/homebrew/bin/python3", 36 | "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", 37 | "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", 38 | "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", 39 | "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" 40 | }; 41 | 42 | foreach (var candidate in candidates) 43 | { 44 | if (TryValidatePython(candidate, out string version, out string fullPath)) 45 | { 46 | status.IsAvailable = true; 47 | status.Version = version; 48 | status.Path = fullPath; 49 | status.Details = $"Found Python {version} at {fullPath}"; 50 | return status; 51 | } 52 | } 53 | 54 | // Try PATH resolution using 'which' command 55 | if (TryFindInPath("python3", out string pathResult) || 56 | TryFindInPath("python", out pathResult)) 57 | { 58 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 59 | { 60 | status.IsAvailable = true; 61 | status.Version = version; 62 | status.Path = fullPath; 63 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 64 | return status; 65 | } 66 | } 67 | 68 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 69 | status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; 70 | } 71 | catch (Exception ex) 72 | { 73 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 74 | } 75 | 76 | return status; 77 | } 78 | 79 | public override string GetPythonInstallUrl() 80 | { 81 | return "https://www.python.org/downloads/macos/"; 82 | } 83 | 84 | public override string GetUVInstallUrl() 85 | { 86 | return "https://docs.astral.sh/uv/getting-started/installation/#macos"; 87 | } 88 | 89 | public override string GetInstallationRecommendations() 90 | { 91 | return @"macOS Installation Recommendations: 92 | 93 | 1. Python: Install via Homebrew (recommended) or python.org 94 | - Homebrew: brew install python3 95 | - Direct download: https://python.org/downloads/macos/ 96 | 97 | 2. UV Package Manager: Install via curl or Homebrew 98 | - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh 99 | - Homebrew: brew install uv 100 | 101 | 3. MCP Server: Will be installed automatically by Unity MCP Bridge 102 | 103 | Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; 104 | } 105 | 106 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 107 | { 108 | version = null; 109 | fullPath = null; 110 | 111 | try 112 | { 113 | var psi = new ProcessStartInfo 114 | { 115 | FileName = pythonPath, 116 | Arguments = "--version", 117 | UseShellExecute = false, 118 | RedirectStandardOutput = true, 119 | RedirectStandardError = true, 120 | CreateNoWindow = true 121 | }; 122 | 123 | // Set PATH to include common locations 124 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 125 | var pathAdditions = new[] 126 | { 127 | "/opt/homebrew/bin", 128 | "/usr/local/bin", 129 | "/usr/bin", 130 | Path.Combine(homeDir, ".local", "bin") 131 | }; 132 | 133 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 134 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 135 | 136 | using var process = Process.Start(psi); 137 | if (process == null) return false; 138 | 139 | string output = process.StandardOutput.ReadToEnd().Trim(); 140 | process.WaitForExit(5000); 141 | 142 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 143 | { 144 | version = output.Substring(7); // Remove "Python " prefix 145 | fullPath = pythonPath; 146 | 147 | // Validate minimum version (Python 4+ or Python 3.10+) 148 | if (TryParseVersion(version, out var major, out var minor)) 149 | { 150 | return major > 3 || (major >= 3 && minor >= 10); 151 | } 152 | } 153 | } 154 | catch 155 | { 156 | // Ignore validation errors 157 | } 158 | 159 | return false; 160 | } 161 | 162 | private bool TryFindInPath(string executable, out string fullPath) 163 | { 164 | fullPath = null; 165 | 166 | try 167 | { 168 | var psi = new ProcessStartInfo 169 | { 170 | FileName = "/usr/bin/which", 171 | Arguments = executable, 172 | UseShellExecute = false, 173 | RedirectStandardOutput = true, 174 | RedirectStandardError = true, 175 | CreateNoWindow = true 176 | }; 177 | 178 | // Enhance PATH for Unity's GUI environment 179 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 180 | var pathAdditions = new[] 181 | { 182 | "/opt/homebrew/bin", 183 | "/usr/local/bin", 184 | "/usr/bin", 185 | "/bin", 186 | Path.Combine(homeDir, ".local", "bin") 187 | }; 188 | 189 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 190 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 191 | 192 | using var process = Process.Start(psi); 193 | if (process == null) return false; 194 | 195 | string output = process.StandardOutput.ReadToEnd().Trim(); 196 | process.WaitForExit(3000); 197 | 198 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) 199 | { 200 | fullPath = output; 201 | return true; 202 | } 203 | } 204 | catch 205 | { 206 | // Ignore errors 207 | } 208 | 209 | return false; 210 | } 211 | } 212 | } 213 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 9 | { 10 | /// <summary> 11 | /// macOS-specific dependency detection 12 | /// </summary> 13 | public class MacOSPlatformDetector : PlatformDetectorBase 14 | { 15 | public override string PlatformName => "macOS"; 16 | 17 | public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 18 | 19 | public override DependencyStatus DetectPython() 20 | { 21 | var status = new DependencyStatus("Python", isRequired: true) 22 | { 23 | InstallationHint = GetPythonInstallUrl() 24 | }; 25 | 26 | try 27 | { 28 | // Check common Python installation paths on macOS 29 | var candidates = new[] 30 | { 31 | "python3", 32 | "python", 33 | "/usr/bin/python3", 34 | "/usr/local/bin/python3", 35 | "/opt/homebrew/bin/python3", 36 | "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", 37 | "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", 38 | "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", 39 | "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" 40 | }; 41 | 42 | foreach (var candidate in candidates) 43 | { 44 | if (TryValidatePython(candidate, out string version, out string fullPath)) 45 | { 46 | status.IsAvailable = true; 47 | status.Version = version; 48 | status.Path = fullPath; 49 | status.Details = $"Found Python {version} at {fullPath}"; 50 | return status; 51 | } 52 | } 53 | 54 | // Try PATH resolution using 'which' command 55 | if (TryFindInPath("python3", out string pathResult) || 56 | TryFindInPath("python", out pathResult)) 57 | { 58 | if (TryValidatePython(pathResult, out string version, out string fullPath)) 59 | { 60 | status.IsAvailable = true; 61 | status.Version = version; 62 | status.Path = fullPath; 63 | status.Details = $"Found Python {version} in PATH at {fullPath}"; 64 | return status; 65 | } 66 | } 67 | 68 | status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; 69 | status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; 70 | } 71 | catch (Exception ex) 72 | { 73 | status.ErrorMessage = $"Error detecting Python: {ex.Message}"; 74 | } 75 | 76 | return status; 77 | } 78 | 79 | public override string GetPythonInstallUrl() 80 | { 81 | return "https://www.python.org/downloads/macos/"; 82 | } 83 | 84 | public override string GetUVInstallUrl() 85 | { 86 | return "https://docs.astral.sh/uv/getting-started/installation/#macos"; 87 | } 88 | 89 | public override string GetInstallationRecommendations() 90 | { 91 | return @"macOS Installation Recommendations: 92 | 93 | 1. Python: Install via Homebrew (recommended) or python.org 94 | - Homebrew: brew install python3 95 | - Direct download: https://python.org/downloads/macos/ 96 | 97 | 2. UV Package Manager: Install via curl or Homebrew 98 | - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh 99 | - Homebrew: brew install uv 100 | 101 | 3. MCP Server: Will be installed automatically by MCP for Unity Bridge 102 | 103 | Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; 104 | } 105 | 106 | private bool TryValidatePython(string pythonPath, out string version, out string fullPath) 107 | { 108 | version = null; 109 | fullPath = null; 110 | 111 | try 112 | { 113 | var psi = new ProcessStartInfo 114 | { 115 | FileName = pythonPath, 116 | Arguments = "--version", 117 | UseShellExecute = false, 118 | RedirectStandardOutput = true, 119 | RedirectStandardError = true, 120 | CreateNoWindow = true 121 | }; 122 | 123 | // Set PATH to include common locations 124 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 125 | var pathAdditions = new[] 126 | { 127 | "/opt/homebrew/bin", 128 | "/usr/local/bin", 129 | "/usr/bin", 130 | Path.Combine(homeDir, ".local", "bin") 131 | }; 132 | 133 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 134 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 135 | 136 | using var process = Process.Start(psi); 137 | if (process == null) return false; 138 | 139 | string output = process.StandardOutput.ReadToEnd().Trim(); 140 | process.WaitForExit(5000); 141 | 142 | if (process.ExitCode == 0 && output.StartsWith("Python ")) 143 | { 144 | version = output.Substring(7); // Remove "Python " prefix 145 | fullPath = pythonPath; 146 | 147 | // Validate minimum version (Python 4+ or Python 3.10+) 148 | if (TryParseVersion(version, out var major, out var minor)) 149 | { 150 | return major > 3 || (major >= 3 && minor >= 10); 151 | } 152 | } 153 | } 154 | catch 155 | { 156 | // Ignore validation errors 157 | } 158 | 159 | return false; 160 | } 161 | 162 | private bool TryFindInPath(string executable, out string fullPath) 163 | { 164 | fullPath = null; 165 | 166 | try 167 | { 168 | var psi = new ProcessStartInfo 169 | { 170 | FileName = "/usr/bin/which", 171 | Arguments = executable, 172 | UseShellExecute = false, 173 | RedirectStandardOutput = true, 174 | RedirectStandardError = true, 175 | CreateNoWindow = true 176 | }; 177 | 178 | // Enhance PATH for Unity's GUI environment 179 | var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 180 | var pathAdditions = new[] 181 | { 182 | "/opt/homebrew/bin", 183 | "/usr/local/bin", 184 | "/usr/bin", 185 | "/bin", 186 | Path.Combine(homeDir, ".local", "bin") 187 | }; 188 | 189 | string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; 190 | psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; 191 | 192 | using var process = Process.Start(psi); 193 | if (process == null) return false; 194 | 195 | string output = process.StandardOutput.ReadToEnd().Trim(); 196 | process.WaitForExit(3000); 197 | 198 | if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) 199 | { 200 | fullPath = output; 201 | return true; 202 | } 203 | } 204 | catch 205 | { 206 | // Ignore errors 207 | } 208 | 209 | return false; 210 | } 211 | } 212 | } 213 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from unity_connection import send_command_with_retry 6 | 7 | 8 | @mcp_for_unity_tool( 9 | description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." 10 | ) 11 | def manage_gameobject( 12 | ctx: Context, 13 | action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], 14 | target: Annotated[str, 15 | "GameObject identifier by name or path for modify/delete/component actions"] | None = None, 16 | search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], 17 | "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, 18 | name: Annotated[str, 19 | "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, 20 | tag: Annotated[str, 21 | "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, 22 | parent: Annotated[str, 23 | "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, 24 | position: Annotated[list[float], 25 | "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, 26 | rotation: Annotated[list[float], 27 | "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, 28 | scale: Annotated[list[float], 29 | "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, 30 | components_to_add: Annotated[list[str], 31 | "List of component names to add"] | None = None, 32 | primitive_type: Annotated[str, 33 | "Primitive type for 'create' action"] | None = None, 34 | save_as_prefab: Annotated[bool, 35 | "If True, saves the created GameObject as a prefab"] | None = None, 36 | prefab_path: Annotated[str, "Path for prefab creation"] | None = None, 37 | prefab_folder: Annotated[str, 38 | "Folder for prefab creation"] | None = None, 39 | # --- Parameters for 'modify' --- 40 | set_active: Annotated[bool, 41 | "If True, sets the GameObject active"] | None = None, 42 | layer: Annotated[str, "Layer name"] | None = None, 43 | components_to_remove: Annotated[list[str], 44 | "List of component names to remove"] | None = None, 45 | component_properties: Annotated[dict[str, dict[str, Any]], 46 | """Dictionary of component names to their properties to set. For example: 47 | `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject 48 | `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component 49 | Example set nested property: 50 | - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, 51 | # --- Parameters for 'find' --- 52 | search_term: Annotated[str, 53 | "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, 54 | find_all: Annotated[bool, 55 | "If True, finds all GameObjects matching the search term"] | None = None, 56 | search_in_children: Annotated[bool, 57 | "If True, searches in children of the GameObject"] | None = None, 58 | search_inactive: Annotated[bool, 59 | "If True, searches inactive GameObjects"] | None = None, 60 | # -- Component Management Arguments -- 61 | component_name: Annotated[str, 62 | "Component name for 'add_component' and 'remove_component' actions"] | None = None, 63 | # Controls whether serialization of private [SerializeField] fields is included 64 | includeNonPublicSerialized: Annotated[bool, 65 | "Controls whether serialization of private [SerializeField] fields is included"] | None = None, 66 | ) -> dict[str, Any]: 67 | ctx.info(f"Processing manage_gameobject: {action}") 68 | try: 69 | # Validate parameter usage to prevent silent failures 70 | if action == "find": 71 | if name is not None: 72 | return { 73 | "success": False, 74 | "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" 75 | } 76 | if search_term is None: 77 | return { 78 | "success": False, 79 | "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." 80 | } 81 | 82 | if action in ["create", "modify"]: 83 | if search_term is not None: 84 | return { 85 | "success": False, 86 | "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." 87 | } 88 | 89 | # Prepare parameters, removing None values 90 | params = { 91 | "action": action, 92 | "target": target, 93 | "searchMethod": search_method, 94 | "name": name, 95 | "tag": tag, 96 | "parent": parent, 97 | "position": position, 98 | "rotation": rotation, 99 | "scale": scale, 100 | "componentsToAdd": components_to_add, 101 | "primitiveType": primitive_type, 102 | "saveAsPrefab": save_as_prefab, 103 | "prefabPath": prefab_path, 104 | "prefabFolder": prefab_folder, 105 | "setActive": set_active, 106 | "layer": layer, 107 | "componentsToRemove": components_to_remove, 108 | "componentProperties": component_properties, 109 | "searchTerm": search_term, 110 | "findAll": find_all, 111 | "searchInChildren": search_in_children, 112 | "searchInactive": search_inactive, 113 | "componentName": component_name, 114 | "includeNonPublicSerialized": includeNonPublicSerialized 115 | } 116 | params = {k: v for k, v in params.items() if v is not None} 117 | 118 | # --- Handle Prefab Path Logic --- 119 | # Check if 'saveAsPrefab' is explicitly True in params 120 | if action == "create" and params.get("saveAsPrefab"): 121 | if "prefabPath" not in params: 122 | if "name" not in params or not params["name"]: 123 | return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} 124 | # Use the provided prefab_folder (which has a default) and the name to construct the path 125 | constructed_path = f"{prefab_folder}/{params['name']}.prefab" 126 | # Ensure clean path separators (Unity prefers '/') 127 | params["prefabPath"] = constructed_path.replace("\\", "/") 128 | elif not params["prefabPath"].lower().endswith(".prefab"): 129 | return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} 130 | # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided 131 | # The C# side only needs the final prefabPath 132 | params.pop("prefabFolder", None) 133 | # -------------------------------- 134 | 135 | # Use centralized retry helper 136 | response = send_command_with_retry("manage_gameobject", params) 137 | 138 | # Check if the response indicates success 139 | # If the response is not successful, raise an exception with the error message 140 | if isinstance(response, dict) and response.get("success"): 141 | return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} 142 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 143 | 144 | except Exception as e: 145 | return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from unity_connection import send_command_with_retry 6 | 7 | 8 | @mcp_for_unity_tool( 9 | description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." 10 | ) 11 | def manage_gameobject( 12 | ctx: Context, 13 | action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], 14 | target: Annotated[str, 15 | "GameObject identifier by name or path for modify/delete/component actions"] | None = None, 16 | search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], 17 | "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, 18 | name: Annotated[str, 19 | "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, 20 | tag: Annotated[str, 21 | "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, 22 | parent: Annotated[str, 23 | "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, 24 | position: Annotated[list[float], 25 | "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, 26 | rotation: Annotated[list[float], 27 | "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, 28 | scale: Annotated[list[float], 29 | "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, 30 | components_to_add: Annotated[list[str], 31 | "List of component names to add"] | None = None, 32 | primitive_type: Annotated[str, 33 | "Primitive type for 'create' action"] | None = None, 34 | save_as_prefab: Annotated[bool, 35 | "If True, saves the created GameObject as a prefab"] | None = None, 36 | prefab_path: Annotated[str, "Path for prefab creation"] | None = None, 37 | prefab_folder: Annotated[str, 38 | "Folder for prefab creation"] | None = None, 39 | # --- Parameters for 'modify' --- 40 | set_active: Annotated[bool, 41 | "If True, sets the GameObject active"] | None = None, 42 | layer: Annotated[str, "Layer name"] | None = None, 43 | components_to_remove: Annotated[list[str], 44 | "List of component names to remove"] | None = None, 45 | component_properties: Annotated[dict[str, dict[str, Any]], 46 | """Dictionary of component names to their properties to set. For example: 47 | `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject 48 | `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component 49 | Example set nested property: 50 | - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, 51 | # --- Parameters for 'find' --- 52 | search_term: Annotated[str, 53 | "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, 54 | find_all: Annotated[bool, 55 | "If True, finds all GameObjects matching the search term"] | None = None, 56 | search_in_children: Annotated[bool, 57 | "If True, searches in children of the GameObject"] | None = None, 58 | search_inactive: Annotated[bool, 59 | "If True, searches inactive GameObjects"] | None = None, 60 | # -- Component Management Arguments -- 61 | component_name: Annotated[str, 62 | "Component name for 'add_component' and 'remove_component' actions"] | None = None, 63 | # Controls whether serialization of private [SerializeField] fields is included 64 | includeNonPublicSerialized: Annotated[bool, 65 | "Controls whether serialization of private [SerializeField] fields is included"] | None = None, 66 | ) -> dict[str, Any]: 67 | ctx.info(f"Processing manage_gameobject: {action}") 68 | try: 69 | # Validate parameter usage to prevent silent failures 70 | if action == "find": 71 | if name is not None: 72 | return { 73 | "success": False, 74 | "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" 75 | } 76 | if search_term is None: 77 | return { 78 | "success": False, 79 | "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." 80 | } 81 | 82 | if action in ["create", "modify"]: 83 | if search_term is not None: 84 | return { 85 | "success": False, 86 | "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." 87 | } 88 | 89 | # Prepare parameters, removing None values 90 | params = { 91 | "action": action, 92 | "target": target, 93 | "searchMethod": search_method, 94 | "name": name, 95 | "tag": tag, 96 | "parent": parent, 97 | "position": position, 98 | "rotation": rotation, 99 | "scale": scale, 100 | "componentsToAdd": components_to_add, 101 | "primitiveType": primitive_type, 102 | "saveAsPrefab": save_as_prefab, 103 | "prefabPath": prefab_path, 104 | "prefabFolder": prefab_folder, 105 | "setActive": set_active, 106 | "layer": layer, 107 | "componentsToRemove": components_to_remove, 108 | "componentProperties": component_properties, 109 | "searchTerm": search_term, 110 | "findAll": find_all, 111 | "searchInChildren": search_in_children, 112 | "searchInactive": search_inactive, 113 | "componentName": component_name, 114 | "includeNonPublicSerialized": includeNonPublicSerialized 115 | } 116 | params = {k: v for k, v in params.items() if v is not None} 117 | 118 | # --- Handle Prefab Path Logic --- 119 | # Check if 'saveAsPrefab' is explicitly True in params 120 | if action == "create" and params.get("saveAsPrefab"): 121 | if "prefabPath" not in params: 122 | if "name" not in params or not params["name"]: 123 | return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} 124 | # Use the provided prefab_folder (which has a default) and the name to construct the path 125 | constructed_path = f"{prefab_folder}/{params['name']}.prefab" 126 | # Ensure clean path separators (Unity prefers '/') 127 | params["prefabPath"] = constructed_path.replace("\\", "/") 128 | elif not params["prefabPath"].lower().endswith(".prefab"): 129 | return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} 130 | # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided 131 | # The C# side only needs the final prefabPath 132 | params.pop("prefabFolder", None) 133 | # -------------------------------- 134 | 135 | # Use centralized retry helper 136 | response = send_command_with_retry("manage_gameobject", params) 137 | 138 | # Check if the response indicates success 139 | # If the response is not successful, raise an exception with the error message 140 | if isinstance(response, dict) and response.get("success"): 141 | return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} 142 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 143 | 144 | except Exception as e: 145 | return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.IO; 2 | using Newtonsoft.Json.Linq; 3 | using NUnit.Framework; 4 | using UnityEditor; 5 | using UnityEditor.SceneManagement; 6 | using UnityEngine; 7 | using MCPForUnity.Editor.Tools.Prefabs; 8 | using MCPForUnity.Editor.Tools; 9 | 10 | namespace MCPForUnityTests.Editor.Tools 11 | { 12 | public class ManagePrefabsTests 13 | { 14 | private const string TempDirectory = "Assets/Temp/ManagePrefabsTests"; 15 | 16 | [SetUp] 17 | public void SetUp() 18 | { 19 | StageUtility.GoToMainStage(); 20 | EnsureTempDirectoryExists(); 21 | } 22 | 23 | [TearDown] 24 | public void TearDown() 25 | { 26 | StageUtility.GoToMainStage(); 27 | } 28 | 29 | [OneTimeTearDown] 30 | public void CleanupAll() 31 | { 32 | StageUtility.GoToMainStage(); 33 | if (AssetDatabase.IsValidFolder(TempDirectory)) 34 | { 35 | AssetDatabase.DeleteAsset(TempDirectory); 36 | } 37 | } 38 | 39 | [Test] 40 | public void OpenStage_OpensPrefabInIsolation() 41 | { 42 | string prefabPath = CreateTestPrefab("OpenStageCube"); 43 | 44 | try 45 | { 46 | var openParams = new JObject 47 | { 48 | ["action"] = "open_stage", 49 | ["prefabPath"] = prefabPath 50 | }; 51 | 52 | var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams)); 53 | 54 | Assert.IsTrue(openResult.Value<bool>("success"), "open_stage should succeed for a valid prefab."); 55 | 56 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 57 | Assert.IsNotNull(stage, "Prefab stage should be open after open_stage."); 58 | Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path."); 59 | 60 | var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" })); 61 | Assert.IsTrue(stageInfo.Value<bool>("success"), "get_prefab_stage should succeed when stage is open."); 62 | 63 | var data = stageInfo["data"] as JObject; 64 | Assert.IsNotNull(data, "Stage info should include data payload."); 65 | Assert.IsTrue(data.Value<bool>("isOpen")); 66 | Assert.AreEqual(prefabPath, data.Value<string>("assetPath")); 67 | } 68 | finally 69 | { 70 | StageUtility.GoToMainStage(); 71 | AssetDatabase.DeleteAsset(prefabPath); 72 | } 73 | } 74 | 75 | [Test] 76 | public void CloseStage_ReturnsSuccess_WhenNoStageOpen() 77 | { 78 | StageUtility.GoToMainStage(); 79 | var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject 80 | { 81 | ["action"] = "close_stage" 82 | })); 83 | 84 | Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed even if no stage is open."); 85 | } 86 | 87 | [Test] 88 | public void CloseStage_ClosesOpenPrefabStage() 89 | { 90 | string prefabPath = CreateTestPrefab("CloseStageCube"); 91 | 92 | try 93 | { 94 | ManagePrefabs.HandleCommand(new JObject 95 | { 96 | ["action"] = "open_stage", 97 | ["prefabPath"] = prefabPath 98 | }); 99 | 100 | var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject 101 | { 102 | ["action"] = "close_stage" 103 | })); 104 | 105 | Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed when stage is open."); 106 | Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage."); 107 | } 108 | finally 109 | { 110 | StageUtility.GoToMainStage(); 111 | AssetDatabase.DeleteAsset(prefabPath); 112 | } 113 | } 114 | 115 | [Test] 116 | public void SaveOpenStage_SavesDirtyChanges() 117 | { 118 | string prefabPath = CreateTestPrefab("SaveStageCube"); 119 | 120 | try 121 | { 122 | ManagePrefabs.HandleCommand(new JObject 123 | { 124 | ["action"] = "open_stage", 125 | ["prefabPath"] = prefabPath 126 | }); 127 | 128 | PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); 129 | Assert.IsNotNull(stage, "Stage should be open before modifying."); 130 | 131 | stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); 132 | 133 | var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject 134 | { 135 | ["action"] = "save_open_stage" 136 | })); 137 | 138 | Assert.IsTrue(saveResult.Value<bool>("success"), "save_open_stage should succeed when stage is open."); 139 | Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving."); 140 | 141 | GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath); 142 | Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage."); 143 | } 144 | finally 145 | { 146 | StageUtility.GoToMainStage(); 147 | AssetDatabase.DeleteAsset(prefabPath); 148 | } 149 | } 150 | 151 | [Test] 152 | public void SaveOpenStage_ReturnsError_WhenNoStageOpen() 153 | { 154 | StageUtility.GoToMainStage(); 155 | 156 | var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject 157 | { 158 | ["action"] = "save_open_stage" 159 | })); 160 | 161 | Assert.IsFalse(saveResult.Value<bool>("success"), "save_open_stage should fail when no stage is open."); 162 | } 163 | 164 | [Test] 165 | public void CreateFromGameObject_CreatesPrefabAndLinksInstance() 166 | { 167 | EnsureTempDirectoryExists(); 168 | StageUtility.GoToMainStage(); 169 | 170 | string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/'); 171 | GameObject sceneObject = new GameObject("ScenePrefabSource"); 172 | 173 | try 174 | { 175 | var result = ToJObject(ManagePrefabs.HandleCommand(new JObject 176 | { 177 | ["action"] = "create_from_gameobject", 178 | ["target"] = sceneObject.name, 179 | ["prefabPath"] = prefabPath 180 | })); 181 | 182 | Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed for a valid scene object."); 183 | 184 | var data = result["data"] as JObject; 185 | Assert.IsNotNull(data, "Response data should include prefab information."); 186 | 187 | string savedPath = data.Value<string>("prefabPath"); 188 | Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); 189 | 190 | GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(savedPath); 191 | Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); 192 | 193 | int instanceId = data.Value<int>("instanceId"); 194 | var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject; 195 | Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId."); 196 | Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab."); 197 | 198 | sceneObject = linkedInstance; 199 | } 200 | finally 201 | { 202 | if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(prefabPath) != null) 203 | { 204 | AssetDatabase.DeleteAsset(prefabPath); 205 | } 206 | 207 | if (sceneObject != null) 208 | { 209 | if (PrefabUtility.IsPartOfPrefabInstance(sceneObject)) 210 | { 211 | PrefabUtility.UnpackPrefabInstance( 212 | sceneObject, 213 | PrefabUnpackMode.Completely, 214 | InteractionMode.AutomatedAction 215 | ); 216 | } 217 | UnityEngine.Object.DestroyImmediate(sceneObject, true); 218 | } 219 | } 220 | } 221 | 222 | private static string CreateTestPrefab(string name) 223 | { 224 | EnsureTempDirectoryExists(); 225 | 226 | GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube); 227 | temp.name = name; 228 | 229 | string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); 230 | PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); 231 | UnityEngine.Object.DestroyImmediate(temp); 232 | 233 | Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab."); 234 | return path; 235 | } 236 | 237 | private static void EnsureTempDirectoryExists() 238 | { 239 | if (!AssetDatabase.IsValidFolder("Assets/Temp")) 240 | { 241 | AssetDatabase.CreateFolder("Assets", "Temp"); 242 | } 243 | 244 | if (!AssetDatabase.IsValidFolder(TempDirectory)) 245 | { 246 | AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests"); 247 | } 248 | } 249 | 250 | private static JObject ToJObject(object result) 251 | { 252 | return result as JObject ?? JObject.FromObject(result); 253 | } 254 | } 255 | } 256 | ``` -------------------------------------------------------------------------------- /.claude/prompts/nl-unity-suite-nl.md: -------------------------------------------------------------------------------- ```markdown 1 | # Unity NL Editing Suite — Additive Test Design 2 | 3 | You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. 4 | 5 | **Print this once, verbatim, early in the run:** 6 | AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha 7 | 8 | --- 9 | 10 | ## Mission 11 | 1) Pick target file (prefer): 12 | - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 13 | 2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other. 14 | 3) Validate each edit with `mcp__unity__validate_script(level:"standard")`. 15 | 4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`. 16 | 17 | **CRITICAL XML FORMAT REQUIREMENTS:** 18 | - Each file must contain EXACTLY one `<testcase>` root element 19 | - NO prologue, epilogue, code fences, or extra characters 20 | - NO markdown formatting or explanations outside the XML 21 | - Use this exact format: 22 | 23 | ```xml 24 | <testcase name="NL-0 — Baseline State Capture" classname="UnityMCP.NL-T"> 25 | <system-out><![CDATA[ 26 | (evidence of what was accomplished) 27 | ]]></system-out> 28 | </testcase> 29 | ``` 30 | 31 | - If test fails, include: `<failure message="reason"/>` 32 | - TESTID must be one of: NL-0, NL-1, NL-2, NL-3, NL-4 33 | 5) **NO RESTORATION** - tests build additively on previous state. 34 | 6) **STRICT FRAGMENT EMISSION** - After each test, immediately emit a clean XML file under `reports/<TESTID>_results.xml` with exactly one `<testcase>` whose `name` begins with the exact test id. No prologue/epilogue or fences. If the test fails, include a `<failure message="..."/>` and still emit. 35 | 36 | --- 37 | 38 | ## Environment & Paths (CI) 39 | - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. 40 | - **Canonical URIs only**: 41 | - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) 42 | - Relative (when supported): `Assets/...` 43 | 44 | CI provides: 45 | - `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created; leave alone) 46 | - `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit) 47 | 48 | --- 49 | 50 | ## Transcript Minimization Rules 51 | - Do not restate tool JSON; summarize in ≤ 2 short lines. 52 | - Never paste full file contents. For matches, include only the matched line and ±1 line. 53 | - Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`. 54 | - Per‑test `system-out` ≤ 400 chars: brief status only (no SHA). 55 | - Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment. 56 | - Avoid quoting multi‑line diffs; reference markers instead. 57 | — Console scans: perform two reads — last 10 `log/info` lines and up to 3 `error` entries (use `include_stacktrace:false`); include ≤ 3 lines total in the fragment; if no errors, state "no errors". 58 | 59 | --- 60 | 61 | ## Tool Mapping 62 | - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` 63 | - Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace` 64 | - For `anchor_insert`, always set `"position": "before"` or `"after"`. 65 | - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) 66 | STRICT OP GUARDRAILS 67 | - Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`. 68 | - For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`. 69 | 70 | - **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body 71 | - **Validation**: `mcp__unity__validate_script(level:"standard")` 72 | - **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers 73 | 74 | --- 75 | 76 | ## Additive Test Design Principles 77 | 78 | **Key Changes from Reset-Based:** 79 | 1. **Dynamic Targeting**: Use `find_in_file` to locate methods/content, never hardcode line numbers 80 | 2. **State Awareness**: Each test expects the file state left by the previous test 81 | 3. **Content-Based Operations**: Target methods by signature, classes by name, not coordinates 82 | 4. **Cumulative Validation**: Ensure the file remains structurally sound throughout the sequence 83 | 5. **Composability**: Tests demonstrate how operations work together in real workflows 84 | 85 | **State Tracking:** 86 | - Track file SHA after each test (`mcp__unity__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments. 87 | - Use content signatures (method names, comment markers) to verify expected state 88 | - Validate structural integrity after each major change 89 | 90 | --- 91 | 92 | ## Execution Order & Additive Test Specs 93 | 94 | ### NL-0. Baseline State Capture 95 | **Goal**: Establish initial file state and verify accessibility 96 | **Actions**: 97 | - Read file head and tail to confirm structure 98 | - Locate key methods: `HasTarget()`, `GetCurrentTarget()`, `Update()`, `ApplyBlend()` 99 | - Record initial SHA for tracking 100 | - **Expected final state**: Unchanged baseline file 101 | 102 | ### NL-1. Core Method Operations (Additive State A) 103 | **Goal**: Demonstrate method replacement operations 104 | **Actions**: 105 | - Replace `HasTarget()` method body: `public bool HasTarget() { return currentTarget != null; }` 106 | - Insert `PrintSeries()` method after `GetCurrentTarget()`: `public void PrintSeries() { Debug.Log("1,2,3"); }` 107 | - Verify both methods exist and are properly formatted 108 | - Delete `PrintSeries()` method (cleanup for next test) 109 | - **Expected final state**: `HasTarget()` modified, file structure intact, no temporary methods 110 | 111 | ### NL-2. Anchor Comment Insertion (Additive State B) 112 | **Goal**: Demonstrate anchor-based insertions above methods 113 | **Actions**: 114 | - Use `find_in_file` to locate current position of `Update()` method 115 | - Insert `// Build marker OK` comment line above `Update()` method 116 | - Verify comment exists and `Update()` still functions 117 | - **Expected final state**: State A + build marker comment above `Update()` 118 | 119 | ### NL-3. End-of-Class Content (Additive State C) 120 | **Goal**: Demonstrate end-of-class insertions with smart brace matching 121 | **Actions**: 122 | - Match the final class-closing brace by scanning from EOF (e.g., last `^\s*}\s*$`) 123 | or compute via `find_in_file` + ranges; insert immediately before it. 124 | - Insert three comment lines before final class brace: 125 | ``` 126 | // Tail test A 127 | // Tail test B 128 | // Tail test C 129 | ``` 130 | - **Expected final state**: State B + tail comments before class closing brace 131 | 132 | ### NL-4. Console State Verification (No State Change) 133 | **Goal**: Verify Unity console integration without file modification 134 | **Actions**: 135 | - Read last 10 Unity console lines (log/info) 136 | - Perform a targeted scan for errors/exceptions (type: errors), up to 3 entries 137 | - Validate no compilation errors from previous operations 138 | - **Expected final state**: State C (unchanged) 139 | - **IMMEDIATELY** write clean XML fragment to `reports/NL-4_results.xml` (no extra text). The `<testcase name>` must start with `NL-4`. Include at most 3 lines total across both reads, or simply state "no errors; console OK" (≤ 400 chars). 140 | 141 | ## Dynamic Targeting Examples 142 | 143 | **Instead of hardcoded coordinates:** 144 | ```json 145 | {"startLine": 31, "startCol": 26, "endLine": 31, "endCol": 58} 146 | ``` 147 | 148 | **Use content-aware targeting:** 149 | ```json 150 | # Find current method location 151 | find_in_file(pattern: "public bool HasTarget\\(\\)") 152 | # Then compute edit ranges from found position 153 | ``` 154 | 155 | **Method targeting by signature:** 156 | ```json 157 | {"op": "replace_method", "className": "LongUnityScriptClaudeTest", "methodName": "HasTarget"} 158 | ``` 159 | 160 | **Anchor-based insertions:** 161 | ```json 162 | {"op": "anchor_insert", "anchor": "private void Update\\(\\)", "position": "before", "text": "// comment"} 163 | ``` 164 | 165 | --- 166 | 167 | ## State Verification Patterns 168 | 169 | **After each test:** 170 | 1. Verify expected content exists: `find_in_file` for key markers 171 | 2. Check structural integrity: `validate_script(level:"standard")` 172 | 3. Update SHA tracking for next test's preconditions 173 | 4. Emit a per‑test fragment to `reports/<TESTID>_results.xml` immediately. If the test failed, still write a single `<testcase>` with a `<failure message="..."/>` and evidence in `system-out`. 174 | 5. Log cumulative changes in test evidence (keep concise per Transcript Minimization Rules; never paste raw tool JSON) 175 | 176 | **Error Recovery:** 177 | - If test fails, log current state but continue (don't restore) 178 | - Next test adapts to actual current state, not expected state 179 | - Demonstrates resilience of operations on varied file conditions 180 | 181 | --- 182 | 183 | ## Benefits of Additive Design 184 | 185 | 1. **Realistic Workflows**: Tests mirror actual development patterns 186 | 2. **Robust Operations**: Proves edits work on evolving files, not just pristine baselines 187 | 3. **Composability Validation**: Shows operations coordinate well together 188 | 4. **Simplified Infrastructure**: No restore scripts or snapshots needed 189 | 5. **Better Failure Analysis**: Failures don't cascade - each test adapts to current reality 190 | 6. **State Evolution Testing**: Validates SDK handles cumulative file modifications correctly 191 | 192 | This additive approach produces a more realistic and maintainable test suite that better represents actual SDK usage patterns. 193 | 194 | --- 195 | 196 | BAN ON EXTRA TOOLS AND DIRS 197 | - Do not use any tools outside `AllowedTools`. Do not create directories; assume `reports/` exists. 198 | 199 | --- 200 | 201 | ```