This is page 2 of 13. Use http://codebase.md/justinpbarnett/unity-mcp?page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources/GetMenuItemsTests.cs: -------------------------------------------------------------------------------- ```csharp using NUnit.Framework; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Resources.MenuItems; using System; using System.Linq; namespace MCPForUnityTests.Editor.Resources.MenuItems { public class GetMenuItemsTests { private static JObject ToJO(object o) => JObject.FromObject(o); [Test] public void NoSearch_ReturnsSuccessAndArray() { var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false }); var jo = ToJO(res); Assert.IsTrue((bool)jo["success"], "Expected success true"); Assert.IsNotNull(jo["data"], "Expected data field present"); Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); // Validate list is sorted ascending when there are multiple items var arr = (JArray)jo["data"]; if (arr.Count >= 2) { var original = arr.Select(t => (string)t).ToList(); var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList(); CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending"); } } [Test] public void SearchNoMatch_ReturnsEmpty() { var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "___unlikely___term___" }); var jo = ToJO(res); Assert.IsTrue((bool)jo["success"], "Expected success true"); Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term"); } [Test] public void SearchMatchesExistingItem_ReturnsContainingItem() { // Get the full list first var listRes = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false }); var listJo = ToJO(listRes); if (listJo["data"] is JArray arr && arr.Count > 0) { var first = (string)arr[0]; // Use a mid-substring (case-insensitive) to avoid edge cases var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first; term = term.ToLowerInvariant(); var res = GetMenuItems.HandleCommand(new JObject { ["search"] = term, ["refresh"] = false }); var jo = ToJO(res); Assert.IsTrue((bool)jo["success"], "Expected success true"); Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); // Expect at least the original item to be present var names = ((JArray)jo["data"]).Select(t => (string)t).ToList(); CollectionAssert.Contains(names, first, "Expected search results to include the sampled item"); } else { Assert.Pass("No menu items available to perform a content-based search assertion."); } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; namespace MCPForUnity.Editor.Dependencies.Models { /// <summary> /// Result of a comprehensive dependency check /// </summary> [Serializable] public class DependencyCheckResult { /// <summary> /// List of all dependency statuses checked /// </summary> public List<DependencyStatus> Dependencies { get; set; } /// <summary> /// Overall system readiness for MCP operations /// </summary> public bool IsSystemReady { get; set; } /// <summary> /// Whether all required dependencies are available /// </summary> public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; /// <summary> /// Whether any optional dependencies are missing /// </summary> public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; /// <summary> /// Summary message about the dependency state /// </summary> public string Summary { get; set; } /// <summary> /// Recommended next steps for the user /// </summary> public List<string> RecommendedActions { get; set; } /// <summary> /// Timestamp when this check was performed /// </summary> public DateTime CheckedAt { get; set; } public DependencyCheckResult() { Dependencies = new List<DependencyStatus>(); RecommendedActions = new List<string>(); CheckedAt = DateTime.UtcNow; } /// <summary> /// Get dependencies by availability status /// </summary> public List<DependencyStatus> GetMissingDependencies() { return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); } /// <summary> /// Get missing required dependencies /// </summary> public List<DependencyStatus> GetMissingRequired() { return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); } /// <summary> /// Generate a user-friendly summary of the dependency state /// </summary> public void GenerateSummary() { var missing = GetMissingDependencies(); var missingRequired = GetMissingRequired(); if (missing.Count == 0) { Summary = "All dependencies are available and ready."; IsSystemReady = true; } else if (missingRequired.Count == 0) { Summary = $"System is ready. {missing.Count} optional dependencies are missing."; IsSystemReady = true; } else { Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; IsSystemReady = false; } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; namespace MCPForUnity.Editor.Dependencies.Models { /// <summary> /// Result of a comprehensive dependency check /// </summary> [Serializable] public class DependencyCheckResult { /// <summary> /// List of all dependency statuses checked /// </summary> public List<DependencyStatus> Dependencies { get; set; } /// <summary> /// Overall system readiness for MCP operations /// </summary> public bool IsSystemReady { get; set; } /// <summary> /// Whether all required dependencies are available /// </summary> public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; /// <summary> /// Whether any optional dependencies are missing /// </summary> public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; /// <summary> /// Summary message about the dependency state /// </summary> public string Summary { get; set; } /// <summary> /// Recommended next steps for the user /// </summary> public List<string> RecommendedActions { get; set; } /// <summary> /// Timestamp when this check was performed /// </summary> public DateTime CheckedAt { get; set; } public DependencyCheckResult() { Dependencies = new List<DependencyStatus>(); RecommendedActions = new List<string>(); CheckedAt = DateTime.UtcNow; } /// <summary> /// Get dependencies by availability status /// </summary> public List<DependencyStatus> GetMissingDependencies() { return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); } /// <summary> /// Get missing required dependencies /// </summary> public List<DependencyStatus> GetMissingRequired() { return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); } /// <summary> /// Generate a user-friendly summary of the dependency state /// </summary> public void GenerateSummary() { var missing = GetMissingDependencies(); var missingRequired = GetMissingRequired(); if (missing.Count == 0) { Summary = "All dependencies are available and ready."; IsSystemReady = true; } else if (missingRequired.Count == 0) { Summary = $"System is ready. {missing.Count} optional dependencies are missing."; IsSystemReady = true; } else { Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; IsSystemReady = false; } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/IClientConfigurationService.cs: -------------------------------------------------------------------------------- ```csharp using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Services { /// <summary> /// Service for configuring MCP clients /// </summary> public interface IClientConfigurationService { /// <summary> /// Configures a specific MCP client /// </summary> /// <param name="client">The client to configure</param> void ConfigureClient(McpClient client); /// <summary> /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) /// </summary> /// <returns>Summary of configuration results</returns> ClientConfigurationSummary ConfigureAllDetectedClients(); /// <summary> /// Checks the configuration status of a client /// </summary> /// <param name="client">The client to check</param> /// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param> /// <returns>True if status changed, false otherwise</returns> bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true); /// <summary> /// Registers MCP for Unity with Claude Code CLI /// </summary> void RegisterClaudeCode(); /// <summary> /// Unregisters MCP for Unity from Claude Code CLI /// </summary> void UnregisterClaudeCode(); /// <summary> /// Gets the configuration file path for a client /// </summary> /// <param name="client">The client</param> /// <returns>Platform-specific config path</returns> string GetConfigPath(McpClient client); /// <summary> /// Generates the configuration JSON for a client /// </summary> /// <param name="client">The client</param> /// <returns>JSON configuration string</returns> string GenerateConfigJson(McpClient client); /// <summary> /// Gets human-readable installation steps for a client /// </summary> /// <param name="client">The client</param> /// <returns>Installation instructions</returns> string GetInstallationSteps(McpClient client); } /// <summary> /// Summary of configuration results for multiple clients /// </summary> public class ClientConfigurationSummary { /// <summary> /// Number of clients successfully configured /// </summary> public int SuccessCount { get; set; } /// <summary> /// Number of clients that failed to configure /// </summary> public int FailureCount { get; set; } /// <summary> /// Number of clients skipped (already configured or tool not found) /// </summary> public int SkippedCount { get; set; } /// <summary> /// Detailed messages for each client /// </summary> public System.Collections.Generic.List<string> Messages { get; set; } = new(); /// <summary> /// Gets a human-readable summary message /// </summary> public string GetSummaryMessage() { return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped"; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/MCPServiceLocator.cs: -------------------------------------------------------------------------------- ```csharp using System; namespace MCPForUnity.Editor.Services { /// <summary> /// Service locator for accessing MCP services without dependency injection /// </summary> public static class MCPServiceLocator { private static IBridgeControlService _bridgeService; private static IClientConfigurationService _clientService; private static IPathResolverService _pathService; private static IPythonToolRegistryService _pythonToolRegistryService; private static ITestRunnerService _testRunnerService; private static IToolSyncService _toolSyncService; private static IPackageUpdateService _packageUpdateService; public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); public static IPathResolverService Paths => _pathService ??= new PathResolverService(); public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService(); public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService(); public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); /// <summary> /// Registers a custom implementation for a service (useful for testing) /// </summary> /// <typeparam name="T">The service interface type</typeparam> /// <param name="implementation">The implementation to register</param> public static void Register<T>(T implementation) where T : class { if (implementation is IBridgeControlService b) _bridgeService = b; else if (implementation is IClientConfigurationService c) _clientService = c; else if (implementation is IPathResolverService p) _pathService = p; else if (implementation is IPythonToolRegistryService ptr) _pythonToolRegistryService = ptr; else if (implementation is ITestRunnerService t) _testRunnerService = t; else if (implementation is IToolSyncService ts) _toolSyncService = ts; else if (implementation is IPackageUpdateService pu) _packageUpdateService = pu; } /// <summary> /// Resets all services to their default implementations (useful for testing) /// </summary> public static void Reset() { (_bridgeService as IDisposable)?.Dispose(); (_clientService as IDisposable)?.Dispose(); (_pathService as IDisposable)?.Dispose(); (_pythonToolRegistryService as IDisposable)?.Dispose(); (_testRunnerService as IDisposable)?.Dispose(); (_toolSyncService as IDisposable)?.Dispose(); (_packageUpdateService as IDisposable)?.Dispose(); _bridgeService = null; _clientService = null; _pathService = null; _pythonToolRegistryService = null; _testRunnerService = null; _toolSyncService = null; _packageUpdateService = null; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py: -------------------------------------------------------------------------------- ```python """ Defines the manage_asset tool for interacting with Unity assets. """ import asyncio from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import async_send_command_with_retry @mcp_for_unity_tool( description="Performs asset operations (import, create, modify, delete, etc.) in Unity." ) async def manage_asset( ctx: Context, action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], asset_type: Annotated[str, "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, properties: Annotated[dict[str, Any], "Dictionary of properties for 'create'/'modify'."] | None = None, destination: Annotated[str, "Target path for 'duplicate'/'move'."] | None = None, generate_preview: Annotated[bool, "Generate a preview/thumbnail for the asset when supported."] = False, search_pattern: Annotated[str, "Search pattern (e.g., '*.prefab')."] | None = None, filter_type: Annotated[str, "Filter type for search"] | None = None, filter_date_after: Annotated[str, "Date after which to filter"] | None = None, page_size: Annotated[int, "Page size for pagination"] | None = None, page_number: Annotated[int, "Page number for pagination"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing manage_asset: {action}") # Ensure properties is a dict if None if properties is None: properties = {} # Coerce numeric inputs defensively def _coerce_int(value, default=None): if value is None: return default try: if isinstance(value, bool): return default if isinstance(value, int): return int(value) s = str(value).strip() if s.lower() in ("", "none", "null"): return default return int(float(s)) except Exception: return default page_size = _coerce_int(page_size) page_number = _coerce_int(page_number) # Prepare parameters for the C# handler params_dict = { "action": action.lower(), "path": path, "assetType": asset_type, "properties": properties, "destination": destination, "generatePreview": generate_preview, "searchPattern": search_pattern, "filterType": filter_type, "filterDateAfter": filter_date_after, "pageSize": page_size, "pageNumber": page_number } # Remove None values to avoid sending unnecessary nulls params_dict = {k: v for k, v in params_dict.items() if v is not None} # Get the current asyncio event loop loop = asyncio.get_running_loop() # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity return result if isinstance(result, dict) else {"success": False, "message": str(result)} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py: -------------------------------------------------------------------------------- ```python """ Defines the manage_asset tool for interacting with Unity assets. """ import asyncio from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import async_send_command_with_retry @mcp_for_unity_tool( description="Performs asset operations (import, create, modify, delete, etc.) in Unity." ) async def manage_asset( ctx: Context, action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], asset_type: Annotated[str, "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, properties: Annotated[dict[str, Any], "Dictionary of properties for 'create'/'modify'."] | None = None, destination: Annotated[str, "Target path for 'duplicate'/'move'."] | None = None, generate_preview: Annotated[bool, "Generate a preview/thumbnail for the asset when supported."] = False, search_pattern: Annotated[str, "Search pattern (e.g., '*.prefab')."] | None = None, filter_type: Annotated[str, "Filter type for search"] | None = None, filter_date_after: Annotated[str, "Date after which to filter"] | None = None, page_size: Annotated[int, "Page size for pagination"] | None = None, page_number: Annotated[int, "Page number for pagination"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing manage_asset: {action}") # Ensure properties is a dict if None if properties is None: properties = {} # Coerce numeric inputs defensively def _coerce_int(value, default=None): if value is None: return default try: if isinstance(value, bool): return default if isinstance(value, int): return int(value) s = str(value).strip() if s.lower() in ("", "none", "null"): return default return int(float(s)) except Exception: return default page_size = _coerce_int(page_size) page_number = _coerce_int(page_number) # Prepare parameters for the C# handler params_dict = { "action": action.lower(), "path": path, "assetType": asset_type, "properties": properties, "destination": destination, "generatePreview": generate_preview, "searchPattern": search_pattern, "filterType": filter_type, "filterDateAfter": filter_date_after, "pageSize": page_size, "pageNumber": page_number } # Remove None values to avoid sending unnecessary nulls params_dict = {k: v for k, v in params_dict.items() if v is not None} # Get the current asyncio event loop loop = asyncio.get_running_loop() # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity return result if isinstance(result, dict) else {"success": False, "message": str(result)} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems { /// <summary> /// Provides read/list/exists capabilities for Unity menu items with caching. /// </summary> public static class MenuItemsReader { private static List<string> _cached; [InitializeOnLoadMethod] private static void Build() => Refresh(); /// <summary> /// Returns the cached list, refreshing if necessary. /// </summary> public static IReadOnlyList<string> AllMenuItems() => _cached ??= Refresh(); /// <summary> /// Rebuilds the cached list from reflection. /// </summary> private static List<string> Refresh() { try { var methods = TypeCache.GetMethodsWithAttribute<MenuItem>(); _cached = methods // Methods can have multiple [MenuItem] attributes; collect them all .SelectMany(m => m .GetCustomAttributes(typeof(MenuItem), false) .OfType<MenuItem>() .Select(attr => attr.menuItem)) .Where(s => !string.IsNullOrEmpty(s)) .Distinct(StringComparer.Ordinal) // Ensure no duplicates .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering .ToList(); return _cached; } catch (Exception e) { McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); _cached = _cached ?? new List<string>(); return _cached; } } /// <summary> /// Returns a list of menu items. Optional 'search' param filters results. /// </summary> public static object List(JObject @params) { string search = @params["search"]?.ToString(); bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false; if (doRefresh || _cached == null) { Refresh(); } IEnumerable<string> result = _cached ?? Enumerable.Empty<string>(); if (!string.IsNullOrEmpty(search)) { result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0); } return Response.Success("Menu items retrieved.", result.ToList()); } /// <summary> /// Checks if a given menu path exists in the cache. /// </summary> public static object Exists(JObject @params) { string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); if (string.IsNullOrWhiteSpace(menuPath)) { return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); } bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false; if (doRefresh || _cached == null) { Refresh(); } bool exists = (_cached ?? new List<string>()).Contains(menuPath); return Response.Success($"Exists check completed for '{menuPath}'.", new { exists }); } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test script for Unity MCP Telemetry System Run this to verify telemetry is working correctly """ import os from pathlib import Path import sys # Add src to Python path for imports sys.path.insert(0, str(Path(__file__).parent)) def test_telemetry_basic(): """Test basic telemetry functionality""" # Avoid stdout noise in tests try: from telemetry import ( get_telemetry, record_telemetry, record_milestone, RecordType, MilestoneType, is_telemetry_enabled ) pass except ImportError as e: # Silent failure path for tests return False # Test telemetry enabled status _ = is_telemetry_enabled() # Test basic record try: record_telemetry(RecordType.VERSION, { "version": "3.0.2", "test_run": True }) pass except Exception as e: # Silent failure path for tests return False # Test milestone recording try: is_first = record_milestone(MilestoneType.FIRST_STARTUP, { "test_mode": True }) _ = is_first except Exception as e: # Silent failure path for tests return False # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: # Silent failure path for tests return False return True def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry os.environ["DISABLE_TELEMETRY"] = "true" # Re-import to get fresh config import importlib import telemetry importlib.reload(telemetry) from telemetry import is_telemetry_enabled, record_telemetry, RecordType _ = is_telemetry_enabled() if not is_telemetry_enabled(): pass # Test that records are ignored when disabled record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) pass return True else: pass return False def test_data_storage(): """Test data storage functionality""" # Silent for tests try: from telemetry import get_telemetry collector = get_telemetry() data_dir = collector.config.data_dir _ = (data_dir, collector.config.uuid_file, collector.config.milestones_file) # Check if files exist if collector.config.uuid_file.exists(): pass else: pass if collector.config.milestones_file.exists(): pass else: pass return True except Exception as e: # Silent failure path for tests return False def main(): """Run all telemetry tests""" # Silent runner for CI tests = [ test_telemetry_basic, test_data_storage, test_telemetry_disabled, ] passed = 0 failed = 0 for test in tests: try: if test(): passed += 1 pass else: failed += 1 pass except Exception as e: failed += 1 pass _ = (passed, failed) if failed == 0: pass return True else: pass return False if __name__ == "__main__": success = main() sys.exit(0 if success else 1) ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/test_telemetry.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test script for MCP for Unity Telemetry System Run this to verify telemetry is working correctly """ import os from pathlib import Path import sys # Add src to Python path for imports sys.path.insert(0, str(Path(__file__).parent)) def test_telemetry_basic(): """Test basic telemetry functionality""" # Avoid stdout noise in tests try: from telemetry import ( get_telemetry, record_telemetry, record_milestone, RecordType, MilestoneType, is_telemetry_enabled ) pass except ImportError as e: # Silent failure path for tests return False # Test telemetry enabled status _ = is_telemetry_enabled() # Test basic record try: record_telemetry(RecordType.VERSION, { "version": "3.0.2", "test_run": True }) pass except Exception as e: # Silent failure path for tests return False # Test milestone recording try: is_first = record_milestone(MilestoneType.FIRST_STARTUP, { "test_mode": True }) _ = is_first except Exception as e: # Silent failure path for tests return False # Test telemetry collector try: collector = get_telemetry() _ = collector except Exception as e: # Silent failure path for tests return False return True def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests # Set environment variable to disable telemetry os.environ["DISABLE_TELEMETRY"] = "true" # Re-import to get fresh config import importlib import telemetry importlib.reload(telemetry) from telemetry import is_telemetry_enabled, record_telemetry, RecordType _ = is_telemetry_enabled() if not is_telemetry_enabled(): pass # Test that records are ignored when disabled record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) pass return True else: pass return False def test_data_storage(): """Test data storage functionality""" # Silent for tests try: from telemetry import get_telemetry collector = get_telemetry() data_dir = collector.config.data_dir _ = (data_dir, collector.config.uuid_file, collector.config.milestones_file) # Check if files exist if collector.config.uuid_file.exists(): pass else: pass if collector.config.milestones_file.exists(): pass else: pass return True except Exception as e: # Silent failure path for tests return False def main(): """Run all telemetry tests""" # Silent runner for CI tests = [ test_telemetry_basic, test_data_storage, test_telemetry_disabled, ] passed = 0 failed = 0 for test in tests: try: if test(): passed += 1 pass else: failed += 1 pass except Exception as e: failed += 1 pass _ = (passed, failed) if failed == 0: pass return True else: pass return False if __name__ == "__main__": success = main() sys.exit(0 if success else 1) ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/read_console.py: -------------------------------------------------------------------------------- ```python """ Defines the read_console tool for accessing Unity Editor console messages. """ from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( description="Gets messages from or clears the Unity Editor console." ) def read_console( ctx: Context, action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int, "Max messages to return"] | None = None, filter_text: Annotated[str, "Text filter for messages"] | None = None, since_timestamp: Annotated[str, "Get messages after this timestamp (ISO 8601)"] | None = None, format: Annotated[Literal['plain', 'detailed', 'json'], "Output format"] | None = None, include_stacktrace: Annotated[bool, "Include stack traces in output"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing read_console: {action}") # Set defaults if values are None action = action if action is not None else 'get' types = types if types is not None else ['error', 'warning', 'log'] format = format if format is not None else 'detailed' include_stacktrace = include_stacktrace if include_stacktrace is not None else True # Normalize action if it's a string if isinstance(action, str): action = action.lower() # Coerce count defensively (string/float -> int) def _coerce_int(value, default=None): if value is None: return default try: if isinstance(value, bool): return default if isinstance(value, int): return int(value) s = str(value).strip() if s.lower() in ("", "none", "null"): return default return int(float(s)) except Exception: return default count = _coerce_int(count) # Prepare parameters for the C# handler params_dict = { "action": action, "types": types, "count": count, "filterText": filter_text, "sinceTimestamp": since_timestamp, "format": format.lower() if isinstance(format, str) else format, "includeStacktrace": include_stacktrace } # Remove None values unless it's 'count' (as None might mean 'all') params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} # Add count back if it was None, explicitly sending null might be important for C# logic if 'count' not in params_dict: params_dict['count'] = None # Use centralized retry helper resp = send_command_with_retry("read_console", params_dict) if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: # Strip stacktrace fields from returned lines if present try: lines = resp.get("data", {}).get("lines", []) for line in lines: if isinstance(line, dict) and "stacktrace" in line: line.pop("stacktrace", None) except Exception: pass return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py: -------------------------------------------------------------------------------- ```python """ Defines the read_console tool for accessing Unity Editor console messages. """ from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( description="Gets messages from or clears the Unity Editor console." ) def read_console( ctx: Context, action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, count: Annotated[int, "Max messages to return"] | None = None, filter_text: Annotated[str, "Text filter for messages"] | None = None, since_timestamp: Annotated[str, "Get messages after this timestamp (ISO 8601)"] | None = None, format: Annotated[Literal['plain', 'detailed', 'json'], "Output format"] | None = None, include_stacktrace: Annotated[bool, "Include stack traces in output"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing read_console: {action}") # Set defaults if values are None action = action if action is not None else 'get' types = types if types is not None else ['error', 'warning', 'log'] format = format if format is not None else 'detailed' include_stacktrace = include_stacktrace if include_stacktrace is not None else True # Normalize action if it's a string if isinstance(action, str): action = action.lower() # Coerce count defensively (string/float -> int) def _coerce_int(value, default=None): if value is None: return default try: if isinstance(value, bool): return default if isinstance(value, int): return int(value) s = str(value).strip() if s.lower() in ("", "none", "null"): return default return int(float(s)) except Exception: return default count = _coerce_int(count) # Prepare parameters for the C# handler params_dict = { "action": action, "types": types, "count": count, "filterText": filter_text, "sinceTimestamp": since_timestamp, "format": format.lower() if isinstance(format, str) else format, "includeStacktrace": include_stacktrace } # Remove None values unless it's 'count' (as None might mean 'all') params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} # Add count back if it was None, explicitly sending null might be important for C# logic if 'count' not in params_dict: params_dict['count'] = None # Use centralized retry helper resp = send_command_with_retry("read_console", params_dict) if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: # Strip stacktrace fields from returned lines if present try: lines = resp.get("data", {}).get("lines", []) for line in lines: if isinstance(line, dict) and "stacktrace" in line: line.pop("stacktrace", None) except Exception: pass return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Resources/Tests/GetTests.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services; using UnityEditor.TestTools.TestRunner.Api; namespace MCPForUnity.Editor.Resources.Tests { /// <summary> /// Provides access to Unity tests from the Test Framework. /// This is a read-only resource that can be queried by MCP clients. /// </summary> [McpForUnityResource("get_tests")] public static class GetTests { public static async Task<object> HandleCommand(JObject @params) { McpLog.Info("[GetTests] Retrieving tests for all modes"); IReadOnlyList<Dictionary<string, string>> result; try { result = await MCPServiceLocator.Tests.GetTestsAsync(mode: null).ConfigureAwait(true); } catch (Exception ex) { McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); return Response.Error("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} tests"; return Response.Success(message, result); } } /// <summary> /// Provides access to Unity tests for a specific mode (EditMode or PlayMode). /// This is a read-only resource that can be queried by MCP clients. /// </summary> [McpForUnityResource("get_tests_for_mode")] public static class GetTestsForMode { public static async Task<object> HandleCommand(JObject @params) { IReadOnlyList<Dictionary<string, string>> result; string modeStr = @params["mode"]?.ToString(); if (string.IsNullOrEmpty(modeStr)) { return Response.Error("'mode' parameter is required"); } if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { return Response.Error(parseError); } McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}"); try { result = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); } catch (Exception ex) { McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); return Response.Error("Failed to retrieve tests"); } string message = $"Retrieved {result.Count} {parsedMode.Value} tests"; return Response.Success(message, result); } } internal static class ModeParser { internal static bool TryParse(string modeStr, out TestMode? mode, out string error) { error = null; mode = null; if (string.IsNullOrWhiteSpace(modeStr)) { error = "'mode' parameter cannot be empty"; return false; } if (modeStr.Equals("edit", StringComparison.OrdinalIgnoreCase)) { mode = TestMode.EditMode; return true; } if (modeStr.Equals("play", StringComparison.OrdinalIgnoreCase)) { mode = TestMode.PlayMode; return true; } error = $"Unknown test mode: '{modeStr}'. Use 'edit' or 'play'"; return false; } } } ``` -------------------------------------------------------------------------------- /.github/scripts/mark_skipped.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Post-processes a JUnit XML so that "expected"/environmental failures (e.g., permission prompts, empty MCP resources, or schema hiccups) are converted to <skipped/>. Leaves real failures intact. Usage: python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml """ from __future__ import annotations import sys import os import re import xml.etree.ElementTree as ET PATTERNS = [ r"\bpermission\b", r"\bpermissions\b", r"\bautoApprove\b", r"\bapproval\b", r"\bdenied\b", r"requested\s+permissions", r"^MCP resources list is empty$", r"No MCP resources detected", r"aggregator.*returned\s*\[\s*\]", r"Unknown resource:\s*unity://", r"Input should be a valid dictionary.*ctx", r"validation error .* ctx", ] def should_skip(msg: str) -> bool: if not msg: return False msg_l = msg.strip() for pat in PATTERNS: if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE): return True return False def summarize_counts(ts: ET.Element): tests = 0 failures = 0 errors = 0 skipped = 0 for case in ts.findall("testcase"): tests += 1 if case.find("failure") is not None: failures += 1 if case.find("error") is not None: errors += 1 if case.find("skipped") is not None: skipped += 1 return tests, failures, errors, skipped def main(path: str) -> int: if not os.path.exists(path): print(f"[mark_skipped] No JUnit at {path}; nothing to do.") return 0 try: tree = ET.parse(path) except ET.ParseError as e: print(f"[mark_skipped] Could not parse {path}: {e}") return 0 root = tree.getroot() suites = root.findall("testsuite") if root.tag == "testsuites" else [root] changed = False for ts in suites: for case in list(ts.findall("testcase")): nodes = [n for n in list(case) if n.tag in ("failure", "error")] if not nodes: continue # If any node matches skip patterns, convert the whole case to skipped. first_match_text = None to_skip = False for n in nodes: msg = (n.get("message") or "") + "\n" + (n.text or "") if should_skip(msg): first_match_text = ( n.text or "").strip() or first_match_text to_skip = True if to_skip: for n in nodes: case.remove(n) reason = "Marked skipped: environment/permission precondition not met" skip = ET.SubElement(case, "skipped") skip.set("message", reason) skip.text = first_match_text or reason changed = True # Recompute tallies per testsuite tests, failures, errors, skipped = summarize_counts(ts) ts.set("tests", str(tests)) ts.set("failures", str(failures)) ts.set("errors", str(errors)) ts.set("skipped", str(skipped)) if changed: tree.write(path, encoding="utf-8", xml_declaration=True) print( f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") else: print(f"[mark_skipped] No environmental failures detected in {path}.") return 0 if __name__ == "__main__": target = ( sys.argv[1] if len(sys.argv) > 1 else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml") ) raise SystemExit(main(target)) ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Data/PythonToolsAsset.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using UnityEngine; namespace MCPForUnity.Editor.Data { /// <summary> /// Registry of Python tool files to sync to the MCP server. /// Add your Python files here - they can be stored anywhere in your project. /// </summary> [CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")] public class PythonToolsAsset : ScriptableObject { [Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")] public List<TextAsset> pythonFiles = new List<TextAsset>(); [Header("Sync Options")] [Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")] public bool useContentHashing = true; [Header("Sync State (Read-only)")] [Tooltip("Internal tracking - do not modify")] public List<PythonFileState> fileStates = new List<PythonFileState>(); /// <summary> /// Gets all valid Python files (filters out null/missing references) /// </summary> public IEnumerable<TextAsset> GetValidFiles() { return pythonFiles.Where(f => f != null); } /// <summary> /// Checks if a file needs syncing /// </summary> public bool NeedsSync(TextAsset file, string currentHash) { if (!useContentHashing) return true; // Always sync if hashing disabled var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file)); return state == null || state.contentHash != currentHash; } /// <summary> /// Records that a file was synced /// </summary> public void RecordSync(TextAsset file, string hash) { string guid = GetAssetGuid(file); var state = fileStates.FirstOrDefault(s => s.assetGuid == guid); if (state == null) { state = new PythonFileState { assetGuid = guid }; fileStates.Add(state); } state.contentHash = hash; state.lastSyncTime = DateTime.UtcNow; state.fileName = file.name; } /// <summary> /// Removes state entries for files no longer in the list /// </summary> public void CleanupStaleStates() { var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid)); fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid)); } private string GetAssetGuid(TextAsset asset) { return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset)); } /// <summary> /// Called when the asset is modified in the Inspector /// Triggers sync to handle file additions/removals /// </summary> private void OnValidate() { // Cleanup stale states immediately CleanupStaleStates(); // Trigger sync after a delay to handle file removals // Delay ensures the asset is saved before sync runs UnityEditor.EditorApplication.delayCall += () => { if (this != null) // Check if asset still exists { MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools(); } }; } } [Serializable] public class PythonFileState { public string assetGuid; public string fileName; public string contentHash; public DateTime lastSyncTime; } } ``` -------------------------------------------------------------------------------- /tests/test_manage_script_uri.py: -------------------------------------------------------------------------------- ```python import tools.manage_script as manage_script # type: ignore import sys import types from pathlib import Path import pytest # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) ROOT = Path(__file__).resolve().parents[1] candidates = [ ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) if SRC is None: searched = "\n".join(str(p) for p in candidates) pytest.skip( "MCP for Unity server source not found. Tried:\n" + searched, allow_module_level=True, ) sys.path.insert(0, str(SRC)) # Stub mcp.server.fastmcp to satisfy imports without full package mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") class _Dummy: pass fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg mcp_pkg.server = server_pkg sys.modules.setdefault("mcp", mcp_pkg) sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) # Import target module after path injection class DummyMCP: def __init__(self): self.tools = {} def tool(self, *args, **kwargs): # ignore decorator kwargs like description def _decorator(fn): self.tools[fn.__name__] = fn return fn return _decorator class DummyCtx: # FastMCP Context placeholder pass def _register_tools(): mcp = DummyMCP() manage_script.register_manage_script_tools(mcp) # populates mcp.tools return mcp.tools def test_split_uri_unity_path(monkeypatch): tools = _register_tools() captured = {} def fake_send(cmd, params): # capture params and return success captured['cmd'] = cmd captured['params'] = params return {"success": True, "message": "ok"} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) fn = tools['apply_text_edits'] uri = "unity://path/Assets/Scripts/MyScript.cs" fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) assert captured['cmd'] == 'manage_script' assert captured['params']['name'] == 'MyScript' assert captured['params']['path'] == 'Assets/Scripts' @pytest.mark.parametrize( "uri, expected_name, expected_path", [ ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", "Foo Bar", "Assets/Scripts"), ("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"), ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"), # outside Assets → fall back to normalized dir ("file:///tmp/Other.cs", "Other", "tmp"), ], ) def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): tools = _register_tools() captured = {} def fake_send(cmd, params): captured['cmd'] = cmd captured['params'] = params return {"success": True, "message": "ok"} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) fn = tools['apply_text_edits'] fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) assert captured['params']['name'] == expected_name assert captured['params']['path'] == expected_path def test_split_uri_plain_path(monkeypatch): tools = _register_tools() captured = {} def fake_send(cmd, params): captured['params'] = params return {"success": True, "message": "ok"} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) fn = tools['apply_text_edits'] fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None) assert captured['params']['name'] == 'Thing' assert captured['params']['path'] == 'Assets/Scripts' ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs: -------------------------------------------------------------------------------- ```csharp using NUnit.Framework; using MCPForUnity.Editor.Helpers; namespace MCPForUnityTests.Editor.Helpers { public class CodexConfigHelperTests { [Test] public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully() { string toml = string.Join("\n", new[] { "[mcp_servers.unityMCP]", "command = \"uv\"", "args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]" }); bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); Assert.IsTrue(result, "Parser should detect server definition"); Assert.AreEqual("uv", command); CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); } [Test] public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully() { string toml = string.Join("\n", new[] { "[mcp_servers.unityMCP]", "command = \"uv\"", "args = [", " \"run\",", " \"--directory\",", " \"/abs/path\",", " \"server.py\",", "]" }); bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma"); Assert.AreEqual("uv", command); CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); } [Test] public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments() { string toml = string.Join("\n", new[] { "[mcp_servers.unityMCP]", "command = \"uv\"", "args = [", " \"run\", # launch command", " \"--directory\",", " \"/abs/path\",", " \"server.py\"", "]" }); bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); Assert.IsTrue(result, "Parser should tolerate comments within the array block"); Assert.AreEqual("uv", command); CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); } [Test] public void TryParseCodexServer_HeaderWithComment_StillDetected() { string toml = string.Join("\n", new[] { "[mcp_servers.unityMCP] # annotated header", "command = \"uv\"", "args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]" }); bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); Assert.IsTrue(result, "Parser should recognize section headers even with inline comments"); Assert.AreEqual("uv", command); CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); } [Test] public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully() { string toml = string.Join("\n", new[] { "[mcp_servers.unityMCP]", "command = 'uv'", "args = ['run', '--directory', '/Users/O''Connor/codex', 'server.py']" }); bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes"); Assert.AreEqual("uv", command); CollectionAssert.AreEqual(new[] { "run", "--directory", "/Users/O'Connor/codex", "server.py" }, args); } } } ``` -------------------------------------------------------------------------------- /deploy-dev.bat: -------------------------------------------------------------------------------- ``` @echo off setlocal enabledelayedexpansion echo =============================================== echo MCP for Unity Development Deployment Script echo =============================================== echo. :: Configuration set "SCRIPT_DIR=%~dp0" set "BRIDGE_SOURCE=%SCRIPT_DIR%MCPForUnity" set "SERVER_SOURCE=%SCRIPT_DIR%MCPForUnity\UnityMcpServer~\src" set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" :: Get user inputs echo Please provide the following paths: echo. :: Package cache location echo Unity Package Cache Location: echo Example: X:\UnityProject\Library\PackageCache\[email protected] set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( echo Error: Package cache path cannot be empty! pause exit /b 1 ) :: Server installation path (with default) echo. echo Server Installation Path: echo Default: %DEFAULT_SERVER_PATH% set /p "SERVER_PATH=Enter server path (or press Enter for default): " if "%SERVER_PATH%"=="" set "SERVER_PATH=%DEFAULT_SERVER_PATH%" :: Backup location (with default) echo. echo Backup Location: echo Default: %DEFAULT_BACKUP_DIR% set /p "BACKUP_DIR=Enter backup directory (or press Enter for default): " if "%BACKUP_DIR%"=="" set "BACKUP_DIR=%DEFAULT_BACKUP_DIR%" :: Validation echo. echo =============================================== echo Validating paths... echo =============================================== if not exist "%BRIDGE_SOURCE%" ( echo Error: Bridge source not found: %BRIDGE_SOURCE% pause exit /b 1 ) if not exist "%SERVER_SOURCE%" ( echo Error: Server source not found: %SERVER_SOURCE% pause exit /b 1 ) if not exist "%PACKAGE_CACHE_PATH%" ( echo Error: Package cache path not found: %PACKAGE_CACHE_PATH% pause exit /b 1 ) if not exist "%SERVER_PATH%" ( echo Error: Server installation path not found: %SERVER_PATH% pause exit /b 1 ) :: Create backup directory if not exist "%BACKUP_DIR%" ( echo Creating backup directory: %BACKUP_DIR% mkdir "%BACKUP_DIR%" ) :: Create timestamped backup subdirectory set "TIMESTAMP=%date:~-4,4%%date:~-10,2%%date:~-7,2%_%time:~0,2%%time:~3,2%%time:~6,2%" set "TIMESTAMP=%TIMESTAMP: =0%" set "TIMESTAMP=%TIMESTAMP::=-%" set "TIMESTAMP=%TIMESTAMP:/=-%" set "BACKUP_SUBDIR=%BACKUP_DIR%\backup_%TIMESTAMP%" mkdir "%BACKUP_SUBDIR%" echo. echo =============================================== echo Starting deployment... echo =============================================== :: Backup original files echo Creating backup of original files... if exist "%PACKAGE_CACHE_PATH%\Editor" ( echo Backing up Unity Bridge files... xcopy "%PACKAGE_CACHE_PATH%\Editor" "%BACKUP_SUBDIR%\UnityBridge\Editor\" /E /I /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to backup Unity Bridge files pause exit /b 1 ) ) if exist "%SERVER_PATH%" ( echo Backing up Python Server files... xcopy "%SERVER_PATH%\*" "%BACKUP_SUBDIR%\PythonServer\" /E /I /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to backup Python Server files pause exit /b 1 ) ) :: Deploy Unity Bridge echo. echo Deploying Unity Bridge code... xcopy "%BRIDGE_SOURCE%\Editor\*" "%PACKAGE_CACHE_PATH%\Editor\" /E /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to deploy Unity Bridge code pause exit /b 1 ) :: Deploy Python Server echo Deploying Python Server code... xcopy "%SERVER_SOURCE%\*" "%SERVER_PATH%\" /E /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to deploy Python Server code pause exit /b 1 ) :: Success echo. echo =============================================== echo Deployment completed successfully! echo =============================================== echo. echo Backup created at: %BACKUP_SUBDIR% echo. echo Next steps: echo 1. Restart Unity Editor to load new Bridge code echo 2. Restart any MCP clients to use new Server code echo 3. Use restore-dev.bat to rollback if needed echo. pause ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/PackageDetector.cs: -------------------------------------------------------------------------------- ```csharp using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Auto-runs legacy/older install detection on package load/update (log-only). /// Runs once per embedded server version using an EditorPrefs version-scoped key. /// </summary> [InitializeOnLoad] public static class PackageDetector { private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; static PackageDetector() { try { string pkgVer = ReadPackageVersionOrFallback(); string key = DetectOnceFlagKeyPrefix + pkgVer; // Always force-run if legacy roots exist or canonical install is missing bool legacyPresent = LegacyRootsExist(); bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) { // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. EditorApplication.delayCall += () => { string error = null; System.Exception capturedEx = null; try { // Ensure any UnityEditor API usage inside runs on the main thread ServerInstaller.EnsureServerInstalled(); } catch (System.Exception ex) { error = ex.Message; capturedEx = ex; } // Unity APIs must stay on main thread try { EditorPrefs.SetBool(key, true); } catch { } // Ensure prefs cleanup happens on main thread try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } if (!string.IsNullOrEmpty(error)) { McpLog.Info($"Server check: {error}. Download via Window > MCP For Unity if needed.", always: false); } }; } } catch { /* ignore */ } } private static string ReadEmbeddedVersionOrFallback() { try { if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) { var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); if (System.IO.File.Exists(p)) return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); } } catch { } return "unknown"; } private static string ReadPackageVersionOrFallback() { try { var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; } catch { } // Fallback to embedded server version if package info unavailable return ReadEmbeddedVersionOrFallback(); } private static bool LegacyRootsExist() { try { string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] roots = { System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") }; foreach (var r in roots) { try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } } } catch { } return false; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/PackageDetector.cs: -------------------------------------------------------------------------------- ```csharp using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Auto-runs legacy/older install detection on package load/update (log-only). /// Runs once per embedded server version using an EditorPrefs version-scoped key. /// </summary> [InitializeOnLoad] public static class PackageDetector { private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; static PackageDetector() { try { string pkgVer = ReadPackageVersionOrFallback(); string key = DetectOnceFlagKeyPrefix + pkgVer; // Always force-run if legacy roots exist or canonical install is missing bool legacyPresent = LegacyRootsExist(); bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) { // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. EditorApplication.delayCall += () => { string error = null; System.Exception capturedEx = null; try { // Ensure any UnityEditor API usage inside runs on the main thread ServerInstaller.EnsureServerInstalled(); } catch (System.Exception ex) { error = ex.Message; capturedEx = ex; } // Unity APIs must stay on main thread try { EditorPrefs.SetBool(key, true); } catch { } // Ensure prefs cleanup happens on main thread try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); // Alternatively: Debug.LogException(capturedEx); } }; } } catch { /* ignore */ } } private static string ReadEmbeddedVersionOrFallback() { try { if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) { var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); if (System.IO.File.Exists(p)) return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); } } catch { } return "unknown"; } private static string ReadPackageVersionOrFallback() { try { var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; } catch { } // Fallback to embedded server version if package info unavailable return ReadEmbeddedVersionOrFallback(); } private static bool LegacyRootsExist() { try { string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] roots = { System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") }; foreach (var r in roots) { try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } } } catch { } return false; } } } ``` -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- ```yaml name: Bump Version on: workflow_dispatch: inputs: version_bump: description: "Version bump type" type: choice options: - patch - minor - major default: patch required: true jobs: bump: name: "Bump version and tag" runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Compute new version id: compute shell: bash run: | set -euo pipefail BUMP="${{ inputs.version_bump }}" CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") echo "Current version: $CURRENT_VERSION" IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION" case "$BUMP" in major) ((MA+=1)); MI=0; PA=0 ;; minor) ((MI+=1)); PA=0 ;; patch) ((PA+=1)) ;; *) echo "Unknown version_bump: $BUMP" >&2 exit 1 ;; esac NEW_VERSION="$MA.$MI.$PA" echo "New version: $NEW_VERSION" echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - name: Update files to new version env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} shell: bash run: | set -euo pipefail echo "Updating MCPForUnity/package.json to $NEW_VERSION" jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp mv MCPForUnity/package.json.tmp MCPForUnity/package.json echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" - name: Commit and push changes env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} shell: bash run: | set -euo pipefail git config user.name "GitHub Actions" git config user.email "[email protected]" git add MCPForUnity/package.json "MCPForUnity/UnityMcpServer~/src/pyproject.toml" "MCPForUnity/UnityMcpServer~/src/server_version.txt" if git diff --cached --quiet; then echo "No version changes to commit." else git commit -m "chore: bump version to ${NEW_VERSION}" fi BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" echo "Pushing to branch: $BRANCH" git push origin "$BRANCH" - name: Create and push tag env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} shell: bash run: | set -euo pipefail TAG="v${NEW_VERSION}" echo "Preparing to create tag $TAG" if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then echo "Tag $TAG already exists on remote. Skipping tag creation." exit 0 fi git tag -a "$TAG" -m "Version ${NEW_VERSION}" git push origin "$TAG" - name: Package server for release env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} shell: bash run: | set -euo pipefail cd MCPForUnity zip -r ../mcp-for-unity-server-v${NEW_VERSION}.zip UnityMcpServer~ cd .. ls -lh mcp-for-unity-server-v${NEW_VERSION}.zip echo "Server package created: mcp-for-unity-server-v${NEW_VERSION}.zip" - name: Create GitHub release with server artifact env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail TAG="v${NEW_VERSION}" # Create release gh release create "$TAG" \ --title "v${NEW_VERSION}" \ --notes "Release v${NEW_VERSION}" \ "mcp-for-unity-server-v${NEW_VERSION}.zip#MCP Server v${NEW_VERSION}" ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py: -------------------------------------------------------------------------------- ```python """ Telemetry decorator for Unity MCP tools """ import functools import inspect import logging import time from typing import Callable, Any from telemetry import record_tool_usage, record_milestone, MilestoneType _log = logging.getLogger("unity-mcp-telemetry") _decorator_log_count = 0 def telemetry_tool(tool_name: str): """Decorator to add telemetry tracking to MCP tools""" def decorator(func: Callable) -> Callable: @functools.wraps(func) def _sync_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None # Extract sub-action (e.g., 'get_hierarchy') from bound args when available sub_action = None try: sig = inspect.signature(func) bound = sig.bind_partial(*args, **kwargs) bound.apply_defaults() sub_action = bound.arguments.get("action") except Exception: sub_action = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info(f"telemetry_decorator sync: tool={tool_name}") _decorator_log_count += 1 result = func(*args, **kwargs) success = True action_val = sub_action or kwargs.get("action") try: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): record_milestone( MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) @functools.wraps(func) async def _async_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None # Extract sub-action (e.g., 'get_hierarchy') from bound args when available sub_action = None try: sig = inspect.signature(func) bound = sig.bind_partial(*args, **kwargs) bound.apply_defaults() sub_action = bound.arguments.get("action") except Exception: sub_action = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info(f"telemetry_decorator async: tool={tool_name}") _decorator_log_count += 1 result = await func(*args, **kwargs) success = True action_val = sub_action or kwargs.get("action") try: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): record_milestone( MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper return decorator ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/CommandRegistry.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; namespace MCPForUnity.Editor.Tools { /// <summary> /// Registry for all MCP command handlers via reflection. /// </summary> public static class CommandRegistry { private static readonly Dictionary<string, Func<JObject, object>> _handlers = new(); private static bool _initialized = false; /// <summary> /// Initialize and auto-discover all tools marked with [McpForUnityTool] /// </summary> public static void Initialize() { if (_initialized) return; AutoDiscoverTools(); _initialized = true; } /// <summary> /// Convert PascalCase or camelCase to snake_case /// </summary> private static string ToSnakeCase(string name) { if (string.IsNullOrEmpty(name)) return name; // Insert underscore before uppercase letters (except first) var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); return s2.ToLower(); } /// <summary> /// Auto-discover all types with [McpForUnityTool] attribute /// </summary> private static void AutoDiscoverTools() { try { var toolTypes = AppDomain.CurrentDomain.GetAssemblies() .Where(a => !a.IsDynamic) .SelectMany(a => { try { return a.GetTypes(); } catch { return new Type[0]; } }) .Where(t => t.GetCustomAttribute<McpForUnityToolAttribute>() != null); foreach (var type in toolTypes) { RegisterToolType(type); } McpLog.Info($"Auto-discovered {_handlers.Count} tools"); } catch (Exception ex) { McpLog.Error($"Failed to auto-discover MCP tools: {ex.Message}"); } } private static void RegisterToolType(Type type) { var attr = type.GetCustomAttribute<McpForUnityToolAttribute>(); // Get command name (explicit or auto-generated) string commandName = attr.CommandName; if (string.IsNullOrEmpty(commandName)) { commandName = ToSnakeCase(type.Name); } // Check for duplicate command names if (_handlers.ContainsKey(commandName)) { McpLog.Warn( $"Duplicate command name '{commandName}' detected. " + $"Tool {type.Name} will override previously registered handler." ); } // Find HandleCommand method var method = type.GetMethod( "HandleCommand", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(JObject) }, null ); if (method == null) { McpLog.Warn( $"MCP tool {type.Name} is marked with [McpForUnityTool] " + $"but has no public static HandleCommand(JObject) method" ); return; } try { var handler = (Func<JObject, object>)Delegate.CreateDelegate( typeof(Func<JObject, object>), method ); _handlers[commandName] = handler; } catch (Exception ex) { McpLog.Error($"Failed to register tool {type.Name}: {ex.Message}"); } } /// <summary> /// Get a command handler by name /// </summary> public static Func<JObject, object> GetHandler(string commandName) { if (!_handlers.TryGetValue(commandName, out var handler)) { throw new InvalidOperationException( $"Unknown or unsupported command type: {commandName}" ); } return handler; } } } ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs: -------------------------------------------------------------------------------- ```csharp using System.Collections.Generic; using System.Linq; using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Services; namespace MCPForUnityTests.Editor.Services { public class PythonToolRegistryServiceTests { private PythonToolRegistryService _service; [SetUp] public void SetUp() { _service = new PythonToolRegistryService(); } [Test] public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist() { var registries = _service.GetAllRegistries().ToList(); // Note: This might find assets in the test project, so we just verify it doesn't throw Assert.IsNotNull(registries, "Should return a non-null list"); } [Test] public void NeedsSync_ReturnsTrue_WhenHashingDisabled() { var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); asset.useContentHashing = false; var textAsset = new TextAsset("print('test')"); bool needsSync = _service.NeedsSync(asset, textAsset); Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled"); Object.DestroyImmediate(asset); } [Test] public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced() { var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); asset.useContentHashing = true; var textAsset = new TextAsset("print('test')"); bool needsSync = _service.NeedsSync(asset, textAsset); Assert.IsTrue(needsSync, "Should need sync for new file"); Object.DestroyImmediate(asset); } [Test] public void NeedsSync_ReturnsFalse_WhenHashMatches() { var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); asset.useContentHashing = true; var textAsset = new TextAsset("print('test')"); // First sync _service.RecordSync(asset, textAsset); // Check if needs sync again bool needsSync = _service.NeedsSync(asset, textAsset); Assert.IsFalse(needsSync, "Should not need sync when hash matches"); Object.DestroyImmediate(asset); } [Test] public void RecordSync_StoresFileState() { var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); var textAsset = new TextAsset("print('test')"); _service.RecordSync(asset, textAsset); Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded"); Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored"); Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored"); Object.DestroyImmediate(asset); } [Test] public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded() { var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); var textAsset = new TextAsset("print('test')"); // Record twice _service.RecordSync(asset, textAsset); var firstHash = asset.fileStates[0].contentHash; _service.RecordSync(asset, textAsset); Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state"); Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same"); Object.DestroyImmediate(asset); } [Test] public void ComputeHash_ReturnsSameHash_ForSameContent() { var textAsset1 = new TextAsset("print('hello')"); var textAsset2 = new TextAsset("print('hello')"); string hash1 = _service.ComputeHash(textAsset1); string hash2 = _service.ComputeHash(textAsset2); Assert.AreEqual(hash1, hash2, "Same content should produce same hash"); } [Test] public void ComputeHash_ReturnsDifferentHash_ForDifferentContent() { var textAsset1 = new TextAsset("print('hello')"); var textAsset2 = new TextAsset("print('world')"); string hash1 = _service.ComputeHash(textAsset1); string hash2 = _service.ComputeHash(textAsset2); Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash"); } } } ``` -------------------------------------------------------------------------------- /restore-dev.bat: -------------------------------------------------------------------------------- ``` @echo off setlocal enabledelayedexpansion echo =============================================== echo MCP for Unity Development Restore Script echo =============================================== echo. echo Note: The Python server is bundled under MCPForUnity\UnityMcpServer~ in the package. echo This script restores your installed server path from backups, not the repo copy. echo. :: Configuration set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" :: Get user inputs echo Please provide the following paths: echo. :: Package cache location echo Unity Package Cache Location: echo Example: X:\UnityProject\Library\PackageCache\[email protected] set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " if "%PACKAGE_CACHE_PATH%"=="" ( echo Error: Package cache path cannot be empty! pause exit /b 1 ) :: Server installation path (with default) echo. echo Server Installation Path: echo Default: %DEFAULT_SERVER_PATH% set /p "SERVER_PATH=Enter server path (or press Enter for default): " if "%SERVER_PATH%"=="" set "SERVER_PATH=%DEFAULT_SERVER_PATH%" :: Backup location (with default) echo. echo Backup Location: echo Default: %DEFAULT_BACKUP_DIR% set /p "BACKUP_DIR=Enter backup directory (or press Enter for default): " if "%BACKUP_DIR%"=="" set "BACKUP_DIR=%DEFAULT_BACKUP_DIR%" :: List available backups echo. echo =============================================== echo Available backups: echo =============================================== set "counter=0" for /d %%d in ("%BACKUP_DIR%\backup_*") do ( set /a counter+=1 set "backup!counter!=%%d" echo !counter!. %%~nxd ) if %counter%==0 ( echo No backups found in %BACKUP_DIR% pause exit /b 1 ) echo. set /p "choice=Select backup to restore (1-%counter%): " :: Validate choice if "%choice%"=="" goto :invalid_choice if %choice% lss 1 goto :invalid_choice if %choice% gtr %counter% goto :invalid_choice set "SELECTED_BACKUP=!backup%choice%!" echo. echo Selected backup: %SELECTED_BACKUP% :: Validation echo. echo =============================================== echo Validating paths... echo =============================================== if not exist "%SELECTED_BACKUP%" ( echo Error: Selected backup not found: %SELECTED_BACKUP% pause exit /b 1 ) if not exist "%PACKAGE_CACHE_PATH%" ( echo Error: Package cache path not found: %PACKAGE_CACHE_PATH% pause exit /b 1 ) if not exist "%SERVER_PATH%" ( echo Error: Server installation path not found: %SERVER_PATH% pause exit /b 1 ) :: Confirm restore echo. echo =============================================== echo WARNING: This will overwrite current files! echo =============================================== echo Restoring from: %SELECTED_BACKUP% echo Unity Bridge target: %PACKAGE_CACHE_PATH%\Editor echo Python Server target: %SERVER_PATH% echo. set /p "confirm=Continue with restore? (y/N): " if /i not "%confirm%"=="y" ( echo Restore cancelled. pause exit /b 0 ) echo. echo =============================================== echo Starting restore... echo =============================================== :: Restore Unity Bridge if exist "%SELECTED_BACKUP%\UnityBridge\Editor" ( echo Restoring Unity Bridge files... rd /s /q "%PACKAGE_CACHE_PATH%\Editor" 2>nul xcopy "%SELECTED_BACKUP%\UnityBridge\Editor\*" "%PACKAGE_CACHE_PATH%\Editor\" /E /I /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to restore Unity Bridge files pause exit /b 1 ) ) else ( echo Warning: No Unity Bridge backup found, skipping... ) :: Restore Python Server if exist "%SELECTED_BACKUP%\PythonServer" ( echo Restoring Python Server files... rd /s /q "%SERVER_PATH%" 2>nul mkdir "%SERVER_PATH%" xcopy "%SELECTED_BACKUP%\PythonServer\*" "%SERVER_PATH%\" /E /I /Y > nul if !errorlevel! neq 0 ( echo Error: Failed to restore Python Server files pause exit /b 1 ) ) else ( echo Warning: No Python Server backup found, skipping... ) :: Success echo. echo =============================================== echo Restore completed successfully! echo =============================================== echo. echo Next steps: echo 1. Restart Unity Editor to load restored Bridge code echo 2. Restart any MCP clients to use restored Server code echo. pause exit /b 0 :invalid_choice echo Invalid choice. Please enter a number between 1 and %counter%. pause exit /b 1 ``` -------------------------------------------------------------------------------- /tests/test_edit_normalization_and_noop.py: -------------------------------------------------------------------------------- ```python import sys import pathlib import importlib.util import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") class _Dummy: pass fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg mcp_pkg.server = server_pkg sys.modules.setdefault("mcp", mcp_pkg) sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) def _load(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") manage_script_edits = _load( SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") class DummyMCP: def __init__(self): self.tools = {} def tool(self, *args, **kwargs): def deco(fn): self.tools[fn.__name__] = fn; return fn return deco def setup_tools(): mcp = DummyMCP() manage_script.register_manage_script_tools(mcp) return mcp.tools def test_normalizes_lsp_and_index_ranges(monkeypatch): tools = setup_tools() apply = tools["apply_text_edits"] calls = [] def fake_send(cmd, params): calls.append(params) return {"success": True} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) # LSP-style edits = [{ "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, "newText": "// lsp\n" }] apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") p = calls[-1] e = p["edits"][0] assert e["startLine"] == 11 and e["startCol"] == 3 # Index pair calls.clear() edits = [{"range": [0, 0], "text": "// idx\n"}] # fake read to provide contents length def fake_read(cmd, params): if params.get("action") == "read": return {"success": True, "data": {"contents": "hello\n"}} return {"success": True} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") # last call is apply_text_edits def test_noop_evidence_shape(monkeypatch): tools = setup_tools() apply = tools["apply_text_edits"] # Route response from Unity indicating no-op def fake_send(cmd, params): return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[ {"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x") assert resp["success"] is True assert resp.get("data", {}).get("no_op") is True def test_atomic_multi_span_and_relaxed(monkeypatch): tools_text = setup_tools() apply_text = tools_text["apply_text_edits"] tools_struct = DummyMCP() manage_script_edits.register_manage_script_edits_tools(tools_struct) # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through sent = {} def fake_send(cmd, params): if params.get("action") == "read": return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} sent.setdefault("calls", []).append(params) return {"success": True} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) edits = [ {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"} ] resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) assert resp["success"] is True # Last manage_script call should include options with applyMode atomic and validate relaxed last = sent["calls"][-1] assert last.get("options", {}).get("applyMode") == "atomic" assert last.get("options", {}).get("validate") == "relaxed" ``` -------------------------------------------------------------------------------- /docs/CURSOR_HELP.md: -------------------------------------------------------------------------------- ```markdown ### Cursor/VSCode/Windsurf: UV path issue on Windows (diagnosis and fix) #### The issue - Some Windows machines have multiple `uv.exe` locations. Our auto-config sometimes picked a less stable path, causing the MCP client to fail to launch the MCP for Unity Server or for the path to be auto-rewritten on repaint/restart. #### Typical symptoms - Cursor shows the MCP for Unity server but never connects or reports it “can’t start.” - Your `%USERPROFILE%\\.cursor\\mcp.json` flips back to a different `command` path when Unity or the MCP for Unity window refreshes. #### Real-world example - Wrong/fragile path (auto-picked): - `C:\Users\mrken.local\bin\uv.exe` (malformed, not standard) - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Packages\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\uv.exe` - Correct/stable path (works with Cursor): - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Links\uv.exe` #### Quick fix (recommended) 1) In MCP for Unity: `Window > MCP for Unity` → select your MCP client (Cursor or Windsurf) 2) If you see “uv Not Found,” click “Choose `uv` Install Location” and browse to: - `C:\Users\<YOU>\AppData\Local\Microsoft\WinGet\Links\uv.exe` 3) If uv is already found but wrong, still click “Choose `uv` Install Location” and select the `Links\uv.exe` path above. This saves a persistent override. 4) Click “Auto Configure” (or re-open the client) and restart Cursor. This sets an override stored in the Editor (key: `MCPForUnity.UvPath`) so MCP for Unity won’t auto-rewrite the config back to a different `uv.exe` later. #### Verify the fix - Confirm global Cursor config is at: `%USERPROFILE%\\.cursor\\mcp.json` - You should see something like: ```json { "mcpServers": { "unityMCP": { "command": "C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe", "args": [ "--directory", "C:\\Users\\YOU\\AppData\\Local\\Programs\\UnityMCP\\UnityMcpServer\\src", "run", "server.py" ] } } } ``` - Manually run the same command in PowerShell to confirm it launches: ```powershell "C:\Users\YOU\AppData\Local\Microsoft\WinGet\Links\uv.exe" --directory "C:\Users\YOU\AppData\Local\Programs\UnityMCP\UnityMcpServer\src" run server.py ``` If that runs without error, restart Cursor and it should connect. #### Why this happens - On Windows, multiple `uv.exe` can exist (WinGet Packages path, a WinGet Links shim, Python Scripts, etc.). The Links shim is the most stable target for GUI apps to launch. - Prior versions of the auto-config could pick the first found path and re-write config on refresh. Choosing a path via the MCP window pins a known‑good absolute path and prevents auto-rewrites. #### Extra notes - Restart Cursor after changing `mcp.json`; it doesn’t always hot-reload that file. - If you also have a project-scoped `.cursor\\mcp.json` in your Unity project folder, that file overrides the global one. ### Why pin the WinGet Links shim (and not the Packages path) - Windows often has multiple `uv.exe` installs and GUI clients (Cursor/Windsurf/VSCode) may launch with a reduced `PATH`. Using an absolute path is safer than `"command": "uv"`. - WinGet publishes stable launch shims in these locations: - User scope: `%LOCALAPPDATA%\Microsoft\WinGet\Links\uv.exe` - Machine scope: `C:\Program Files\WinGet\Links\uv.exe` These shims survive upgrades and are intended as the portable entrypoints. See the WinGet notes: [discussion](https://github.com/microsoft/winget-pkgs/discussions/184459) • [how to find installs](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program) - The `Packages` root is where payloads live and can change across updates, so avoid pointing your config at it. Recommended practice - Prefer the WinGet Links shim paths above. If present, select one via “Choose `uv` Install Location”. - If the unity window keeps rewriting to a different `uv.exe`, pick the Links shim again; MCP for Unity saves a pinned override and will stop auto-rewrites. - If neither Links path exists, a reasonable fallback is `~/.local/bin/uv.exe` (uv tools bin) or a Scoop shim, but Links is preferred for stability. References - WinGet portable Links: [GitHub discussion](https://github.com/microsoft/winget-pkgs/discussions/184459) - WinGet install locations: [Super User](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program) - GUI client PATH caveats (Cursor): [Cursor community thread](https://forum.cursor.com/t/mcp-feature-client-closed-fix/54651?page=4) - uv tools install location (`~/.local/bin`): [Astral docs](https://docs.astral.sh/uv/concepts/tools/) ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Setup/SetupWizard.cs: -------------------------------------------------------------------------------- ```csharp using System; using MCPForUnity.Editor.Dependencies; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Windows; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Setup { /// <summary> /// Handles automatic triggering of the setup wizard /// </summary> [InitializeOnLoad] public static class SetupWizard { private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; private static bool _hasCheckedThisSession = false; static SetupWizard() { // Skip in batch mode if (Application.isBatchMode) return; // Show setup wizard on package import EditorApplication.delayCall += CheckSetupNeeded; } /// <summary> /// Check if setup wizard should be shown /// </summary> private static void CheckSetupNeeded() { if (_hasCheckedThisSession) return; _hasCheckedThisSession = true; try { // Check if setup was already completed or dismissed in previous sessions bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); // Only show setup wizard if it hasn't been completed or dismissed before if (!(setupCompleted || setupDismissed)) { McpLog.Info("Package imported - showing setup wizard", always: false); var dependencyResult = DependencyManager.CheckAllDependencies(); EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); } else { McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); } } catch (Exception ex) { McpLog.Error($"Error checking setup status: {ex.Message}"); } } /// <summary> /// Show the setup wizard window /// </summary> public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) { try { dependencyResult ??= DependencyManager.CheckAllDependencies(); SetupWizardWindow.ShowWindow(dependencyResult); } catch (Exception ex) { McpLog.Error($"Error showing setup wizard: {ex.Message}"); } } /// <summary> /// Mark setup as completed /// </summary> public static void MarkSetupCompleted() { EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); McpLog.Info("Setup marked as completed"); } /// <summary> /// Mark setup as dismissed /// </summary> public static void MarkSetupDismissed() { EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); McpLog.Info("Setup marked as dismissed"); } /// <summary> /// Force show setup wizard (for manual invocation) /// </summary> [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] public static void ShowSetupWizardManual() { ShowSetupWizard(); } /// <summary> /// Check dependencies and show status /// </summary> [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] public static void CheckDependencies() { var result = DependencyManager.CheckAllDependencies(); if (!result.IsSystemReady) { bool showWizard = EditorUtility.DisplayDialog( "MCP for Unity - Dependencies", $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", "Open Setup Wizard", "Close" ); if (showWizard) { ShowSetupWizard(result); } } else { EditorUtility.DisplayDialog( "MCP for Unity - Dependencies", "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", "OK" ); } } /// <summary> /// Open MCP Client Configuration window /// </summary> [MenuItem("Window/MCP For Unity/Open MCP Window", priority = 4)] public static void OpenClientConfiguration() { Windows.MCPForUnityEditorWindow.ShowWindow(); } } } ``` -------------------------------------------------------------------------------- /tests/test_improved_anchor_matching.py: -------------------------------------------------------------------------------- ```python """ Test the improved anchor matching logic. """ import sys import pathlib import importlib.util import types # add server src to path and load modules ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") class _Dummy: pass fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg mcp_pkg.server = server_pkg sys.modules.setdefault("mcp", mcp_pkg) sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) def load_module(path, name): spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module manage_script_edits_module = load_module( SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module") def test_improved_anchor_matching(): """Test that our improved anchor matching finds the right closing brace.""" test_code = '''using UnityEngine; public class TestClass : MonoBehaviour { void Start() { Debug.Log("test"); } void Update() { // Update logic } }''' import re # Test the problematic anchor pattern anchor_pattern = r"\s*}\s*$" flags = re.MULTILINE # Test our improved function best_match = manage_script_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) assert best_match is not None, "anchor pattern not found" match_pos = best_match.start() line_num = test_code[:match_pos].count('\n') + 1 total_lines = test_code.count('\n') + 1 assert line_num >= total_lines - \ 2, f"expected match near end (>= {total_lines-2}), got line {line_num}" def test_old_vs_new_matching(): """Compare old vs new matching behavior.""" test_code = '''using UnityEngine; public class TestClass : MonoBehaviour { void Start() { Debug.Log("test"); } void Update() { if (condition) { DoSomething(); } } void LateUpdate() { // More logic } }''' import re anchor_pattern = r"\s*}\s*$" flags = re.MULTILINE # Old behavior (first match) old_match = re.search(anchor_pattern, test_code, flags) old_line = test_code[:old_match.start()].count( '\n') + 1 if old_match else None # New behavior (improved matching) new_match = manage_script_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) new_line = test_code[:new_match.start()].count( '\n') + 1 if new_match else None assert old_line is not None and new_line is not None, "failed to locate anchors" assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})" total_lines = test_code.count('\n') + 1 assert new_line >= total_lines - \ 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}" def test_apply_edits_with_improved_matching(): """Test that _apply_edits_locally uses improved matching.""" original_code = '''using UnityEngine; public class TestClass : MonoBehaviour { public string message = "Hello World"; void Start() { Debug.Log(message); } }''' # Test anchor_insert with the problematic pattern edits = [{ "op": "anchor_insert", "anchor": r"\s*}\s*$", # This should now find the class end "position": "before", "text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n" }] result = manage_script_edits_module._apply_edits_locally( original_code, edits) lines = result.split('\n') try: idx = next(i for i, line in enumerate(lines) if "NewMethod" in line) except StopIteration: assert False, "NewMethod not found in result" total_lines = len(lines) assert idx >= total_lines - \ 5, f"method inserted too early (idx={idx}, total_lines={total_lines})" if __name__ == "__main__": print("Testing improved anchor matching...") print("="*60) success1 = test_improved_anchor_matching() print("\n" + "="*60) print("Comparing old vs new behavior...") success2 = test_old_vs_new_matching() print("\n" + "="*60) print("Testing _apply_edits_locally with improved matching...") success3 = test_apply_edits_with_improved_matching() print("\n" + "="*60) if success1 and success2 and success3: print("🎉 ALL TESTS PASSED! Improved anchor matching is working!") else: print("💥 Some tests failed. Need more work on anchor matching.") ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/ToolSyncService.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using System.Linq; using MCPForUnity.Editor.Helpers; using UnityEditor; namespace MCPForUnity.Editor.Services { public class ToolSyncService : IToolSyncService { private readonly IPythonToolRegistryService _registryService; public ToolSyncService(IPythonToolRegistryService registryService = null) { _registryService = registryService ?? MCPServiceLocator.PythonToolRegistry; } public ToolSyncResult SyncProjectTools(string destToolsDir) { var result = new ToolSyncResult(); try { Directory.CreateDirectory(destToolsDir); // Get all PythonToolsAsset instances in the project var registries = _registryService.GetAllRegistries().ToList(); if (!registries.Any()) { McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools"); return result; } var syncedFiles = new HashSet<string>(); // Batch all asset modifications together to minimize reimports AssetDatabase.StartAssetEditing(); try { foreach (var registry in registries) { foreach (var file in registry.GetValidFiles()) { try { // Check if needs syncing (hash-based or always) if (_registryService.NeedsSync(registry, file)) { string destPath = Path.Combine(destToolsDir, file.name + ".py"); // Write the Python file content File.WriteAllText(destPath, file.text); // Record sync _registryService.RecordSync(registry, file); result.CopiedCount++; syncedFiles.Add(destPath); McpLog.Info($"Synced Python tool: {file.name}.py"); } else { string destPath = Path.Combine(destToolsDir, file.name + ".py"); syncedFiles.Add(destPath); result.SkippedCount++; } } catch (Exception ex) { result.ErrorCount++; result.Messages.Add($"Failed to sync {file.name}: {ex.Message}"); } } // Cleanup stale states in registry registry.CleanupStaleStates(); EditorUtility.SetDirty(registry); } // Cleanup stale Python files in destination CleanupStaleFiles(destToolsDir, syncedFiles); } finally { // End batch editing - this triggers a single asset refresh AssetDatabase.StopAssetEditing(); } // Save all modified registries AssetDatabase.SaveAssets(); } catch (Exception ex) { result.ErrorCount++; result.Messages.Add($"Sync failed: {ex.Message}"); } return result; } private void CleanupStaleFiles(string destToolsDir, HashSet<string> currentFiles) { try { if (!Directory.Exists(destToolsDir)) return; // Find all .py files in destination that aren't in our current set var existingFiles = Directory.GetFiles(destToolsDir, "*.py"); foreach (var file in existingFiles) { if (!currentFiles.Contains(file)) { try { File.Delete(file); McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}"); } catch (Exception ex) { McpLog.Warn($"Failed to cleanup {file}: {ex.Message}"); } } } } catch (Exception ex) { McpLog.Warn($"Failed to cleanup stale files: {ex.Message}"); } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs: -------------------------------------------------------------------------------- ```csharp using Newtonsoft.Json; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Helpers { public static class ConfigJsonBuilder { public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) { var root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container; if (isVSCode) { container = EnsureObject(root, "servers"); } else { container = EnsureObject(root, "mcpServers"); } var unity = new JObject(); PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); container["unityMCP"] = unity; return root.ToString(Formatting.Indented); } public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) { if (root == null) root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); container["unityMCP"] = unity; return root; } /// <summary> /// Centralized builder that applies all caveats consistently. /// - Sets command/args with provided directory /// - Ensures env exists /// - Adds type:"stdio" for VSCode /// - Adds disabled:false for Windsurf/Kiro only when missing /// </summary> private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) { unity["command"] = uvPath; // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners string effectiveDir = directory; #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); if (isCursor && !string.IsNullOrEmpty(directory)) { // Replace canonical path segment with the symlink path if present const string canonical = "/Library/Application Support/"; const string symlinkSeg = "/Library/AppSupport/"; try { // Normalize to full path style if (directory.Contains(canonical)) { var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); if (System.IO.Directory.Exists(candidate)) { effectiveDir = candidate; } } else { // If installer returned XDG-style on macOS, map to canonical symlink string norm = directory.Replace('\\', '/'); int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); if (idx >= 0) { string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); if (System.IO.Directory.Exists(candidate)) { effectiveDir = candidate; } } } } catch { /* fallback to original directory on any error */ } } #endif unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); if (isVSCode) { unity["type"] = "stdio"; } else { // Remove type if it somehow exists from previous clients if (unity["type"] != null) unity.Remove("type"); } if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) { if (unity["env"] == null) { unity["env"] = new JObject(); } if (unity["disabled"] == null) { unity["disabled"] = false; } } } private static JObject EnsureObject(JObject parent, string name) { if (parent[name] is JObject o) return o; var created = new JObject(); parent[name] = created; return created; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs: -------------------------------------------------------------------------------- ```csharp using Newtonsoft.Json; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Helpers { public static class ConfigJsonBuilder { public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) { var root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container; if (isVSCode) { container = EnsureObject(root, "servers"); } else { container = EnsureObject(root, "mcpServers"); } var unity = new JObject(); PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); container["unityMCP"] = unity; return root.ToString(Formatting.Indented); } public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) { if (root == null) root = new JObject(); bool isVSCode = client?.mcpType == McpTypes.VSCode; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); JObject unity = container["unityMCP"] as JObject ?? new JObject(); PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); container["unityMCP"] = unity; return root; } /// <summary> /// Centralized builder that applies all caveats consistently. /// - Sets command/args with provided directory /// - Ensures env exists /// - Adds type:"stdio" for VSCode /// - Adds disabled:false for Windsurf/Kiro only when missing /// </summary> private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) { unity["command"] = uvPath; // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners string effectiveDir = directory; #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); if (isCursor && !string.IsNullOrEmpty(directory)) { // Replace canonical path segment with the symlink path if present const string canonical = "/Library/Application Support/"; const string symlinkSeg = "/Library/AppSupport/"; try { // Normalize to full path style if (directory.Contains(canonical)) { var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); if (System.IO.Directory.Exists(candidate)) { effectiveDir = candidate; } } else { // If installer returned XDG-style on macOS, map to canonical symlink string norm = directory.Replace('\\', '/'); int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); if (idx >= 0) { string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); if (System.IO.Directory.Exists(candidate)) { effectiveDir = candidate; } } } } catch { /* fallback to original directory on any error */ } } #endif unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); if (isVSCode) { unity["type"] = "stdio"; } else { // Remove type if it somehow exists from previous clients if (unity["type"] != null) unity.Remove("type"); } if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) { if (unity["env"] == null) { unity["env"] = new JObject(); } if (unity["disabled"] == null) { unity["disabled"] = false; } } } private static JObject EnsureObject(JObject parent, string name) { if (parent[name] is JObject o) return o; var created = new JObject(); parent[name] = created; return created; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/DependencyManager.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Dependencies.PlatformDetectors; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Dependencies { /// <summary> /// Main orchestrator for dependency validation and management /// </summary> public static class DependencyManager { private static readonly List<IPlatformDetector> _detectors = new List<IPlatformDetector> { new WindowsPlatformDetector(), new MacOSPlatformDetector(), new LinuxPlatformDetector() }; private static IPlatformDetector _currentDetector; /// <summary> /// Get the platform detector for the current operating system /// </summary> public static IPlatformDetector GetCurrentPlatformDetector() { if (_currentDetector == null) { _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); if (_currentDetector == null) { throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); } } return _currentDetector; } /// <summary> /// Perform a comprehensive dependency check /// </summary> public static DependencyCheckResult CheckAllDependencies() { var result = new DependencyCheckResult(); try { var detector = GetCurrentPlatformDetector(); McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); // Check Python var pythonStatus = detector.DetectPython(); result.Dependencies.Add(pythonStatus); // Check UV var uvStatus = detector.DetectUV(); result.Dependencies.Add(uvStatus); // Check MCP Server var serverStatus = detector.DetectMCPServer(); result.Dependencies.Add(serverStatus); // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); } catch (Exception ex) { McpLog.Error($"Error during dependency check: {ex.Message}"); result.Summary = $"Dependency check failed: {ex.Message}"; result.IsSystemReady = false; } return result; } /// <summary> /// Get installation recommendations for the current platform /// </summary> public static string GetInstallationRecommendations() { try { var detector = GetCurrentPlatformDetector(); return detector.GetInstallationRecommendations(); } catch (Exception ex) { return $"Error getting installation recommendations: {ex.Message}"; } } /// <summary> /// Get platform-specific installation URLs /// </summary> public static (string pythonUrl, string uvUrl) GetInstallationUrls() { try { var detector = GetCurrentPlatformDetector(); return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); } catch { return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); } } private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) { var missing = result.GetMissingDependencies(); if (missing.Count == 0) { result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); return; } foreach (var dep in missing) { if (dep.Name == "Python") { result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); } else if (dep.Name == "UV Package Manager") { result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); } else if (dep.Name == "MCP Server") { result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); } } if (result.GetMissingRequired().Count > 0) { result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/DependencyManager.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Dependencies.PlatformDetectors; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Dependencies { /// <summary> /// Main orchestrator for dependency validation and management /// </summary> public static class DependencyManager { private static readonly List<IPlatformDetector> _detectors = new List<IPlatformDetector> { new WindowsPlatformDetector(), new MacOSPlatformDetector(), new LinuxPlatformDetector() }; private static IPlatformDetector _currentDetector; /// <summary> /// Get the platform detector for the current operating system /// </summary> public static IPlatformDetector GetCurrentPlatformDetector() { if (_currentDetector == null) { _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); if (_currentDetector == null) { throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); } } return _currentDetector; } /// <summary> /// Perform a comprehensive dependency check /// </summary> public static DependencyCheckResult CheckAllDependencies() { var result = new DependencyCheckResult(); try { var detector = GetCurrentPlatformDetector(); McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); // Check Python var pythonStatus = detector.DetectPython(); result.Dependencies.Add(pythonStatus); // Check UV var uvStatus = detector.DetectUV(); result.Dependencies.Add(uvStatus); // Check MCP Server var serverStatus = detector.DetectMCPServer(); result.Dependencies.Add(serverStatus); // Generate summary and recommendations result.GenerateSummary(); GenerateRecommendations(result, detector); McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); } catch (Exception ex) { McpLog.Error($"Error during dependency check: {ex.Message}"); result.Summary = $"Dependency check failed: {ex.Message}"; result.IsSystemReady = false; } return result; } /// <summary> /// Get installation recommendations for the current platform /// </summary> public static string GetInstallationRecommendations() { try { var detector = GetCurrentPlatformDetector(); return detector.GetInstallationRecommendations(); } catch (Exception ex) { return $"Error getting installation recommendations: {ex.Message}"; } } /// <summary> /// Get platform-specific installation URLs /// </summary> public static (string pythonUrl, string uvUrl) GetInstallationUrls() { try { var detector = GetCurrentPlatformDetector(); return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); } catch { return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); } } private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) { var missing = result.GetMissingDependencies(); if (missing.Count == 0) { result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); return; } foreach (var dep in missing) { if (dep.Name == "Python") { result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); } else if (dep.Name == "UV Package Manager") { result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); } else if (dep.Name == "MCP Server") { result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); } } if (result.GetMissingRequired().Count > 0) { result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Setup/SetupWizard.cs: -------------------------------------------------------------------------------- ```csharp using System; using MCPForUnity.Editor.Dependencies; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Windows; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Setup { /// <summary> /// Handles automatic triggering of the setup wizard /// </summary> [InitializeOnLoad] public static class SetupWizard { private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; private static bool _hasCheckedThisSession = false; static SetupWizard() { // Skip in batch mode if (Application.isBatchMode) return; // Show setup wizard on package import EditorApplication.delayCall += CheckSetupNeeded; } /// <summary> /// Check if setup wizard should be shown /// </summary> private static void CheckSetupNeeded() { if (_hasCheckedThisSession) return; _hasCheckedThisSession = true; try { // Check if setup was already completed or dismissed in previous sessions bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); // Only show setup wizard if it hasn't been completed or dismissed before if (!(setupCompleted || setupDismissed)) { McpLog.Info("Package imported - showing setup wizard", always: false); var dependencyResult = DependencyManager.CheckAllDependencies(); EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); } else { McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); } } catch (Exception ex) { McpLog.Error($"Error checking setup status: {ex.Message}"); } } /// <summary> /// Show the setup wizard window /// </summary> public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) { try { dependencyResult ??= DependencyManager.CheckAllDependencies(); SetupWizardWindow.ShowWindow(dependencyResult); } catch (Exception ex) { McpLog.Error($"Error showing setup wizard: {ex.Message}"); } } /// <summary> /// Mark setup as completed /// </summary> public static void MarkSetupCompleted() { EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); McpLog.Info("Setup marked as completed"); } /// <summary> /// Mark setup as dismissed /// </summary> public static void MarkSetupDismissed() { EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); McpLog.Info("Setup marked as dismissed"); } /// <summary> /// Force show setup wizard (for manual invocation) /// </summary> [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] public static void ShowSetupWizardManual() { ShowSetupWizard(); } /// <summary> /// Check dependencies and show status /// </summary> [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] public static void CheckDependencies() { var result = DependencyManager.CheckAllDependencies(); if (!result.IsSystemReady) { bool showWizard = EditorUtility.DisplayDialog( "MCP for Unity - Dependencies", $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", "Open Setup Wizard", "Close" ); if (showWizard) { ShowSetupWizard(result); } } else { EditorUtility.DisplayDialog( "MCP for Unity - Dependencies", "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", "OK" ); } } /// <summary> /// Open MCP Client Configuration window /// </summary> [MenuItem("Window/MCP For Unity/Open MCP Window %#m", priority = 4)] public static void OpenClientConfiguration() { Windows.MCPForUnityEditorWindowNew.ShowWindow(); } /// <summary> /// Open legacy MCP Client Configuration window /// </summary> [MenuItem("Window/MCP For Unity/Open Legacy MCP Window", priority = 5)] public static void OpenLegacyClientConfiguration() { Windows.MCPForUnityEditorWindow.ShowWindow(); } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/McpPathResolver.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using UnityEngine; using UnityEditor; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Shared helper for resolving MCP server directory paths with support for /// development mode, embedded servers, and installed packages /// </summary> public static class McpPathResolver { private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; /// <summary> /// Resolves the MCP server directory path with comprehensive logic /// including development mode support and fallback mechanisms /// </summary> public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) { string pythonDir = McpConfigFileHelper.ResolveServerSource(); try { // Only check dev paths if we're using a file-based package (development mode) bool isDevelopmentMode = IsDevelopmentMode(); if (isDevelopmentMode) { string currentPackagePath = Path.GetDirectoryName(Application.dataPath); string[] devPaths = { Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), }; foreach (string devPath in devPaths) { if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) { if (debugLogsEnabled) { Debug.Log($"Currently in development mode. Package: {devPath}"); } return devPath; } } } // Resolve via shared helper (handles local registry and older fallback) only if dev override on if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) { if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) { return embedded; } } // Log only if the resolved path does not actually contain server.py if (debugLogsEnabled) { bool hasServer = false; try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } if (!hasServer) { Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); } } } catch (Exception e) { Debug.LogError($"Error finding package path: {e.Message}"); } return pythonDir; } /// <summary> /// Checks if the current Unity project is in development mode /// (i.e., the package is referenced as a local file path in manifest.json) /// </summary> private static bool IsDevelopmentMode() { try { // Only treat as development if manifest explicitly references a local file path for the package string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); if (!File.Exists(manifestPath)) return false; string manifestContent = File.ReadAllText(manifestPath); // Look specifically for our package dependency set to a file: URL // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) { int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); // Crude but effective: check for "file:" in the same line/value if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } catch { return false; } } /// <summary> /// Gets the appropriate PATH prepend for the current platform when running external processes /// </summary> public static string GetPathPrepend() { if (Application.platform == RuntimePlatform.OSXEditor) return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; else if (Application.platform == RuntimePlatform.LinuxEditor) return "/usr/local/bin:/usr/bin:/bin"; return null; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/McpPathResolver.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using UnityEngine; using UnityEditor; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Shared helper for resolving Python server directory paths with support for /// development mode, embedded servers, and installed packages /// </summary> public static class McpPathResolver { private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; /// <summary> /// Resolves the Python server directory path with comprehensive logic /// including development mode support and fallback mechanisms /// </summary> public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) { string pythonDir = McpConfigFileHelper.ResolveServerSource(); try { // Only check dev paths if we're using a file-based package (development mode) bool isDevelopmentMode = IsDevelopmentMode(); if (isDevelopmentMode) { string currentPackagePath = Path.GetDirectoryName(Application.dataPath); string[] devPaths = { Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), }; foreach (string devPath in devPaths) { if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) { if (debugLogsEnabled) { Debug.Log($"Currently in development mode. Package: {devPath}"); } return devPath; } } } // Resolve via shared helper (handles local registry and older fallback) only if dev override on if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) { if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) { return embedded; } } // Log only if the resolved path does not actually contain server.py if (debugLogsEnabled) { bool hasServer = false; try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } if (!hasServer) { Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); } } } catch (Exception e) { Debug.LogError($"Error finding package path: {e.Message}"); } return pythonDir; } /// <summary> /// Checks if the current Unity project is in development mode /// (i.e., the package is referenced as a local file path in manifest.json) /// </summary> private static bool IsDevelopmentMode() { try { // Only treat as development if manifest explicitly references a local file path for the package string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); if (!File.Exists(manifestPath)) return false; string manifestContent = File.ReadAllText(manifestPath); // Look specifically for our package dependency set to a file: URL // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) { int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); // Crude but effective: check for "file:" in the same line/value if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } catch { return false; } } /// <summary> /// Gets the appropriate PATH prepend for the current platform when running external processes /// </summary> public static string GetPathPrepend() { if (Application.platform == RuntimePlatform.OSXEditor) return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; else if (Application.platform == RuntimePlatform.LinuxEditor) return "/usr/local/bin:/usr/bin:/bin"; return null; } } } ``` -------------------------------------------------------------------------------- /docs/TELEMETRY.md: -------------------------------------------------------------------------------- ```markdown # MCP for Unity Telemetry MCP for Unity includes privacy-focused, anonymous telemetry to help us improve the product. This document explains what data is collected, how to opt out, and our privacy practices. ## 🔒 Privacy First - **Anonymous**: We use randomly generated UUIDs - no personal information - **Non-blocking**: Telemetry never interferes with your Unity workflow - **Easy opt-out**: Simple environment variable or Unity Editor setting - **Transparent**: All collected data types are documented here ## 📊 What We Collect ### Usage Analytics - **Tool Usage**: Which MCP tools you use (manage_script, manage_scene, etc.) - **Performance**: Execution times and success/failure rates - **System Info**: Unity version, platform (Windows/Mac/Linux), MCP version - **Milestones**: First-time usage events (first script creation, first tool use, etc.) ### Technical Diagnostics - **Connection Events**: Bridge startup/connection success/failures - **Error Reports**: Anonymized error messages (truncated to 200 chars) - **Server Health**: Startup time, connection latency ### What We **DON'T** Collect - ❌ Your code or script contents - ❌ Project names, file names, or paths - ❌ Personal information or identifiers - ❌ Sensitive project data - ❌ IP addresses (beyond what's needed for HTTP requests) ## 🚫 How to Opt Out ### Method 1: Environment Variable (Recommended) Set any of these environment variables to `true`: ```bash # Disable all telemetry export DISABLE_TELEMETRY=true # MCP for Unity specific export UNITY_MCP_DISABLE_TELEMETRY=true # MCP protocol wide export MCP_DISABLE_TELEMETRY=true ``` ### Method 2: Unity Editor (Coming Soon) In Unity Editor: `Window > MCP for Unity > Settings > Disable Telemetry` ### Method 3: Manual Config Add to your MCP client config: ```json { "env": { "DISABLE_TELEMETRY": "true" } } ``` ## 🔧 Technical Implementation ### Architecture - **Python Server**: Core telemetry collection and transmission - **Unity Bridge**: Local event collection from Unity Editor - **Anonymous UUIDs**: Generated per-installation for aggregate analytics - **Thread-safe**: Non-blocking background transmission - **Fail-safe**: Errors never interrupt your workflow ### Data Storage Telemetry data is stored locally in: - **Windows**: `%APPDATA%\UnityMCP\` - **macOS**: `~/Library/Application Support/UnityMCP/` - **Linux**: `~/.local/share/UnityMCP/` Files created: - `customer_uuid.txt`: Anonymous identifier - `milestones.json`: One-time events tracker ### Data Transmission - **Endpoint**: `https://api-prod.coplay.dev/telemetry/events` - **Method**: HTTPS POST with JSON payload - **Retry**: Background thread with graceful failure - **Timeout**: 10 second timeout, no retries on failure ## 📈 How We Use This Data ### Product Improvement - **Feature Usage**: Understand which tools are most/least used - **Performance**: Identify slow operations to optimize - **Reliability**: Track error rates and connection issues - **Compatibility**: Ensure Unity version compatibility ### Development Priorities - **Roadmap**: Focus development on most-used features - **Bug Fixes**: Prioritize fixes based on error frequency - **Platform Support**: Allocate resources based on platform usage - **Documentation**: Improve docs for commonly problematic areas ### What We Don't Do - ❌ Sell data to third parties - ❌ Use data for advertising/marketing - ❌ Track individual developers - ❌ Store sensitive project information ## 🛠️ For Developers ### Testing Telemetry ```bash cd MCPForUnity/UnityMcpServer~/src python test_telemetry.py ``` ### Custom Telemetry Events ```python from telemetry import record_telemetry, RecordType record_telemetry(RecordType.USAGE, { "custom_event": "my_feature_used", "metadata": "optional_data" }) ``` ### Telemetry Status Check ```python from telemetry import is_telemetry_enabled if is_telemetry_enabled(): print("Telemetry is active") else: print("Telemetry is disabled") ``` ## 📋 Data Retention Policy - **Aggregated Data**: Retained indefinitely for product insights - **Raw Events**: Automatically purged after 90 days - **Personal Data**: None collected, so none to purge - **Opt-out**: Immediate - no data sent after opting out ## 🤝 Contact & Transparency - **Questions**: [Discord Community](https://discord.gg/y4p8KfzrN4) - **Issues**: [GitHub Issues](https://github.com/CoplayDev/unity-mcp/issues) - **Privacy Concerns**: Create a GitHub issue with "Privacy" label - **Source Code**: All telemetry code is open source in this repository ## 📊 Example Telemetry Event Here's what a typical telemetry event looks like: ```json { "record": "tool_execution", "timestamp": 1704067200, "customer_uuid": "550e8400-e29b-41d4-a716-446655440000", "session_id": "abc123-def456-ghi789", "version": "3.0.2", "platform": "posix", "data": { "tool_name": "manage_script", "success": true, "duration_ms": 42.5 } } ``` Notice: - ✅ Anonymous UUID (randomly generated) - ✅ Tool performance metrics - ✅ Success/failure tracking - ❌ No code content - ❌ No project information - ❌ No personal data --- *MCP for Unity Telemetry is designed to respect your privacy while helping us build a better tool. Thank you for helping improve MCP for Unity!* ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Base class for platform-specific dependency detection /// </summary> public abstract class PlatformDetectorBase : IPlatformDetector { public abstract string PlatformName { get; } public abstract bool CanDetect { get; } public abstract DependencyStatus DetectPython(); public abstract string GetPythonInstallUrl(); public abstract string GetUVInstallUrl(); public abstract string GetInstallationRecommendations(); public virtual DependencyStatus DetectUV() { var status = new DependencyStatus("UV Package Manager", isRequired: true) { InstallationHint = GetUVInstallUrl() }; try { // Use existing UV detection from ServerInstaller string uvPath = ServerInstaller.FindUvPath(); if (!string.IsNullOrEmpty(uvPath)) { if (TryValidateUV(uvPath, out string version)) { status.IsAvailable = true; status.Version = version; status.Path = uvPath; status.Details = $"Found UV {version} at {uvPath}"; return status; } } status.ErrorMessage = "UV package manager not found. Please install UV."; status.Details = "UV is required for managing Python dependencies."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting UV: {ex.Message}"; } return status; } public virtual DependencyStatus DetectMCPServer() { var status = new DependencyStatus("MCP Server", isRequired: false); try { // Check if server is installed string serverPath = ServerInstaller.GetServerPath(); string serverPy = Path.Combine(serverPath, "server.py"); if (File.Exists(serverPy)) { status.IsAvailable = true; status.Path = serverPath; // Try to get version string versionFile = Path.Combine(serverPath, "server_version.txt"); if (File.Exists(versionFile)) { status.Version = File.ReadAllText(versionFile).Trim(); } status.Details = $"MCP Server found at {serverPath}"; } else { // Check for embedded server if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) { status.IsAvailable = true; status.Path = embeddedPath; status.Details = "MCP Server available (embedded in package)"; } else { status.ErrorMessage = "MCP Server not found"; status.Details = "Server will be installed automatically when needed"; } } } catch (Exception ex) { status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; } return status; } protected bool TryValidateUV(string uvPath, out string version) { version = null; try { var psi = new ProcessStartInfo { FileName = uvPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("uv ")) { version = output.Substring(3); // Remove "uv " prefix return true; } } catch { // Ignore validation errors } return false; } protected bool TryParseVersion(string version, out int major, out int minor) { major = 0; minor = 0; try { var parts = version.Split('.'); if (parts.Length >= 2) { return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); } } catch { // Ignore parsing errors } return false; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Base class for platform-specific dependency detection /// </summary> public abstract class PlatformDetectorBase : IPlatformDetector { public abstract string PlatformName { get; } public abstract bool CanDetect { get; } public abstract DependencyStatus DetectPython(); public abstract string GetPythonInstallUrl(); public abstract string GetUVInstallUrl(); public abstract string GetInstallationRecommendations(); public virtual DependencyStatus DetectUV() { var status = new DependencyStatus("UV Package Manager", isRequired: true) { InstallationHint = GetUVInstallUrl() }; try { // Use existing UV detection from ServerInstaller string uvPath = ServerInstaller.FindUvPath(); if (!string.IsNullOrEmpty(uvPath)) { if (TryValidateUV(uvPath, out string version)) { status.IsAvailable = true; status.Version = version; status.Path = uvPath; status.Details = $"Found UV {version} at {uvPath}"; return status; } } status.ErrorMessage = "UV package manager not found. Please install UV."; status.Details = "UV is required for managing Python dependencies."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting UV: {ex.Message}"; } return status; } public virtual DependencyStatus DetectMCPServer() { var status = new DependencyStatus("MCP Server", isRequired: false); try { // Check if server is installed string serverPath = ServerInstaller.GetServerPath(); string serverPy = Path.Combine(serverPath, "server.py"); if (File.Exists(serverPy)) { status.IsAvailable = true; status.Path = serverPath; // Try to get version string versionFile = Path.Combine(serverPath, "server_version.txt"); if (File.Exists(versionFile)) { status.Version = File.ReadAllText(versionFile).Trim(); } status.Details = $"MCP Server found at {serverPath}"; } else { // Check for embedded server if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) { status.IsAvailable = true; status.Path = embeddedPath; status.Details = "MCP Server available (embedded in package)"; } else { status.ErrorMessage = "MCP Server not found"; status.Details = "Server will be installed automatically when needed"; } } } catch (Exception ex) { status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; } return status; } protected bool TryValidateUV(string uvPath, out string version) { version = null; try { var psi = new ProcessStartInfo { FileName = uvPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("uv ")) { version = output.Substring(3); // Remove "uv " prefix return true; } } catch { // Ignore validation errors } return false; } protected bool TryParseVersion(string version, out int major, out int minor) { major = 0; minor = 0; try { var parts = version.Split('.'); if (parts.Length >= 2) { return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); } } catch { // Ignore parsing errors } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ServerPathResolver.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Helpers { public static class ServerPathResolver { /// <summary> /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package /// or common development locations. Returns true if found and sets srcPath to the folder /// containing server.py. /// </summary> public static bool TryFindEmbeddedServerSource(out string srcPath) { // 1) Repo development layouts commonly used alongside this package try { string projectRoot = Path.GetDirectoryName(Application.dataPath); string[] devCandidates = { Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), }; foreach (string candidate in devCandidates) { string full = Path.GetFullPath(candidate); if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) { srcPath = full; return true; } } } catch { /* ignore */ } // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. try { #if UNITY_2021_2_OR_NEWER // Primary: the package that owns this assembly var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); if (owner != null) { if (TryResolveWithinPackage(owner, out srcPath)) { return true; } } // Secondary: scan all registered packages locally foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) { if (TryResolveWithinPackage(p, out srcPath)) { return true; } } #else // Older Unity versions: use Package Manager Client.List as a fallback var list = UnityEditor.PackageManager.Client.List(); while (!list.IsCompleted) { } if (list.Status == UnityEditor.PackageManager.StatusCode.Success) { foreach (var pkg in list.Result) { if (TryResolveWithinPackage(pkg, out srcPath)) { return true; } } } #endif } catch { /* ignore */ } // 3) Fallback to previous common install locations try { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] candidates = { Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), }; foreach (string candidate in candidates) { if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) { srcPath = candidate; return true; } } } catch { /* ignore */ } srcPath = null; return false; } private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) { const string CurrentId = "com.coplaydev.unity-mcp"; srcPath = null; if (p == null || p.name != CurrentId) { return false; } string packagePath = p.resolvedPath; // Preferred tilde folder (embedded but excluded from import) string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) { srcPath = embeddedTilde; return true; } // Legacy non-tilde folder string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) { srcPath = embedded; return true; } // Dev-linked sibling of the package folder string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) { srcPath = sibling; return true; } return false; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Helpers { public static class ServerPathResolver { /// <summary> /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package /// or common development locations. Returns true if found and sets srcPath to the folder /// containing server.py. /// </summary> public static bool TryFindEmbeddedServerSource(out string srcPath) { // 1) Repo development layouts commonly used alongside this package try { string projectRoot = Path.GetDirectoryName(Application.dataPath); string[] devCandidates = { Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), }; foreach (string candidate in devCandidates) { string full = Path.GetFullPath(candidate); if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) { srcPath = full; return true; } } } catch { /* ignore */ } // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. try { #if UNITY_2021_2_OR_NEWER // Primary: the package that owns this assembly var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); if (owner != null) { if (TryResolveWithinPackage(owner, out srcPath)) { return true; } } // Secondary: scan all registered packages locally foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) { if (TryResolveWithinPackage(p, out srcPath)) { return true; } } #else // Older Unity versions: use Package Manager Client.List as a fallback var list = UnityEditor.PackageManager.Client.List(); while (!list.IsCompleted) { } if (list.Status == UnityEditor.PackageManager.StatusCode.Success) { foreach (var pkg in list.Result) { if (TryResolveWithinPackage(pkg, out srcPath)) { return true; } } } #endif } catch { /* ignore */ } // 3) Fallback to previous common install locations try { string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] candidates = { Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), }; foreach (string candidate in candidates) { if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) { srcPath = candidate; return true; } } } catch { /* ignore */ } srcPath = null; return false; } private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) { const string CurrentId = "com.coplaydev.unity-mcp"; srcPath = null; if (p == null || p.name != CurrentId) { return false; } string packagePath = p.resolvedPath; // Preferred tilde folder (embedded but excluded from import) string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) { srcPath = embeddedTilde; return true; } // Legacy non-tilde folder string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) { srcPath = embedded; return true; } // Dev-linked sibling of the package folder string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) { srcPath = sibling; return true; } return false; } } } ``` -------------------------------------------------------------------------------- /mcp_source.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Generic helper to switch the MCP for Unity package source in a Unity project's Packages/manifest.json. This is useful for switching between upstream and local repos while working on the MCP. Usage: python mcp_source.py [--manifest /abs/path/to/manifest.json] [--repo /abs/path/to/unity-mcp] [--choice 1|2|3] Choices: 1) Upstream main (CoplayDev/unity-mcp) 2) Your remote current branch (derived from `origin` and current branch) 3) Local repo workspace (file: URL to MCPForUnity in your checkout) """ from __future__ import annotations import argparse import json import pathlib import subprocess import sys from typing import Optional PKG_NAME = "com.coplaydev.unity-mcp" BRIDGE_SUBPATH = "MCPForUnity" def run_git(repo: pathlib.Path, *args: str) -> str: result = subprocess.run([ "git", "-C", str(repo), *args ], capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") return result.stdout.strip() def normalize_origin_to_https(url: str) -> str: """Map common SSH origin forms to https for Unity's git URL scheme.""" if url.startswith("[email protected]:"): owner_repo = url.split(":", 1)[1] if owner_repo.endswith(".git"): owner_repo = owner_repo[:-4] return f"https://github.com/{owner_repo}.git" # already https or file: etc. return url def detect_repo_root(explicit: Optional[str]) -> pathlib.Path: if explicit: return pathlib.Path(explicit).resolve() # Prefer the git toplevel from the script's directory here = pathlib.Path(__file__).resolve().parent try: top = run_git(here, "rev-parse", "--show-toplevel") return pathlib.Path(top) except Exception: return here def detect_branch(repo: pathlib.Path) -> str: return run_git(repo, "rev-parse", "--abbrev-ref", "HEAD") def detect_origin(repo: pathlib.Path) -> str: url = run_git(repo, "remote", "get-url", "origin") return normalize_origin_to_https(url) def find_manifest(explicit: Optional[str]) -> pathlib.Path: if explicit: return pathlib.Path(explicit).resolve() # Walk up from CWD looking for Packages/manifest.json cur = pathlib.Path.cwd().resolve() for parent in [cur, *cur.parents]: candidate = parent / "Packages" / "manifest.json" if candidate.exists(): return candidate raise FileNotFoundError( "Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") def read_json(path: pathlib.Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def write_json(path: pathlib.Path, data: dict) -> None: with path.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) f.write("\n") def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity" # Ensure origin is https origin = origin_https # If origin is a local file path or non-https, try to coerce to https github if possible if origin.startswith("file:"): # Not meaningful for remote option; keep upstream origin_remote = upstream else: origin_remote = origin return [ ("[1] Upstream main", upstream), ("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), ("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), ] def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Switch MCP for Unity package source") p.add_argument("--manifest", help="Path to Packages/manifest.json") p.add_argument( "--repo", help="Path to unity-mcp repo root (for local file option)") p.add_argument( "--choice", choices=["1", "2", "3"], help="Pick option non-interactively") return p.parse_args() def main() -> None: args = parse_args() try: repo_root = detect_repo_root(args.repo) branch = detect_branch(repo_root) origin = detect_origin(repo_root) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) options = build_options(repo_root, branch, origin) try: manifest_path = find_manifest(args.manifest) except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) print("Select MCP package source by number:") for label, _ in options: print(label) if args.choice: choice = args.choice else: choice = input("Enter 1-3: ").strip() if choice not in {"1", "2", "3"}: print("Invalid selection.", file=sys.stderr) sys.exit(1) idx = int(choice) - 1 _, chosen = options[idx] data = read_json(manifest_path) deps = data.get("dependencies", {}) if PKG_NAME not in deps: print( f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) sys.exit(1) print(f"\nUpdating {PKG_NAME} → {chosen}") deps[PKG_NAME] = chosen data["dependencies"] = deps write_json(manifest_path, data) print(f"Done. Wrote to: {manifest_path}") print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") if __name__ == "__main__": main() ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Linq; using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Data; namespace MCPForUnityTests.Editor.Data { public class PythonToolsAssetTests { private PythonToolsAsset _asset; [SetUp] public void SetUp() { _asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); } [TearDown] public void TearDown() { if (_asset != null) { UnityEngine.Object.DestroyImmediate(_asset, true); } } [Test] public void GetValidFiles_ReturnsEmptyList_WhenNoFilesAdded() { var validFiles = _asset.GetValidFiles().ToList(); Assert.IsEmpty(validFiles, "Should return empty list when no files added"); } [Test] public void GetValidFiles_FiltersOutNullReferences() { _asset.pythonFiles.Add(null); _asset.pythonFiles.Add(new TextAsset("print('test')")); _asset.pythonFiles.Add(null); var validFiles = _asset.GetValidFiles().ToList(); Assert.AreEqual(1, validFiles.Count, "Should filter out null references"); } [Test] public void GetValidFiles_ReturnsAllNonNullFiles() { var file1 = new TextAsset("print('test1')"); var file2 = new TextAsset("print('test2')"); _asset.pythonFiles.Add(file1); _asset.pythonFiles.Add(file2); var validFiles = _asset.GetValidFiles().ToList(); Assert.AreEqual(2, validFiles.Count, "Should return all non-null files"); CollectionAssert.Contains(validFiles, file1); CollectionAssert.Contains(validFiles, file2); } [Test] public void NeedsSync_ReturnsTrue_WhenHashingDisabled() { _asset.useContentHashing = false; var textAsset = new TextAsset("print('test')"); bool needsSync = _asset.NeedsSync(textAsset, "any_hash"); Assert.IsTrue(needsSync, "Should always need sync when hashing disabled"); } [Test] public void NeedsSync_ReturnsTrue_WhenFileNotInStates() { _asset.useContentHashing = true; var textAsset = new TextAsset("print('test')"); bool needsSync = _asset.NeedsSync(textAsset, "new_hash"); Assert.IsTrue(needsSync, "Should need sync for new file"); } [Test] public void NeedsSync_ReturnsFalse_WhenHashMatches() { _asset.useContentHashing = true; var textAsset = new TextAsset("print('test')"); string hash = "test_hash_123"; // Record the file with a hash _asset.RecordSync(textAsset, hash); // Check if needs sync with same hash bool needsSync = _asset.NeedsSync(textAsset, hash); Assert.IsFalse(needsSync, "Should not need sync when hash matches"); } [Test] public void NeedsSync_ReturnsTrue_WhenHashDiffers() { _asset.useContentHashing = true; var textAsset = new TextAsset("print('test')"); // Record with one hash _asset.RecordSync(textAsset, "old_hash"); // Check with different hash bool needsSync = _asset.NeedsSync(textAsset, "new_hash"); Assert.IsTrue(needsSync, "Should need sync when hash differs"); } [Test] public void RecordSync_AddsNewFileState() { var textAsset = new TextAsset("print('test')"); string hash = "test_hash"; _asset.RecordSync(textAsset, hash); Assert.AreEqual(1, _asset.fileStates.Count, "Should add one file state"); Assert.AreEqual(hash, _asset.fileStates[0].contentHash, "Should store the hash"); Assert.IsNotNull(_asset.fileStates[0].assetGuid, "Should store the GUID"); } [Test] public void RecordSync_UpdatesExistingFileState() { var textAsset = new TextAsset("print('test')"); // Record first time _asset.RecordSync(textAsset, "hash1"); var firstTime = _asset.fileStates[0].lastSyncTime; // Wait a tiny bit to ensure time difference System.Threading.Thread.Sleep(10); // Record second time with different hash _asset.RecordSync(textAsset, "hash2"); Assert.AreEqual(1, _asset.fileStates.Count, "Should still have only one state"); Assert.AreEqual("hash2", _asset.fileStates[0].contentHash, "Should update the hash"); Assert.Greater(_asset.fileStates[0].lastSyncTime, firstTime, "Should update sync time"); } [Test] public void CleanupStaleStates_KeepsStatesForCurrentFiles() { var file1 = new TextAsset("print('test1')"); _asset.pythonFiles.Add(file1); _asset.RecordSync(file1, "hash1"); _asset.CleanupStaleStates(); Assert.AreEqual(1, _asset.fileStates.Count, "Should keep state for current file"); } [Test] public void CleanupStaleStates_HandlesEmptyFilesList() { // Add some states without corresponding files _asset.fileStates.Add(new PythonFileState { assetGuid = "fake_guid_1", contentHash = "hash1", fileName = "test1.py", lastSyncTime = DateTime.UtcNow }); _asset.CleanupStaleStates(); Assert.IsEmpty(_asset.fileStates, "Should remove all states when no files exist"); } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/port_discovery.py: -------------------------------------------------------------------------------- ```python """ Port discovery utility for MCP for Unity Server. What changed and why: - Unity now writes a per-project port file named like `~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting each other's saved port. The legacy file `unity-mcp-port.json` may still exist. - This module now scans for both patterns, prefers the most recently modified file, and verifies that the port is actually a MCP for Unity listener (quick socket connect + ping) before choosing it. """ import glob import json import logging from pathlib import Path import socket from typing import Optional, List logger = logging.getLogger("mcp-for-unity-server") class PortDiscovery: """Handles port discovery from Unity Bridge registry""" REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file DEFAULT_PORT = 6400 CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE @staticmethod def get_registry_dir() -> Path: return Path.home() / ".unity-mcp" @staticmethod def list_candidate_files() -> List[Path]: """Return candidate registry files, newest first. Includes hashed per-project files and the legacy file (if present). """ base = PortDiscovery.get_registry_dir() hashed = sorted( (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) legacy = PortDiscovery.get_registry_path() if legacy.exists(): # Put legacy at the end so hashed, per-project files win hashed.append(legacy) return hashed @staticmethod def _try_probe_unity_mcp(port: int) -> bool: """Quickly check if a MCP for Unity listener is on this port. Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. """ try: with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: s.settimeout(PortDiscovery.CONNECT_TIMEOUT) try: s.sendall(b"ping") data = s.recv(512) # Minimal validation: look for a success pong response if data and b'"message":"pong"' in data: return True except Exception: return False except Exception: return False return False @staticmethod def _read_latest_status() -> Optional[dict]: try: base = PortDiscovery.get_registry_dir() status_files = sorted( (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) if not status_files: return None with status_files[0].open('r') as f: return json.load(f) except Exception: return None @staticmethod def discover_unity_port() -> int: """ Discover Unity port by scanning per-project and legacy registry files. Prefer the newest file whose port responds; fall back to first parsed value; finally default to 6400. Returns: Port number to connect to """ # Prefer the latest heartbeat status if it points to a responsive port status = PortDiscovery._read_latest_status() if status: port = status.get('unity_port') if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): logger.info(f"Using Unity port from status: {port}") return port candidates = PortDiscovery.list_candidate_files() first_seen_port: Optional[int] = None for path in candidates: try: with open(path, 'r') as f: cfg = json.load(f) unity_port = cfg.get('unity_port') if isinstance(unity_port, int): if first_seen_port is None: first_seen_port = unity_port if PortDiscovery._try_probe_unity_mcp(unity_port): logger.info( f"Using Unity port from {path.name}: {unity_port}") return unity_port except Exception as e: logger.warning(f"Could not read port registry {path}: {e}") if first_seen_port is not None: logger.info( f"No responsive port found; using first seen value {first_seen_port}") return first_seen_port # Fallback to default port logger.info( f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") return PortDiscovery.DEFAULT_PORT @staticmethod def get_port_config() -> Optional[dict]: """ Get the most relevant port configuration from registry. Returns the most recent hashed file's config if present, otherwise the legacy file's config. Returns None if nothing exists. Returns: Port configuration dict or None if not found """ candidates = PortDiscovery.list_candidate_files() if not candidates: return None for path in candidates: try: with open(path, 'r') as f: return json.load(f) except Exception as e: logger.warning( f"Could not read port configuration {path}: {e}") return None ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/port_discovery.py: -------------------------------------------------------------------------------- ```python """ Port discovery utility for MCP for Unity Server. What changed and why: - Unity now writes a per-project port file named like `~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting each other's saved port. The legacy file `unity-mcp-port.json` may still exist. - This module now scans for both patterns, prefers the most recently modified file, and verifies that the port is actually a MCP for Unity listener (quick socket connect + ping) before choosing it. """ import glob import json import logging from pathlib import Path import socket from typing import Optional, List logger = logging.getLogger("mcp-for-unity-server") class PortDiscovery: """Handles port discovery from Unity Bridge registry""" REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file DEFAULT_PORT = 6400 CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE @staticmethod def get_registry_dir() -> Path: return Path.home() / ".unity-mcp" @staticmethod def list_candidate_files() -> List[Path]: """Return candidate registry files, newest first. Includes hashed per-project files and the legacy file (if present). """ base = PortDiscovery.get_registry_dir() hashed = sorted( (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) legacy = PortDiscovery.get_registry_path() if legacy.exists(): # Put legacy at the end so hashed, per-project files win hashed.append(legacy) return hashed @staticmethod def _try_probe_unity_mcp(port: int) -> bool: """Quickly check if a MCP for Unity listener is on this port. Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. """ try: with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: s.settimeout(PortDiscovery.CONNECT_TIMEOUT) try: s.sendall(b"ping") data = s.recv(512) # Minimal validation: look for a success pong response if data and b'"message":"pong"' in data: return True except Exception: return False except Exception: return False return False @staticmethod def _read_latest_status() -> Optional[dict]: try: base = PortDiscovery.get_registry_dir() status_files = sorted( (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) if not status_files: return None with status_files[0].open('r') as f: return json.load(f) except Exception: return None @staticmethod def discover_unity_port() -> int: """ Discover Unity port by scanning per-project and legacy registry files. Prefer the newest file whose port responds; fall back to first parsed value; finally default to 6400. Returns: Port number to connect to """ # Prefer the latest heartbeat status if it points to a responsive port status = PortDiscovery._read_latest_status() if status: port = status.get('unity_port') if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): logger.info(f"Using Unity port from status: {port}") return port candidates = PortDiscovery.list_candidate_files() first_seen_port: Optional[int] = None for path in candidates: try: with open(path, 'r') as f: cfg = json.load(f) unity_port = cfg.get('unity_port') if isinstance(unity_port, int): if first_seen_port is None: first_seen_port = unity_port if PortDiscovery._try_probe_unity_mcp(unity_port): logger.info( f"Using Unity port from {path.name}: {unity_port}") return unity_port except Exception as e: logger.warning(f"Could not read port registry {path}: {e}") if first_seen_port is not None: logger.info( f"No responsive port found; using first seen value {first_seen_port}") return first_seen_port # Fallback to default port logger.info( f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") return PortDiscovery.DEFAULT_PORT @staticmethod def get_port_config() -> Optional[dict]: """ Get the most relevant port configuration from registry. Returns the most recent hashed file's config if present, otherwise the legacy file's config. Returns None if nothing exists. Returns: Port configuration dict or None if not found """ candidates = PortDiscovery.list_candidate_files() if not candidates: return None for path in candidates: try: with open(path, 'r') as f: return json.load(f) except Exception as e: logger.warning( f"Could not read port configuration {path}: {e}") return None ```