This is page 3 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /docs/CURSOR_HELP.md: -------------------------------------------------------------------------------- ```markdown 1 | ### Cursor/VSCode/Windsurf: UV path issue on Windows (diagnosis and fix) 2 | 3 | #### The issue 4 | - 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. 5 | 6 | #### Typical symptoms 7 | - Cursor shows the MCP for Unity server but never connects or reports it “can’t start.” 8 | - Your `%USERPROFILE%\\.cursor\\mcp.json` flips back to a different `command` path when Unity or the MCP for Unity window refreshes. 9 | 10 | #### Real-world example 11 | - Wrong/fragile path (auto-picked): 12 | - `C:\Users\mrken.local\bin\uv.exe` (malformed, not standard) 13 | - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Packages\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\uv.exe` 14 | - Correct/stable path (works with Cursor): 15 | - `C:\Users\mrken\AppData\Local\Microsoft\WinGet\Links\uv.exe` 16 | 17 | #### Quick fix (recommended) 18 | 1) In MCP for Unity: `Window > MCP for Unity` → select your MCP client (Cursor or Windsurf) 19 | 2) If you see “uv Not Found,” click “Choose `uv` Install Location” and browse to: 20 | - `C:\Users\<YOU>\AppData\Local\Microsoft\WinGet\Links\uv.exe` 21 | 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. 22 | 4) Click “Auto Configure” (or re-open the client) and restart Cursor. 23 | 24 | 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. 25 | 26 | #### Verify the fix 27 | - Confirm global Cursor config is at: `%USERPROFILE%\\.cursor\\mcp.json` 28 | - You should see something like: 29 | 30 | ```json 31 | { 32 | "mcpServers": { 33 | "unityMCP": { 34 | "command": "C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe", 35 | "args": [ 36 | "--directory", 37 | "C:\\Users\\YOU\\AppData\\Local\\Programs\\UnityMCP\\UnityMcpServer\\src", 38 | "run", 39 | "server.py" 40 | ] 41 | } 42 | } 43 | } 44 | ``` 45 | 46 | - Manually run the same command in PowerShell to confirm it launches: 47 | 48 | ```powershell 49 | "C:\Users\YOU\AppData\Local\Microsoft\WinGet\Links\uv.exe" --directory "C:\Users\YOU\AppData\Local\Programs\UnityMCP\UnityMcpServer\src" run server.py 50 | ``` 51 | 52 | If that runs without error, restart Cursor and it should connect. 53 | 54 | #### Why this happens 55 | - 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. 56 | - 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. 57 | 58 | #### Extra notes 59 | - Restart Cursor after changing `mcp.json`; it doesn’t always hot-reload that file. 60 | - If you also have a project-scoped `.cursor\\mcp.json` in your Unity project folder, that file overrides the global one. 61 | 62 | 63 | ### Why pin the WinGet Links shim (and not the Packages path) 64 | 65 | - 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"`. 66 | - WinGet publishes stable launch shims in these locations: 67 | - User scope: `%LOCALAPPDATA%\Microsoft\WinGet\Links\uv.exe` 68 | - Machine scope: `C:\Program Files\WinGet\Links\uv.exe` 69 | 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) 70 | - The `Packages` root is where payloads live and can change across updates, so avoid pointing your config at it. 71 | 72 | Recommended practice 73 | 74 | - Prefer the WinGet Links shim paths above. If present, select one via “Choose `uv` Install Location”. 75 | - 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. 76 | - 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. 77 | 78 | References 79 | 80 | - WinGet portable Links: [GitHub discussion](https://github.com/microsoft/winget-pkgs/discussions/184459) 81 | - WinGet install locations: [Super User](https://superuser.com/questions/1739292/how-to-know-where-winget-installed-a-program) 82 | - GUI client PATH caveats (Cursor): [Cursor community thread](https://forum.cursor.com/t/mcp-feature-client-closed-fix/54651?page=4) 83 | - uv tools install location (`~/.local/bin`): [Astral docs](https://docs.astral.sh/uv/concepts/tools/) 84 | 85 | 86 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Setup/SetupWizard.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using MCPForUnity.Editor.Dependencies; 3 | using MCPForUnity.Editor.Dependencies.Models; 4 | using MCPForUnity.Editor.Helpers; 5 | using MCPForUnity.Editor.Windows; 6 | using UnityEditor; 7 | using UnityEngine; 8 | 9 | namespace MCPForUnity.Editor.Setup 10 | { 11 | /// <summary> 12 | /// Handles automatic triggering of the setup wizard 13 | /// </summary> 14 | [InitializeOnLoad] 15 | public static class SetupWizard 16 | { 17 | private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; 18 | private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; 19 | private static bool _hasCheckedThisSession = false; 20 | 21 | static SetupWizard() 22 | { 23 | // Skip in batch mode 24 | if (Application.isBatchMode) 25 | return; 26 | 27 | // Show setup wizard on package import 28 | EditorApplication.delayCall += CheckSetupNeeded; 29 | } 30 | 31 | /// <summary> 32 | /// Check if setup wizard should be shown 33 | /// </summary> 34 | private static void CheckSetupNeeded() 35 | { 36 | if (_hasCheckedThisSession) 37 | return; 38 | 39 | _hasCheckedThisSession = true; 40 | 41 | try 42 | { 43 | // Check if setup was already completed or dismissed in previous sessions 44 | bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); 45 | bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); 46 | 47 | // Only show setup wizard if it hasn't been completed or dismissed before 48 | if (!(setupCompleted || setupDismissed)) 49 | { 50 | McpLog.Info("Package imported - showing setup wizard", always: false); 51 | 52 | var dependencyResult = DependencyManager.CheckAllDependencies(); 53 | EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); 54 | } 55 | else 56 | { 57 | McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); 58 | } 59 | } 60 | catch (Exception ex) 61 | { 62 | McpLog.Error($"Error checking setup status: {ex.Message}"); 63 | } 64 | } 65 | 66 | /// <summary> 67 | /// Show the setup wizard window 68 | /// </summary> 69 | public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) 70 | { 71 | try 72 | { 73 | dependencyResult ??= DependencyManager.CheckAllDependencies(); 74 | SetupWizardWindow.ShowWindow(dependencyResult); 75 | } 76 | catch (Exception ex) 77 | { 78 | McpLog.Error($"Error showing setup wizard: {ex.Message}"); 79 | } 80 | } 81 | 82 | /// <summary> 83 | /// Mark setup as completed 84 | /// </summary> 85 | public static void MarkSetupCompleted() 86 | { 87 | EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); 88 | McpLog.Info("Setup marked as completed"); 89 | } 90 | 91 | /// <summary> 92 | /// Mark setup as dismissed 93 | /// </summary> 94 | public static void MarkSetupDismissed() 95 | { 96 | EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); 97 | McpLog.Info("Setup marked as dismissed"); 98 | } 99 | 100 | /// <summary> 101 | /// Force show setup wizard (for manual invocation) 102 | /// </summary> 103 | [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] 104 | public static void ShowSetupWizardManual() 105 | { 106 | ShowSetupWizard(); 107 | } 108 | 109 | /// <summary> 110 | /// Check dependencies and show status 111 | /// </summary> 112 | [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] 113 | public static void CheckDependencies() 114 | { 115 | var result = DependencyManager.CheckAllDependencies(); 116 | 117 | if (!result.IsSystemReady) 118 | { 119 | bool showWizard = EditorUtility.DisplayDialog( 120 | "MCP for Unity - Dependencies", 121 | $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", 122 | "Open Setup Wizard", 123 | "Close" 124 | ); 125 | 126 | if (showWizard) 127 | { 128 | ShowSetupWizard(result); 129 | } 130 | } 131 | else 132 | { 133 | EditorUtility.DisplayDialog( 134 | "MCP for Unity - Dependencies", 135 | "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", 136 | "OK" 137 | ); 138 | } 139 | } 140 | 141 | /// <summary> 142 | /// Open MCP Client Configuration window 143 | /// </summary> 144 | [MenuItem("Window/MCP For Unity/Open MCP Window", priority = 4)] 145 | public static void OpenClientConfiguration() 146 | { 147 | Windows.MCPForUnityEditorWindow.ShowWindow(); 148 | } 149 | } 150 | } 151 | ``` -------------------------------------------------------------------------------- /tests/test_improved_anchor_matching.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Test the improved anchor matching logic. 3 | """ 4 | 5 | import sys 6 | import pathlib 7 | import importlib.util 8 | import types 9 | 10 | # add server src to path and load modules 11 | ROOT = pathlib.Path(__file__).resolve().parents[1] 12 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 13 | sys.path.insert(0, str(SRC)) 14 | 15 | # stub mcp.server.fastmcp 16 | mcp_pkg = types.ModuleType("mcp") 17 | server_pkg = types.ModuleType("mcp.server") 18 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 19 | 20 | 21 | class _Dummy: 22 | pass 23 | 24 | 25 | fastmcp_pkg.FastMCP = _Dummy 26 | fastmcp_pkg.Context = _Dummy 27 | server_pkg.fastmcp = fastmcp_pkg 28 | mcp_pkg.server = server_pkg 29 | sys.modules.setdefault("mcp", mcp_pkg) 30 | sys.modules.setdefault("mcp.server", server_pkg) 31 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 32 | 33 | 34 | def load_module(path, name): 35 | spec = importlib.util.spec_from_file_location(name, path) 36 | module = importlib.util.module_from_spec(spec) 37 | spec.loader.exec_module(module) 38 | return module 39 | 40 | 41 | manage_script_edits_module = load_module( 42 | SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module") 43 | 44 | 45 | def test_improved_anchor_matching(): 46 | """Test that our improved anchor matching finds the right closing brace.""" 47 | 48 | test_code = '''using UnityEngine; 49 | 50 | public class TestClass : MonoBehaviour 51 | { 52 | void Start() 53 | { 54 | Debug.Log("test"); 55 | } 56 | 57 | void Update() 58 | { 59 | // Update logic 60 | } 61 | }''' 62 | 63 | import re 64 | 65 | # Test the problematic anchor pattern 66 | anchor_pattern = r"\s*}\s*$" 67 | flags = re.MULTILINE 68 | 69 | # Test our improved function 70 | best_match = manage_script_edits_module._find_best_anchor_match( 71 | anchor_pattern, test_code, flags, prefer_last=True 72 | ) 73 | 74 | assert best_match is not None, "anchor pattern not found" 75 | match_pos = best_match.start() 76 | line_num = test_code[:match_pos].count('\n') + 1 77 | total_lines = test_code.count('\n') + 1 78 | assert line_num >= total_lines - \ 79 | 2, f"expected match near end (>= {total_lines-2}), got line {line_num}" 80 | 81 | 82 | def test_old_vs_new_matching(): 83 | """Compare old vs new matching behavior.""" 84 | 85 | test_code = '''using UnityEngine; 86 | 87 | public class TestClass : MonoBehaviour 88 | { 89 | void Start() 90 | { 91 | Debug.Log("test"); 92 | } 93 | 94 | void Update() 95 | { 96 | if (condition) 97 | { 98 | DoSomething(); 99 | } 100 | } 101 | 102 | void LateUpdate() 103 | { 104 | // More logic 105 | } 106 | }''' 107 | 108 | import re 109 | 110 | anchor_pattern = r"\s*}\s*$" 111 | flags = re.MULTILINE 112 | 113 | # Old behavior (first match) 114 | old_match = re.search(anchor_pattern, test_code, flags) 115 | old_line = test_code[:old_match.start()].count( 116 | '\n') + 1 if old_match else None 117 | 118 | # New behavior (improved matching) 119 | new_match = manage_script_edits_module._find_best_anchor_match( 120 | anchor_pattern, test_code, flags, prefer_last=True 121 | ) 122 | new_line = test_code[:new_match.start()].count( 123 | '\n') + 1 if new_match else None 124 | 125 | assert old_line is not None and new_line is not None, "failed to locate anchors" 126 | assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})" 127 | total_lines = test_code.count('\n') + 1 128 | assert new_line >= total_lines - \ 129 | 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}" 130 | 131 | 132 | def test_apply_edits_with_improved_matching(): 133 | """Test that _apply_edits_locally uses improved matching.""" 134 | 135 | original_code = '''using UnityEngine; 136 | 137 | public class TestClass : MonoBehaviour 138 | { 139 | public string message = "Hello World"; 140 | 141 | void Start() 142 | { 143 | Debug.Log(message); 144 | } 145 | }''' 146 | 147 | # Test anchor_insert with the problematic pattern 148 | edits = [{ 149 | "op": "anchor_insert", 150 | "anchor": r"\s*}\s*$", # This should now find the class end 151 | "position": "before", 152 | "text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n" 153 | }] 154 | 155 | result = manage_script_edits_module._apply_edits_locally( 156 | original_code, edits) 157 | lines = result.split('\n') 158 | try: 159 | idx = next(i for i, line in enumerate(lines) if "NewMethod" in line) 160 | except StopIteration: 161 | assert False, "NewMethod not found in result" 162 | total_lines = len(lines) 163 | assert idx >= total_lines - \ 164 | 5, f"method inserted too early (idx={idx}, total_lines={total_lines})" 165 | 166 | 167 | if __name__ == "__main__": 168 | print("Testing improved anchor matching...") 169 | print("="*60) 170 | 171 | success1 = test_improved_anchor_matching() 172 | 173 | print("\n" + "="*60) 174 | print("Comparing old vs new behavior...") 175 | success2 = test_old_vs_new_matching() 176 | 177 | print("\n" + "="*60) 178 | print("Testing _apply_edits_locally with improved matching...") 179 | success3 = test_apply_edits_with_improved_matching() 180 | 181 | print("\n" + "="*60) 182 | if success1 and success2 and success3: 183 | print("🎉 ALL TESTS PASSED! Improved anchor matching is working!") 184 | else: 185 | print("💥 Some tests failed. Need more work on anchor matching.") 186 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/ToolSyncService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using MCPForUnity.Editor.Helpers; 6 | using UnityEditor; 7 | 8 | namespace MCPForUnity.Editor.Services 9 | { 10 | public class ToolSyncService : IToolSyncService 11 | { 12 | private readonly IPythonToolRegistryService _registryService; 13 | 14 | public ToolSyncService(IPythonToolRegistryService registryService = null) 15 | { 16 | _registryService = registryService ?? MCPServiceLocator.PythonToolRegistry; 17 | } 18 | 19 | public ToolSyncResult SyncProjectTools(string destToolsDir) 20 | { 21 | var result = new ToolSyncResult(); 22 | 23 | try 24 | { 25 | Directory.CreateDirectory(destToolsDir); 26 | 27 | // Get all PythonToolsAsset instances in the project 28 | var registries = _registryService.GetAllRegistries().ToList(); 29 | 30 | if (!registries.Any()) 31 | { 32 | McpLog.Info("No PythonToolsAsset found. Create one via Assets > Create > MCP For Unity > Python Tools"); 33 | return result; 34 | } 35 | 36 | var syncedFiles = new HashSet<string>(); 37 | 38 | // Batch all asset modifications together to minimize reimports 39 | AssetDatabase.StartAssetEditing(); 40 | try 41 | { 42 | foreach (var registry in registries) 43 | { 44 | foreach (var file in registry.GetValidFiles()) 45 | { 46 | try 47 | { 48 | // Check if needs syncing (hash-based or always) 49 | if (_registryService.NeedsSync(registry, file)) 50 | { 51 | string destPath = Path.Combine(destToolsDir, file.name + ".py"); 52 | 53 | // Write the Python file content 54 | File.WriteAllText(destPath, file.text); 55 | 56 | // Record sync 57 | _registryService.RecordSync(registry, file); 58 | 59 | result.CopiedCount++; 60 | syncedFiles.Add(destPath); 61 | McpLog.Info($"Synced Python tool: {file.name}.py"); 62 | } 63 | else 64 | { 65 | string destPath = Path.Combine(destToolsDir, file.name + ".py"); 66 | syncedFiles.Add(destPath); 67 | result.SkippedCount++; 68 | } 69 | } 70 | catch (Exception ex) 71 | { 72 | result.ErrorCount++; 73 | result.Messages.Add($"Failed to sync {file.name}: {ex.Message}"); 74 | } 75 | } 76 | 77 | // Cleanup stale states in registry 78 | registry.CleanupStaleStates(); 79 | EditorUtility.SetDirty(registry); 80 | } 81 | 82 | // Cleanup stale Python files in destination 83 | CleanupStaleFiles(destToolsDir, syncedFiles); 84 | } 85 | finally 86 | { 87 | // End batch editing - this triggers a single asset refresh 88 | AssetDatabase.StopAssetEditing(); 89 | } 90 | 91 | // Save all modified registries 92 | AssetDatabase.SaveAssets(); 93 | } 94 | catch (Exception ex) 95 | { 96 | result.ErrorCount++; 97 | result.Messages.Add($"Sync failed: {ex.Message}"); 98 | } 99 | 100 | return result; 101 | } 102 | 103 | private void CleanupStaleFiles(string destToolsDir, HashSet<string> currentFiles) 104 | { 105 | try 106 | { 107 | if (!Directory.Exists(destToolsDir)) return; 108 | 109 | // Find all .py files in destination that aren't in our current set 110 | var existingFiles = Directory.GetFiles(destToolsDir, "*.py"); 111 | 112 | foreach (var file in existingFiles) 113 | { 114 | if (!currentFiles.Contains(file)) 115 | { 116 | try 117 | { 118 | File.Delete(file); 119 | McpLog.Info($"Cleaned up stale tool: {Path.GetFileName(file)}"); 120 | } 121 | catch (Exception ex) 122 | { 123 | McpLog.Warn($"Failed to cleanup {file}: {ex.Message}"); 124 | } 125 | } 126 | } 127 | } 128 | catch (Exception ex) 129 | { 130 | McpLog.Warn($"Failed to cleanup stale files: {ex.Message}"); 131 | } 132 | } 133 | } 134 | } 135 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs: -------------------------------------------------------------------------------- ```csharp 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | using MCPForUnity.Editor.Models; 4 | 5 | namespace MCPForUnity.Editor.Helpers 6 | { 7 | public static class ConfigJsonBuilder 8 | { 9 | public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) 10 | { 11 | var root = new JObject(); 12 | bool isVSCode = client?.mcpType == McpTypes.VSCode; 13 | JObject container; 14 | if (isVSCode) 15 | { 16 | container = EnsureObject(root, "servers"); 17 | } 18 | else 19 | { 20 | container = EnsureObject(root, "mcpServers"); 21 | } 22 | 23 | var unity = new JObject(); 24 | PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); 25 | 26 | container["unityMCP"] = unity; 27 | 28 | return root.ToString(Formatting.Indented); 29 | } 30 | 31 | public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) 32 | { 33 | if (root == null) root = new JObject(); 34 | bool isVSCode = client?.mcpType == McpTypes.VSCode; 35 | JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); 36 | JObject unity = container["unityMCP"] as JObject ?? new JObject(); 37 | PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); 38 | 39 | container["unityMCP"] = unity; 40 | return root; 41 | } 42 | 43 | /// <summary> 44 | /// Centralized builder that applies all caveats consistently. 45 | /// - Sets command/args with provided directory 46 | /// - Ensures env exists 47 | /// - Adds type:"stdio" for VSCode 48 | /// - Adds disabled:false for Windsurf/Kiro only when missing 49 | /// </summary> 50 | private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) 51 | { 52 | unity["command"] = uvPath; 53 | 54 | // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners 55 | string effectiveDir = directory; 56 | #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX 57 | bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); 58 | if (isCursor && !string.IsNullOrEmpty(directory)) 59 | { 60 | // Replace canonical path segment with the symlink path if present 61 | const string canonical = "/Library/Application Support/"; 62 | const string symlinkSeg = "/Library/AppSupport/"; 63 | try 64 | { 65 | // Normalize to full path style 66 | if (directory.Contains(canonical)) 67 | { 68 | var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); 69 | if (System.IO.Directory.Exists(candidate)) 70 | { 71 | effectiveDir = candidate; 72 | } 73 | } 74 | else 75 | { 76 | // If installer returned XDG-style on macOS, map to canonical symlink 77 | string norm = directory.Replace('\\', '/'); 78 | int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); 79 | if (idx >= 0) 80 | { 81 | string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; 82 | string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... 83 | string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); 84 | if (System.IO.Directory.Exists(candidate)) 85 | { 86 | effectiveDir = candidate; 87 | } 88 | } 89 | } 90 | } 91 | catch { /* fallback to original directory on any error */ } 92 | } 93 | #endif 94 | 95 | unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); 96 | 97 | if (isVSCode) 98 | { 99 | unity["type"] = "stdio"; 100 | } 101 | else 102 | { 103 | // Remove type if it somehow exists from previous clients 104 | if (unity["type"] != null) unity.Remove("type"); 105 | } 106 | 107 | if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) 108 | { 109 | if (unity["env"] == null) 110 | { 111 | unity["env"] = new JObject(); 112 | } 113 | 114 | if (unity["disabled"] == null) 115 | { 116 | unity["disabled"] = false; 117 | } 118 | } 119 | } 120 | 121 | private static JObject EnsureObject(JObject parent, string name) 122 | { 123 | if (parent[name] is JObject o) return o; 124 | var created = new JObject(); 125 | parent[name] = created; 126 | return created; 127 | } 128 | } 129 | } 130 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs: -------------------------------------------------------------------------------- ```csharp 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | using MCPForUnity.Editor.Models; 4 | 5 | namespace MCPForUnity.Editor.Helpers 6 | { 7 | public static class ConfigJsonBuilder 8 | { 9 | public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) 10 | { 11 | var root = new JObject(); 12 | bool isVSCode = client?.mcpType == McpTypes.VSCode; 13 | JObject container; 14 | if (isVSCode) 15 | { 16 | container = EnsureObject(root, "servers"); 17 | } 18 | else 19 | { 20 | container = EnsureObject(root, "mcpServers"); 21 | } 22 | 23 | var unity = new JObject(); 24 | PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); 25 | 26 | container["unityMCP"] = unity; 27 | 28 | return root.ToString(Formatting.Indented); 29 | } 30 | 31 | public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) 32 | { 33 | if (root == null) root = new JObject(); 34 | bool isVSCode = client?.mcpType == McpTypes.VSCode; 35 | JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); 36 | JObject unity = container["unityMCP"] as JObject ?? new JObject(); 37 | PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); 38 | 39 | container["unityMCP"] = unity; 40 | return root; 41 | } 42 | 43 | /// <summary> 44 | /// Centralized builder that applies all caveats consistently. 45 | /// - Sets command/args with provided directory 46 | /// - Ensures env exists 47 | /// - Adds type:"stdio" for VSCode 48 | /// - Adds disabled:false for Windsurf/Kiro only when missing 49 | /// </summary> 50 | private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) 51 | { 52 | unity["command"] = uvPath; 53 | 54 | // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners 55 | string effectiveDir = directory; 56 | #if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX 57 | bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); 58 | if (isCursor && !string.IsNullOrEmpty(directory)) 59 | { 60 | // Replace canonical path segment with the symlink path if present 61 | const string canonical = "/Library/Application Support/"; 62 | const string symlinkSeg = "/Library/AppSupport/"; 63 | try 64 | { 65 | // Normalize to full path style 66 | if (directory.Contains(canonical)) 67 | { 68 | var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); 69 | if (System.IO.Directory.Exists(candidate)) 70 | { 71 | effectiveDir = candidate; 72 | } 73 | } 74 | else 75 | { 76 | // If installer returned XDG-style on macOS, map to canonical symlink 77 | string norm = directory.Replace('\\', '/'); 78 | int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); 79 | if (idx >= 0) 80 | { 81 | string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; 82 | string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... 83 | string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); 84 | if (System.IO.Directory.Exists(candidate)) 85 | { 86 | effectiveDir = candidate; 87 | } 88 | } 89 | } 90 | } 91 | catch { /* fallback to original directory on any error */ } 92 | } 93 | #endif 94 | 95 | unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); 96 | 97 | if (isVSCode) 98 | { 99 | unity["type"] = "stdio"; 100 | } 101 | else 102 | { 103 | // Remove type if it somehow exists from previous clients 104 | if (unity["type"] != null) unity.Remove("type"); 105 | } 106 | 107 | if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) 108 | { 109 | if (unity["env"] == null) 110 | { 111 | unity["env"] = new JObject(); 112 | } 113 | 114 | if (unity["disabled"] == null) 115 | { 116 | unity["disabled"] = false; 117 | } 118 | } 119 | } 120 | 121 | private static JObject EnsureObject(JObject parent, string name) 122 | { 123 | if (parent[name] is JObject o) return o; 124 | var created = new JObject(); 125 | parent[name] = created; 126 | return created; 127 | } 128 | } 129 | } 130 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/DependencyManager.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Dependencies.PlatformDetectors; 7 | using MCPForUnity.Editor.Helpers; 8 | using UnityEditor; 9 | using UnityEngine; 10 | 11 | namespace MCPForUnity.Editor.Dependencies 12 | { 13 | /// <summary> 14 | /// Main orchestrator for dependency validation and management 15 | /// </summary> 16 | public static class DependencyManager 17 | { 18 | private static readonly List<IPlatformDetector> _detectors = new List<IPlatformDetector> 19 | { 20 | new WindowsPlatformDetector(), 21 | new MacOSPlatformDetector(), 22 | new LinuxPlatformDetector() 23 | }; 24 | 25 | private static IPlatformDetector _currentDetector; 26 | 27 | /// <summary> 28 | /// Get the platform detector for the current operating system 29 | /// </summary> 30 | public static IPlatformDetector GetCurrentPlatformDetector() 31 | { 32 | if (_currentDetector == null) 33 | { 34 | _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); 35 | if (_currentDetector == null) 36 | { 37 | throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); 38 | } 39 | } 40 | return _currentDetector; 41 | } 42 | 43 | /// <summary> 44 | /// Perform a comprehensive dependency check 45 | /// </summary> 46 | public static DependencyCheckResult CheckAllDependencies() 47 | { 48 | var result = new DependencyCheckResult(); 49 | 50 | try 51 | { 52 | var detector = GetCurrentPlatformDetector(); 53 | McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); 54 | 55 | // Check Python 56 | var pythonStatus = detector.DetectPython(); 57 | result.Dependencies.Add(pythonStatus); 58 | 59 | // Check UV 60 | var uvStatus = detector.DetectUV(); 61 | result.Dependencies.Add(uvStatus); 62 | 63 | // Check MCP Server 64 | var serverStatus = detector.DetectMCPServer(); 65 | result.Dependencies.Add(serverStatus); 66 | 67 | // Generate summary and recommendations 68 | result.GenerateSummary(); 69 | GenerateRecommendations(result, detector); 70 | 71 | McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); 72 | } 73 | catch (Exception ex) 74 | { 75 | McpLog.Error($"Error during dependency check: {ex.Message}"); 76 | result.Summary = $"Dependency check failed: {ex.Message}"; 77 | result.IsSystemReady = false; 78 | } 79 | 80 | return result; 81 | } 82 | 83 | /// <summary> 84 | /// Get installation recommendations for the current platform 85 | /// </summary> 86 | public static string GetInstallationRecommendations() 87 | { 88 | try 89 | { 90 | var detector = GetCurrentPlatformDetector(); 91 | return detector.GetInstallationRecommendations(); 92 | } 93 | catch (Exception ex) 94 | { 95 | return $"Error getting installation recommendations: {ex.Message}"; 96 | } 97 | } 98 | 99 | /// <summary> 100 | /// Get platform-specific installation URLs 101 | /// </summary> 102 | public static (string pythonUrl, string uvUrl) GetInstallationUrls() 103 | { 104 | try 105 | { 106 | var detector = GetCurrentPlatformDetector(); 107 | return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); 108 | } 109 | catch 110 | { 111 | return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); 112 | } 113 | } 114 | 115 | private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) 116 | { 117 | var missing = result.GetMissingDependencies(); 118 | 119 | if (missing.Count == 0) 120 | { 121 | result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); 122 | return; 123 | } 124 | 125 | foreach (var dep in missing) 126 | { 127 | if (dep.Name == "Python") 128 | { 129 | result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); 130 | } 131 | else if (dep.Name == "UV Package Manager") 132 | { 133 | result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); 134 | } 135 | else if (dep.Name == "MCP Server") 136 | { 137 | result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); 138 | } 139 | } 140 | 141 | if (result.GetMissingRequired().Count > 0) 142 | { 143 | result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); 144 | } 145 | } 146 | } 147 | } 148 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/DependencyManager.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Dependencies.Models; 6 | using MCPForUnity.Editor.Dependencies.PlatformDetectors; 7 | using MCPForUnity.Editor.Helpers; 8 | using UnityEditor; 9 | using UnityEngine; 10 | 11 | namespace MCPForUnity.Editor.Dependencies 12 | { 13 | /// <summary> 14 | /// Main orchestrator for dependency validation and management 15 | /// </summary> 16 | public static class DependencyManager 17 | { 18 | private static readonly List<IPlatformDetector> _detectors = new List<IPlatformDetector> 19 | { 20 | new WindowsPlatformDetector(), 21 | new MacOSPlatformDetector(), 22 | new LinuxPlatformDetector() 23 | }; 24 | 25 | private static IPlatformDetector _currentDetector; 26 | 27 | /// <summary> 28 | /// Get the platform detector for the current operating system 29 | /// </summary> 30 | public static IPlatformDetector GetCurrentPlatformDetector() 31 | { 32 | if (_currentDetector == null) 33 | { 34 | _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); 35 | if (_currentDetector == null) 36 | { 37 | throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); 38 | } 39 | } 40 | return _currentDetector; 41 | } 42 | 43 | /// <summary> 44 | /// Perform a comprehensive dependency check 45 | /// </summary> 46 | public static DependencyCheckResult CheckAllDependencies() 47 | { 48 | var result = new DependencyCheckResult(); 49 | 50 | try 51 | { 52 | var detector = GetCurrentPlatformDetector(); 53 | McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); 54 | 55 | // Check Python 56 | var pythonStatus = detector.DetectPython(); 57 | result.Dependencies.Add(pythonStatus); 58 | 59 | // Check UV 60 | var uvStatus = detector.DetectUV(); 61 | result.Dependencies.Add(uvStatus); 62 | 63 | // Check MCP Server 64 | var serverStatus = detector.DetectMCPServer(); 65 | result.Dependencies.Add(serverStatus); 66 | 67 | // Generate summary and recommendations 68 | result.GenerateSummary(); 69 | GenerateRecommendations(result, detector); 70 | 71 | McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); 72 | } 73 | catch (Exception ex) 74 | { 75 | McpLog.Error($"Error during dependency check: {ex.Message}"); 76 | result.Summary = $"Dependency check failed: {ex.Message}"; 77 | result.IsSystemReady = false; 78 | } 79 | 80 | return result; 81 | } 82 | 83 | /// <summary> 84 | /// Get installation recommendations for the current platform 85 | /// </summary> 86 | public static string GetInstallationRecommendations() 87 | { 88 | try 89 | { 90 | var detector = GetCurrentPlatformDetector(); 91 | return detector.GetInstallationRecommendations(); 92 | } 93 | catch (Exception ex) 94 | { 95 | return $"Error getting installation recommendations: {ex.Message}"; 96 | } 97 | } 98 | 99 | /// <summary> 100 | /// Get platform-specific installation URLs 101 | /// </summary> 102 | public static (string pythonUrl, string uvUrl) GetInstallationUrls() 103 | { 104 | try 105 | { 106 | var detector = GetCurrentPlatformDetector(); 107 | return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); 108 | } 109 | catch 110 | { 111 | return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); 112 | } 113 | } 114 | 115 | private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) 116 | { 117 | var missing = result.GetMissingDependencies(); 118 | 119 | if (missing.Count == 0) 120 | { 121 | result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); 122 | return; 123 | } 124 | 125 | foreach (var dep in missing) 126 | { 127 | if (dep.Name == "Python") 128 | { 129 | result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); 130 | } 131 | else if (dep.Name == "UV Package Manager") 132 | { 133 | result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); 134 | } 135 | else if (dep.Name == "MCP Server") 136 | { 137 | result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); 138 | } 139 | } 140 | 141 | if (result.GetMissingRequired().Count > 0) 142 | { 143 | result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); 144 | } 145 | } 146 | } 147 | } 148 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Setup/SetupWizard.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using MCPForUnity.Editor.Dependencies; 3 | using MCPForUnity.Editor.Dependencies.Models; 4 | using MCPForUnity.Editor.Helpers; 5 | using MCPForUnity.Editor.Windows; 6 | using UnityEditor; 7 | using UnityEngine; 8 | 9 | namespace MCPForUnity.Editor.Setup 10 | { 11 | /// <summary> 12 | /// Handles automatic triggering of the setup wizard 13 | /// </summary> 14 | [InitializeOnLoad] 15 | public static class SetupWizard 16 | { 17 | private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; 18 | private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; 19 | private static bool _hasCheckedThisSession = false; 20 | 21 | static SetupWizard() 22 | { 23 | // Skip in batch mode 24 | if (Application.isBatchMode) 25 | return; 26 | 27 | // Show setup wizard on package import 28 | EditorApplication.delayCall += CheckSetupNeeded; 29 | } 30 | 31 | /// <summary> 32 | /// Check if setup wizard should be shown 33 | /// </summary> 34 | private static void CheckSetupNeeded() 35 | { 36 | if (_hasCheckedThisSession) 37 | return; 38 | 39 | _hasCheckedThisSession = true; 40 | 41 | try 42 | { 43 | // Check if setup was already completed or dismissed in previous sessions 44 | bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); 45 | bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); 46 | 47 | // Only show setup wizard if it hasn't been completed or dismissed before 48 | if (!(setupCompleted || setupDismissed)) 49 | { 50 | McpLog.Info("Package imported - showing setup wizard", always: false); 51 | 52 | var dependencyResult = DependencyManager.CheckAllDependencies(); 53 | EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); 54 | } 55 | else 56 | { 57 | McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); 58 | } 59 | } 60 | catch (Exception ex) 61 | { 62 | McpLog.Error($"Error checking setup status: {ex.Message}"); 63 | } 64 | } 65 | 66 | /// <summary> 67 | /// Show the setup wizard window 68 | /// </summary> 69 | public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) 70 | { 71 | try 72 | { 73 | dependencyResult ??= DependencyManager.CheckAllDependencies(); 74 | SetupWizardWindow.ShowWindow(dependencyResult); 75 | } 76 | catch (Exception ex) 77 | { 78 | McpLog.Error($"Error showing setup wizard: {ex.Message}"); 79 | } 80 | } 81 | 82 | /// <summary> 83 | /// Mark setup as completed 84 | /// </summary> 85 | public static void MarkSetupCompleted() 86 | { 87 | EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); 88 | McpLog.Info("Setup marked as completed"); 89 | } 90 | 91 | /// <summary> 92 | /// Mark setup as dismissed 93 | /// </summary> 94 | public static void MarkSetupDismissed() 95 | { 96 | EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); 97 | McpLog.Info("Setup marked as dismissed"); 98 | } 99 | 100 | /// <summary> 101 | /// Force show setup wizard (for manual invocation) 102 | /// </summary> 103 | [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] 104 | public static void ShowSetupWizardManual() 105 | { 106 | ShowSetupWizard(); 107 | } 108 | 109 | /// <summary> 110 | /// Check dependencies and show status 111 | /// </summary> 112 | [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] 113 | public static void CheckDependencies() 114 | { 115 | var result = DependencyManager.CheckAllDependencies(); 116 | 117 | if (!result.IsSystemReady) 118 | { 119 | bool showWizard = EditorUtility.DisplayDialog( 120 | "MCP for Unity - Dependencies", 121 | $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", 122 | "Open Setup Wizard", 123 | "Close" 124 | ); 125 | 126 | if (showWizard) 127 | { 128 | ShowSetupWizard(result); 129 | } 130 | } 131 | else 132 | { 133 | EditorUtility.DisplayDialog( 134 | "MCP for Unity - Dependencies", 135 | "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", 136 | "OK" 137 | ); 138 | } 139 | } 140 | 141 | /// <summary> 142 | /// Open MCP Client Configuration window 143 | /// </summary> 144 | [MenuItem("Window/MCP For Unity/Open MCP Window %#m", priority = 4)] 145 | public static void OpenClientConfiguration() 146 | { 147 | Windows.MCPForUnityEditorWindowNew.ShowWindow(); 148 | } 149 | 150 | /// <summary> 151 | /// Open legacy MCP Client Configuration window 152 | /// </summary> 153 | [MenuItem("Window/MCP For Unity/Open Legacy MCP Window", priority = 5)] 154 | public static void OpenLegacyClientConfiguration() 155 | { 156 | Windows.MCPForUnityEditorWindow.ShowWindow(); 157 | } 158 | } 159 | } 160 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/McpPathResolver.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEngine; 4 | using UnityEditor; 5 | using MCPForUnity.Editor.Helpers; 6 | 7 | namespace MCPForUnity.Editor.Helpers 8 | { 9 | /// <summary> 10 | /// Shared helper for resolving MCP server directory paths with support for 11 | /// development mode, embedded servers, and installed packages 12 | /// </summary> 13 | public static class McpPathResolver 14 | { 15 | private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; 16 | 17 | /// <summary> 18 | /// Resolves the MCP server directory path with comprehensive logic 19 | /// including development mode support and fallback mechanisms 20 | /// </summary> 21 | public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) 22 | { 23 | string pythonDir = McpConfigFileHelper.ResolveServerSource(); 24 | 25 | try 26 | { 27 | // Only check dev paths if we're using a file-based package (development mode) 28 | bool isDevelopmentMode = IsDevelopmentMode(); 29 | if (isDevelopmentMode) 30 | { 31 | string currentPackagePath = Path.GetDirectoryName(Application.dataPath); 32 | string[] devPaths = { 33 | Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), 34 | Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), 35 | }; 36 | 37 | foreach (string devPath in devPaths) 38 | { 39 | if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) 40 | { 41 | if (debugLogsEnabled) 42 | { 43 | Debug.Log($"Currently in development mode. Package: {devPath}"); 44 | } 45 | return devPath; 46 | } 47 | } 48 | } 49 | 50 | // Resolve via shared helper (handles local registry and older fallback) only if dev override on 51 | if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) 52 | { 53 | if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) 54 | { 55 | return embedded; 56 | } 57 | } 58 | 59 | // Log only if the resolved path does not actually contain server.py 60 | if (debugLogsEnabled) 61 | { 62 | bool hasServer = false; 63 | try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } 64 | if (!hasServer) 65 | { 66 | Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); 67 | } 68 | } 69 | } 70 | catch (Exception e) 71 | { 72 | Debug.LogError($"Error finding package path: {e.Message}"); 73 | } 74 | 75 | return pythonDir; 76 | } 77 | 78 | /// <summary> 79 | /// Checks if the current Unity project is in development mode 80 | /// (i.e., the package is referenced as a local file path in manifest.json) 81 | /// </summary> 82 | private static bool IsDevelopmentMode() 83 | { 84 | try 85 | { 86 | // Only treat as development if manifest explicitly references a local file path for the package 87 | string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); 88 | if (!File.Exists(manifestPath)) return false; 89 | 90 | string manifestContent = File.ReadAllText(manifestPath); 91 | // Look specifically for our package dependency set to a file: URL 92 | // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk 93 | if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) 94 | { 95 | int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); 96 | // Crude but effective: check for "file:" in the same line/value 97 | if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 98 | && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) 99 | { 100 | return true; 101 | } 102 | } 103 | return false; 104 | } 105 | catch 106 | { 107 | return false; 108 | } 109 | } 110 | 111 | /// <summary> 112 | /// Gets the appropriate PATH prepend for the current platform when running external processes 113 | /// </summary> 114 | public static string GetPathPrepend() 115 | { 116 | if (Application.platform == RuntimePlatform.OSXEditor) 117 | return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; 118 | else if (Application.platform == RuntimePlatform.LinuxEditor) 119 | return "/usr/local/bin:/usr/bin:/bin"; 120 | return null; 121 | } 122 | } 123 | } 124 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/McpPathResolver.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEngine; 4 | using UnityEditor; 5 | using MCPForUnity.Editor.Helpers; 6 | 7 | namespace MCPForUnity.Editor.Helpers 8 | { 9 | /// <summary> 10 | /// Shared helper for resolving Python server directory paths with support for 11 | /// development mode, embedded servers, and installed packages 12 | /// </summary> 13 | public static class McpPathResolver 14 | { 15 | private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; 16 | 17 | /// <summary> 18 | /// Resolves the Python server directory path with comprehensive logic 19 | /// including development mode support and fallback mechanisms 20 | /// </summary> 21 | public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) 22 | { 23 | string pythonDir = McpConfigFileHelper.ResolveServerSource(); 24 | 25 | try 26 | { 27 | // Only check dev paths if we're using a file-based package (development mode) 28 | bool isDevelopmentMode = IsDevelopmentMode(); 29 | if (isDevelopmentMode) 30 | { 31 | string currentPackagePath = Path.GetDirectoryName(Application.dataPath); 32 | string[] devPaths = { 33 | Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), 34 | Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), 35 | }; 36 | 37 | foreach (string devPath in devPaths) 38 | { 39 | if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) 40 | { 41 | if (debugLogsEnabled) 42 | { 43 | Debug.Log($"Currently in development mode. Package: {devPath}"); 44 | } 45 | return devPath; 46 | } 47 | } 48 | } 49 | 50 | // Resolve via shared helper (handles local registry and older fallback) only if dev override on 51 | if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, false)) 52 | { 53 | if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) 54 | { 55 | return embedded; 56 | } 57 | } 58 | 59 | // Log only if the resolved path does not actually contain server.py 60 | if (debugLogsEnabled) 61 | { 62 | bool hasServer = false; 63 | try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } 64 | if (!hasServer) 65 | { 66 | Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); 67 | } 68 | } 69 | } 70 | catch (Exception e) 71 | { 72 | Debug.LogError($"Error finding package path: {e.Message}"); 73 | } 74 | 75 | return pythonDir; 76 | } 77 | 78 | /// <summary> 79 | /// Checks if the current Unity project is in development mode 80 | /// (i.e., the package is referenced as a local file path in manifest.json) 81 | /// </summary> 82 | private static bool IsDevelopmentMode() 83 | { 84 | try 85 | { 86 | // Only treat as development if manifest explicitly references a local file path for the package 87 | string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); 88 | if (!File.Exists(manifestPath)) return false; 89 | 90 | string manifestContent = File.ReadAllText(manifestPath); 91 | // Look specifically for our package dependency set to a file: URL 92 | // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk 93 | if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) 94 | { 95 | int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); 96 | // Crude but effective: check for "file:" in the same line/value 97 | if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 98 | && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) 99 | { 100 | return true; 101 | } 102 | } 103 | return false; 104 | } 105 | catch 106 | { 107 | return false; 108 | } 109 | } 110 | 111 | /// <summary> 112 | /// Gets the appropriate PATH prepend for the current platform when running external processes 113 | /// </summary> 114 | public static string GetPathPrepend() 115 | { 116 | if (Application.platform == RuntimePlatform.OSXEditor) 117 | return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; 118 | else if (Application.platform == RuntimePlatform.LinuxEditor) 119 | return "/usr/local/bin:/usr/bin:/bin"; 120 | return null; 121 | } 122 | } 123 | } 124 | ``` -------------------------------------------------------------------------------- /docs/TELEMETRY.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP for Unity Telemetry 2 | 3 | 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. 4 | 5 | ## 🔒 Privacy First 6 | 7 | - **Anonymous**: We use randomly generated UUIDs - no personal information 8 | - **Non-blocking**: Telemetry never interferes with your Unity workflow 9 | - **Easy opt-out**: Simple environment variable or Unity Editor setting 10 | - **Transparent**: All collected data types are documented here 11 | 12 | ## 📊 What We Collect 13 | 14 | ### Usage Analytics 15 | - **Tool Usage**: Which MCP tools you use (manage_script, manage_scene, etc.) 16 | - **Performance**: Execution times and success/failure rates 17 | - **System Info**: Unity version, platform (Windows/Mac/Linux), MCP version 18 | - **Milestones**: First-time usage events (first script creation, first tool use, etc.) 19 | 20 | ### Technical Diagnostics 21 | - **Connection Events**: Bridge startup/connection success/failures 22 | - **Error Reports**: Anonymized error messages (truncated to 200 chars) 23 | - **Server Health**: Startup time, connection latency 24 | 25 | ### What We **DON'T** Collect 26 | - ❌ Your code or script contents 27 | - ❌ Project names, file names, or paths 28 | - ❌ Personal information or identifiers 29 | - ❌ Sensitive project data 30 | - ❌ IP addresses (beyond what's needed for HTTP requests) 31 | 32 | ## 🚫 How to Opt Out 33 | 34 | ### Method 1: Environment Variable (Recommended) 35 | Set any of these environment variables to `true`: 36 | 37 | ```bash 38 | # Disable all telemetry 39 | export DISABLE_TELEMETRY=true 40 | 41 | # MCP for Unity specific 42 | export UNITY_MCP_DISABLE_TELEMETRY=true 43 | 44 | # MCP protocol wide 45 | export MCP_DISABLE_TELEMETRY=true 46 | ``` 47 | 48 | ### Method 2: Unity Editor (Coming Soon) 49 | In Unity Editor: `Window > MCP for Unity > Settings > Disable Telemetry` 50 | 51 | ### Method 3: Manual Config 52 | Add to your MCP client config: 53 | ```json 54 | { 55 | "env": { 56 | "DISABLE_TELEMETRY": "true" 57 | } 58 | } 59 | ``` 60 | 61 | ## 🔧 Technical Implementation 62 | 63 | ### Architecture 64 | - **Python Server**: Core telemetry collection and transmission 65 | - **Unity Bridge**: Local event collection from Unity Editor 66 | - **Anonymous UUIDs**: Generated per-installation for aggregate analytics 67 | - **Thread-safe**: Non-blocking background transmission 68 | - **Fail-safe**: Errors never interrupt your workflow 69 | 70 | ### Data Storage 71 | Telemetry data is stored locally in: 72 | - **Windows**: `%APPDATA%\UnityMCP\` 73 | - **macOS**: `~/Library/Application Support/UnityMCP/` 74 | - **Linux**: `~/.local/share/UnityMCP/` 75 | 76 | Files created: 77 | - `customer_uuid.txt`: Anonymous identifier 78 | - `milestones.json`: One-time events tracker 79 | 80 | ### Data Transmission 81 | - **Endpoint**: `https://api-prod.coplay.dev/telemetry/events` 82 | - **Method**: HTTPS POST with JSON payload 83 | - **Retry**: Background thread with graceful failure 84 | - **Timeout**: 10 second timeout, no retries on failure 85 | 86 | ## 📈 How We Use This Data 87 | 88 | ### Product Improvement 89 | - **Feature Usage**: Understand which tools are most/least used 90 | - **Performance**: Identify slow operations to optimize 91 | - **Reliability**: Track error rates and connection issues 92 | - **Compatibility**: Ensure Unity version compatibility 93 | 94 | ### Development Priorities 95 | - **Roadmap**: Focus development on most-used features 96 | - **Bug Fixes**: Prioritize fixes based on error frequency 97 | - **Platform Support**: Allocate resources based on platform usage 98 | - **Documentation**: Improve docs for commonly problematic areas 99 | 100 | ### What We Don't Do 101 | - ❌ Sell data to third parties 102 | - ❌ Use data for advertising/marketing 103 | - ❌ Track individual developers 104 | - ❌ Store sensitive project information 105 | 106 | ## 🛠️ For Developers 107 | 108 | ### Testing Telemetry 109 | ```bash 110 | cd MCPForUnity/UnityMcpServer~/src 111 | python test_telemetry.py 112 | ``` 113 | 114 | ### Custom Telemetry Events 115 | ```python 116 | from telemetry import record_telemetry, RecordType 117 | 118 | record_telemetry(RecordType.USAGE, { 119 | "custom_event": "my_feature_used", 120 | "metadata": "optional_data" 121 | }) 122 | ``` 123 | 124 | ### Telemetry Status Check 125 | ```python 126 | from telemetry import is_telemetry_enabled 127 | 128 | if is_telemetry_enabled(): 129 | print("Telemetry is active") 130 | else: 131 | print("Telemetry is disabled") 132 | ``` 133 | 134 | ## 📋 Data Retention Policy 135 | 136 | - **Aggregated Data**: Retained indefinitely for product insights 137 | - **Raw Events**: Automatically purged after 90 days 138 | - **Personal Data**: None collected, so none to purge 139 | - **Opt-out**: Immediate - no data sent after opting out 140 | 141 | ## 🤝 Contact & Transparency 142 | 143 | - **Questions**: [Discord Community](https://discord.gg/y4p8KfzrN4) 144 | - **Issues**: [GitHub Issues](https://github.com/CoplayDev/unity-mcp/issues) 145 | - **Privacy Concerns**: Create a GitHub issue with "Privacy" label 146 | - **Source Code**: All telemetry code is open source in this repository 147 | 148 | ## 📊 Example Telemetry Event 149 | 150 | Here's what a typical telemetry event looks like: 151 | 152 | ```json 153 | { 154 | "record": "tool_execution", 155 | "timestamp": 1704067200, 156 | "customer_uuid": "550e8400-e29b-41d4-a716-446655440000", 157 | "session_id": "abc123-def456-ghi789", 158 | "version": "3.0.2", 159 | "platform": "posix", 160 | "data": { 161 | "tool_name": "manage_script", 162 | "success": true, 163 | "duration_ms": 42.5 164 | } 165 | } 166 | ``` 167 | 168 | Notice: 169 | - ✅ Anonymous UUID (randomly generated) 170 | - ✅ Tool performance metrics 171 | - ✅ Success/failure tracking 172 | - ❌ No code content 173 | - ❌ No project information 174 | - ❌ No personal data 175 | 176 | --- 177 | 178 | *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!* 179 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using MCPForUnity.Editor.Dependencies.Models; 5 | using MCPForUnity.Editor.Helpers; 6 | 7 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 8 | { 9 | /// <summary> 10 | /// Base class for platform-specific dependency detection 11 | /// </summary> 12 | public abstract class PlatformDetectorBase : IPlatformDetector 13 | { 14 | public abstract string PlatformName { get; } 15 | public abstract bool CanDetect { get; } 16 | 17 | public abstract DependencyStatus DetectPython(); 18 | public abstract string GetPythonInstallUrl(); 19 | public abstract string GetUVInstallUrl(); 20 | public abstract string GetInstallationRecommendations(); 21 | 22 | public virtual DependencyStatus DetectUV() 23 | { 24 | var status = new DependencyStatus("UV Package Manager", isRequired: true) 25 | { 26 | InstallationHint = GetUVInstallUrl() 27 | }; 28 | 29 | try 30 | { 31 | // Use existing UV detection from ServerInstaller 32 | string uvPath = ServerInstaller.FindUvPath(); 33 | if (!string.IsNullOrEmpty(uvPath)) 34 | { 35 | if (TryValidateUV(uvPath, out string version)) 36 | { 37 | status.IsAvailable = true; 38 | status.Version = version; 39 | status.Path = uvPath; 40 | status.Details = $"Found UV {version} at {uvPath}"; 41 | return status; 42 | } 43 | } 44 | 45 | status.ErrorMessage = "UV package manager not found. Please install UV."; 46 | status.Details = "UV is required for managing Python dependencies."; 47 | } 48 | catch (Exception ex) 49 | { 50 | status.ErrorMessage = $"Error detecting UV: {ex.Message}"; 51 | } 52 | 53 | return status; 54 | } 55 | 56 | public virtual DependencyStatus DetectMCPServer() 57 | { 58 | var status = new DependencyStatus("MCP Server", isRequired: false); 59 | 60 | try 61 | { 62 | // Check if server is installed 63 | string serverPath = ServerInstaller.GetServerPath(); 64 | string serverPy = Path.Combine(serverPath, "server.py"); 65 | 66 | if (File.Exists(serverPy)) 67 | { 68 | status.IsAvailable = true; 69 | status.Path = serverPath; 70 | 71 | // Try to get version 72 | string versionFile = Path.Combine(serverPath, "server_version.txt"); 73 | if (File.Exists(versionFile)) 74 | { 75 | status.Version = File.ReadAllText(versionFile).Trim(); 76 | } 77 | 78 | status.Details = $"MCP Server found at {serverPath}"; 79 | } 80 | else 81 | { 82 | // Check for embedded server 83 | if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) 84 | { 85 | status.IsAvailable = true; 86 | status.Path = embeddedPath; 87 | status.Details = "MCP Server available (embedded in package)"; 88 | } 89 | else 90 | { 91 | status.ErrorMessage = "MCP Server not found"; 92 | status.Details = "Server will be installed automatically when needed"; 93 | } 94 | } 95 | } 96 | catch (Exception ex) 97 | { 98 | status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; 99 | } 100 | 101 | return status; 102 | } 103 | 104 | protected bool TryValidateUV(string uvPath, out string version) 105 | { 106 | version = null; 107 | 108 | try 109 | { 110 | var psi = new ProcessStartInfo 111 | { 112 | FileName = uvPath, 113 | Arguments = "--version", 114 | UseShellExecute = false, 115 | RedirectStandardOutput = true, 116 | RedirectStandardError = true, 117 | CreateNoWindow = true 118 | }; 119 | 120 | using var process = Process.Start(psi); 121 | if (process == null) return false; 122 | 123 | string output = process.StandardOutput.ReadToEnd().Trim(); 124 | process.WaitForExit(5000); 125 | 126 | if (process.ExitCode == 0 && output.StartsWith("uv ")) 127 | { 128 | version = output.Substring(3); // Remove "uv " prefix 129 | return true; 130 | } 131 | } 132 | catch 133 | { 134 | // Ignore validation errors 135 | } 136 | 137 | return false; 138 | } 139 | 140 | protected bool TryParseVersion(string version, out int major, out int minor) 141 | { 142 | major = 0; 143 | minor = 0; 144 | 145 | try 146 | { 147 | var parts = version.Split('.'); 148 | if (parts.Length >= 2) 149 | { 150 | return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); 151 | } 152 | } 153 | catch 154 | { 155 | // Ignore parsing errors 156 | } 157 | 158 | return false; 159 | } 160 | } 161 | } 162 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using MCPForUnity.Editor.Dependencies.Models; 5 | using MCPForUnity.Editor.Helpers; 6 | 7 | namespace MCPForUnity.Editor.Dependencies.PlatformDetectors 8 | { 9 | /// <summary> 10 | /// Base class for platform-specific dependency detection 11 | /// </summary> 12 | public abstract class PlatformDetectorBase : IPlatformDetector 13 | { 14 | public abstract string PlatformName { get; } 15 | public abstract bool CanDetect { get; } 16 | 17 | public abstract DependencyStatus DetectPython(); 18 | public abstract string GetPythonInstallUrl(); 19 | public abstract string GetUVInstallUrl(); 20 | public abstract string GetInstallationRecommendations(); 21 | 22 | public virtual DependencyStatus DetectUV() 23 | { 24 | var status = new DependencyStatus("UV Package Manager", isRequired: true) 25 | { 26 | InstallationHint = GetUVInstallUrl() 27 | }; 28 | 29 | try 30 | { 31 | // Use existing UV detection from ServerInstaller 32 | string uvPath = ServerInstaller.FindUvPath(); 33 | if (!string.IsNullOrEmpty(uvPath)) 34 | { 35 | if (TryValidateUV(uvPath, out string version)) 36 | { 37 | status.IsAvailable = true; 38 | status.Version = version; 39 | status.Path = uvPath; 40 | status.Details = $"Found UV {version} at {uvPath}"; 41 | return status; 42 | } 43 | } 44 | 45 | status.ErrorMessage = "UV package manager not found. Please install UV."; 46 | status.Details = "UV is required for managing Python dependencies."; 47 | } 48 | catch (Exception ex) 49 | { 50 | status.ErrorMessage = $"Error detecting UV: {ex.Message}"; 51 | } 52 | 53 | return status; 54 | } 55 | 56 | public virtual DependencyStatus DetectMCPServer() 57 | { 58 | var status = new DependencyStatus("MCP Server", isRequired: false); 59 | 60 | try 61 | { 62 | // Check if server is installed 63 | string serverPath = ServerInstaller.GetServerPath(); 64 | string serverPy = Path.Combine(serverPath, "server.py"); 65 | 66 | if (File.Exists(serverPy)) 67 | { 68 | status.IsAvailable = true; 69 | status.Path = serverPath; 70 | 71 | // Try to get version 72 | string versionFile = Path.Combine(serverPath, "server_version.txt"); 73 | if (File.Exists(versionFile)) 74 | { 75 | status.Version = File.ReadAllText(versionFile).Trim(); 76 | } 77 | 78 | status.Details = $"MCP Server found at {serverPath}"; 79 | } 80 | else 81 | { 82 | // Check for embedded server 83 | if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) 84 | { 85 | status.IsAvailable = true; 86 | status.Path = embeddedPath; 87 | status.Details = "MCP Server available (embedded in package)"; 88 | } 89 | else 90 | { 91 | status.ErrorMessage = "MCP Server not found"; 92 | status.Details = "Server will be installed automatically when needed"; 93 | } 94 | } 95 | } 96 | catch (Exception ex) 97 | { 98 | status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; 99 | } 100 | 101 | return status; 102 | } 103 | 104 | protected bool TryValidateUV(string uvPath, out string version) 105 | { 106 | version = null; 107 | 108 | try 109 | { 110 | var psi = new ProcessStartInfo 111 | { 112 | FileName = uvPath, 113 | Arguments = "--version", 114 | UseShellExecute = false, 115 | RedirectStandardOutput = true, 116 | RedirectStandardError = true, 117 | CreateNoWindow = true 118 | }; 119 | 120 | using var process = Process.Start(psi); 121 | if (process == null) return false; 122 | 123 | string output = process.StandardOutput.ReadToEnd().Trim(); 124 | process.WaitForExit(5000); 125 | 126 | if (process.ExitCode == 0 && output.StartsWith("uv ")) 127 | { 128 | version = output.Substring(3); // Remove "uv " prefix 129 | return true; 130 | } 131 | } 132 | catch 133 | { 134 | // Ignore validation errors 135 | } 136 | 137 | return false; 138 | } 139 | 140 | protected bool TryParseVersion(string version, out int major, out int minor) 141 | { 142 | major = 0; 143 | minor = 0; 144 | 145 | try 146 | { 147 | var parts = version.Split('.'); 148 | if (parts.Length >= 2) 149 | { 150 | return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); 151 | } 152 | } 153 | catch 154 | { 155 | // Ignore parsing errors 156 | } 157 | 158 | return false; 159 | } 160 | } 161 | } 162 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/ServerPathResolver.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEditor; 4 | using UnityEngine; 5 | 6 | namespace MCPForUnity.Editor.Helpers 7 | { 8 | public static class ServerPathResolver 9 | { 10 | /// <summary> 11 | /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package 12 | /// or common development locations. Returns true if found and sets srcPath to the folder 13 | /// containing server.py. 14 | /// </summary> 15 | public static bool TryFindEmbeddedServerSource(out string srcPath) 16 | { 17 | // 1) Repo development layouts commonly used alongside this package 18 | try 19 | { 20 | string projectRoot = Path.GetDirectoryName(Application.dataPath); 21 | string[] devCandidates = 22 | { 23 | Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), 24 | Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), 25 | }; 26 | foreach (string candidate in devCandidates) 27 | { 28 | string full = Path.GetFullPath(candidate); 29 | if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) 30 | { 31 | srcPath = full; 32 | return true; 33 | } 34 | } 35 | } 36 | catch { /* ignore */ } 37 | 38 | // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. 39 | try 40 | { 41 | #if UNITY_2021_2_OR_NEWER 42 | // Primary: the package that owns this assembly 43 | var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); 44 | if (owner != null) 45 | { 46 | if (TryResolveWithinPackage(owner, out srcPath)) 47 | { 48 | return true; 49 | } 50 | } 51 | 52 | // Secondary: scan all registered packages locally 53 | foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) 54 | { 55 | if (TryResolveWithinPackage(p, out srcPath)) 56 | { 57 | return true; 58 | } 59 | } 60 | #else 61 | // Older Unity versions: use Package Manager Client.List as a fallback 62 | var list = UnityEditor.PackageManager.Client.List(); 63 | while (!list.IsCompleted) { } 64 | if (list.Status == UnityEditor.PackageManager.StatusCode.Success) 65 | { 66 | foreach (var pkg in list.Result) 67 | { 68 | if (TryResolveWithinPackage(pkg, out srcPath)) 69 | { 70 | return true; 71 | } 72 | } 73 | } 74 | #endif 75 | } 76 | catch { /* ignore */ } 77 | 78 | // 3) Fallback to previous common install locations 79 | try 80 | { 81 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 82 | string[] candidates = 83 | { 84 | Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), 85 | Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), 86 | }; 87 | foreach (string candidate in candidates) 88 | { 89 | if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) 90 | { 91 | srcPath = candidate; 92 | return true; 93 | } 94 | } 95 | } 96 | catch { /* ignore */ } 97 | 98 | srcPath = null; 99 | return false; 100 | } 101 | 102 | private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) 103 | { 104 | const string CurrentId = "com.coplaydev.unity-mcp"; 105 | 106 | srcPath = null; 107 | if (p == null || p.name != CurrentId) 108 | { 109 | return false; 110 | } 111 | 112 | string packagePath = p.resolvedPath; 113 | 114 | // Preferred tilde folder (embedded but excluded from import) 115 | string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); 116 | if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) 117 | { 118 | srcPath = embeddedTilde; 119 | return true; 120 | } 121 | 122 | // Legacy non-tilde folder 123 | string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); 124 | if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) 125 | { 126 | srcPath = embedded; 127 | return true; 128 | } 129 | 130 | // Dev-linked sibling of the package folder 131 | string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); 132 | if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) 133 | { 134 | srcPath = sibling; 135 | return true; 136 | } 137 | 138 | return false; 139 | } 140 | } 141 | } 142 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using UnityEditor; 4 | using UnityEngine; 5 | 6 | namespace MCPForUnity.Editor.Helpers 7 | { 8 | public static class ServerPathResolver 9 | { 10 | /// <summary> 11 | /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package 12 | /// or common development locations. Returns true if found and sets srcPath to the folder 13 | /// containing server.py. 14 | /// </summary> 15 | public static bool TryFindEmbeddedServerSource(out string srcPath) 16 | { 17 | // 1) Repo development layouts commonly used alongside this package 18 | try 19 | { 20 | string projectRoot = Path.GetDirectoryName(Application.dataPath); 21 | string[] devCandidates = 22 | { 23 | Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), 24 | Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), 25 | }; 26 | foreach (string candidate in devCandidates) 27 | { 28 | string full = Path.GetFullPath(candidate); 29 | if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) 30 | { 31 | srcPath = full; 32 | return true; 33 | } 34 | } 35 | } 36 | catch { /* ignore */ } 37 | 38 | // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. 39 | try 40 | { 41 | #if UNITY_2021_2_OR_NEWER 42 | // Primary: the package that owns this assembly 43 | var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); 44 | if (owner != null) 45 | { 46 | if (TryResolveWithinPackage(owner, out srcPath)) 47 | { 48 | return true; 49 | } 50 | } 51 | 52 | // Secondary: scan all registered packages locally 53 | foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) 54 | { 55 | if (TryResolveWithinPackage(p, out srcPath)) 56 | { 57 | return true; 58 | } 59 | } 60 | #else 61 | // Older Unity versions: use Package Manager Client.List as a fallback 62 | var list = UnityEditor.PackageManager.Client.List(); 63 | while (!list.IsCompleted) { } 64 | if (list.Status == UnityEditor.PackageManager.StatusCode.Success) 65 | { 66 | foreach (var pkg in list.Result) 67 | { 68 | if (TryResolveWithinPackage(pkg, out srcPath)) 69 | { 70 | return true; 71 | } 72 | } 73 | } 74 | #endif 75 | } 76 | catch { /* ignore */ } 77 | 78 | // 3) Fallback to previous common install locations 79 | try 80 | { 81 | string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; 82 | string[] candidates = 83 | { 84 | Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), 85 | Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), 86 | }; 87 | foreach (string candidate in candidates) 88 | { 89 | if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) 90 | { 91 | srcPath = candidate; 92 | return true; 93 | } 94 | } 95 | } 96 | catch { /* ignore */ } 97 | 98 | srcPath = null; 99 | return false; 100 | } 101 | 102 | private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) 103 | { 104 | const string CurrentId = "com.coplaydev.unity-mcp"; 105 | 106 | srcPath = null; 107 | if (p == null || p.name != CurrentId) 108 | { 109 | return false; 110 | } 111 | 112 | string packagePath = p.resolvedPath; 113 | 114 | // Preferred tilde folder (embedded but excluded from import) 115 | string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); 116 | if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) 117 | { 118 | srcPath = embeddedTilde; 119 | return true; 120 | } 121 | 122 | // Legacy non-tilde folder 123 | string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); 124 | if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) 125 | { 126 | srcPath = embedded; 127 | return true; 128 | } 129 | 130 | // Dev-linked sibling of the package folder 131 | string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); 132 | if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) 133 | { 134 | srcPath = sibling; 135 | return true; 136 | } 137 | 138 | return false; 139 | } 140 | } 141 | } 142 | ``` -------------------------------------------------------------------------------- /mcp_source.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Generic helper to switch the MCP for Unity package source in a Unity project's 4 | Packages/manifest.json. This is useful for switching between upstream and local repos while working on the MCP. 5 | 6 | Usage: 7 | python mcp_source.py [--manifest /abs/path/to/manifest.json] [--repo /abs/path/to/unity-mcp] [--choice 1|2|3] 8 | 9 | Choices: 10 | 1) Upstream main (CoplayDev/unity-mcp) 11 | 2) Your remote current branch (derived from `origin` and current branch) 12 | 3) Local repo workspace (file: URL to MCPForUnity in your checkout) 13 | """ 14 | 15 | from __future__ import annotations 16 | 17 | import argparse 18 | import json 19 | import pathlib 20 | import subprocess 21 | import sys 22 | from typing import Optional 23 | 24 | PKG_NAME = "com.coplaydev.unity-mcp" 25 | BRIDGE_SUBPATH = "MCPForUnity" 26 | 27 | 28 | def run_git(repo: pathlib.Path, *args: str) -> str: 29 | result = subprocess.run([ 30 | "git", "-C", str(repo), *args 31 | ], capture_output=True, text=True) 32 | if result.returncode != 0: 33 | raise RuntimeError(result.stderr.strip() 34 | or f"git {' '.join(args)} failed") 35 | return result.stdout.strip() 36 | 37 | 38 | def normalize_origin_to_https(url: str) -> str: 39 | """Map common SSH origin forms to https for Unity's git URL scheme.""" 40 | if url.startswith("[email protected]:"): 41 | owner_repo = url.split(":", 1)[1] 42 | if owner_repo.endswith(".git"): 43 | owner_repo = owner_repo[:-4] 44 | return f"https://github.com/{owner_repo}.git" 45 | # already https or file: etc. 46 | return url 47 | 48 | 49 | def detect_repo_root(explicit: Optional[str]) -> pathlib.Path: 50 | if explicit: 51 | return pathlib.Path(explicit).resolve() 52 | # Prefer the git toplevel from the script's directory 53 | here = pathlib.Path(__file__).resolve().parent 54 | try: 55 | top = run_git(here, "rev-parse", "--show-toplevel") 56 | return pathlib.Path(top) 57 | except Exception: 58 | return here 59 | 60 | 61 | def detect_branch(repo: pathlib.Path) -> str: 62 | return run_git(repo, "rev-parse", "--abbrev-ref", "HEAD") 63 | 64 | 65 | def detect_origin(repo: pathlib.Path) -> str: 66 | url = run_git(repo, "remote", "get-url", "origin") 67 | return normalize_origin_to_https(url) 68 | 69 | 70 | def find_manifest(explicit: Optional[str]) -> pathlib.Path: 71 | if explicit: 72 | return pathlib.Path(explicit).resolve() 73 | # Walk up from CWD looking for Packages/manifest.json 74 | cur = pathlib.Path.cwd().resolve() 75 | for parent in [cur, *cur.parents]: 76 | candidate = parent / "Packages" / "manifest.json" 77 | if candidate.exists(): 78 | return candidate 79 | raise FileNotFoundError( 80 | "Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") 81 | 82 | 83 | def read_json(path: pathlib.Path) -> dict: 84 | with path.open("r", encoding="utf-8") as f: 85 | return json.load(f) 86 | 87 | 88 | def write_json(path: pathlib.Path, data: dict) -> None: 89 | with path.open("w", encoding="utf-8") as f: 90 | json.dump(data, f, indent=2) 91 | f.write("\n") 92 | 93 | 94 | def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): 95 | upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity" 96 | # Ensure origin is https 97 | origin = origin_https 98 | # If origin is a local file path or non-https, try to coerce to https github if possible 99 | if origin.startswith("file:"): 100 | # Not meaningful for remote option; keep upstream 101 | origin_remote = upstream 102 | else: 103 | origin_remote = origin 104 | return [ 105 | ("[1] Upstream main", upstream), 106 | ("[2] Remote current branch", 107 | f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), 108 | ("[3] Local workspace", 109 | f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), 110 | ] 111 | 112 | 113 | def parse_args() -> argparse.Namespace: 114 | p = argparse.ArgumentParser( 115 | description="Switch MCP for Unity package source") 116 | p.add_argument("--manifest", help="Path to Packages/manifest.json") 117 | p.add_argument( 118 | "--repo", help="Path to unity-mcp repo root (for local file option)") 119 | p.add_argument( 120 | "--choice", choices=["1", "2", "3"], help="Pick option non-interactively") 121 | return p.parse_args() 122 | 123 | 124 | def main() -> None: 125 | args = parse_args() 126 | try: 127 | repo_root = detect_repo_root(args.repo) 128 | branch = detect_branch(repo_root) 129 | origin = detect_origin(repo_root) 130 | except Exception as e: 131 | print(f"Error: {e}", file=sys.stderr) 132 | sys.exit(1) 133 | 134 | options = build_options(repo_root, branch, origin) 135 | 136 | try: 137 | manifest_path = find_manifest(args.manifest) 138 | except Exception as e: 139 | print(f"Error: {e}", file=sys.stderr) 140 | sys.exit(1) 141 | 142 | print("Select MCP package source by number:") 143 | for label, _ in options: 144 | print(label) 145 | 146 | if args.choice: 147 | choice = args.choice 148 | else: 149 | choice = input("Enter 1-3: ").strip() 150 | 151 | if choice not in {"1", "2", "3"}: 152 | print("Invalid selection.", file=sys.stderr) 153 | sys.exit(1) 154 | 155 | idx = int(choice) - 1 156 | _, chosen = options[idx] 157 | 158 | data = read_json(manifest_path) 159 | deps = data.get("dependencies", {}) 160 | if PKG_NAME not in deps: 161 | print( 162 | f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) 163 | sys.exit(1) 164 | 165 | print(f"\nUpdating {PKG_NAME} → {chosen}") 166 | deps[PKG_NAME] = chosen 167 | data["dependencies"] = deps 168 | write_json(manifest_path, data) 169 | print(f"Done. Wrote to: {manifest_path}") 170 | print("Tip: In Unity, open Package Manager and Refresh to re-resolve packages.") 171 | 172 | 173 | if __name__ == "__main__": 174 | main() 175 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Data/PythonToolsAssetTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Linq; 3 | using NUnit.Framework; 4 | using UnityEngine; 5 | using MCPForUnity.Editor.Data; 6 | 7 | namespace MCPForUnityTests.Editor.Data 8 | { 9 | public class PythonToolsAssetTests 10 | { 11 | private PythonToolsAsset _asset; 12 | 13 | [SetUp] 14 | public void SetUp() 15 | { 16 | _asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 17 | } 18 | 19 | [TearDown] 20 | public void TearDown() 21 | { 22 | if (_asset != null) 23 | { 24 | UnityEngine.Object.DestroyImmediate(_asset, true); 25 | } 26 | } 27 | 28 | [Test] 29 | public void GetValidFiles_ReturnsEmptyList_WhenNoFilesAdded() 30 | { 31 | var validFiles = _asset.GetValidFiles().ToList(); 32 | 33 | Assert.IsEmpty(validFiles, "Should return empty list when no files added"); 34 | } 35 | 36 | [Test] 37 | public void GetValidFiles_FiltersOutNullReferences() 38 | { 39 | _asset.pythonFiles.Add(null); 40 | _asset.pythonFiles.Add(new TextAsset("print('test')")); 41 | _asset.pythonFiles.Add(null); 42 | 43 | var validFiles = _asset.GetValidFiles().ToList(); 44 | 45 | Assert.AreEqual(1, validFiles.Count, "Should filter out null references"); 46 | } 47 | 48 | [Test] 49 | public void GetValidFiles_ReturnsAllNonNullFiles() 50 | { 51 | var file1 = new TextAsset("print('test1')"); 52 | var file2 = new TextAsset("print('test2')"); 53 | 54 | _asset.pythonFiles.Add(file1); 55 | _asset.pythonFiles.Add(file2); 56 | 57 | var validFiles = _asset.GetValidFiles().ToList(); 58 | 59 | Assert.AreEqual(2, validFiles.Count, "Should return all non-null files"); 60 | CollectionAssert.Contains(validFiles, file1); 61 | CollectionAssert.Contains(validFiles, file2); 62 | } 63 | 64 | [Test] 65 | public void NeedsSync_ReturnsTrue_WhenHashingDisabled() 66 | { 67 | _asset.useContentHashing = false; 68 | var textAsset = new TextAsset("print('test')"); 69 | 70 | bool needsSync = _asset.NeedsSync(textAsset, "any_hash"); 71 | 72 | Assert.IsTrue(needsSync, "Should always need sync when hashing disabled"); 73 | } 74 | 75 | [Test] 76 | public void NeedsSync_ReturnsTrue_WhenFileNotInStates() 77 | { 78 | _asset.useContentHashing = true; 79 | var textAsset = new TextAsset("print('test')"); 80 | 81 | bool needsSync = _asset.NeedsSync(textAsset, "new_hash"); 82 | 83 | Assert.IsTrue(needsSync, "Should need sync for new file"); 84 | } 85 | 86 | [Test] 87 | public void NeedsSync_ReturnsFalse_WhenHashMatches() 88 | { 89 | _asset.useContentHashing = true; 90 | var textAsset = new TextAsset("print('test')"); 91 | string hash = "test_hash_123"; 92 | 93 | // Record the file with a hash 94 | _asset.RecordSync(textAsset, hash); 95 | 96 | // Check if needs sync with same hash 97 | bool needsSync = _asset.NeedsSync(textAsset, hash); 98 | 99 | Assert.IsFalse(needsSync, "Should not need sync when hash matches"); 100 | } 101 | 102 | [Test] 103 | public void NeedsSync_ReturnsTrue_WhenHashDiffers() 104 | { 105 | _asset.useContentHashing = true; 106 | var textAsset = new TextAsset("print('test')"); 107 | 108 | // Record with one hash 109 | _asset.RecordSync(textAsset, "old_hash"); 110 | 111 | // Check with different hash 112 | bool needsSync = _asset.NeedsSync(textAsset, "new_hash"); 113 | 114 | Assert.IsTrue(needsSync, "Should need sync when hash differs"); 115 | } 116 | 117 | [Test] 118 | public void RecordSync_AddsNewFileState() 119 | { 120 | var textAsset = new TextAsset("print('test')"); 121 | string hash = "test_hash"; 122 | 123 | _asset.RecordSync(textAsset, hash); 124 | 125 | Assert.AreEqual(1, _asset.fileStates.Count, "Should add one file state"); 126 | Assert.AreEqual(hash, _asset.fileStates[0].contentHash, "Should store the hash"); 127 | Assert.IsNotNull(_asset.fileStates[0].assetGuid, "Should store the GUID"); 128 | } 129 | 130 | [Test] 131 | public void RecordSync_UpdatesExistingFileState() 132 | { 133 | var textAsset = new TextAsset("print('test')"); 134 | 135 | // Record first time 136 | _asset.RecordSync(textAsset, "hash1"); 137 | var firstTime = _asset.fileStates[0].lastSyncTime; 138 | 139 | // Wait a tiny bit to ensure time difference 140 | System.Threading.Thread.Sleep(10); 141 | 142 | // Record second time with different hash 143 | _asset.RecordSync(textAsset, "hash2"); 144 | 145 | Assert.AreEqual(1, _asset.fileStates.Count, "Should still have only one state"); 146 | Assert.AreEqual("hash2", _asset.fileStates[0].contentHash, "Should update the hash"); 147 | Assert.Greater(_asset.fileStates[0].lastSyncTime, firstTime, "Should update sync time"); 148 | } 149 | 150 | [Test] 151 | public void CleanupStaleStates_KeepsStatesForCurrentFiles() 152 | { 153 | var file1 = new TextAsset("print('test1')"); 154 | 155 | _asset.pythonFiles.Add(file1); 156 | _asset.RecordSync(file1, "hash1"); 157 | 158 | _asset.CleanupStaleStates(); 159 | 160 | Assert.AreEqual(1, _asset.fileStates.Count, "Should keep state for current file"); 161 | } 162 | 163 | [Test] 164 | public void CleanupStaleStates_HandlesEmptyFilesList() 165 | { 166 | // Add some states without corresponding files 167 | _asset.fileStates.Add(new PythonFileState 168 | { 169 | assetGuid = "fake_guid_1", 170 | contentHash = "hash1", 171 | fileName = "test1.py", 172 | lastSyncTime = DateTime.UtcNow 173 | }); 174 | 175 | _asset.CleanupStaleStates(); 176 | 177 | Assert.IsEmpty(_asset.fileStates, "Should remove all states when no files exist"); 178 | } 179 | } 180 | } 181 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/port_discovery.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Port discovery utility for MCP for Unity Server. 3 | 4 | What changed and why: 5 | - Unity now writes a per-project port file named like 6 | `~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting 7 | each other's saved port. The legacy file `unity-mcp-port.json` may still 8 | exist. 9 | - This module now scans for both patterns, prefers the most recently 10 | modified file, and verifies that the port is actually a MCP for Unity listener 11 | (quick socket connect + ping) before choosing it. 12 | """ 13 | 14 | import glob 15 | import json 16 | import logging 17 | from pathlib import Path 18 | import socket 19 | from typing import Optional, List 20 | 21 | logger = logging.getLogger("mcp-for-unity-server") 22 | 23 | 24 | class PortDiscovery: 25 | """Handles port discovery from Unity Bridge registry""" 26 | REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file 27 | DEFAULT_PORT = 6400 28 | CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery 29 | 30 | @staticmethod 31 | def get_registry_path() -> Path: 32 | """Get the path to the port registry file""" 33 | return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE 34 | 35 | @staticmethod 36 | def get_registry_dir() -> Path: 37 | return Path.home() / ".unity-mcp" 38 | 39 | @staticmethod 40 | def list_candidate_files() -> List[Path]: 41 | """Return candidate registry files, newest first. 42 | Includes hashed per-project files and the legacy file (if present). 43 | """ 44 | base = PortDiscovery.get_registry_dir() 45 | hashed = sorted( 46 | (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), 47 | key=lambda p: p.stat().st_mtime, 48 | reverse=True, 49 | ) 50 | legacy = PortDiscovery.get_registry_path() 51 | if legacy.exists(): 52 | # Put legacy at the end so hashed, per-project files win 53 | hashed.append(legacy) 54 | return hashed 55 | 56 | @staticmethod 57 | def _try_probe_unity_mcp(port: int) -> bool: 58 | """Quickly check if a MCP for Unity listener is on this port. 59 | Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. 60 | """ 61 | try: 62 | with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: 63 | s.settimeout(PortDiscovery.CONNECT_TIMEOUT) 64 | try: 65 | s.sendall(b"ping") 66 | data = s.recv(512) 67 | # Minimal validation: look for a success pong response 68 | if data and b'"message":"pong"' in data: 69 | return True 70 | except Exception: 71 | return False 72 | except Exception: 73 | return False 74 | return False 75 | 76 | @staticmethod 77 | def _read_latest_status() -> Optional[dict]: 78 | try: 79 | base = PortDiscovery.get_registry_dir() 80 | status_files = sorted( 81 | (Path(p) 82 | for p in glob.glob(str(base / "unity-mcp-status-*.json"))), 83 | key=lambda p: p.stat().st_mtime, 84 | reverse=True, 85 | ) 86 | if not status_files: 87 | return None 88 | with status_files[0].open('r') as f: 89 | return json.load(f) 90 | except Exception: 91 | return None 92 | 93 | @staticmethod 94 | def discover_unity_port() -> int: 95 | """ 96 | Discover Unity port by scanning per-project and legacy registry files. 97 | Prefer the newest file whose port responds; fall back to first parsed 98 | value; finally default to 6400. 99 | 100 | Returns: 101 | Port number to connect to 102 | """ 103 | # Prefer the latest heartbeat status if it points to a responsive port 104 | status = PortDiscovery._read_latest_status() 105 | if status: 106 | port = status.get('unity_port') 107 | if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): 108 | logger.info(f"Using Unity port from status: {port}") 109 | return port 110 | 111 | candidates = PortDiscovery.list_candidate_files() 112 | 113 | first_seen_port: Optional[int] = None 114 | 115 | for path in candidates: 116 | try: 117 | with open(path, 'r') as f: 118 | cfg = json.load(f) 119 | unity_port = cfg.get('unity_port') 120 | if isinstance(unity_port, int): 121 | if first_seen_port is None: 122 | first_seen_port = unity_port 123 | if PortDiscovery._try_probe_unity_mcp(unity_port): 124 | logger.info( 125 | f"Using Unity port from {path.name}: {unity_port}") 126 | return unity_port 127 | except Exception as e: 128 | logger.warning(f"Could not read port registry {path}: {e}") 129 | 130 | if first_seen_port is not None: 131 | logger.info( 132 | f"No responsive port found; using first seen value {first_seen_port}") 133 | return first_seen_port 134 | 135 | # Fallback to default port 136 | logger.info( 137 | f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") 138 | return PortDiscovery.DEFAULT_PORT 139 | 140 | @staticmethod 141 | def get_port_config() -> Optional[dict]: 142 | """ 143 | Get the most relevant port configuration from registry. 144 | Returns the most recent hashed file's config if present, 145 | otherwise the legacy file's config. Returns None if nothing exists. 146 | 147 | Returns: 148 | Port configuration dict or None if not found 149 | """ 150 | candidates = PortDiscovery.list_candidate_files() 151 | if not candidates: 152 | return None 153 | for path in candidates: 154 | try: 155 | with open(path, 'r') as f: 156 | return json.load(f) 157 | except Exception as e: 158 | logger.warning( 159 | f"Could not read port configuration {path}: {e}") 160 | return None 161 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/port_discovery.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Port discovery utility for MCP for Unity Server. 3 | 4 | What changed and why: 5 | - Unity now writes a per-project port file named like 6 | `~/.unity-mcp/unity-mcp-port-<hash>.json` to avoid projects overwriting 7 | each other's saved port. The legacy file `unity-mcp-port.json` may still 8 | exist. 9 | - This module now scans for both patterns, prefers the most recently 10 | modified file, and verifies that the port is actually a MCP for Unity listener 11 | (quick socket connect + ping) before choosing it. 12 | """ 13 | 14 | import glob 15 | import json 16 | import logging 17 | from pathlib import Path 18 | import socket 19 | from typing import Optional, List 20 | 21 | logger = logging.getLogger("mcp-for-unity-server") 22 | 23 | 24 | class PortDiscovery: 25 | """Handles port discovery from Unity Bridge registry""" 26 | REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file 27 | DEFAULT_PORT = 6400 28 | CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery 29 | 30 | @staticmethod 31 | def get_registry_path() -> Path: 32 | """Get the path to the port registry file""" 33 | return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE 34 | 35 | @staticmethod 36 | def get_registry_dir() -> Path: 37 | return Path.home() / ".unity-mcp" 38 | 39 | @staticmethod 40 | def list_candidate_files() -> List[Path]: 41 | """Return candidate registry files, newest first. 42 | Includes hashed per-project files and the legacy file (if present). 43 | """ 44 | base = PortDiscovery.get_registry_dir() 45 | hashed = sorted( 46 | (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), 47 | key=lambda p: p.stat().st_mtime, 48 | reverse=True, 49 | ) 50 | legacy = PortDiscovery.get_registry_path() 51 | if legacy.exists(): 52 | # Put legacy at the end so hashed, per-project files win 53 | hashed.append(legacy) 54 | return hashed 55 | 56 | @staticmethod 57 | def _try_probe_unity_mcp(port: int) -> bool: 58 | """Quickly check if a MCP for Unity listener is on this port. 59 | Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. 60 | """ 61 | try: 62 | with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: 63 | s.settimeout(PortDiscovery.CONNECT_TIMEOUT) 64 | try: 65 | s.sendall(b"ping") 66 | data = s.recv(512) 67 | # Minimal validation: look for a success pong response 68 | if data and b'"message":"pong"' in data: 69 | return True 70 | except Exception: 71 | return False 72 | except Exception: 73 | return False 74 | return False 75 | 76 | @staticmethod 77 | def _read_latest_status() -> Optional[dict]: 78 | try: 79 | base = PortDiscovery.get_registry_dir() 80 | status_files = sorted( 81 | (Path(p) 82 | for p in glob.glob(str(base / "unity-mcp-status-*.json"))), 83 | key=lambda p: p.stat().st_mtime, 84 | reverse=True, 85 | ) 86 | if not status_files: 87 | return None 88 | with status_files[0].open('r') as f: 89 | return json.load(f) 90 | except Exception: 91 | return None 92 | 93 | @staticmethod 94 | def discover_unity_port() -> int: 95 | """ 96 | Discover Unity port by scanning per-project and legacy registry files. 97 | Prefer the newest file whose port responds; fall back to first parsed 98 | value; finally default to 6400. 99 | 100 | Returns: 101 | Port number to connect to 102 | """ 103 | # Prefer the latest heartbeat status if it points to a responsive port 104 | status = PortDiscovery._read_latest_status() 105 | if status: 106 | port = status.get('unity_port') 107 | if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): 108 | logger.info(f"Using Unity port from status: {port}") 109 | return port 110 | 111 | candidates = PortDiscovery.list_candidate_files() 112 | 113 | first_seen_port: Optional[int] = None 114 | 115 | for path in candidates: 116 | try: 117 | with open(path, 'r') as f: 118 | cfg = json.load(f) 119 | unity_port = cfg.get('unity_port') 120 | if isinstance(unity_port, int): 121 | if first_seen_port is None: 122 | first_seen_port = unity_port 123 | if PortDiscovery._try_probe_unity_mcp(unity_port): 124 | logger.info( 125 | f"Using Unity port from {path.name}: {unity_port}") 126 | return unity_port 127 | except Exception as e: 128 | logger.warning(f"Could not read port registry {path}: {e}") 129 | 130 | if first_seen_port is not None: 131 | logger.info( 132 | f"No responsive port found; using first seen value {first_seen_port}") 133 | return first_seen_port 134 | 135 | # Fallback to default port 136 | logger.info( 137 | f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") 138 | return PortDiscovery.DEFAULT_PORT 139 | 140 | @staticmethod 141 | def get_port_config() -> Optional[dict]: 142 | """ 143 | Get the most relevant port configuration from registry. 144 | Returns the most recent hashed file's config if present, 145 | otherwise the legacy file's config. Returns None if nothing exists. 146 | 147 | Returns: 148 | Port configuration dict or None if not found 149 | """ 150 | candidates = PortDiscovery.list_candidate_files() 151 | if not candidates: 152 | return None 153 | for path in candidates: 154 | try: 155 | with open(path, 'r') as f: 156 | return json.load(f) 157 | except Exception as e: 158 | logger.warning( 159 | f"Could not read port configuration {path}: {e}") 160 | return None 161 | ``` -------------------------------------------------------------------------------- /tests/test_script_tools.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import pathlib 3 | import importlib.util 4 | import types 5 | import pytest 6 | import asyncio 7 | 8 | # add server src to path and load modules without triggering package imports 9 | ROOT = pathlib.Path(__file__).resolve().parents[1] 10 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 11 | sys.path.insert(0, str(SRC)) 12 | 13 | # stub mcp.server.fastmcp to satisfy imports without full dependency 14 | mcp_pkg = types.ModuleType("mcp") 15 | server_pkg = types.ModuleType("mcp.server") 16 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 17 | 18 | 19 | class _Dummy: 20 | pass 21 | 22 | 23 | fastmcp_pkg.FastMCP = _Dummy 24 | fastmcp_pkg.Context = _Dummy 25 | server_pkg.fastmcp = fastmcp_pkg 26 | mcp_pkg.server = server_pkg 27 | sys.modules.setdefault("mcp", mcp_pkg) 28 | sys.modules.setdefault("mcp.server", server_pkg) 29 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 30 | 31 | 32 | def load_module(path, name): 33 | spec = importlib.util.spec_from_file_location(name, path) 34 | module = importlib.util.module_from_spec(spec) 35 | spec.loader.exec_module(module) 36 | return module 37 | 38 | 39 | manage_script_module = load_module( 40 | SRC / "tools" / "manage_script.py", "manage_script_module") 41 | manage_asset_module = load_module( 42 | SRC / "tools" / "manage_asset.py", "manage_asset_module") 43 | 44 | 45 | class DummyMCP: 46 | def __init__(self): 47 | self.tools = {} 48 | 49 | def tool(self, *args, **kwargs): # accept decorator kwargs like description 50 | def decorator(func): 51 | self.tools[func.__name__] = func 52 | return func 53 | return decorator 54 | 55 | 56 | def setup_manage_script(): 57 | mcp = DummyMCP() 58 | manage_script_module.register_manage_script_tools(mcp) 59 | return mcp.tools 60 | 61 | 62 | def setup_manage_asset(): 63 | mcp = DummyMCP() 64 | manage_asset_module.register_manage_asset_tools(mcp) 65 | return mcp.tools 66 | 67 | 68 | def test_apply_text_edits_long_file(monkeypatch): 69 | tools = setup_manage_script() 70 | apply_edits = tools["apply_text_edits"] 71 | captured = {} 72 | 73 | def fake_send(cmd, params): 74 | captured["cmd"] = cmd 75 | captured["params"] = params 76 | return {"success": True} 77 | 78 | monkeypatch.setattr(manage_script_module, 79 | "send_command_with_retry", fake_send) 80 | 81 | edit = {"startLine": 1005, "startCol": 0, 82 | "endLine": 1005, "endCol": 5, "newText": "Hello"} 83 | resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) 84 | assert captured["cmd"] == "manage_script" 85 | assert captured["params"]["action"] == "apply_text_edits" 86 | assert captured["params"]["edits"][0]["startLine"] == 1005 87 | assert resp["success"] is True 88 | 89 | 90 | def test_sequential_edits_use_precondition(monkeypatch): 91 | tools = setup_manage_script() 92 | apply_edits = tools["apply_text_edits"] 93 | calls = [] 94 | 95 | def fake_send(cmd, params): 96 | calls.append(params) 97 | return {"success": True, "sha256": f"hash{len(calls)}"} 98 | 99 | monkeypatch.setattr(manage_script_module, 100 | "send_command_with_retry", fake_send) 101 | 102 | edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, 103 | "endCol": 0, "newText": "//header\n"} 104 | resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) 105 | edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, 106 | "endCol": 0, "newText": "//second\n"} 107 | resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", 108 | [edit2], precondition_sha256=resp1["sha256"]) 109 | 110 | assert calls[1]["precondition_sha256"] == resp1["sha256"] 111 | assert resp2["sha256"] == "hash2" 112 | 113 | 114 | def test_apply_text_edits_forwards_options(monkeypatch): 115 | tools = setup_manage_script() 116 | apply_edits = tools["apply_text_edits"] 117 | captured = {} 118 | 119 | def fake_send(cmd, params): 120 | captured["params"] = params 121 | return {"success": True} 122 | 123 | monkeypatch.setattr(manage_script_module, 124 | "send_command_with_retry", fake_send) 125 | 126 | opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} 127 | apply_edits(None, "unity://path/Assets/Scripts/File.cs", 128 | [{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts) 129 | assert captured["params"].get("options") == opts 130 | 131 | 132 | def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch): 133 | tools = setup_manage_script() 134 | apply_edits = tools["apply_text_edits"] 135 | captured = {} 136 | 137 | def fake_send(cmd, params): 138 | captured["params"] = params 139 | return {"success": True} 140 | 141 | monkeypatch.setattr(manage_script_module, 142 | "send_command_with_retry", fake_send) 143 | 144 | edits = [ 145 | {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, 146 | {"startLine": 3, "startCol": 2, "endLine": 3, 147 | "endCol": 2, "newText": "// tail\n"}, 148 | ] 149 | apply_edits(None, "unity://path/Assets/Scripts/File.cs", 150 | edits, precondition_sha256="x") 151 | opts = captured["params"].get("options", {}) 152 | assert opts.get("applyMode") == "atomic" 153 | 154 | 155 | def test_manage_asset_prefab_modify_request(monkeypatch): 156 | tools = setup_manage_asset() 157 | manage_asset = tools["manage_asset"] 158 | captured = {} 159 | 160 | async def fake_async(cmd, params, loop=None): 161 | captured["cmd"] = cmd 162 | captured["params"] = params 163 | return {"success": True} 164 | 165 | monkeypatch.setattr(manage_asset_module, 166 | "async_send_command_with_retry", fake_async) 167 | monkeypatch.setattr(manage_asset_module, 168 | "get_unity_connection", lambda: object()) 169 | 170 | async def run(): 171 | resp = await manage_asset( 172 | None, 173 | action="modify", 174 | path="Assets/Prefabs/Player.prefab", 175 | properties={"hp": 100}, 176 | ) 177 | assert captured["cmd"] == "manage_asset" 178 | assert captured["params"]["action"] == "modify" 179 | assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" 180 | assert captured["params"]["properties"] == {"hp": 100} 181 | assert resp["success"] is True 182 | 183 | asyncio.run(run()) 184 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/PackageUpdateService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Net; 3 | using MCPForUnity.Editor.Helpers; 4 | using Newtonsoft.Json.Linq; 5 | using UnityEditor; 6 | 7 | namespace MCPForUnity.Editor.Services 8 | { 9 | /// <summary> 10 | /// Service for checking package updates from GitHub 11 | /// </summary> 12 | public class PackageUpdateService : IPackageUpdateService 13 | { 14 | private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck"; 15 | private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion"; 16 | private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; 17 | 18 | /// <inheritdoc/> 19 | public UpdateCheckResult CheckForUpdate(string currentVersion) 20 | { 21 | // Check cache first - only check once per day 22 | string lastCheckDate = EditorPrefs.GetString(LastCheckDateKey, ""); 23 | string cachedLatestVersion = EditorPrefs.GetString(CachedVersionKey, ""); 24 | 25 | if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion)) 26 | { 27 | return new UpdateCheckResult 28 | { 29 | CheckSucceeded = true, 30 | LatestVersion = cachedLatestVersion, 31 | UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion), 32 | Message = "Using cached version check" 33 | }; 34 | } 35 | 36 | // Don't check for Asset Store installations 37 | if (!IsGitInstallation()) 38 | { 39 | return new UpdateCheckResult 40 | { 41 | CheckSucceeded = false, 42 | UpdateAvailable = false, 43 | Message = "Asset Store installations are updated via Unity Asset Store" 44 | }; 45 | } 46 | 47 | // Fetch latest version from GitHub 48 | string latestVersion = FetchLatestVersionFromGitHub(); 49 | 50 | if (!string.IsNullOrEmpty(latestVersion)) 51 | { 52 | // Cache the result 53 | EditorPrefs.SetString(LastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd")); 54 | EditorPrefs.SetString(CachedVersionKey, latestVersion); 55 | 56 | return new UpdateCheckResult 57 | { 58 | CheckSucceeded = true, 59 | LatestVersion = latestVersion, 60 | UpdateAvailable = IsNewerVersion(latestVersion, currentVersion), 61 | Message = "Successfully checked for updates" 62 | }; 63 | } 64 | 65 | return new UpdateCheckResult 66 | { 67 | CheckSucceeded = false, 68 | UpdateAvailable = false, 69 | Message = "Failed to check for updates (network issue or offline)" 70 | }; 71 | } 72 | 73 | /// <inheritdoc/> 74 | public bool IsNewerVersion(string version1, string version2) 75 | { 76 | try 77 | { 78 | // Remove any "v" prefix 79 | version1 = version1.TrimStart('v', 'V'); 80 | version2 = version2.TrimStart('v', 'V'); 81 | 82 | var version1Parts = version1.Split('.'); 83 | var version2Parts = version2.Split('.'); 84 | 85 | for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++) 86 | { 87 | if (int.TryParse(version1Parts[i], out int v1Num) && 88 | int.TryParse(version2Parts[i], out int v2Num)) 89 | { 90 | if (v1Num > v2Num) return true; 91 | if (v1Num < v2Num) return false; 92 | } 93 | } 94 | return false; 95 | } 96 | catch 97 | { 98 | return false; 99 | } 100 | } 101 | 102 | /// <inheritdoc/> 103 | public bool IsGitInstallation() 104 | { 105 | // Git packages are installed via Package Manager and have a package.json in Packages/ 106 | // Asset Store packages are in Assets/ 107 | string packageRoot = AssetPathUtility.GetMcpPackageRootPath(); 108 | 109 | if (string.IsNullOrEmpty(packageRoot)) 110 | { 111 | return false; 112 | } 113 | 114 | // If the package is in Packages/ it's a PM install (likely Git) 115 | // If it's in Assets/ it's an Asset Store install 116 | return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase); 117 | } 118 | 119 | /// <inheritdoc/> 120 | public void ClearCache() 121 | { 122 | EditorPrefs.DeleteKey(LastCheckDateKey); 123 | EditorPrefs.DeleteKey(CachedVersionKey); 124 | } 125 | 126 | /// <summary> 127 | /// Fetches the latest version from GitHub's main branch package.json 128 | /// </summary> 129 | private string FetchLatestVersionFromGitHub() 130 | { 131 | try 132 | { 133 | // GitHub API endpoint (Option 1 - has rate limits): 134 | // https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest 135 | // 136 | // We use Option 2 (package.json directly) because: 137 | // - No API rate limits (GitHub serves raw files freely) 138 | // - Simpler - just parse JSON for version field 139 | // - More reliable - doesn't require releases to be published 140 | // - Direct source of truth from the main branch 141 | 142 | using (var client = new WebClient()) 143 | { 144 | client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker"); 145 | string jsonContent = client.DownloadString(PackageJsonUrl); 146 | 147 | var packageJson = JObject.Parse(jsonContent); 148 | string version = packageJson["version"]?.ToString(); 149 | 150 | return string.IsNullOrEmpty(version) ? null : version; 151 | } 152 | } 153 | catch (Exception ex) 154 | { 155 | // Silent fail - don't interrupt the user if network is unavailable 156 | McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}"); 157 | return null; 158 | } 159 | } 160 | } 161 | } 162 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/AssetPathUtility.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using Newtonsoft.Json.Linq; 4 | using UnityEditor; 5 | using UnityEngine; 6 | using PackageInfo = UnityEditor.PackageManager.PackageInfo; 7 | 8 | namespace MCPForUnity.Editor.Helpers 9 | { 10 | /// <summary> 11 | /// Provides common utility methods for working with Unity asset paths. 12 | /// </summary> 13 | public static class AssetPathUtility 14 | { 15 | /// <summary> 16 | /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". 17 | /// </summary> 18 | public static string SanitizeAssetPath(string path) 19 | { 20 | if (string.IsNullOrEmpty(path)) 21 | { 22 | return path; 23 | } 24 | 25 | path = path.Replace('\\', '/'); 26 | if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 27 | { 28 | return "Assets/" + path.TrimStart('/'); 29 | } 30 | 31 | return path; 32 | } 33 | 34 | /// <summary> 35 | /// Gets the MCP for Unity package root path. 36 | /// Works for registry Package Manager, local Package Manager, and Asset Store installations. 37 | /// </summary> 38 | /// <returns>The package root path (virtual for PM, absolute for Asset Store), or null if not found</returns> 39 | public static string GetMcpPackageRootPath() 40 | { 41 | try 42 | { 43 | // Try Package Manager first (registry and local installs) 44 | var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); 45 | if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) 46 | { 47 | return packageInfo.assetPath; 48 | } 49 | 50 | // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) 51 | string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); 52 | 53 | if (guids.Length == 0) 54 | { 55 | McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); 56 | return null; 57 | } 58 | 59 | string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); 60 | 61 | // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs 62 | // Extract {packageRoot} 63 | int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal); 64 | 65 | if (editorIndex >= 0) 66 | { 67 | return scriptPath.Substring(0, editorIndex); 68 | } 69 | 70 | McpLog.Warn($"Could not determine package root from script path: {scriptPath}"); 71 | return null; 72 | } 73 | catch (Exception ex) 74 | { 75 | McpLog.Error($"Failed to get package root path: {ex.Message}"); 76 | return null; 77 | } 78 | } 79 | 80 | /// <summary> 81 | /// Reads and parses the package.json file for MCP for Unity. 82 | /// Handles both Package Manager (registry/local) and Asset Store installations. 83 | /// </summary> 84 | /// <returns>JObject containing package.json data, or null if not found or parse failed</returns> 85 | public static JObject GetPackageJson() 86 | { 87 | try 88 | { 89 | string packageRoot = GetMcpPackageRootPath(); 90 | if (string.IsNullOrEmpty(packageRoot)) 91 | { 92 | return null; 93 | } 94 | 95 | string packageJsonPath = Path.Combine(packageRoot, "package.json"); 96 | 97 | // Convert virtual asset path to file system path 98 | if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) 99 | { 100 | // Package Manager install - must use PackageInfo.resolvedPath 101 | // Virtual paths like "Packages/..." don't work with File.Exists() 102 | // Registry packages live in Library/PackageCache/package@version/ 103 | var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); 104 | if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) 105 | { 106 | packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json"); 107 | } 108 | else 109 | { 110 | McpLog.Warn("Could not resolve Package Manager path for package.json"); 111 | return null; 112 | } 113 | } 114 | else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 115 | { 116 | // Asset Store install - convert to absolute file system path 117 | // Application.dataPath is the absolute path to the Assets folder 118 | string relativePath = packageRoot.Substring("Assets/".Length); 119 | packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json"); 120 | } 121 | 122 | if (!File.Exists(packageJsonPath)) 123 | { 124 | McpLog.Warn($"package.json not found at: {packageJsonPath}"); 125 | return null; 126 | } 127 | 128 | string json = File.ReadAllText(packageJsonPath); 129 | return JObject.Parse(json); 130 | } 131 | catch (Exception ex) 132 | { 133 | McpLog.Warn($"Failed to read or parse package.json: {ex.Message}"); 134 | return null; 135 | } 136 | } 137 | 138 | /// <summary> 139 | /// Gets the version string from the package.json file. 140 | /// </summary> 141 | /// <returns>Version string, or "unknown" if not found</returns> 142 | public static string GetPackageVersion() 143 | { 144 | try 145 | { 146 | var packageJson = GetPackageJson(); 147 | if (packageJson == null) 148 | { 149 | return "unknown"; 150 | } 151 | 152 | string version = packageJson["version"]?.ToString(); 153 | return string.IsNullOrEmpty(version) ? "unknown" : version; 154 | } 155 | catch (Exception ex) 156 | { 157 | McpLog.Warn($"Failed to get package version: {ex.Message}"); 158 | return "unknown"; 159 | } 160 | } 161 | } 162 | } 163 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.IO; 2 | using System.Linq; 3 | using MCPForUnity.Editor.Data; 4 | using MCPForUnity.Editor.Services; 5 | using UnityEditor; 6 | using UnityEngine; 7 | 8 | namespace MCPForUnity.Editor.Helpers 9 | { 10 | /// <summary> 11 | /// Automatically syncs Python tools to the MCP server when: 12 | /// - PythonToolsAsset is modified 13 | /// - Python files are imported/reimported 14 | /// - Unity starts up 15 | /// </summary> 16 | [InitializeOnLoad] 17 | public class PythonToolSyncProcessor : AssetPostprocessor 18 | { 19 | private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled"; 20 | private static bool _isSyncing = false; 21 | 22 | static PythonToolSyncProcessor() 23 | { 24 | // Sync on Unity startup 25 | EditorApplication.delayCall += () => 26 | { 27 | if (IsAutoSyncEnabled()) 28 | { 29 | SyncAllTools(); 30 | } 31 | }; 32 | } 33 | 34 | /// <summary> 35 | /// Called after any assets are imported, deleted, or moved 36 | /// </summary> 37 | private static void OnPostprocessAllAssets( 38 | string[] importedAssets, 39 | string[] deletedAssets, 40 | string[] movedAssets, 41 | string[] movedFromAssetPaths) 42 | { 43 | // Prevent infinite loop - don't process if we're currently syncing 44 | if (_isSyncing || !IsAutoSyncEnabled()) 45 | return; 46 | 47 | bool needsSync = false; 48 | 49 | // Only check for .py file changes, not PythonToolsAsset changes 50 | // (PythonToolsAsset changes are internal state updates from syncing) 51 | foreach (string path in importedAssets.Concat(movedAssets)) 52 | { 53 | // Check if any .py files were modified 54 | if (path.EndsWith(".py")) 55 | { 56 | needsSync = true; 57 | break; 58 | } 59 | } 60 | 61 | // Check if any .py files were deleted 62 | if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py"))) 63 | { 64 | needsSync = true; 65 | } 66 | 67 | if (needsSync) 68 | { 69 | SyncAllTools(); 70 | } 71 | } 72 | 73 | /// <summary> 74 | /// Syncs all Python tools from all PythonToolsAsset instances to the MCP server 75 | /// </summary> 76 | public static void SyncAllTools() 77 | { 78 | // Prevent re-entrant calls 79 | if (_isSyncing) 80 | { 81 | McpLog.Warn("Sync already in progress, skipping..."); 82 | return; 83 | } 84 | 85 | _isSyncing = true; 86 | try 87 | { 88 | if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath)) 89 | { 90 | McpLog.Warn("Cannot sync Python tools: MCP server source not found"); 91 | return; 92 | } 93 | 94 | string toolsDir = Path.Combine(srcPath, "tools", "custom"); 95 | 96 | var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir); 97 | 98 | if (result.Success) 99 | { 100 | if (result.CopiedCount > 0 || result.SkippedCount > 0) 101 | { 102 | McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped"); 103 | } 104 | } 105 | else 106 | { 107 | McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors"); 108 | foreach (var msg in result.Messages) 109 | { 110 | McpLog.Error($" - {msg}"); 111 | } 112 | } 113 | } 114 | catch (System.Exception ex) 115 | { 116 | McpLog.Error($"Python tool sync exception: {ex.Message}"); 117 | } 118 | finally 119 | { 120 | _isSyncing = false; 121 | } 122 | } 123 | 124 | /// <summary> 125 | /// Checks if auto-sync is enabled (default: true) 126 | /// </summary> 127 | public static bool IsAutoSyncEnabled() 128 | { 129 | return EditorPrefs.GetBool(SyncEnabledKey, true); 130 | } 131 | 132 | /// <summary> 133 | /// Enables or disables auto-sync 134 | /// </summary> 135 | public static void SetAutoSyncEnabled(bool enabled) 136 | { 137 | EditorPrefs.SetBool(SyncEnabledKey, enabled); 138 | McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}"); 139 | } 140 | 141 | /// <summary> 142 | /// Menu item to reimport all Python files in the project 143 | /// </summary> 144 | [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)] 145 | public static void ReimportPythonFiles() 146 | { 147 | // Find all Python files (imported as TextAssets by PythonFileImporter) 148 | var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" }) 149 | .Select(AssetDatabase.GUIDToAssetPath) 150 | .Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase)) 151 | .ToArray(); 152 | 153 | foreach (string path in pythonGuids) 154 | { 155 | AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); 156 | } 157 | 158 | int count = pythonGuids.Length; 159 | McpLog.Info($"Reimported {count} Python files"); 160 | AssetDatabase.Refresh(); 161 | } 162 | 163 | /// <summary> 164 | /// Menu item to manually trigger sync 165 | /// </summary> 166 | [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)] 167 | public static void ManualSync() 168 | { 169 | McpLog.Info("Manually syncing Python tools..."); 170 | SyncAllTools(); 171 | } 172 | 173 | /// <summary> 174 | /// Menu item to toggle auto-sync 175 | /// </summary> 176 | [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)] 177 | public static void ToggleAutoSync() 178 | { 179 | SetAutoSyncEnabled(!IsAutoSyncEnabled()); 180 | } 181 | 182 | /// <summary> 183 | /// Validate menu item (shows checkmark when enabled) 184 | /// </summary> 185 | [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)] 186 | public static bool ToggleAutoSyncValidate() 187 | { 188 | Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled()); 189 | return true; 190 | } 191 | } 192 | } 193 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Runtime.InteropServices; 4 | using System.Text; 5 | using UnityEditor; 6 | 7 | namespace MCPForUnity.Editor.Helpers 8 | { 9 | /// <summary> 10 | /// Shared helpers for reading and writing MCP client configuration files. 11 | /// Consolidates file atomics and server directory resolution so the editor 12 | /// window can focus on UI concerns only. 13 | /// </summary> 14 | public static class McpConfigFileHelper 15 | { 16 | public static string ExtractDirectoryArg(string[] args) 17 | { 18 | if (args == null) return null; 19 | for (int i = 0; i < args.Length - 1; i++) 20 | { 21 | if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) 22 | { 23 | return args[i + 1]; 24 | } 25 | } 26 | return null; 27 | } 28 | 29 | public static bool PathsEqual(string a, string b) 30 | { 31 | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; 32 | try 33 | { 34 | string na = Path.GetFullPath(a.Trim()); 35 | string nb = Path.GetFullPath(b.Trim()); 36 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 37 | { 38 | return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); 39 | } 40 | return string.Equals(na, nb, StringComparison.Ordinal); 41 | } 42 | catch 43 | { 44 | return false; 45 | } 46 | } 47 | 48 | /// <summary> 49 | /// Resolves the server directory to use for MCP tools, preferring 50 | /// existing config values and falling back to installed/embedded copies. 51 | /// </summary> 52 | public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) 53 | { 54 | string serverSrc = ExtractDirectoryArg(existingArgs); 55 | bool serverValid = !string.IsNullOrEmpty(serverSrc) 56 | && File.Exists(Path.Combine(serverSrc, "server.py")); 57 | if (!serverValid) 58 | { 59 | if (!string.IsNullOrEmpty(pythonDir) 60 | && File.Exists(Path.Combine(pythonDir, "server.py"))) 61 | { 62 | serverSrc = pythonDir; 63 | } 64 | else 65 | { 66 | serverSrc = ResolveServerSource(); 67 | } 68 | } 69 | 70 | try 71 | { 72 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) 73 | { 74 | string norm = serverSrc.Replace('\\', '/'); 75 | int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); 76 | if (idx >= 0) 77 | { 78 | string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; 79 | string suffix = norm.Substring(idx + "/.local/share/".Length); 80 | serverSrc = Path.Combine(home, "Library", "Application Support", suffix); 81 | } 82 | } 83 | } 84 | catch 85 | { 86 | // Ignore failures and fall back to the original path. 87 | } 88 | 89 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 90 | && !string.IsNullOrEmpty(serverSrc) 91 | && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 92 | && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) 93 | { 94 | serverSrc = ServerInstaller.GetServerPath(); 95 | } 96 | 97 | return serverSrc; 98 | } 99 | 100 | public static void WriteAtomicFile(string path, string contents) 101 | { 102 | string tmp = path + ".tmp"; 103 | string backup = path + ".backup"; 104 | bool writeDone = false; 105 | try 106 | { 107 | File.WriteAllText(tmp, contents, new UTF8Encoding(false)); 108 | try 109 | { 110 | File.Replace(tmp, path, backup); 111 | writeDone = true; 112 | } 113 | catch (FileNotFoundException) 114 | { 115 | File.Move(tmp, path); 116 | writeDone = true; 117 | } 118 | catch (PlatformNotSupportedException) 119 | { 120 | if (File.Exists(path)) 121 | { 122 | try 123 | { 124 | if (File.Exists(backup)) File.Delete(backup); 125 | } 126 | catch { } 127 | File.Move(path, backup); 128 | } 129 | File.Move(tmp, path); 130 | writeDone = true; 131 | } 132 | } 133 | catch (Exception ex) 134 | { 135 | try 136 | { 137 | if (!writeDone && File.Exists(backup)) 138 | { 139 | try { File.Copy(backup, path, true); } catch { } 140 | } 141 | } 142 | catch { } 143 | throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); 144 | } 145 | finally 146 | { 147 | try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } 148 | try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } 149 | } 150 | } 151 | 152 | public static string ResolveServerSource() 153 | { 154 | try 155 | { 156 | string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); 157 | if (!string.IsNullOrEmpty(remembered) 158 | && File.Exists(Path.Combine(remembered, "server.py"))) 159 | { 160 | return remembered; 161 | } 162 | 163 | ServerInstaller.EnsureServerInstalled(); 164 | string installed = ServerInstaller.GetServerPath(); 165 | if (File.Exists(Path.Combine(installed, "server.py"))) 166 | { 167 | return installed; 168 | } 169 | 170 | bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); 171 | if (useEmbedded 172 | && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) 173 | && File.Exists(Path.Combine(embedded, "server.py"))) 174 | { 175 | return embedded; 176 | } 177 | 178 | return installed; 179 | } 180 | catch 181 | { 182 | return ServerInstaller.GetServerPath(); 183 | } 184 | } 185 | } 186 | } 187 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Runtime.InteropServices; 4 | using System.Text; 5 | using UnityEditor; 6 | 7 | namespace MCPForUnity.Editor.Helpers 8 | { 9 | /// <summary> 10 | /// Shared helpers for reading and writing MCP client configuration files. 11 | /// Consolidates file atomics and server directory resolution so the editor 12 | /// window can focus on UI concerns only. 13 | /// </summary> 14 | public static class McpConfigFileHelper 15 | { 16 | public static string ExtractDirectoryArg(string[] args) 17 | { 18 | if (args == null) return null; 19 | for (int i = 0; i < args.Length - 1; i++) 20 | { 21 | if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) 22 | { 23 | return args[i + 1]; 24 | } 25 | } 26 | return null; 27 | } 28 | 29 | public static bool PathsEqual(string a, string b) 30 | { 31 | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; 32 | try 33 | { 34 | string na = Path.GetFullPath(a.Trim()); 35 | string nb = Path.GetFullPath(b.Trim()); 36 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 37 | { 38 | return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); 39 | } 40 | return string.Equals(na, nb, StringComparison.Ordinal); 41 | } 42 | catch 43 | { 44 | return false; 45 | } 46 | } 47 | 48 | /// <summary> 49 | /// Resolves the server directory to use for MCP tools, preferring 50 | /// existing config values and falling back to installed/embedded copies. 51 | /// </summary> 52 | public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) 53 | { 54 | string serverSrc = ExtractDirectoryArg(existingArgs); 55 | bool serverValid = !string.IsNullOrEmpty(serverSrc) 56 | && File.Exists(Path.Combine(serverSrc, "server.py")); 57 | if (!serverValid) 58 | { 59 | if (!string.IsNullOrEmpty(pythonDir) 60 | && File.Exists(Path.Combine(pythonDir, "server.py"))) 61 | { 62 | serverSrc = pythonDir; 63 | } 64 | else 65 | { 66 | serverSrc = ResolveServerSource(); 67 | } 68 | } 69 | 70 | try 71 | { 72 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) 73 | { 74 | string norm = serverSrc.Replace('\\', '/'); 75 | int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); 76 | if (idx >= 0) 77 | { 78 | string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; 79 | string suffix = norm.Substring(idx + "/.local/share/".Length); 80 | serverSrc = Path.Combine(home, "Library", "Application Support", suffix); 81 | } 82 | } 83 | } 84 | catch 85 | { 86 | // Ignore failures and fall back to the original path. 87 | } 88 | 89 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 90 | && !string.IsNullOrEmpty(serverSrc) 91 | && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 92 | && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) 93 | { 94 | serverSrc = ServerInstaller.GetServerPath(); 95 | } 96 | 97 | return serverSrc; 98 | } 99 | 100 | public static void WriteAtomicFile(string path, string contents) 101 | { 102 | string tmp = path + ".tmp"; 103 | string backup = path + ".backup"; 104 | bool writeDone = false; 105 | try 106 | { 107 | File.WriteAllText(tmp, contents, new UTF8Encoding(false)); 108 | try 109 | { 110 | File.Replace(tmp, path, backup); 111 | writeDone = true; 112 | } 113 | catch (FileNotFoundException) 114 | { 115 | File.Move(tmp, path); 116 | writeDone = true; 117 | } 118 | catch (PlatformNotSupportedException) 119 | { 120 | if (File.Exists(path)) 121 | { 122 | try 123 | { 124 | if (File.Exists(backup)) File.Delete(backup); 125 | } 126 | catch { } 127 | File.Move(path, backup); 128 | } 129 | File.Move(tmp, path); 130 | writeDone = true; 131 | } 132 | } 133 | catch (Exception ex) 134 | { 135 | try 136 | { 137 | if (!writeDone && File.Exists(backup)) 138 | { 139 | try { File.Copy(backup, path, true); } catch { } 140 | } 141 | } 142 | catch { } 143 | throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); 144 | } 145 | finally 146 | { 147 | try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } 148 | try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } 149 | } 150 | } 151 | 152 | public static string ResolveServerSource() 153 | { 154 | try 155 | { 156 | string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); 157 | if (!string.IsNullOrEmpty(remembered) 158 | && File.Exists(Path.Combine(remembered, "server.py"))) 159 | { 160 | return remembered; 161 | } 162 | 163 | ServerInstaller.EnsureServerInstalled(); 164 | string installed = ServerInstaller.GetServerPath(); 165 | if (File.Exists(Path.Combine(installed, "server.py"))) 166 | { 167 | return installed; 168 | } 169 | 170 | bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); 171 | if (useEmbedded 172 | && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) 173 | && File.Exists(Path.Combine(embedded, "server.py"))) 174 | { 175 | return embedded; 176 | } 177 | 178 | return installed; 179 | } 180 | catch 181 | { 182 | return ServerInstaller.GetServerPath(); 183 | } 184 | } 185 | } 186 | } 187 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/BridgeControlService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Net; 4 | using System.Net.Sockets; 5 | using System.Text; 6 | 7 | namespace MCPForUnity.Editor.Services 8 | { 9 | /// <summary> 10 | /// Implementation of bridge control service 11 | /// </summary> 12 | public class BridgeControlService : IBridgeControlService 13 | { 14 | public bool IsRunning => MCPForUnityBridge.IsRunning; 15 | public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); 16 | public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode(); 17 | 18 | public void Start() 19 | { 20 | // If server is installed, use auto-connect mode 21 | // Otherwise use standard mode 22 | string serverPath = MCPServiceLocator.Paths.GetMcpServerPath(); 23 | if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py"))) 24 | { 25 | MCPForUnityBridge.StartAutoConnect(); 26 | } 27 | else 28 | { 29 | MCPForUnityBridge.Start(); 30 | } 31 | } 32 | 33 | public void Stop() 34 | { 35 | MCPForUnityBridge.Stop(); 36 | } 37 | 38 | public BridgeVerificationResult Verify(int port) 39 | { 40 | var result = new BridgeVerificationResult 41 | { 42 | Success = false, 43 | HandshakeValid = false, 44 | PingSucceeded = false, 45 | Message = "Verification not started" 46 | }; 47 | 48 | const int ConnectTimeoutMs = 1000; 49 | const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout 50 | 51 | try 52 | { 53 | using (var client = new TcpClient()) 54 | { 55 | // Attempt connection 56 | var connectTask = client.ConnectAsync(IPAddress.Loopback, port); 57 | if (!connectTask.Wait(ConnectTimeoutMs)) 58 | { 59 | result.Message = "Connection timeout"; 60 | return result; 61 | } 62 | 63 | using (var stream = client.GetStream()) 64 | { 65 | try { client.NoDelay = true; } catch { } 66 | 67 | // 1) Read handshake line (ASCII, newline-terminated) 68 | string handshake = ReadLineAscii(stream, 2000); 69 | if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) 70 | { 71 | result.Message = "Bridge handshake missing FRAMING=1"; 72 | return result; 73 | } 74 | 75 | result.HandshakeValid = true; 76 | 77 | // 2) Send framed "ping" 78 | byte[] payload = Encoding.UTF8.GetBytes("ping"); 79 | WriteFrame(stream, payload, FrameTimeoutMs); 80 | 81 | // 3) Read framed response and check for pong 82 | string response = ReadFrameUtf8(stream, FrameTimeoutMs); 83 | if (!string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0) 84 | { 85 | result.PingSucceeded = true; 86 | result.Success = true; 87 | result.Message = "Bridge verified successfully"; 88 | } 89 | else 90 | { 91 | result.Message = $"Ping failed; response='{response}'"; 92 | } 93 | } 94 | } 95 | } 96 | catch (Exception ex) 97 | { 98 | result.Message = $"Verification error: {ex.Message}"; 99 | } 100 | 101 | return result; 102 | } 103 | 104 | // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts 105 | private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) 106 | { 107 | if (payload == null) throw new ArgumentNullException(nameof(payload)); 108 | if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); 109 | 110 | byte[] header = new byte[8]; 111 | ulong len = (ulong)payload.LongLength; 112 | header[0] = (byte)(len >> 56); 113 | header[1] = (byte)(len >> 48); 114 | header[2] = (byte)(len >> 40); 115 | header[3] = (byte)(len >> 32); 116 | header[4] = (byte)(len >> 24); 117 | header[5] = (byte)(len >> 16); 118 | header[6] = (byte)(len >> 8); 119 | header[7] = (byte)(len); 120 | 121 | stream.WriteTimeout = timeoutMs; 122 | stream.Write(header, 0, header.Length); 123 | stream.Write(payload, 0, payload.Length); 124 | } 125 | 126 | private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) 127 | { 128 | byte[] header = ReadExact(stream, 8, timeoutMs); 129 | ulong len = ((ulong)header[0] << 56) 130 | | ((ulong)header[1] << 48) 131 | | ((ulong)header[2] << 40) 132 | | ((ulong)header[3] << 32) 133 | | ((ulong)header[4] << 24) 134 | | ((ulong)header[5] << 16) 135 | | ((ulong)header[6] << 8) 136 | | header[7]; 137 | if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); 138 | if (len > int.MaxValue) throw new IOException("Frame too large"); 139 | byte[] payload = ReadExact(stream, (int)len, timeoutMs); 140 | return Encoding.UTF8.GetString(payload); 141 | } 142 | 143 | private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) 144 | { 145 | byte[] buffer = new byte[count]; 146 | int offset = 0; 147 | stream.ReadTimeout = timeoutMs; 148 | while (offset < count) 149 | { 150 | int read = stream.Read(buffer, offset, count - offset); 151 | if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); 152 | offset += read; 153 | } 154 | return buffer; 155 | } 156 | 157 | private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) 158 | { 159 | stream.ReadTimeout = timeoutMs; 160 | using (var ms = new MemoryStream()) 161 | { 162 | byte[] one = new byte[1]; 163 | while (ms.Length < maxLen) 164 | { 165 | int n = stream.Read(one, 0, 1); 166 | if (n <= 0) break; 167 | if (one[0] == (byte)'\n') break; 168 | ms.WriteByte(one[0]); 169 | } 170 | return Encoding.ASCII.GetString(ms.ToArray()); 171 | } 172 | } 173 | } 174 | } 175 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Telemetry decorator for MCP for Unity tools 3 | """ 4 | 5 | import functools 6 | import inspect 7 | import logging 8 | import time 9 | from typing import Callable, Any 10 | 11 | from telemetry import record_resource_usage, record_tool_usage, record_milestone, MilestoneType 12 | 13 | _log = logging.getLogger("unity-mcp-telemetry") 14 | _decorator_log_count = 0 15 | 16 | 17 | def telemetry_tool(tool_name: str): 18 | """Decorator to add telemetry tracking to MCP tools""" 19 | def decorator(func: Callable) -> Callable: 20 | @functools.wraps(func) 21 | def _sync_wrapper(*args, **kwargs) -> Any: 22 | start_time = time.time() 23 | success = False 24 | error = None 25 | # Extract sub-action (e.g., 'get_hierarchy') from bound args when available 26 | sub_action = None 27 | try: 28 | sig = inspect.signature(func) 29 | bound = sig.bind_partial(*args, **kwargs) 30 | bound.apply_defaults() 31 | sub_action = bound.arguments.get("action") 32 | except Exception: 33 | sub_action = None 34 | try: 35 | global _decorator_log_count 36 | if _decorator_log_count < 10: 37 | _log.info(f"telemetry_decorator sync: tool={tool_name}") 38 | _decorator_log_count += 1 39 | result = func(*args, **kwargs) 40 | success = True 41 | action_val = sub_action or kwargs.get("action") 42 | try: 43 | if tool_name == "manage_script" and action_val == "create": 44 | record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) 45 | elif tool_name.startswith("manage_scene"): 46 | record_milestone( 47 | MilestoneType.FIRST_SCENE_MODIFICATION) 48 | record_milestone(MilestoneType.FIRST_TOOL_USAGE) 49 | except Exception: 50 | _log.debug("milestone emit failed", exc_info=True) 51 | return result 52 | except Exception as e: 53 | error = str(e) 54 | raise 55 | finally: 56 | duration_ms = (time.time() - start_time) * 1000 57 | try: 58 | record_tool_usage(tool_name, success, 59 | duration_ms, error, sub_action=sub_action) 60 | except Exception: 61 | _log.debug("record_tool_usage failed", exc_info=True) 62 | 63 | @functools.wraps(func) 64 | async def _async_wrapper(*args, **kwargs) -> Any: 65 | start_time = time.time() 66 | success = False 67 | error = None 68 | # Extract sub-action (e.g., 'get_hierarchy') from bound args when available 69 | sub_action = None 70 | try: 71 | sig = inspect.signature(func) 72 | bound = sig.bind_partial(*args, **kwargs) 73 | bound.apply_defaults() 74 | sub_action = bound.arguments.get("action") 75 | except Exception: 76 | sub_action = None 77 | try: 78 | global _decorator_log_count 79 | if _decorator_log_count < 10: 80 | _log.info(f"telemetry_decorator async: tool={tool_name}") 81 | _decorator_log_count += 1 82 | result = await func(*args, **kwargs) 83 | success = True 84 | action_val = sub_action or kwargs.get("action") 85 | try: 86 | if tool_name == "manage_script" and action_val == "create": 87 | record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) 88 | elif tool_name.startswith("manage_scene"): 89 | record_milestone( 90 | MilestoneType.FIRST_SCENE_MODIFICATION) 91 | record_milestone(MilestoneType.FIRST_TOOL_USAGE) 92 | except Exception: 93 | _log.debug("milestone emit failed", exc_info=True) 94 | return result 95 | except Exception as e: 96 | error = str(e) 97 | raise 98 | finally: 99 | duration_ms = (time.time() - start_time) * 1000 100 | try: 101 | record_tool_usage(tool_name, success, 102 | duration_ms, error, sub_action=sub_action) 103 | except Exception: 104 | _log.debug("record_tool_usage failed", exc_info=True) 105 | 106 | return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper 107 | return decorator 108 | 109 | 110 | def telemetry_resource(resource_name: str): 111 | """Decorator to add telemetry tracking to MCP resources""" 112 | def decorator(func: Callable) -> Callable: 113 | @functools.wraps(func) 114 | def _sync_wrapper(*args, **kwargs) -> Any: 115 | start_time = time.time() 116 | success = False 117 | error = None 118 | try: 119 | global _decorator_log_count 120 | if _decorator_log_count < 10: 121 | _log.info( 122 | f"telemetry_decorator sync: resource={resource_name}") 123 | _decorator_log_count += 1 124 | result = func(*args, **kwargs) 125 | success = True 126 | return result 127 | except Exception as e: 128 | error = str(e) 129 | raise 130 | finally: 131 | duration_ms = (time.time() - start_time) * 1000 132 | try: 133 | record_resource_usage(resource_name, success, 134 | duration_ms, error) 135 | except Exception: 136 | _log.debug("record_resource_usage failed", exc_info=True) 137 | 138 | @functools.wraps(func) 139 | async def _async_wrapper(*args, **kwargs) -> Any: 140 | start_time = time.time() 141 | success = False 142 | error = None 143 | try: 144 | global _decorator_log_count 145 | if _decorator_log_count < 10: 146 | _log.info( 147 | f"telemetry_decorator async: resource={resource_name}") 148 | _decorator_log_count += 1 149 | result = await func(*args, **kwargs) 150 | success = True 151 | return result 152 | except Exception as e: 153 | error = str(e) 154 | raise 155 | finally: 156 | duration_ms = (time.time() - start_time) * 1000 157 | try: 158 | record_resource_usage(resource_name, success, 159 | duration_ms, error) 160 | except Exception: 161 | _log.debug("record_resource_usage failed", exc_info=True) 162 | 163 | return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper 164 | return decorator 165 | ```