This is page 3 of 13. Use http://codebase.md/justinpbarnett/unity-mcp?page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /tests/test_script_tools.py: -------------------------------------------------------------------------------- ```python import sys import pathlib import importlib.util import types import pytest import asyncio # add server src to path and load modules without triggering package imports ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp to satisfy imports without full dependency mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") class _Dummy: pass fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg mcp_pkg.server = server_pkg sys.modules.setdefault("mcp", mcp_pkg) sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) def load_module(path, name): spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module manage_script_module = load_module( SRC / "tools" / "manage_script.py", "manage_script_module") manage_asset_module = load_module( SRC / "tools" / "manage_asset.py", "manage_asset_module") class DummyMCP: def __init__(self): self.tools = {} def tool(self, *args, **kwargs): # accept decorator kwargs like description def decorator(func): self.tools[func.__name__] = func return func return decorator def setup_manage_script(): mcp = DummyMCP() manage_script_module.register_manage_script_tools(mcp) return mcp.tools def setup_manage_asset(): mcp = DummyMCP() manage_asset_module.register_manage_asset_tools(mcp) return mcp.tools def test_apply_text_edits_long_file(monkeypatch): tools = setup_manage_script() apply_edits = tools["apply_text_edits"] captured = {} def fake_send(cmd, params): captured["cmd"] = cmd captured["params"] = params return {"success": True} monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) assert captured["cmd"] == "manage_script" assert captured["params"]["action"] == "apply_text_edits" assert captured["params"]["edits"][0]["startLine"] == 1005 assert resp["success"] is True def test_sequential_edits_use_precondition(monkeypatch): tools = setup_manage_script() apply_edits = tools["apply_text_edits"] calls = [] def fake_send(cmd, params): calls.append(params) return {"success": True, "sha256": f"hash{len(calls)}"} monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) assert calls[1]["precondition_sha256"] == resp1["sha256"] assert resp2["sha256"] == "hash2" def test_apply_text_edits_forwards_options(monkeypatch): tools = setup_manage_script() apply_edits = tools["apply_text_edits"] captured = {} def fake_send(cmd, params): captured["params"] = params return {"success": True} monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts) assert captured["params"].get("options") == opts def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch): tools = setup_manage_script() apply_edits = tools["apply_text_edits"] captured = {} def fake_send(cmd, params): captured["params"] = params return {"success": True} monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) edits = [ {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}, ] apply_edits(None, "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x") opts = captured["params"].get("options", {}) assert opts.get("applyMode") == "atomic" def test_manage_asset_prefab_modify_request(monkeypatch): tools = setup_manage_asset() manage_asset = tools["manage_asset"] captured = {} async def fake_async(cmd, params, loop=None): captured["cmd"] = cmd captured["params"] = params return {"success": True} monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async) monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object()) async def run(): resp = await manage_asset( None, action="modify", path="Assets/Prefabs/Player.prefab", properties={"hp": 100}, ) assert captured["cmd"] == "manage_asset" assert captured["params"]["action"] == "modify" assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" assert captured["params"]["properties"] == {"hp": 100} assert resp["success"] is True asyncio.run(run()) ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/PackageUpdateService.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Net; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; namespace MCPForUnity.Editor.Services { /// <summary> /// Service for checking package updates from GitHub /// </summary> public class PackageUpdateService : IPackageUpdateService { private const string LastCheckDateKey = "MCPForUnity.LastUpdateCheck"; private const string CachedVersionKey = "MCPForUnity.LatestKnownVersion"; private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; /// <inheritdoc/> public UpdateCheckResult CheckForUpdate(string currentVersion) { // Check cache first - only check once per day string lastCheckDate = EditorPrefs.GetString(LastCheckDateKey, ""); string cachedLatestVersion = EditorPrefs.GetString(CachedVersionKey, ""); if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion)) { return new UpdateCheckResult { CheckSucceeded = true, LatestVersion = cachedLatestVersion, UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion), Message = "Using cached version check" }; } // Don't check for Asset Store installations if (!IsGitInstallation()) { return new UpdateCheckResult { CheckSucceeded = false, UpdateAvailable = false, Message = "Asset Store installations are updated via Unity Asset Store" }; } // Fetch latest version from GitHub string latestVersion = FetchLatestVersionFromGitHub(); if (!string.IsNullOrEmpty(latestVersion)) { // Cache the result EditorPrefs.SetString(LastCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd")); EditorPrefs.SetString(CachedVersionKey, latestVersion); return new UpdateCheckResult { CheckSucceeded = true, LatestVersion = latestVersion, UpdateAvailable = IsNewerVersion(latestVersion, currentVersion), Message = "Successfully checked for updates" }; } return new UpdateCheckResult { CheckSucceeded = false, UpdateAvailable = false, Message = "Failed to check for updates (network issue or offline)" }; } /// <inheritdoc/> public bool IsNewerVersion(string version1, string version2) { try { // Remove any "v" prefix version1 = version1.TrimStart('v', 'V'); version2 = version2.TrimStart('v', 'V'); var version1Parts = version1.Split('.'); var version2Parts = version2.Split('.'); for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++) { if (int.TryParse(version1Parts[i], out int v1Num) && int.TryParse(version2Parts[i], out int v2Num)) { if (v1Num > v2Num) return true; if (v1Num < v2Num) return false; } } return false; } catch { return false; } } /// <inheritdoc/> public bool IsGitInstallation() { // Git packages are installed via Package Manager and have a package.json in Packages/ // Asset Store packages are in Assets/ string packageRoot = AssetPathUtility.GetMcpPackageRootPath(); if (string.IsNullOrEmpty(packageRoot)) { return false; } // If the package is in Packages/ it's a PM install (likely Git) // If it's in Assets/ it's an Asset Store install return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase); } /// <inheritdoc/> public void ClearCache() { EditorPrefs.DeleteKey(LastCheckDateKey); EditorPrefs.DeleteKey(CachedVersionKey); } /// <summary> /// Fetches the latest version from GitHub's main branch package.json /// </summary> private string FetchLatestVersionFromGitHub() { try { // GitHub API endpoint (Option 1 - has rate limits): // https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest // // We use Option 2 (package.json directly) because: // - No API rate limits (GitHub serves raw files freely) // - Simpler - just parse JSON for version field // - More reliable - doesn't require releases to be published // - Direct source of truth from the main branch using (var client = new WebClient()) { client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker"); string jsonContent = client.DownloadString(PackageJsonUrl); var packageJson = JObject.Parse(jsonContent); string version = packageJson["version"]?.ToString(); return string.IsNullOrEmpty(version) ? null : version; } } catch (Exception ex) { // Silent fail - don't interrupt the user if network is unavailable McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}"); return null; } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/AssetPathUtility.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using PackageInfo = UnityEditor.PackageManager.PackageInfo; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Provides common utility methods for working with Unity asset paths. /// </summary> public static class AssetPathUtility { /// <summary> /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". /// </summary> public static string SanitizeAssetPath(string path) { if (string.IsNullOrEmpty(path)) { return path; } path = path.Replace('\\', '/'); if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { return "Assets/" + path.TrimStart('/'); } return path; } /// <summary> /// Gets the MCP for Unity package root path. /// Works for registry Package Manager, local Package Manager, and Asset Store installations. /// </summary> /// <returns>The package root path (virtual for PM, absolute for Asset Store), or null if not found</returns> public static string GetMcpPackageRootPath() { try { // Try Package Manager first (registry and local installs) var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) { return packageInfo.assetPath; } // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); if (guids.Length == 0) { McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); return null; } string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs // Extract {packageRoot} int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal); if (editorIndex >= 0) { return scriptPath.Substring(0, editorIndex); } McpLog.Warn($"Could not determine package root from script path: {scriptPath}"); return null; } catch (Exception ex) { McpLog.Error($"Failed to get package root path: {ex.Message}"); return null; } } /// <summary> /// Reads and parses the package.json file for MCP for Unity. /// Handles both Package Manager (registry/local) and Asset Store installations. /// </summary> /// <returns>JObject containing package.json data, or null if not found or parse failed</returns> public static JObject GetPackageJson() { try { string packageRoot = GetMcpPackageRootPath(); if (string.IsNullOrEmpty(packageRoot)) { return null; } string packageJsonPath = Path.Combine(packageRoot, "package.json"); // Convert virtual asset path to file system path if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) { // Package Manager install - must use PackageInfo.resolvedPath // Virtual paths like "Packages/..." don't work with File.Exists() // Registry packages live in Library/PackageCache/package@version/ var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) { packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json"); } else { McpLog.Warn("Could not resolve Package Manager path for package.json"); return null; } } else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { // Asset Store install - convert to absolute file system path // Application.dataPath is the absolute path to the Assets folder string relativePath = packageRoot.Substring("Assets/".Length); packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json"); } if (!File.Exists(packageJsonPath)) { McpLog.Warn($"package.json not found at: {packageJsonPath}"); return null; } string json = File.ReadAllText(packageJsonPath); return JObject.Parse(json); } catch (Exception ex) { McpLog.Warn($"Failed to read or parse package.json: {ex.Message}"); return null; } } /// <summary> /// Gets the version string from the package.json file. /// </summary> /// <returns>Version string, or "unknown" if not found</returns> public static string GetPackageVersion() { try { var packageJson = GetPackageJson(); if (packageJson == null) { return "unknown"; } string version = packageJson["version"]?.ToString(); return string.IsNullOrEmpty(version) ? "unknown" : version; } catch (Exception ex) { McpLog.Warn($"Failed to get package version: {ex.Message}"); return "unknown"; } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/PythonToolSyncProcessor.cs: -------------------------------------------------------------------------------- ```csharp using System.IO; using System.Linq; using MCPForUnity.Editor.Data; using MCPForUnity.Editor.Services; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Automatically syncs Python tools to the MCP server when: /// - PythonToolsAsset is modified /// - Python files are imported/reimported /// - Unity starts up /// </summary> [InitializeOnLoad] public class PythonToolSyncProcessor : AssetPostprocessor { private const string SyncEnabledKey = "MCPForUnity.AutoSyncEnabled"; private static bool _isSyncing = false; static PythonToolSyncProcessor() { // Sync on Unity startup EditorApplication.delayCall += () => { if (IsAutoSyncEnabled()) { SyncAllTools(); } }; } /// <summary> /// Called after any assets are imported, deleted, or moved /// </summary> private static void OnPostprocessAllAssets( string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { // Prevent infinite loop - don't process if we're currently syncing if (_isSyncing || !IsAutoSyncEnabled()) return; bool needsSync = false; // Only check for .py file changes, not PythonToolsAsset changes // (PythonToolsAsset changes are internal state updates from syncing) foreach (string path in importedAssets.Concat(movedAssets)) { // Check if any .py files were modified if (path.EndsWith(".py")) { needsSync = true; break; } } // Check if any .py files were deleted if (!needsSync && deletedAssets.Any(path => path.EndsWith(".py"))) { needsSync = true; } if (needsSync) { SyncAllTools(); } } /// <summary> /// Syncs all Python tools from all PythonToolsAsset instances to the MCP server /// </summary> public static void SyncAllTools() { // Prevent re-entrant calls if (_isSyncing) { McpLog.Warn("Sync already in progress, skipping..."); return; } _isSyncing = true; try { if (!ServerPathResolver.TryFindEmbeddedServerSource(out string srcPath)) { McpLog.Warn("Cannot sync Python tools: MCP server source not found"); return; } string toolsDir = Path.Combine(srcPath, "tools", "custom"); var result = MCPServiceLocator.ToolSync.SyncProjectTools(toolsDir); if (result.Success) { if (result.CopiedCount > 0 || result.SkippedCount > 0) { McpLog.Info($"Python tools synced: {result.CopiedCount} copied, {result.SkippedCount} skipped"); } } else { McpLog.Error($"Python tool sync failed with {result.ErrorCount} errors"); foreach (var msg in result.Messages) { McpLog.Error($" - {msg}"); } } } catch (System.Exception ex) { McpLog.Error($"Python tool sync exception: {ex.Message}"); } finally { _isSyncing = false; } } /// <summary> /// Checks if auto-sync is enabled (default: true) /// </summary> public static bool IsAutoSyncEnabled() { return EditorPrefs.GetBool(SyncEnabledKey, true); } /// <summary> /// Enables or disables auto-sync /// </summary> public static void SetAutoSyncEnabled(bool enabled) { EditorPrefs.SetBool(SyncEnabledKey, enabled); McpLog.Info($"Python tool auto-sync {(enabled ? "enabled" : "disabled")}"); } /// <summary> /// Menu item to reimport all Python files in the project /// </summary> [MenuItem("Window/MCP For Unity/Tool Sync/Reimport Python Files", priority = 99)] public static void ReimportPythonFiles() { // Find all Python files (imported as TextAssets by PythonFileImporter) var pythonGuids = AssetDatabase.FindAssets("t:TextAsset", new[] { "Assets" }) .Select(AssetDatabase.GUIDToAssetPath) .Where(path => path.EndsWith(".py", System.StringComparison.OrdinalIgnoreCase)) .ToArray(); foreach (string path in pythonGuids) { AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate); } int count = pythonGuids.Length; McpLog.Info($"Reimported {count} Python files"); AssetDatabase.Refresh(); } /// <summary> /// Menu item to manually trigger sync /// </summary> [MenuItem("Window/MCP For Unity/Tool Sync/Sync Python Tools", priority = 100)] public static void ManualSync() { McpLog.Info("Manually syncing Python tools..."); SyncAllTools(); } /// <summary> /// Menu item to toggle auto-sync /// </summary> [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", priority = 101)] public static void ToggleAutoSync() { SetAutoSyncEnabled(!IsAutoSyncEnabled()); } /// <summary> /// Validate menu item (shows checkmark when enabled) /// </summary> [MenuItem("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", true, priority = 101)] public static bool ToggleAutoSyncValidate() { Menu.SetChecked("Window/MCP For Unity/Tool Sync/Auto-Sync Python Tools", IsAutoSyncEnabled()); return true; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using System.Runtime.InteropServices; using System.Text; using UnityEditor; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Shared helpers for reading and writing MCP client configuration files. /// Consolidates file atomics and server directory resolution so the editor /// window can focus on UI concerns only. /// </summary> public static class McpConfigFileHelper { public static string ExtractDirectoryArg(string[] args) { if (args == null) return null; for (int i = 0; i < args.Length - 1; i++) { if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) { return args[i + 1]; } } return null; } public static bool PathsEqual(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; try { string na = Path.GetFullPath(a.Trim()); string nb = Path.GetFullPath(b.Trim()); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); } return string.Equals(na, nb, StringComparison.Ordinal); } catch { return false; } } /// <summary> /// Resolves the server directory to use for MCP tools, preferring /// existing config values and falling back to installed/embedded copies. /// </summary> public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) { string serverSrc = ExtractDirectoryArg(existingArgs); bool serverValid = !string.IsNullOrEmpty(serverSrc) && File.Exists(Path.Combine(serverSrc, "server.py")); if (!serverValid) { if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) { serverSrc = pythonDir; } else { serverSrc = ResolveServerSource(); } } try { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) { string norm = serverSrc.Replace('\\', '/'); int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); if (idx >= 0) { string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; string suffix = norm.Substring(idx + "/.local/share/".Length); serverSrc = Path.Combine(home, "Library", "Application Support", suffix); } } } catch { // Ignore failures and fall back to the original path. } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(serverSrc) && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) { serverSrc = ServerInstaller.GetServerPath(); } return serverSrc; } public static void WriteAtomicFile(string path, string contents) { string tmp = path + ".tmp"; string backup = path + ".backup"; bool writeDone = false; try { File.WriteAllText(tmp, contents, new UTF8Encoding(false)); try { File.Replace(tmp, path, backup); writeDone = true; } catch (FileNotFoundException) { File.Move(tmp, path); writeDone = true; } catch (PlatformNotSupportedException) { if (File.Exists(path)) { try { if (File.Exists(backup)) File.Delete(backup); } catch { } File.Move(path, backup); } File.Move(tmp, path); writeDone = true; } } catch (Exception ex) { try { if (!writeDone && File.Exists(backup)) { try { File.Copy(backup, path, true); } catch { } } } catch { } throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); } finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } } } public static string ResolveServerSource() { try { string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) { return remembered; } ServerInstaller.EnsureServerInstalled(); string installed = ServerInstaller.GetServerPath(); if (File.Exists(Path.Combine(installed, "server.py"))) { return installed; } bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) && File.Exists(Path.Combine(embedded, "server.py"))) { return embedded; } return installed; } catch { return ServerInstaller.GetServerPath(); } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using System.Runtime.InteropServices; using System.Text; using UnityEditor; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Shared helpers for reading and writing MCP client configuration files. /// Consolidates file atomics and server directory resolution so the editor /// window can focus on UI concerns only. /// </summary> public static class McpConfigFileHelper { public static string ExtractDirectoryArg(string[] args) { if (args == null) return null; for (int i = 0; i < args.Length - 1; i++) { if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) { return args[i + 1]; } } return null; } public static bool PathsEqual(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; try { string na = Path.GetFullPath(a.Trim()); string nb = Path.GetFullPath(b.Trim()); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); } return string.Equals(na, nb, StringComparison.Ordinal); } catch { return false; } } /// <summary> /// Resolves the server directory to use for MCP tools, preferring /// existing config values and falling back to installed/embedded copies. /// </summary> public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) { string serverSrc = ExtractDirectoryArg(existingArgs); bool serverValid = !string.IsNullOrEmpty(serverSrc) && File.Exists(Path.Combine(serverSrc, "server.py")); if (!serverValid) { if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) { serverSrc = pythonDir; } else { serverSrc = ResolveServerSource(); } } try { if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) { string norm = serverSrc.Replace('\\', '/'); int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); if (idx >= 0) { string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; string suffix = norm.Substring(idx + "/.local/share/".Length); serverSrc = Path.Combine(home, "Library", "Application Support", suffix); } } } catch { // Ignore failures and fall back to the original path. } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(serverSrc) && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) { serverSrc = ServerInstaller.GetServerPath(); } return serverSrc; } public static void WriteAtomicFile(string path, string contents) { string tmp = path + ".tmp"; string backup = path + ".backup"; bool writeDone = false; try { File.WriteAllText(tmp, contents, new UTF8Encoding(false)); try { File.Replace(tmp, path, backup); writeDone = true; } catch (FileNotFoundException) { File.Move(tmp, path); writeDone = true; } catch (PlatformNotSupportedException) { if (File.Exists(path)) { try { if (File.Exists(backup)) File.Delete(backup); } catch { } File.Move(path, backup); } File.Move(tmp, path); writeDone = true; } } catch (Exception ex) { try { if (!writeDone && File.Exists(backup)) { try { File.Copy(backup, path, true); } catch { } } } catch { } throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); } finally { try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } } } public static string ResolveServerSource() { try { string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) { return remembered; } ServerInstaller.EnsureServerInstalled(); string installed = ServerInstaller.GetServerPath(); if (File.Exists(Path.Combine(installed, "server.py"))) { return installed; } bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) && File.Exists(Path.Combine(embedded, "server.py"))) { return embedded; } return installed; } catch { return ServerInstaller.GetServerPath(); } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/BridgeControlService.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; namespace MCPForUnity.Editor.Services { /// <summary> /// Implementation of bridge control service /// </summary> public class BridgeControlService : IBridgeControlService { public bool IsRunning => MCPForUnityBridge.IsRunning; public int CurrentPort => MCPForUnityBridge.GetCurrentPort(); public bool IsAutoConnectMode => MCPForUnityBridge.IsAutoConnectMode(); public void Start() { // If server is installed, use auto-connect mode // Otherwise use standard mode string serverPath = MCPServiceLocator.Paths.GetMcpServerPath(); if (!string.IsNullOrEmpty(serverPath) && File.Exists(Path.Combine(serverPath, "server.py"))) { MCPForUnityBridge.StartAutoConnect(); } else { MCPForUnityBridge.Start(); } } public void Stop() { MCPForUnityBridge.Stop(); } public BridgeVerificationResult Verify(int port) { var result = new BridgeVerificationResult { Success = false, HandshakeValid = false, PingSucceeded = false, Message = "Verification not started" }; const int ConnectTimeoutMs = 1000; const int FrameTimeoutMs = 30000; // Match bridge frame I/O timeout try { using (var client = new TcpClient()) { // Attempt connection var connectTask = client.ConnectAsync(IPAddress.Loopback, port); if (!connectTask.Wait(ConnectTimeoutMs)) { result.Message = "Connection timeout"; return result; } using (var stream = client.GetStream()) { try { client.NoDelay = true; } catch { } // 1) Read handshake line (ASCII, newline-terminated) string handshake = ReadLineAscii(stream, 2000); if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) { result.Message = "Bridge handshake missing FRAMING=1"; return result; } result.HandshakeValid = true; // 2) Send framed "ping" byte[] payload = Encoding.UTF8.GetBytes("ping"); WriteFrame(stream, payload, FrameTimeoutMs); // 3) Read framed response and check for pong string response = ReadFrameUtf8(stream, FrameTimeoutMs); if (!string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0) { result.PingSucceeded = true; result.Success = true; result.Message = "Bridge verified successfully"; } else { result.Message = $"Ping failed; response='{response}'"; } } } } catch (Exception ex) { result.Message = $"Verification error: {ex.Message}"; } return result; } // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); byte[] header = new byte[8]; ulong len = (ulong)payload.LongLength; header[0] = (byte)(len >> 56); header[1] = (byte)(len >> 48); header[2] = (byte)(len >> 40); header[3] = (byte)(len >> 32); header[4] = (byte)(len >> 24); header[5] = (byte)(len >> 16); header[6] = (byte)(len >> 8); header[7] = (byte)(len); stream.WriteTimeout = timeoutMs; stream.Write(header, 0, header.Length); stream.Write(payload, 0, payload.Length); } private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) { byte[] header = ReadExact(stream, 8, timeoutMs); ulong len = ((ulong)header[0] << 56) | ((ulong)header[1] << 48) | ((ulong)header[2] << 40) | ((ulong)header[3] << 32) | ((ulong)header[4] << 24) | ((ulong)header[5] << 16) | ((ulong)header[6] << 8) | header[7]; if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); if (len > int.MaxValue) throw new IOException("Frame too large"); byte[] payload = ReadExact(stream, (int)len, timeoutMs); return Encoding.UTF8.GetString(payload); } private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) { byte[] buffer = new byte[count]; int offset = 0; stream.ReadTimeout = timeoutMs; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); offset += read; } return buffer; } private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) { stream.ReadTimeout = timeoutMs; using (var ms = new MemoryStream()) { byte[] one = new byte[1]; while (ms.Length < maxLen) { int n = stream.Read(one, 0, 1); if (n <= 0) break; if (one[0] == (byte)'\n') break; ms.WriteByte(one[0]); } return Encoding.ASCII.GetString(ms.ToArray()); } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py: -------------------------------------------------------------------------------- ```python """ Telemetry decorator for MCP for Unity tools """ import functools import inspect import logging import time from typing import Callable, Any from telemetry import record_resource_usage, record_tool_usage, record_milestone, MilestoneType _log = logging.getLogger("unity-mcp-telemetry") _decorator_log_count = 0 def telemetry_tool(tool_name: str): """Decorator to add telemetry tracking to MCP tools""" def decorator(func: Callable) -> Callable: @functools.wraps(func) def _sync_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None # Extract sub-action (e.g., 'get_hierarchy') from bound args when available sub_action = None try: sig = inspect.signature(func) bound = sig.bind_partial(*args, **kwargs) bound.apply_defaults() sub_action = bound.arguments.get("action") except Exception: sub_action = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info(f"telemetry_decorator sync: tool={tool_name}") _decorator_log_count += 1 result = func(*args, **kwargs) success = True action_val = sub_action or kwargs.get("action") try: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): record_milestone( MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) @functools.wraps(func) async def _async_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None # Extract sub-action (e.g., 'get_hierarchy') from bound args when available sub_action = None try: sig = inspect.signature(func) bound = sig.bind_partial(*args, **kwargs) bound.apply_defaults() sub_action = bound.arguments.get("action") except Exception: sub_action = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info(f"telemetry_decorator async: tool={tool_name}") _decorator_log_count += 1 result = await func(*args, **kwargs) success = True action_val = sub_action or kwargs.get("action") try: if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): record_milestone( MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper return decorator def telemetry_resource(resource_name: str): """Decorator to add telemetry tracking to MCP resources""" def decorator(func: Callable) -> Callable: @functools.wraps(func) def _sync_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info( f"telemetry_decorator sync: resource={resource_name}") _decorator_log_count += 1 result = func(*args, **kwargs) success = True return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_resource_usage(resource_name, success, duration_ms, error) except Exception: _log.debug("record_resource_usage failed", exc_info=True) @functools.wraps(func) async def _async_wrapper(*args, **kwargs) -> Any: start_time = time.time() success = False error = None try: global _decorator_log_count if _decorator_log_count < 10: _log.info( f"telemetry_decorator async: resource={resource_name}") _decorator_log_count += 1 result = await func(*args, **kwargs) success = True return result except Exception as e: error = str(e) raise finally: duration_ms = (time.time() - start_time) * 1000 try: record_resource_usage(resource_name, success, duration_ms, error) except Exception: _log.debug("record_resource_usage failed", exc_info=True) return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper return decorator ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs: -------------------------------------------------------------------------------- ```csharp using System; using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Tools; using static MCPForUnity.Editor.Tools.ManageGameObject; namespace MCPForUnityTests.Editor.Tools { public class ComponentResolverTests { [Test] public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() { bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); Assert.IsTrue(result, "Should resolve Transform component"); Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); Assert.IsEmpty(error, "Should have no error message"); } [Test] public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() { bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); Assert.IsEmpty(error, "Should have no error message"); } [Test] public void TryResolve_ReturnsTrue_ForCustomComponentShortName() { bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); Assert.IsTrue(result, "Should resolve CustomComponent"); Assert.IsNotNull(type, "Should return valid type"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); Assert.IsEmpty(error, "Should have no error message"); } [Test] public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() { bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); Assert.IsNotNull(type, "Should return valid type"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); Assert.AreEqual("TestNamespace.CustomComponent", type.FullName, "Should have correct full name"); Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); Assert.IsEmpty(error, "Should have no error message"); } [Test] public void TryResolve_ReturnsFalse_ForNonExistentComponent() { bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); Assert.IsFalse(result, "Should not resolve non-existent component"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); Assert.That(error, Does.Contain("not found"), "Error should mention component not found"); } [Test] public void TryResolve_ReturnsFalse_ForEmptyString() { bool result = ComponentResolver.TryResolve("", out Type type, out string error); Assert.IsFalse(result, "Should not resolve empty string"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); } [Test] public void TryResolve_ReturnsFalse_ForNullString() { bool result = ComponentResolver.TryResolve(null, out Type type, out string error); Assert.IsFalse(result, "Should not resolve null string"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); Assert.That(error, Does.Contain("null or empty"), "Error should mention null or empty"); } [Test] public void TryResolve_CachesResolvedTypes() { // First call bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); // Second call should use cache bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); Assert.IsTrue(result1, "First call should succeed"); Assert.IsTrue(result2, "Second call should succeed"); Assert.AreSame(type1, type2, "Should return same type instance (cached)"); Assert.IsEmpty(error1, "First call should have no error"); Assert.IsEmpty(error2, "Second call should have no error"); } [Test] public void TryResolve_PrefersPlayerAssemblies() { // Test that custom user scripts (in Player assemblies) are found bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); Assert.IsTrue(result, "Should resolve user script from Player assembly"); Assert.IsNotNull(type, "Should return valid type"); // Verify it's not from an Editor assembly by checking the assembly name string assemblyName = type.Assembly.GetName().Name; Assert.That(assemblyName, Does.Not.Contain("Editor"), "User script should come from Player assembly, not Editor assembly"); // Verify it's from the TestAsmdef assembly (which is a Player assembly) Assert.AreEqual("TestAsmdef", assemblyName, "CustomComponent should be resolved from TestAsmdef assembly"); } [Test] public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() { // This test would need duplicate component names to be meaningful // For now, test with a built-in component that should not have duplicates bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); Assert.IsTrue(result, "Transform should resolve uniquely"); Assert.AreEqual(typeof(Transform), type, "Should return correct type"); Assert.IsEmpty(error, "Should have no ambiguity error"); } [Test] public void ResolvedType_IsValidComponent() { bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); Assert.IsTrue(result, "Should resolve Rigidbody"); Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/CodexConfigHelper.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using System.Linq; using MCPForUnity.External.Tommy; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Codex CLI specific configuration helpers. Handles TOML snippet /// generation and lightweight parsing so Codex can join the auto-setup /// flow alongside JSON-based clients. /// </summary> public static class CodexConfigHelper { public static bool IsCodexConfigured(string pythonDir) { try { string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (string.IsNullOrEmpty(basePath)) return false; string configPath = Path.Combine(basePath, ".codex", "config.toml"); if (!File.Exists(configPath)) return false; string toml = File.ReadAllText(configPath); if (!TryParseCodexServer(toml, out _, out var args)) return false; string dir = McpConfigFileHelper.ExtractDirectoryArg(args); if (string.IsNullOrEmpty(dir)) return false; return McpConfigFileHelper.PathsEqual(dir, pythonDir); } catch { return false; } } public static string BuildCodexServerBlock(string uvPath, string serverSrc) { var table = new TomlTable(); var mcpServers = new TomlTable(); mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); table["mcp_servers"] = mcpServers; using var writer = new StringWriter(); table.WriteTo(writer); return writer.ToString(); } public static string UpsertCodexServerBlock(string existingToml, string uvPath, string serverSrc) { // Parse existing TOML or create new root table var root = TryParseToml(existingToml) ?? new TomlTable(); // Ensure mcp_servers table exists if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable)) { root["mcp_servers"] = new TomlTable(); } var mcpServers = root["mcp_servers"] as TomlTable; // Create or update unityMCP table mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath, serverSrc); // Serialize back to TOML using var writer = new StringWriter(); root.WriteTo(writer); return writer.ToString(); } public static bool TryParseCodexServer(string toml, out string command, out string[] args) { command = null; args = null; var root = TryParseToml(toml); if (root == null) return false; if (!TryGetTable(root, "mcp_servers", out var servers) && !TryGetTable(root, "mcpServers", out servers)) { return false; } if (!TryGetTable(servers, "unityMCP", out var unity)) { return false; } command = GetTomlString(unity, "command"); args = GetTomlStringArray(unity, "args"); return !string.IsNullOrEmpty(command) && args != null; } /// <summary> /// Safely parses TOML string, returning null on failure /// </summary> private static TomlTable TryParseToml(string toml) { if (string.IsNullOrWhiteSpace(toml)) return null; try { using var reader = new StringReader(toml); return TOML.Parse(reader); } catch (TomlParseException) { return null; } catch (TomlSyntaxException) { return null; } catch (FormatException) { return null; } } /// <summary> /// Creates a TomlTable for the unityMCP server configuration /// </summary> private static TomlTable CreateUnityMcpTable(string uvPath, string serverSrc) { var unityMCP = new TomlTable(); unityMCP["command"] = new TomlString { Value = uvPath }; var argsArray = new TomlArray(); argsArray.Add(new TomlString { Value = "run" }); argsArray.Add(new TomlString { Value = "--directory" }); argsArray.Add(new TomlString { Value = serverSrc }); argsArray.Add(new TomlString { Value = "server.py" }); unityMCP["args"] = argsArray; return unityMCP; } private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) { table = null; if (parent == null) return false; if (parent.TryGetNode(key, out var node)) { if (node is TomlTable tbl) { table = tbl; return true; } if (node is TomlArray array) { var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault(); if (firstTable != null) { table = firstTable; return true; } } } return false; } private static string GetTomlString(TomlTable table, string key) { if (table != null && table.TryGetNode(key, out var node)) { if (node is TomlString str) return str.Value; if (node.HasValue) return node.ToString(); } return null; } private static string[] GetTomlStringArray(TomlTable table, string key) { if (table == null) return null; if (!table.TryGetNode(key, out var node)) return null; if (node is TomlArray array) { List<string> values = new List<string>(); foreach (TomlNode element in array.Children) { if (element is TomlString str) { values.Add(str.Value); } else if (element.HasValue) { values.Add(element.ToString()); } } return values.Count > 0 ? values.ToArray() : Array.Empty<string>(); } if (node is TomlString single) { return new[] { single.Value }; } return null; } } } ``` -------------------------------------------------------------------------------- /tests/test_transport_framing.py: -------------------------------------------------------------------------------- ```python from unity_connection import UnityConnection import sys import json import struct import socket import threading import time import select from pathlib import Path import pytest # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] candidates = [ ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) if SRC is None: searched = "\n".join(str(p) for p in candidates) pytest.skip( "MCP for Unity server source not found. Tried:\n" + searched, allow_module_level=True, ) sys.path.insert(0, str(SRC)) def start_dummy_server(greeting: bytes, respond_ping: bool = False): """Start a minimal TCP server for handshake tests.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) sock.listen(1) port = sock.getsockname()[1] ready = threading.Event() def _run(): ready.set() conn, _ = sock.accept() conn.settimeout(1.0) if greeting: conn.sendall(greeting) if respond_ping: try: # Read exactly n bytes helper def _read_exact(n: int) -> bytes: buf = b"" while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: break buf += chunk return buf header = _read_exact(8) if len(header) == 8: length = struct.unpack(">Q", header)[0] payload = _read_exact(length) if payload == b'{"type":"ping"}': resp = b'{"type":"pong"}' conn.sendall(struct.pack(">Q", len(resp)) + resp) except Exception: pass time.sleep(0.1) try: conn.close() except Exception: pass finally: sock.close() threading.Thread(target=_run, daemon=True).start() ready.wait() return port def start_handshake_enforcing_server(): """Server that drops connection if client sends data before handshake.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) sock.listen(1) port = sock.getsockname()[1] ready = threading.Event() def _run(): ready.set() conn, _ = sock.accept() # If client sends any data before greeting, disconnect (poll briefly) try: conn.setblocking(False) deadline = time.time() + 0.15 # short, reduces race with legitimate clients while time.time() < deadline: r, _, _ = select.select([conn], [], [], 0.01) if r: try: peek = conn.recv(1, socket.MSG_PEEK) except BlockingIOError: peek = b"" except Exception: peek = b"\x00" if peek: conn.close() sock.close() return # No pre-handshake data observed; send greeting conn.setblocking(True) conn.sendall(b"MCP/0.1 FRAMING=1\n") time.sleep(0.1) finally: try: conn.close() finally: sock.close() threading.Thread(target=_run, daemon=True).start() ready.wait() return port def test_handshake_requires_framing(): port = start_dummy_server(b"MCP/0.1\n") conn = UnityConnection(host="127.0.0.1", port=port) assert conn.connect() is False assert conn.sock is None def test_small_frame_ping_pong(): port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True) conn = UnityConnection(host="127.0.0.1", port=port) try: assert conn.connect() is True assert conn.use_framing is True payload = b'{"type":"ping"}' conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) resp = conn.receive_full_response(conn.sock) assert json.loads(resp.decode("utf-8"))["type"] == "pong" finally: conn.disconnect() def test_unframed_data_disconnect(): port = start_handshake_enforcing_server() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("127.0.0.1", port)) sock.settimeout(1.0) sock.sendall(b"BAD") time.sleep(0.4) try: data = sock.recv(1024) assert data == b"" except (ConnectionResetError, ConnectionAbortedError): # Some platforms raise instead of returning empty bytes when the # server closes the connection after detecting pre-handshake data. pass finally: sock.close() def test_zero_length_payload_heartbeat(): # Server that sends handshake and a zero-length heartbeat frame followed by a pong payload import socket import struct import threading import time sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) sock.listen(1) port = sock.getsockname()[1] ready = threading.Event() def _run(): ready.set() conn, _ = sock.accept() try: conn.sendall(b"MCP/0.1 FRAMING=1\n") time.sleep(0.02) # Heartbeat frame (length=0) conn.sendall(struct.pack(">Q", 0)) time.sleep(0.02) # Real payload frame payload = b'{"type":"pong"}' conn.sendall(struct.pack(">Q", len(payload)) + payload) time.sleep(0.02) finally: try: conn.close() except Exception: pass sock.close() threading.Thread(target=_run, daemon=True).start() ready.wait() conn = UnityConnection(host="127.0.0.1", port=port) try: assert conn.connect() is True # Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen) resp = conn.receive_full_response(conn.sock) assert resp in (b'{"type":"pong"}', b"") finally: conn.disconnect() @pytest.mark.skip(reason="TODO: oversized payload should disconnect") def test_oversized_payload_rejected(): pass @pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect") def test_partial_frame_timeout(): pass @pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations") def test_parallel_invocations_no_interleaving(): pass @pytest.mark.skip(reason="TODO: reconnection after drop mid-command") def test_reconnect_mid_command(): pass ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Windows-specific dependency detection /// </summary> public class WindowsPlatformDetector : PlatformDetectorBase { public override string PlatformName => "Windows"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths var candidates = new[] { "python.exe", "python3.exe", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python313", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python312", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python311", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python313", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python312", "python.exe") }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'where' command if (TryFindInPath("python.exe", out string pathResult) || TryFindInPath("python3.exe", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths and PATH environment variable."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#windows"; } public override string GetInstallationRecommendations() { return @"Windows Installation Recommendations: 1. Python: Install from Microsoft Store or python.org - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' - Direct download: https://python.org/downloads/windows/ 2. UV Package Manager: Install via PowerShell - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" - Or download from: https://github.com/astral-sh/uv/releases 3. MCP Server: Will be installed automatically by Unity MCP Bridge"; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "where", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) { // Take the first result var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); if (lines.Length > 0) { fullPath = lines[0].Trim(); return File.Exists(fullPath); } } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Windows-specific dependency detection /// </summary> public class WindowsPlatformDetector : PlatformDetectorBase { public override string PlatformName => "Windows"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths var candidates = new[] { "python.exe", "python3.exe", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python313", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python312", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Python", "Python311", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python313", "python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python312", "python.exe") }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'where' command if (TryFindInPath("python.exe", out string pathResult) || TryFindInPath("python3.exe", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths and PATH environment variable."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#windows"; } public override string GetInstallationRecommendations() { return @"Windows Installation Recommendations: 1. Python: Install from Microsoft Store or python.org - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' - Direct download: https://python.org/downloads/windows/ 2. UV Package Manager: Install via PowerShell - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" - Or download from: https://github.com/astral-sh/uv/releases 3. MCP Server: Will be installed automatically by MCP for Unity Bridge"; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "where", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) { // Take the first result var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); if (lines.Length > 0) { fullPath = lines[0].Trim(); return File.Exists(fullPath); } } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs: -------------------------------------------------------------------------------- ```csharp using System; using NUnit.Framework; using UnityEngine; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; using System.Reflection; namespace MCPForUnityTests.Editor.Tools { /// <summary> /// In-memory tests for ManageScript validation logic. /// These tests focus on the validation methods directly without creating files. /// </summary> public class ManageScriptValidationTests { [Test] public void HandleCommand_NullParams_ReturnsError() { var result = ManageScript.HandleCommand(null); Assert.IsNotNull(result, "Should handle null parameters gracefully"); } [Test] public void HandleCommand_InvalidAction_ReturnsError() { var paramsObj = new JObject { ["action"] = "invalid_action", ["name"] = "TestScript", ["path"] = "Assets/Scripts" }; var result = ManageScript.HandleCommand(paramsObj); Assert.IsNotNull(result, "Should return error result for invalid action"); } [Test] public void CheckBalancedDelimiters_ValidCode_ReturnsTrue() { string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}"; bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected); Assert.IsTrue(result, "Valid C# code should pass balance check"); } [Test] public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse() { string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace"; bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected); Assert.IsFalse(result, "Unbalanced code should fail balance check"); } [Test] public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue() { string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}"; bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected); Assert.IsTrue(result, "Code with braces in strings should pass balance check"); } [Test] public void CheckScopedBalance_ValidCode_ReturnsTrue() { string validCode = "{ Debug.Log(\"test\"); }"; bool result = CallCheckScopedBalance(validCode, 0, validCode.Length); Assert.IsTrue(result, "Valid scoped code should pass balance check"); } [Test] public void CheckScopedBalance_ShouldTolerateOuterContext_ReturnsTrue() { // This simulates a snippet extracted from a larger context string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context"; bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length); // Scoped balance should tolerate some imbalance from outer context Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance"); } [Test] public void TicTacToe3D_ValidationScenario_DoesNotCrash() { // Test the scenario that was causing issues without file I/O string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}"; // Test that the validation methods don't crash on this code bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected); bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length); Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation"); Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation"); } // Helper methods to access private ManageScript methods via reflection private bool CallCheckBalancedDelimiters(string contents, out int line, out char expected) { line = 0; expected = ' '; try { var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters", BindingFlags.NonPublic | BindingFlags.Static); if (method != null) { var parameters = new object[] { contents, line, expected }; var result = (bool)method.Invoke(null, parameters); line = (int)parameters[1]; expected = (char)parameters[2]; return result; } } catch (Exception ex) { Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}"); } // Fallback: basic structural check return BasicBalanceCheck(contents); } private bool CallCheckScopedBalance(string text, int start, int end) { try { var method = typeof(ManageScript).GetMethod("CheckScopedBalance", BindingFlags.NonPublic | BindingFlags.Static); if (method != null) { return (bool)method.Invoke(null, new object[] { text, start, end }); } } catch (Exception ex) { Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}"); } return true; // Default to passing if we can't test the actual method } private bool BasicBalanceCheck(string contents) { // Simple fallback balance check int braceCount = 0; bool inString = false; bool escaped = false; for (int i = 0; i < contents.Length; i++) { char c = contents[i]; if (escaped) { escaped = false; continue; } if (inString) { if (c == '\\') escaped = true; else if (c == '"') inString = false; continue; } if (c == '"') inString = true; else if (c == '{') braceCount++; else if (c == '}') braceCount--; if (braceCount < 0) return false; } return braceCount == 0; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Data/McpClients.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Data { public class McpClients { public List<McpClient> clients = new() { // 1) Cursor new() { name = "Cursor", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), mcpType = McpTypes.Cursor, configStatus = "Not Configured", }, // 2) Claude Code new() { name = "Claude Code", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), mcpType = McpTypes.ClaudeCode, configStatus = "Not Configured", }, // 3) Windsurf new() { name = "Windsurf", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), mcpType = McpTypes.Windsurf, configStatus = "Not Configured", }, // 4) Claude Desktop new() { name = "Claude Desktop", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json" ), mcpType = McpTypes.ClaudeDesktop, configStatus = "Not Configured", }, // 5) VSCode GitHub Copilot new() { name = "VSCode GitHub Copilot", // Windows path is canonical under %AppData%\Code\User windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json" ), // macOS: ~/Library/Application Support/Code/User/mcp.json macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json" ), // Linux: ~/.config/Code/User/mcp.json linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json" ), mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, // 3) Kiro new() { name = "Kiro", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), mcpType = McpTypes.Kiro, configStatus = "Not Configured", }, // 4) Codex CLI new() { name = "Codex CLI", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), mcpType = McpTypes.Codex, configStatus = "Not Configured", }, }; // Initialize status enums after construction public McpClients() { foreach (var client in clients) { if (client.configStatus == "Not Configured") { client.status = McpStatus.NotConfigured; } } } } } ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using MCPForUnity.Editor.Tools; using static MCPForUnity.Editor.Tools.ManageGameObject; namespace MCPForUnityTests.Editor.Tools { public class AIPropertyMatchingTests { private List<string> sampleProperties; [SetUp] public void SetUp() { sampleProperties = new List<string> { "maxReachDistance", "maxHorizontalDistance", "maxVerticalDistance", "moveSpeed", "healthPoints", "playerName", "isEnabled", "mass", "velocity", "transform" }; } [Test] public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() { var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); Assert.IsNotEmpty(properties, "Transform should have properties"); Assert.Contains("position", properties, "Transform should have position property"); Assert.Contains("rotation", properties, "Transform should have rotation property"); Assert.Contains("localScale", properties, "Transform should have localScale property"); } [Test] public void GetAllComponentProperties_ReturnsEmpty_ForNullType() { var properties = ComponentResolver.GetAllComponentProperties(null); Assert.IsEmpty(properties, "Null type should return empty list"); } [Test] public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() { var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties); Assert.IsEmpty(suggestions, "Null input should return no suggestions"); } [Test] public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() { var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties); Assert.IsEmpty(suggestions, "Empty input should return no suggestions"); } [Test] public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() { var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>()); Assert.IsEmpty(suggestions, "Empty property list should return no suggestions"); } [Test] public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() { var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties); Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces"); Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match"); } [Test] public void GetAIPropertySuggestions_FindsMultipleWordMatches() { var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties); Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance"); Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance"); } [Test] public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() { var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital"); } [Test] public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms() { var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties); // Note: Current algorithm might not find "mass" but should handle it gracefully Assert.IsNotNull(suggestions, "Should return valid suggestions list"); } [Test] public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber() { // Test with input that might match many properties var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties); Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer"); } [Test] public void GetAIPropertySuggestions_CachesResults() { var input = "Max Reach Distance"; // First call var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); // Second call should use cache (tested indirectly by ensuring consistency) var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent"); CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical"); } [Test] public void GetAIPropertySuggestions_HandlesUnityNamingConventions() { var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" }; var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties); var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties); Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention"); Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention"); Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention"); } [Test] public void GetAIPropertySuggestions_PrioritizesExactMatches() { var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" }; var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties); Assert.IsNotEmpty(suggestions, "Should find suggestions"); Assert.Contains("speed", suggestions, "Exact match should be included in results"); // Note: Implementation may or may not prioritize exact matches first } [Test] public void GetAIPropertySuggestions_HandlesCaseInsensitive() { var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties); Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input"); Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input"); } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Data/McpClients.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Data { public class McpClients { public List<McpClient> clients = new() { // 1) Cursor new() { name = "Cursor", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json" ), mcpType = McpTypes.Cursor, configStatus = "Not Configured", }, // 2) Claude Code new() { name = "Claude Code", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), mcpType = McpTypes.ClaudeCode, configStatus = "Not Configured", }, // 3) Windsurf new() { name = "Windsurf", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json" ), mcpType = McpTypes.Windsurf, configStatus = "Not Configured", }, // 4) Claude Desktop new() { name = "Claude Desktop", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json" ), mcpType = McpTypes.ClaudeDesktop, configStatus = "Not Configured", }, // 5) VSCode GitHub Copilot new() { name = "VSCode GitHub Copilot", // Windows path is canonical under %AppData%\Code\User windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json" ), // macOS: ~/Library/Application Support/Code/User/mcp.json macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json" ), // Linux: ~/.config/Code/User/mcp.json linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json" ), mcpType = McpTypes.VSCode, configStatus = "Not Configured", }, // 3) Kiro new() { name = "Kiro", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json" ), mcpType = McpTypes.Kiro, configStatus = "Not Configured", }, // 4) Codex CLI new() { name = "Codex CLI", windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), macConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml" ), mcpType = McpTypes.Codex, configStatus = "Not Configured", }, }; // Initialize status enums after construction public McpClients() { foreach (var client in clients) { if (client.configStatus == "Not Configured") { client.status = McpStatus.NotConfigured; } } } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/server.py: -------------------------------------------------------------------------------- ```python from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType from mcp.server.fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools from resources import register_all_resources from unity_connection import get_unity_connection, UnityConnection import time # Configure logging using settings from config logging.basicConfig( level=getattr(logging, config.log_level), format=config.log_format, stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio force=True # Ensure our handler replaces any prior stdout handlers ) logger = logging.getLogger("mcp-for-unity-server") # Also write logs to a rotating file so logs are available when launched via stdio try: import os as _os _log_dir = _os.path.join(_os.path.expanduser( "~/Library/Application Support/UnityMCP"), "Logs") _os.makedirs(_log_dir, exist_ok=True) _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") _fh = RotatingFileHandler( _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") _fh.setFormatter(logging.Formatter(config.log_format)) _fh.setLevel(getattr(logging, config.log_level)) logger.addHandler(_fh) # Also route telemetry logger to the same rotating file and normal level try: tlog = logging.getLogger("unity-mcp-telemetry") tlog.setLevel(getattr(logging, config.log_level)) tlog.addHandler(_fh) except Exception: # Never let logging setup break startup pass except Exception: # Never let logging setup break startup pass # Quieten noisy third-party loggers to avoid clutter during stdio handshake for noisy in ("httpx", "urllib3"): try: logging.getLogger(noisy).setLevel( max(logging.WARNING, getattr(logging, config.log_level))) except Exception: pass # Import telemetry only after logging is configured to ensure its logs use stderr and proper levels # Ensure a slightly higher telemetry timeout unless explicitly overridden by env try: # Ensure generous timeout unless explicitly overridden by env if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" except Exception: pass # Global connection state _unity_connection: UnityConnection = None @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" global _unity_connection logger.info("MCP for Unity Server starting up") # Record server startup telemetry start_time = time.time() start_clk = time.perf_counter() try: from pathlib import Path ver_path = Path(__file__).parent / "server_version.txt" server_version = ver_path.read_text(encoding="utf-8").strip() except Exception: server_version = "unknown" # Defer initial telemetry by 1s to avoid stdio handshake interference import threading def _emit_startup(): try: record_telemetry(RecordType.STARTUP, { "server_version": server_version, "startup_time": start_time, }) record_milestone(MilestoneType.FIRST_STARTUP) except Exception: logger.debug("Deferred startup telemetry failed", exc_info=True) threading.Timer(1.0, _emit_startup).start() try: skip_connect = os.environ.get( "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") if skip_connect: logger.info( "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") else: _unity_connection = get_unity_connection() logger.info("Connected to Unity on startup") # Record successful Unity connection (deferred) import threading as _t _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "connected", "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() except ConnectionError as e: logger.warning("Could not connect to Unity on startup: %s", e) _unity_connection = None # Record connection failure (deferred) import threading as _t _err_msg = str(e)[:200] _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "failed", "error": _err_msg, "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() except Exception as e: logger.warning( "Unexpected error connecting to Unity on startup: %s", e) _unity_connection = None import threading as _t _err_msg = str(e)[:200] _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "failed", "error": _err_msg, "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() try: # Yield the connection object so it can be attached to the context # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) yield {"bridge": _unity_connection} finally: if _unity_connection: _unity_connection.disconnect() _unity_connection = None logger.info("MCP for Unity Server shut down") # Initialize MCP server mcp = FastMCP( name="mcp-for-unity-server", lifespan=server_lifespan ) # Register all tools register_all_tools(mcp) # Register all resources register_all_resources(mcp) @mcp.prompt() def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" return ( "Available MCP for Unity Server Tools:\n\n" "- `manage_editor`: Controls editor state and queries info.\n" "- `execute_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" "- `manage_scene`: Manages scenes.\n" "- `manage_gameobject`: Manages GameObjects in the scene.\n" "- `manage_script`: Manages C# script files.\n" "- `manage_asset`: Manages prefabs and assets.\n" "- `manage_shader`: Manages shaders.\n\n" "Tips:\n" "- Create prefabs for reusable GameObjects.\n" "- Always include a camera and main light in your scenes.\n" "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" "- Use `execute_menu_item` for interacting with Unity systems and third party tools like a user would.\n" ) # Run the server if __name__ == "__main__": mcp.run(transport='stdio') ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Linux-specific dependency detection /// </summary> public class LinuxPlatformDetector : PlatformDetectorBase { public override string PlatformName => "Linux"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths on Linux var candidates = new[] { "python3", "python", "/usr/bin/python3", "/usr/local/bin/python3", "/opt/python/bin/python3", "/snap/bin/python3" }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths including system, snap, and user-local locations."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://www.python.org/downloads/source/"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#linux"; } public override string GetInstallationRecommendations() { return @"Linux Installation Recommendations: 1. Python: Install via package manager or pyenv - Ubuntu/Debian: sudo apt install python3 python3-pip - Fedora/RHEL: sudo dnf install python3 python3-pip - Arch: sudo pacman -S python python-pip - Or use pyenv: https://github.com/pyenv/pyenv 2. UV Package Manager: Install via curl - Run: curl -LsSf https://astral.sh/uv/install.sh | sh - Or download from: https://github.com/astral-sh/uv/releases 3. MCP Server: Will be installed automatically by MCP for Unity Note: Make sure ~/.local/bin is in your PATH for user-local installations."; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Set PATH to include common locations var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Enhance PATH for Unity's GUI environment var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) { fullPath = output; return true; } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// Linux-specific dependency detection /// </summary> public class LinuxPlatformDetector : PlatformDetectorBase { public override string PlatformName => "Linux"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths on Linux var candidates = new[] { "python3", "python", "/usr/bin/python3", "/usr/local/bin/python3", "/opt/python/bin/python3", "/snap/bin/python3" }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths including system, snap, and user-local locations."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://www.python.org/downloads/source/"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#linux"; } public override string GetInstallationRecommendations() { return @"Linux Installation Recommendations: 1. Python: Install via package manager or pyenv - Ubuntu/Debian: sudo apt install python3 python3-pip - Fedora/RHEL: sudo dnf install python3 python3-pip - Arch: sudo pacman -S python python-pip - Or use pyenv: https://github.com/pyenv/pyenv 2. UV Package Manager: Install via curl - Run: curl -LsSf https://astral.sh/uv/install.sh | sh - Or download from: https://github.com/astral-sh/uv/releases 3. MCP Server: Will be installed automatically by Unity MCP Bridge Note: Make sure ~/.local/bin is in your PATH for user-local installations."; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Set PATH to include common locations var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Enhance PATH for Unity's GUI environment var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) { fullPath = output; return true; } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/TelemetryHelper.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Threading; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Unity Bridge telemetry helper for collecting usage analytics /// Following privacy-first approach with easy opt-out mechanisms /// </summary> public static class TelemetryHelper { private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; private static Action<Dictionary<string, object>> s_sender; /// <summary> /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) /// </summary> public static bool IsEnabled { get { // Check environment variables first var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(envDisable) && (envDisable.ToLower() == "true" || envDisable == "1")) { return false; } var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(unityMcpDisable) && (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) { return false; } // Honor protocol-wide opt-out as well var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(mcpDisable) && (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) { return false; } // Check EditorPrefs return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); } } /// <summary> /// Get or generate customer UUID for anonymous tracking /// </summary> public static string GetCustomerUUID() { var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); if (string.IsNullOrEmpty(uuid)) { uuid = System.Guid.NewGuid().ToString(); UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); } return uuid; } /// <summary> /// Disable telemetry (stored in EditorPrefs) /// </summary> public static void DisableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); } /// <summary> /// Enable telemetry (stored in EditorPrefs) /// </summary> public static void EnableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); } /// <summary> /// Send telemetry data to MCP server for processing /// This is a lightweight bridge - the actual telemetry logic is in the MCP server /// </summary> public static void RecordEvent(string eventType, Dictionary<string, object> data = null) { if (!IsEnabled) return; try { var telemetryData = new Dictionary<string, object> { ["event_type"] = eventType, ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["customer_uuid"] = GetCustomerUUID(), ["unity_version"] = Application.unityVersion, ["platform"] = Application.platform.ToString(), ["source"] = "unity_bridge" }; if (data != null) { telemetryData["data"] = data; } // Send to MCP server via existing bridge communication // The MCP server will handle actual telemetry transmission SendTelemetryToMcpServer(telemetryData); } catch (Exception e) { // Never let telemetry errors interfere with functionality if (IsDebugEnabled()) { McpLog.Warn($"Telemetry error (non-blocking): {e.Message}"); } } } /// <summary> /// Allows the bridge to register a concrete sender for telemetry payloads. /// </summary> public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender) { Interlocked.Exchange(ref s_sender, sender); } public static void UnregisterTelemetrySender() { Interlocked.Exchange(ref s_sender, null); } /// <summary> /// Record bridge startup event /// </summary> public static void RecordBridgeStartup() { RecordEvent("bridge_startup", new Dictionary<string, object> { ["bridge_version"] = "3.0.2", ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() }); } /// <summary> /// Record bridge connection event /// </summary> public static void RecordBridgeConnection(bool success, string error = null) { var data = new Dictionary<string, object> { ["success"] = success }; if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } RecordEvent("bridge_connection", data); } /// <summary> /// Record tool execution from Unity side /// </summary> public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) { var data = new Dictionary<string, object> { ["tool_name"] = toolName, ["success"] = success, ["duration_ms"] = Math.Round(durationMs, 2) }; if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } RecordEvent("tool_execution_unity", data); } private static void SendTelemetryToMcpServer(Dictionary<string, object> telemetryData) { var sender = Volatile.Read(ref s_sender); if (sender != null) { try { sender(telemetryData); return; } catch (Exception e) { if (IsDebugEnabled()) { McpLog.Warn($"Telemetry sender error (non-blocking): {e.Message}"); } } } // Fallback: log when debug is enabled if (IsDebugEnabled()) { McpLog.Info($"Telemetry: {telemetryData["event_type"]}"); } } private static bool IsDebugEnabled() { try { return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/server.py: -------------------------------------------------------------------------------- ```python from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType from mcp.server.fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler import os from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection import time # Configure logging using settings from config logging.basicConfig( level=getattr(logging, config.log_level), format=config.log_format, stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio force=True # Ensure our handler replaces any prior stdout handlers ) logger = logging.getLogger("mcp-for-unity-server") # Also write logs to a rotating file so logs are available when launched via stdio try: import os as _os _log_dir = _os.path.join(_os.path.expanduser( "~/Library/Application Support/UnityMCP"), "Logs") _os.makedirs(_log_dir, exist_ok=True) _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") _fh = RotatingFileHandler( _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") _fh.setFormatter(logging.Formatter(config.log_format)) _fh.setLevel(getattr(logging, config.log_level)) logger.addHandler(_fh) # Also route telemetry logger to the same rotating file and normal level try: tlog = logging.getLogger("unity-mcp-telemetry") tlog.setLevel(getattr(logging, config.log_level)) tlog.addHandler(_fh) except Exception: # Never let logging setup break startup pass except Exception: # Never let logging setup break startup pass # Quieten noisy third-party loggers to avoid clutter during stdio handshake for noisy in ("httpx", "urllib3"): try: logging.getLogger(noisy).setLevel( max(logging.WARNING, getattr(logging, config.log_level))) except Exception: pass # Import telemetry only after logging is configured to ensure its logs use stderr and proper levels # Ensure a slightly higher telemetry timeout unless explicitly overridden by env try: # Ensure generous timeout unless explicitly overridden by env if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" except Exception: pass # Global connection state _unity_connection: UnityConnection = None @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" global _unity_connection logger.info("MCP for Unity Server starting up") # Record server startup telemetry start_time = time.time() start_clk = time.perf_counter() try: from pathlib import Path ver_path = Path(__file__).parent / "server_version.txt" server_version = ver_path.read_text(encoding="utf-8").strip() except Exception: server_version = "unknown" # Defer initial telemetry by 1s to avoid stdio handshake interference import threading def _emit_startup(): try: record_telemetry(RecordType.STARTUP, { "server_version": server_version, "startup_time": start_time, }) record_milestone(MilestoneType.FIRST_STARTUP) except Exception: logger.debug("Deferred startup telemetry failed", exc_info=True) threading.Timer(1.0, _emit_startup).start() try: skip_connect = os.environ.get( "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") if skip_connect: logger.info( "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") else: _unity_connection = get_unity_connection() logger.info("Connected to Unity on startup") # Record successful Unity connection (deferred) import threading as _t _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "connected", "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() except ConnectionError as e: logger.warning("Could not connect to Unity on startup: %s", e) _unity_connection = None # Record connection failure (deferred) import threading as _t _err_msg = str(e)[:200] _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "failed", "error": _err_msg, "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() except Exception as e: logger.warning( "Unexpected error connecting to Unity on startup: %s", e) _unity_connection = None import threading as _t _err_msg = str(e)[:200] _t.Timer(1.0, lambda: record_telemetry( RecordType.UNITY_CONNECTION, { "status": "failed", "error": _err_msg, "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() try: # Yield the connection object so it can be attached to the context # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) yield {"bridge": _unity_connection} finally: if _unity_connection: _unity_connection.disconnect() _unity_connection = None logger.info("MCP for Unity Server shut down") # Initialize MCP server mcp = FastMCP( name="mcp-for-unity-server", lifespan=server_lifespan ) # Register all tools register_all_tools(mcp) # Asset Creation Strategy @mcp.prompt() def asset_creation_strategy() -> str: """Guide for discovering and using MCP for Unity tools effectively.""" return ( "Available MCP for Unity Server Tools:\n\n" "- `manage_editor`: Controls editor state and queries info.\n" "- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" "- `manage_scene`: Manages scenes.\n" "- `manage_gameobject`: Manages GameObjects in the scene.\n" "- `manage_script`: Manages C# script files.\n" "- `manage_asset`: Manages prefabs and assets.\n" "- `manage_shader`: Manages shaders.\n\n" "Tips:\n" "- Create prefabs for reusable GameObjects.\n" "- Always include a camera and main light in your scenes.\n" "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" "- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n" "- List menu items before using them if you are unsure of the menu path.\n" "- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n" ) # Run the server if __name__ == "__main__": mcp.run(transport='stdio') ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Collections.Generic; using System.Threading; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// <summary> /// Unity Bridge telemetry helper for collecting usage analytics /// Following privacy-first approach with easy opt-out mechanisms /// </summary> public static class TelemetryHelper { private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; private static Action<Dictionary<string, object>> s_sender; /// <summary> /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) /// </summary> public static bool IsEnabled { get { // Check environment variables first var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(envDisable) && (envDisable.ToLower() == "true" || envDisable == "1")) { return false; } var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(unityMcpDisable) && (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) { return false; } // Honor protocol-wide opt-out as well var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); if (!string.IsNullOrEmpty(mcpDisable) && (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) { return false; } // Check EditorPrefs return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); } } /// <summary> /// Get or generate customer UUID for anonymous tracking /// </summary> public static string GetCustomerUUID() { var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); if (string.IsNullOrEmpty(uuid)) { uuid = System.Guid.NewGuid().ToString(); UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); } return uuid; } /// <summary> /// Disable telemetry (stored in EditorPrefs) /// </summary> public static void DisableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); } /// <summary> /// Enable telemetry (stored in EditorPrefs) /// </summary> public static void EnableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); } /// <summary> /// Send telemetry data to Python server for processing /// This is a lightweight bridge - the actual telemetry logic is in Python /// </summary> public static void RecordEvent(string eventType, Dictionary<string, object> data = null) { if (!IsEnabled) return; try { var telemetryData = new Dictionary<string, object> { ["event_type"] = eventType, ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["customer_uuid"] = GetCustomerUUID(), ["unity_version"] = Application.unityVersion, ["platform"] = Application.platform.ToString(), ["source"] = "unity_bridge" }; if (data != null) { telemetryData["data"] = data; } // Send to Python server via existing bridge communication // The Python server will handle actual telemetry transmission SendTelemetryToPythonServer(telemetryData); } catch (Exception e) { // Never let telemetry errors interfere with functionality if (IsDebugEnabled()) { Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}"); } } } /// <summary> /// Allows the bridge to register a concrete sender for telemetry payloads. /// </summary> public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender) { Interlocked.Exchange(ref s_sender, sender); } public static void UnregisterTelemetrySender() { Interlocked.Exchange(ref s_sender, null); } /// <summary> /// Record bridge startup event /// </summary> public static void RecordBridgeStartup() { RecordEvent("bridge_startup", new Dictionary<string, object> { ["bridge_version"] = "3.0.2", ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() }); } /// <summary> /// Record bridge connection event /// </summary> public static void RecordBridgeConnection(bool success, string error = null) { var data = new Dictionary<string, object> { ["success"] = success }; if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } RecordEvent("bridge_connection", data); } /// <summary> /// Record tool execution from Unity side /// </summary> public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) { var data = new Dictionary<string, object> { ["tool_name"] = toolName, ["success"] = success, ["duration_ms"] = Math.Round(durationMs, 2) }; if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } RecordEvent("tool_execution_unity", data); } private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData) { var sender = Volatile.Read(ref s_sender); if (sender != null) { try { sender(telemetryData); return; } catch (Exception e) { if (IsDebugEnabled()) { Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}"); } } } // Fallback: log when debug is enabled if (IsDebugEnabled()) { Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}"); } } private static bool IsDebugEnabled() { try { return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } } } } ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// macOS-specific dependency detection /// </summary> public class MacOSPlatformDetector : PlatformDetectorBase { public override string PlatformName => "macOS"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths on macOS var candidates = new[] { "python3", "python", "/usr/bin/python3", "/usr/local/bin/python3", "/opt/homebrew/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://www.python.org/downloads/macos/"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#macos"; } public override string GetInstallationRecommendations() { return @"macOS Installation Recommendations: 1. Python: Install via Homebrew (recommended) or python.org - Homebrew: brew install python3 - Direct download: https://python.org/downloads/macos/ 2. UV Package Manager: Install via curl or Homebrew - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh - Homebrew: brew install uv 3. MCP Server: Will be installed automatically by Unity MCP Bridge Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Set PATH to include common locations var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Enhance PATH for Unity's GUI environment var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) { fullPath = output; return true; } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using MCPForUnity.Editor.Dependencies.Models; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Dependencies.PlatformDetectors { /// <summary> /// macOS-specific dependency detection /// </summary> public class MacOSPlatformDetector : PlatformDetectorBase { public override string PlatformName => "macOS"; public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); public override DependencyStatus DetectPython() { var status = new DependencyStatus("Python", isRequired: true) { InstallationHint = GetPythonInstallUrl() }; try { // Check common Python installation paths on macOS var candidates = new[] { "python3", "python", "/usr/bin/python3", "/usr/local/bin/python3", "/opt/homebrew/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" }; foreach (var candidate in candidates) { if (TryValidatePython(candidate, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} at {fullPath}"; return status; } } // Try PATH resolution using 'which' command if (TryFindInPath("python3", out string pathResult) || TryFindInPath("python", out pathResult)) { if (TryValidatePython(pathResult, out string version, out string fullPath)) { status.IsAvailable = true; status.Version = version; status.Path = fullPath; status.Details = $"Found Python {version} in PATH at {fullPath}"; return status; } } status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; } catch (Exception ex) { status.ErrorMessage = $"Error detecting Python: {ex.Message}"; } return status; } public override string GetPythonInstallUrl() { return "https://www.python.org/downloads/macos/"; } public override string GetUVInstallUrl() { return "https://docs.astral.sh/uv/getting-started/installation/#macos"; } public override string GetInstallationRecommendations() { return @"macOS Installation Recommendations: 1. Python: Install via Homebrew (recommended) or python.org - Homebrew: brew install python3 - Direct download: https://python.org/downloads/macos/ 2. UV Package Manager: Install via curl or Homebrew - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh - Homebrew: brew install uv 3. MCP Server: Will be installed automatically by MCP for Unity Bridge Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; } private bool TryValidatePython(string pythonPath, out string version, out string fullPath) { version = null; fullPath = null; try { var psi = new ProcessStartInfo { FileName = pythonPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Set PATH to include common locations var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(5000); if (process.ExitCode == 0 && output.StartsWith("Python ")) { version = output.Substring(7); // Remove "Python " prefix fullPath = pythonPath; // Validate minimum version (Python 4+ or Python 3.10+) if (TryParseVersion(version, out var major, out var minor)) { return major > 3 || (major >= 3 && minor >= 10); } } } catch { // Ignore validation errors } return false; } private bool TryFindInPath(string executable, out string fullPath) { fullPath = null; try { var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = executable, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; // Enhance PATH for Unity's GUI environment var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var pathAdditions = new[] { "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", Path.Combine(homeDir, ".local", "bin") }; string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; using var process = Process.Start(psi); if (process == null) return false; string output = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(3000); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) { fullPath = output; return true; } } catch { // Ignore errors } return false; } } } ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py: -------------------------------------------------------------------------------- ```python from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." ) def manage_gameobject( ctx: Context, action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, name: Annotated[str, "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, tag: Annotated[str, "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, parent: Annotated[str, "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, position: Annotated[list[float], "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, rotation: Annotated[list[float], "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, scale: Annotated[list[float], "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, components_to_add: Annotated[list[str], "List of component names to add"] | None = None, primitive_type: Annotated[str, "Primitive type for 'create' action"] | None = None, save_as_prefab: Annotated[bool, "If True, saves the created GameObject as a prefab"] | None = None, prefab_path: Annotated[str, "Path for prefab creation"] | None = None, prefab_folder: Annotated[str, "Folder for prefab creation"] | None = None, # --- Parameters for 'modify' --- set_active: Annotated[bool, "If True, sets the GameObject active"] | None = None, layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], "List of component names to remove"] | None = None, component_properties: Annotated[dict[str, dict[str, Any]], """Dictionary of component names to their properties to set. For example: `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component Example set nested property: - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, # --- Parameters for 'find' --- search_term: Annotated[str, "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, find_all: Annotated[bool, "If True, finds all GameObjects matching the search term"] | None = None, search_in_children: Annotated[bool, "If True, searches in children of the GameObject"] | None = None, search_inactive: Annotated[bool, "If True, searches inactive GameObjects"] | None = None, # -- Component Management Arguments -- component_name: Annotated[str, "Component name for 'add_component' and 'remove_component' actions"] | None = None, # Controls whether serialization of private [SerializeField] fields is included includeNonPublicSerialized: Annotated[bool, "Controls whether serialization of private [SerializeField] fields is included"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_gameobject: {action}") try: # Validate parameter usage to prevent silent failures if action == "find": if name is not None: return { "success": False, "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" } if search_term is None: return { "success": False, "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." } if action in ["create", "modify"]: if search_term is not None: return { "success": False, "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." } # Prepare parameters, removing None values params = { "action": action, "target": target, "searchMethod": search_method, "name": name, "tag": tag, "parent": parent, "position": position, "rotation": rotation, "scale": scale, "componentsToAdd": components_to_add, "primitiveType": primitive_type, "saveAsPrefab": save_as_prefab, "prefabPath": prefab_path, "prefabFolder": prefab_folder, "setActive": set_active, "layer": layer, "componentsToRemove": components_to_remove, "componentProperties": component_properties, "searchTerm": search_term, "findAll": find_all, "searchInChildren": search_in_children, "searchInactive": search_inactive, "componentName": component_name, "includeNonPublicSerialized": includeNonPublicSerialized } params = {k: v for k, v in params.items() if v is not None} # --- Handle Prefab Path Logic --- # Check if 'saveAsPrefab' is explicitly True in params if action == "create" and params.get("saveAsPrefab"): if "prefabPath" not in params: if "name" not in params or not params["name"]: return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} # Use the provided prefab_folder (which has a default) and the name to construct the path constructed_path = f"{prefab_folder}/{params['name']}.prefab" # Ensure clean path separators (Unity prefers '/') params["prefabPath"] = constructed_path.replace("\\", "/") elif not params["prefabPath"].lower().endswith(".prefab"): return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided # The C# side only needs the final prefabPath params.pop("prefabFolder", None) # -------------------------------- # Use centralized retry helper response = send_command_with_retry("manage_gameobject", params) # Check if the response indicates success # If the response is not successful, raise an exception with the error message if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py: -------------------------------------------------------------------------------- ```python from typing import Annotated, Any, Literal from mcp.server.fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." ) def manage_gameobject( ctx: Context, action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, name: Annotated[str, "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, tag: Annotated[str, "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, parent: Annotated[str, "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, position: Annotated[list[float], "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, rotation: Annotated[list[float], "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, scale: Annotated[list[float], "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, components_to_add: Annotated[list[str], "List of component names to add"] | None = None, primitive_type: Annotated[str, "Primitive type for 'create' action"] | None = None, save_as_prefab: Annotated[bool, "If True, saves the created GameObject as a prefab"] | None = None, prefab_path: Annotated[str, "Path for prefab creation"] | None = None, prefab_folder: Annotated[str, "Folder for prefab creation"] | None = None, # --- Parameters for 'modify' --- set_active: Annotated[bool, "If True, sets the GameObject active"] | None = None, layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], "List of component names to remove"] | None = None, component_properties: Annotated[dict[str, dict[str, Any]], """Dictionary of component names to their properties to set. For example: `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component Example set nested property: - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, # --- Parameters for 'find' --- search_term: Annotated[str, "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, find_all: Annotated[bool, "If True, finds all GameObjects matching the search term"] | None = None, search_in_children: Annotated[bool, "If True, searches in children of the GameObject"] | None = None, search_inactive: Annotated[bool, "If True, searches inactive GameObjects"] | None = None, # -- Component Management Arguments -- component_name: Annotated[str, "Component name for 'add_component' and 'remove_component' actions"] | None = None, # Controls whether serialization of private [SerializeField] fields is included includeNonPublicSerialized: Annotated[bool, "Controls whether serialization of private [SerializeField] fields is included"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_gameobject: {action}") try: # Validate parameter usage to prevent silent failures if action == "find": if name is not None: return { "success": False, "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" } if search_term is None: return { "success": False, "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." } if action in ["create", "modify"]: if search_term is not None: return { "success": False, "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." } # Prepare parameters, removing None values params = { "action": action, "target": target, "searchMethod": search_method, "name": name, "tag": tag, "parent": parent, "position": position, "rotation": rotation, "scale": scale, "componentsToAdd": components_to_add, "primitiveType": primitive_type, "saveAsPrefab": save_as_prefab, "prefabPath": prefab_path, "prefabFolder": prefab_folder, "setActive": set_active, "layer": layer, "componentsToRemove": components_to_remove, "componentProperties": component_properties, "searchTerm": search_term, "findAll": find_all, "searchInChildren": search_in_children, "searchInactive": search_inactive, "componentName": component_name, "includeNonPublicSerialized": includeNonPublicSerialized } params = {k: v for k, v in params.items() if v is not None} # --- Handle Prefab Path Logic --- # Check if 'saveAsPrefab' is explicitly True in params if action == "create" and params.get("saveAsPrefab"): if "prefabPath" not in params: if "name" not in params or not params["name"]: return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} # Use the provided prefab_folder (which has a default) and the name to construct the path constructed_path = f"{prefab_folder}/{params['name']}.prefab" # Ensure clean path separators (Unity prefers '/') params["prefabPath"] = constructed_path.replace("\\", "/") elif not params["prefabPath"].lower().endswith(".prefab"): return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided # The C# side only needs the final prefabPath params.pop("prefabFolder", None) # -------------------------------- # Use centralized retry helper response = send_command_with_retry("manage_gameobject", params) # Check if the response indicates success # If the response is not successful, raise an exception with the error message if isinstance(response, dict) and response.get("success"): return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} return response if isinstance(response, dict) else {"success": False, "message": str(response)} except Exception as e: return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs: -------------------------------------------------------------------------------- ```csharp using System.IO; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using MCPForUnity.Editor.Tools.Prefabs; using MCPForUnity.Editor.Tools; namespace MCPForUnityTests.Editor.Tools { public class ManagePrefabsTests { private const string TempDirectory = "Assets/Temp/ManagePrefabsTests"; [SetUp] public void SetUp() { StageUtility.GoToMainStage(); EnsureTempDirectoryExists(); } [TearDown] public void TearDown() { StageUtility.GoToMainStage(); } [OneTimeTearDown] public void CleanupAll() { StageUtility.GoToMainStage(); if (AssetDatabase.IsValidFolder(TempDirectory)) { AssetDatabase.DeleteAsset(TempDirectory); } } [Test] public void OpenStage_OpensPrefabInIsolation() { string prefabPath = CreateTestPrefab("OpenStageCube"); try { var openParams = new JObject { ["action"] = "open_stage", ["prefabPath"] = prefabPath }; var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams)); Assert.IsTrue(openResult.Value<bool>("success"), "open_stage should succeed for a valid prefab."); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); Assert.IsNotNull(stage, "Prefab stage should be open after open_stage."); Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path."); var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" })); Assert.IsTrue(stageInfo.Value<bool>("success"), "get_prefab_stage should succeed when stage is open."); var data = stageInfo["data"] as JObject; Assert.IsNotNull(data, "Stage info should include data payload."); Assert.IsTrue(data.Value<bool>("isOpen")); Assert.AreEqual(prefabPath, data.Value<string>("assetPath")); } finally { StageUtility.GoToMainStage(); AssetDatabase.DeleteAsset(prefabPath); } } [Test] public void CloseStage_ReturnsSuccess_WhenNoStageOpen() { StageUtility.GoToMainStage(); var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "close_stage" })); Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed even if no stage is open."); } [Test] public void CloseStage_ClosesOpenPrefabStage() { string prefabPath = CreateTestPrefab("CloseStageCube"); try { ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_stage", ["prefabPath"] = prefabPath }); var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "close_stage" })); Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed when stage is open."); Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage."); } finally { StageUtility.GoToMainStage(); AssetDatabase.DeleteAsset(prefabPath); } } [Test] public void SaveOpenStage_SavesDirtyChanges() { string prefabPath = CreateTestPrefab("SaveStageCube"); try { ManagePrefabs.HandleCommand(new JObject { ["action"] = "open_stage", ["prefabPath"] = prefabPath }); PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); Assert.IsNotNull(stage, "Stage should be open before modifying."); stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f); var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "save_open_stage" })); Assert.IsTrue(saveResult.Value<bool>("success"), "save_open_stage should succeed when stage is open."); Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving."); GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath); Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage."); } finally { StageUtility.GoToMainStage(); AssetDatabase.DeleteAsset(prefabPath); } } [Test] public void SaveOpenStage_ReturnsError_WhenNoStageOpen() { StageUtility.GoToMainStage(); var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "save_open_stage" })); Assert.IsFalse(saveResult.Value<bool>("success"), "save_open_stage should fail when no stage is open."); } [Test] public void CreateFromGameObject_CreatesPrefabAndLinksInstance() { EnsureTempDirectoryExists(); StageUtility.GoToMainStage(); string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/'); GameObject sceneObject = new GameObject("ScenePrefabSource"); try { var result = ToJObject(ManagePrefabs.HandleCommand(new JObject { ["action"] = "create_from_gameobject", ["target"] = sceneObject.name, ["prefabPath"] = prefabPath })); Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed for a valid scene object."); var data = result["data"] as JObject; Assert.IsNotNull(data, "Response data should include prefab information."); string savedPath = data.Value<string>("prefabPath"); Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(savedPath); Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); int instanceId = data.Value<int>("instanceId"); var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject; Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId."); Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab."); sceneObject = linkedInstance; } finally { if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(prefabPath) != null) { AssetDatabase.DeleteAsset(prefabPath); } if (sceneObject != null) { if (PrefabUtility.IsPartOfPrefabInstance(sceneObject)) { PrefabUtility.UnpackPrefabInstance( sceneObject, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction ); } UnityEngine.Object.DestroyImmediate(sceneObject, true); } } } private static string CreateTestPrefab(string name) { EnsureTempDirectoryExists(); GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube); temp.name = name; string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/'); PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success); UnityEngine.Object.DestroyImmediate(temp); Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab."); return path; } private static void EnsureTempDirectoryExists() { if (!AssetDatabase.IsValidFolder("Assets/Temp")) { AssetDatabase.CreateFolder("Assets", "Temp"); } if (!AssetDatabase.IsValidFolder(TempDirectory)) { AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests"); } } private static JObject ToJObject(object result) { return result as JObject ?? JObject.FromObject(result); } } } ``` -------------------------------------------------------------------------------- /.claude/prompts/nl-unity-suite-nl.md: -------------------------------------------------------------------------------- ```markdown # Unity NL Editing Suite — Additive Test Design You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents. **Print this once, verbatim, early in the run:** AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha --- ## Mission 1) Pick target file (prefer): - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other. 3) Validate each edit with `mcp__unity__validate_script(level:"standard")`. 4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`. **CRITICAL XML FORMAT REQUIREMENTS:** - Each file must contain EXACTLY one `<testcase>` root element - NO prologue, epilogue, code fences, or extra characters - NO markdown formatting or explanations outside the XML - Use this exact format: ```xml <testcase name="NL-0 — Baseline State Capture" classname="UnityMCP.NL-T"> <system-out><![CDATA[ (evidence of what was accomplished) ]]></system-out> </testcase> ``` - If test fails, include: `<failure message="reason"/>` - TESTID must be one of: NL-0, NL-1, NL-2, NL-3, NL-4 5) **NO RESTORATION** - tests build additively on previous state. 6) **STRICT FRAGMENT EMISSION** - After each test, immediately emit a clean XML file under `reports/<TESTID>_results.xml` with exactly one `<testcase>` whose `name` begins with the exact test id. No prologue/epilogue or fences. If the test fails, include a `<failure message="..."/>` and still emit. --- ## Environment & Paths (CI) - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - **Canonical URIs only**: - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) - Relative (when supported): `Assets/...` CI provides: - `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created; leave alone) - `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit) --- ## Transcript Minimization Rules - Do not restate tool JSON; summarize in ≤ 2 short lines. - Never paste full file contents. For matches, include only the matched line and ±1 line. - Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`. - Per‑test `system-out` ≤ 400 chars: brief status only (no SHA). - Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment. - Avoid quoting multi‑line diffs; reference markers instead. — Console scans: perform two reads — last 10 `log/info` lines and up to 3 `error` entries (use `include_stacktrace:false`); include ≤ 3 lines total in the fragment; if no errors, state "no errors". --- ## Tool Mapping - **Anchors/regex/structured**: `mcp__unity__script_apply_edits` - Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace` - For `anchor_insert`, always set `"position": "before"` or `"after"`. - **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) STRICT OP GUARDRAILS - Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`. - For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`. - **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body - **Validation**: `mcp__unity__validate_script(level:"standard")` - **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers --- ## Additive Test Design Principles **Key Changes from Reset-Based:** 1. **Dynamic Targeting**: Use `find_in_file` to locate methods/content, never hardcode line numbers 2. **State Awareness**: Each test expects the file state left by the previous test 3. **Content-Based Operations**: Target methods by signature, classes by name, not coordinates 4. **Cumulative Validation**: Ensure the file remains structurally sound throughout the sequence 5. **Composability**: Tests demonstrate how operations work together in real workflows **State Tracking:** - Track file SHA after each test (`mcp__unity__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments. - Use content signatures (method names, comment markers) to verify expected state - Validate structural integrity after each major change --- ## Execution Order & Additive Test Specs ### NL-0. Baseline State Capture **Goal**: Establish initial file state and verify accessibility **Actions**: - Read file head and tail to confirm structure - Locate key methods: `HasTarget()`, `GetCurrentTarget()`, `Update()`, `ApplyBlend()` - Record initial SHA for tracking - **Expected final state**: Unchanged baseline file ### NL-1. Core Method Operations (Additive State A) **Goal**: Demonstrate method replacement operations **Actions**: - Replace `HasTarget()` method body: `public bool HasTarget() { return currentTarget != null; }` - Insert `PrintSeries()` method after `GetCurrentTarget()`: `public void PrintSeries() { Debug.Log("1,2,3"); }` - Verify both methods exist and are properly formatted - Delete `PrintSeries()` method (cleanup for next test) - **Expected final state**: `HasTarget()` modified, file structure intact, no temporary methods ### NL-2. Anchor Comment Insertion (Additive State B) **Goal**: Demonstrate anchor-based insertions above methods **Actions**: - Use `find_in_file` to locate current position of `Update()` method - Insert `// Build marker OK` comment line above `Update()` method - Verify comment exists and `Update()` still functions - **Expected final state**: State A + build marker comment above `Update()` ### NL-3. End-of-Class Content (Additive State C) **Goal**: Demonstrate end-of-class insertions with smart brace matching **Actions**: - Match the final class-closing brace by scanning from EOF (e.g., last `^\s*}\s*$`) or compute via `find_in_file` + ranges; insert immediately before it. - Insert three comment lines before final class brace: ``` // Tail test A // Tail test B // Tail test C ``` - **Expected final state**: State B + tail comments before class closing brace ### NL-4. Console State Verification (No State Change) **Goal**: Verify Unity console integration without file modification **Actions**: - Read last 10 Unity console lines (log/info) - Perform a targeted scan for errors/exceptions (type: errors), up to 3 entries - Validate no compilation errors from previous operations - **Expected final state**: State C (unchanged) - **IMMEDIATELY** write clean XML fragment to `reports/NL-4_results.xml` (no extra text). The `<testcase name>` must start with `NL-4`. Include at most 3 lines total across both reads, or simply state "no errors; console OK" (≤ 400 chars). ## Dynamic Targeting Examples **Instead of hardcoded coordinates:** ```json {"startLine": 31, "startCol": 26, "endLine": 31, "endCol": 58} ``` **Use content-aware targeting:** ```json # Find current method location find_in_file(pattern: "public bool HasTarget\\(\\)") # Then compute edit ranges from found position ``` **Method targeting by signature:** ```json {"op": "replace_method", "className": "LongUnityScriptClaudeTest", "methodName": "HasTarget"} ``` **Anchor-based insertions:** ```json {"op": "anchor_insert", "anchor": "private void Update\\(\\)", "position": "before", "text": "// comment"} ``` --- ## State Verification Patterns **After each test:** 1. Verify expected content exists: `find_in_file` for key markers 2. Check structural integrity: `validate_script(level:"standard")` 3. Update SHA tracking for next test's preconditions 4. Emit a per‑test fragment to `reports/<TESTID>_results.xml` immediately. If the test failed, still write a single `<testcase>` with a `<failure message="..."/>` and evidence in `system-out`. 5. Log cumulative changes in test evidence (keep concise per Transcript Minimization Rules; never paste raw tool JSON) **Error Recovery:** - If test fails, log current state but continue (don't restore) - Next test adapts to actual current state, not expected state - Demonstrates resilience of operations on varied file conditions --- ## Benefits of Additive Design 1. **Realistic Workflows**: Tests mirror actual development patterns 2. **Robust Operations**: Proves edits work on evolving files, not just pristine baselines 3. **Composability Validation**: Shows operations coordinate well together 4. **Simplified Infrastructure**: No restore scripts or snapshots needed 5. **Better Failure Analysis**: Failures don't cascade - each test adapts to current reality 6. **State Evolution Testing**: Validates SDK handles cumulative file modifications correctly This additive approach produces a more realistic and maintainable test suite that better represents actual SDK usage patterns. --- BAN ON EXTRA TOOLS AND DIRS - Do not use any tools outside `AllowedTools`. Do not create directories; assume `reports/` exists. --- ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/PathResolverService.cs: -------------------------------------------------------------------------------- ```csharp using System; using System.Diagnostics; using System.IO; using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Services { /// <summary> /// Implementation of path resolver service with override support /// </summary> public class PathResolverService : IPathResolverService { private const string PythonDirOverrideKey = "MCPForUnity.PythonDirOverride"; private const string UvPathOverrideKey = "MCPForUnity.UvPath"; private const string ClaudeCliPathOverrideKey = "MCPForUnity.ClaudeCliPath"; public bool HasMcpServerOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(PythonDirOverrideKey, null)); public bool HasUvPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(UvPathOverrideKey, null)); public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(ClaudeCliPathOverrideKey, null)); public string GetMcpServerPath() { // Check for override first string overridePath = EditorPrefs.GetString(PythonDirOverrideKey, null); if (!string.IsNullOrEmpty(overridePath) && File.Exists(Path.Combine(overridePath, "server.py"))) { return overridePath; } // Fall back to automatic detection return McpPathResolver.FindPackagePythonDirectory(false); } public string GetUvPath() { // Check for override first string overridePath = EditorPrefs.GetString(UvPathOverrideKey, null); if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { return overridePath; } // Fall back to automatic detection try { return ServerInstaller.FindUvPath(); } catch { return null; } } public string GetClaudeCliPath() { // Check for override first string overridePath = EditorPrefs.GetString(ClaudeCliPathOverrideKey, null); if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) { return overridePath; } // Fall back to automatic detection return ExecPath.ResolveClaude(); } public bool IsPythonDetected() { try { // Windows-specific Python detection if (Application.platform == RuntimePlatform.WindowsEditor) { // Common Windows Python installation paths string[] windowsCandidates = { @"C:\Python313\python.exe", @"C:\Python312\python.exe", @"C:\Python311\python.exe", @"C:\Python310\python.exe", @"C:\Python39\python.exe", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), }; foreach (string c in windowsCandidates) { if (File.Exists(c)) return true; } // Try 'where python' command (Windows equivalent of 'which') var psi = new ProcessStartInfo { FileName = "where", Arguments = "python", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using (var p = Process.Start(psi)) { string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) { string[] lines = outp.Split('\n'); foreach (string line in lines) { string trimmed = line.Trim(); if (File.Exists(trimmed)) return true; } } } } else { // macOS/Linux detection string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; string[] candidates = { "/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3", "/opt/local/bin/python3", Path.Combine(home, ".local", "bin", "python3"), "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", }; foreach (string c in candidates) { if (File.Exists(c)) return true; } // Try 'which python3' var psi = new ProcessStartInfo { FileName = "/usr/bin/which", Arguments = "python3", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using (var p = Process.Start(psi)) { string outp = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(2000); if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; } } } catch { } return false; } public bool IsUvDetected() { return !string.IsNullOrEmpty(GetUvPath()); } public bool IsClaudeCliDetected() { return !string.IsNullOrEmpty(GetClaudeCliPath()); } public void SetMcpServerOverride(string path) { if (string.IsNullOrEmpty(path)) { ClearMcpServerOverride(); return; } if (!File.Exists(Path.Combine(path, "server.py"))) { throw new ArgumentException("The selected folder does not contain server.py"); } EditorPrefs.SetString(PythonDirOverrideKey, path); } public void SetUvPathOverride(string path) { if (string.IsNullOrEmpty(path)) { ClearUvPathOverride(); return; } if (!File.Exists(path)) { throw new ArgumentException("The selected UV executable does not exist"); } EditorPrefs.SetString(UvPathOverrideKey, path); } public void SetClaudeCliPathOverride(string path) { if (string.IsNullOrEmpty(path)) { ClearClaudeCliPathOverride(); return; } if (!File.Exists(path)) { throw new ArgumentException("The selected Claude CLI executable does not exist"); } EditorPrefs.SetString(ClaudeCliPathOverrideKey, path); // Also update the ExecPath helper for backwards compatibility ExecPath.SetClaudeCliPath(path); } public void ClearMcpServerOverride() { EditorPrefs.DeleteKey(PythonDirOverrideKey); } public void ClearUvPathOverride() { EditorPrefs.DeleteKey(UvPathOverrideKey); } public void ClearClaudeCliPathOverride() { EditorPrefs.DeleteKey(ClaudeCliPathOverrideKey); } } } ```