This is page 2 of 18. Use http://codebase.md/justinpbarnett/unity-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .claude │ ├── prompts │ │ ├── nl-unity-suite-nl.md │ │ └── nl-unity-suite-t.md │ └── settings.json ├── .github │ ├── scripts │ │ └── mark_skipped.py │ └── workflows │ ├── bump-version.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ └── unity-tests.yml ├── .gitignore ├── deploy-dev.bat ├── docs │ ├── CURSOR_HELP.md │ ├── CUSTOM_TOOLS.md │ ├── README-DEV-zh.md │ ├── README-DEV.md │ ├── screenshots │ │ ├── v5_01_uninstall.png │ │ ├── v5_02_install.png │ │ ├── v5_03_open_mcp_window.png │ │ ├── v5_04_rebuild_mcp_server.png │ │ ├── v5_05_rebuild_success.png │ │ ├── v6_2_create_python_tools_asset.png │ │ ├── v6_2_python_tools_asset.png │ │ ├── v6_new_ui_asset_store_version.png │ │ ├── v6_new_ui_dark.png │ │ └── v6_new_ui_light.png │ ├── TELEMETRY.md │ ├── v5_MIGRATION.md │ └── v6_NEW_UI_CHANGES.md ├── LICENSE ├── logo.png ├── mcp_source.py ├── MCPForUnity │ ├── Editor │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Data │ │ │ ├── DefaultServerConfig.cs │ │ │ ├── DefaultServerConfig.cs.meta │ │ │ ├── McpClients.cs │ │ │ ├── McpClients.cs.meta │ │ │ ├── PythonToolsAsset.cs │ │ │ └── PythonToolsAsset.cs.meta │ │ ├── Data.meta │ │ ├── Dependencies │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── McpConfigFileHelper.cs │ │ │ ├── McpConfigFileHelper.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpPathResolver.cs │ │ │ ├── McpPathResolver.cs.meta │ │ │ ├── PackageDetector.cs │ │ │ ├── PackageDetector.cs.meta │ │ │ ├── PackageInstaller.cs │ │ │ ├── PackageInstaller.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PythonToolSyncProcessor.cs │ │ │ ├── PythonToolSyncProcessor.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── ServerInstaller.cs │ │ │ ├── ServerInstaller.cs.meta │ │ │ ├── ServerPathResolver.cs │ │ │ ├── ServerPathResolver.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── Vector3Helper.cs │ │ │ └── Vector3Helper.cs.meta │ │ ├── Helpers.meta │ │ ├── Importers │ │ │ ├── PythonFileImporter.cs │ │ │ └── PythonFileImporter.cs.meta │ │ ├── Importers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── MCPForUnityBridge.cs │ │ ├── MCPForUnityBridge.cs.meta │ │ ├── Models │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpStatus.cs │ │ │ ├── McpStatus.cs.meta │ │ │ ├── McpTypes.cs │ │ │ ├── McpTypes.cs.meta │ │ │ ├── ServerConfig.cs │ │ │ └── ServerConfig.cs.meta │ │ ├── Models.meta │ │ ├── Resources │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Tests │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPythonToolRegistryService.cs │ │ │ ├── IPythonToolRegistryService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolSyncService.cs │ │ │ ├── IToolSyncService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PythonToolRegistryService.cs │ │ │ ├── PythonToolRegistryService.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolSyncService.cs │ │ │ └── ToolSyncService.cs.meta │ │ ├── Services.meta │ │ ├── Setup │ │ │ ├── SetupWizard.cs │ │ │ ├── SetupWizard.cs.meta │ │ │ ├── SetupWizardWindow.cs │ │ │ └── SetupWizardWindow.cs.meta │ │ ├── Setup.meta │ │ ├── Tools │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageGameObject.cs │ │ │ ├── ManageGameObject.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RunTests.cs │ │ │ └── RunTests.cs.meta │ │ ├── Tools.meta │ │ ├── Windows │ │ │ ├── ManualConfigEditorWindow.cs │ │ │ ├── ManualConfigEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.cs │ │ │ ├── MCPForUnityEditorWindowNew.cs.meta │ │ │ ├── MCPForUnityEditorWindowNew.uss │ │ │ ├── MCPForUnityEditorWindowNew.uss.meta │ │ │ ├── MCPForUnityEditorWindowNew.uxml │ │ │ ├── MCPForUnityEditorWindowNew.uxml.meta │ │ │ ├── VSCodeManualSetupWindow.cs │ │ │ └── VSCodeManualSetupWindow.cs.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── package.json │ ├── package.json.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ └── UnityMcpServer~ │ └── src │ ├── __init__.py │ ├── config.py │ ├── Dockerfile │ ├── models.py │ ├── module_discovery.py │ ├── port_discovery.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── registry │ │ ├── __init__.py │ │ ├── resource_registry.py │ │ └── tool_registry.py │ ├── reload_sentinel.py │ ├── resources │ │ ├── __init__.py │ │ ├── menu_items.py │ │ └── tests.py │ ├── server_version.txt │ ├── server.py │ ├── telemetry_decorator.py │ ├── telemetry.py │ ├── test_telemetry.py │ ├── tools │ │ ├── __init__.py │ │ ├── execute_menu_item.py │ │ ├── manage_asset.py │ │ ├── manage_editor.py │ │ ├── manage_gameobject.py │ │ ├── manage_prefabs.py │ │ ├── manage_scene.py │ │ ├── manage_script.py │ │ ├── manage_shader.py │ │ ├── read_console.py │ │ ├── resource_tools.py │ │ ├── run_tests.py │ │ └── script_apply_edits.py │ ├── unity_connection.py │ └── uv.lock ├── prune_tool_results.py ├── README-zh.md ├── README.md ├── restore-dev.bat ├── scripts │ └── validate-nlt-coverage.sh ├── test_unity_socket_framing.py ├── TestProjects │ └── UnityMCPTests │ ├── .gitignore │ ├── Assets │ │ ├── Editor.meta │ │ ├── Scenes │ │ │ ├── SampleScene.unity │ │ │ └── SampleScene.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ └── TestAsmdef.asmdef.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Tests │ │ │ ├── EditMode │ │ │ │ ├── Data │ │ │ │ │ ├── PythonToolsAssetTests.cs │ │ │ │ │ └── PythonToolsAssetTests.cs.meta │ │ │ │ ├── Data.meta │ │ │ │ ├── Helpers │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs │ │ │ │ │ ├── PythonToolRegistryServiceTests.cs.meta │ │ │ │ │ ├── ToolSyncServiceTests.cs │ │ │ │ │ └── ToolSyncServiceTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── Tools │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManagePrefabsTests.cs │ │ │ │ │ ├── ManagePrefabsTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ └── ManageScriptValidationTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows │ │ │ │ │ ├── ManualConfigJsonBuilderTests.cs │ │ │ │ │ └── ManualConfigJsonBuilderTests.cs.meta │ │ │ │ └── Windows.meta │ │ │ └── EditMode.meta │ │ └── Tests.meta │ ├── Packages │ │ └── manifest.json │ └── ProjectSettings │ ├── Packages │ │ └── com.unity.testtools.codecoverage │ │ └── Settings.json │ └── ProjectVersion.txt ├── tests │ ├── conftest.py │ ├── test_edit_normalization_and_noop.py │ ├── test_edit_strict_and_warnings.py │ ├── test_find_in_file_minimal.py │ ├── test_get_sha.py │ ├── test_improved_anchor_matching.py │ ├── test_logging_stdout.py │ ├── test_manage_script_uri.py │ ├── test_read_console_truncate.py │ ├── test_read_resource_minimal.py │ ├── test_resources_api.py │ ├── test_script_editing.py │ ├── test_script_tools.py │ ├── test_telemetry_endpoint_validation.py │ ├── test_telemetry_queue_worker.py │ ├── test_telemetry_subaction.py │ ├── test_transport_framing.py │ └── test_validate_script_summary.py ├── tools │ └── stress_mcp.py └── UnityMcpBridge ├── Editor │ ├── AssemblyInfo.cs │ ├── AssemblyInfo.cs.meta │ ├── Data │ │ ├── DefaultServerConfig.cs │ │ ├── DefaultServerConfig.cs.meta │ │ ├── McpClients.cs │ │ └── McpClients.cs.meta │ ├── Data.meta │ ├── Dependencies │ │ ├── DependencyManager.cs │ │ ├── DependencyManager.cs.meta │ │ ├── Models │ │ │ ├── DependencyCheckResult.cs │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ ├── DependencyStatus.cs │ │ │ └── DependencyStatus.cs.meta │ │ ├── Models.meta │ │ ├── PlatformDetectors │ │ │ ├── IPlatformDetector.cs │ │ │ ├── IPlatformDetector.cs.meta │ │ │ ├── LinuxPlatformDetector.cs │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ ├── MacOSPlatformDetector.cs │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ ├── PlatformDetectorBase.cs │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ ├── WindowsPlatformDetector.cs │ │ │ └── WindowsPlatformDetector.cs.meta │ │ └── PlatformDetectors.meta │ ├── Dependencies.meta │ ├── External │ │ ├── Tommy.cs │ │ └── Tommy.cs.meta │ ├── External.meta │ ├── Helpers │ │ ├── AssetPathUtility.cs │ │ ├── AssetPathUtility.cs.meta │ │ ├── CodexConfigHelper.cs │ │ ├── CodexConfigHelper.cs.meta │ │ ├── ConfigJsonBuilder.cs │ │ ├── ConfigJsonBuilder.cs.meta │ │ ├── ExecPath.cs │ │ ├── ExecPath.cs.meta │ │ ├── GameObjectSerializer.cs │ │ ├── GameObjectSerializer.cs.meta │ │ ├── McpConfigFileHelper.cs │ │ ├── McpConfigFileHelper.cs.meta │ │ ├── McpConfigurationHelper.cs │ │ ├── McpConfigurationHelper.cs.meta │ │ ├── McpLog.cs │ │ ├── McpLog.cs.meta │ │ ├── McpPathResolver.cs │ │ ├── McpPathResolver.cs.meta │ │ ├── PackageDetector.cs │ │ ├── PackageDetector.cs.meta │ │ ├── PackageInstaller.cs │ │ ├── PackageInstaller.cs.meta │ │ ├── PortManager.cs │ │ ├── PortManager.cs.meta │ │ ├── Response.cs │ │ ├── Response.cs.meta │ │ ├── ServerInstaller.cs │ │ ├── ServerInstaller.cs.meta │ │ ├── ServerPathResolver.cs │ │ ├── ServerPathResolver.cs.meta │ │ ├── TelemetryHelper.cs │ │ ├── TelemetryHelper.cs.meta │ │ ├── Vector3Helper.cs │ │ └── Vector3Helper.cs.meta │ ├── Helpers.meta │ ├── MCPForUnity.Editor.asmdef │ ├── MCPForUnity.Editor.asmdef.meta │ ├── MCPForUnityBridge.cs │ ├── MCPForUnityBridge.cs.meta │ ├── Models │ │ ├── Command.cs │ │ ├── Command.cs.meta │ │ ├── McpClient.cs │ │ ├── McpClient.cs.meta │ │ ├── McpConfig.cs │ │ ├── McpConfig.cs.meta │ │ ├── MCPConfigServer.cs │ │ ├── MCPConfigServer.cs.meta │ │ ├── MCPConfigServers.cs │ │ ├── MCPConfigServers.cs.meta │ │ ├── McpStatus.cs │ │ ├── McpStatus.cs.meta │ │ ├── McpTypes.cs │ │ ├── McpTypes.cs.meta │ │ ├── ServerConfig.cs │ │ └── ServerConfig.cs.meta │ ├── Models.meta │ ├── Setup │ │ ├── SetupWizard.cs │ │ ├── SetupWizard.cs.meta │ │ ├── SetupWizardWindow.cs │ │ └── SetupWizardWindow.cs.meta │ ├── Setup.meta │ ├── Tools │ │ ├── CommandRegistry.cs │ │ ├── CommandRegistry.cs.meta │ │ ├── ManageAsset.cs │ │ ├── ManageAsset.cs.meta │ │ ├── ManageEditor.cs │ │ ├── ManageEditor.cs.meta │ │ ├── ManageGameObject.cs │ │ ├── ManageGameObject.cs.meta │ │ ├── ManageScene.cs │ │ ├── ManageScene.cs.meta │ │ ├── ManageScript.cs │ │ ├── ManageScript.cs.meta │ │ ├── ManageShader.cs │ │ ├── ManageShader.cs.meta │ │ ├── McpForUnityToolAttribute.cs │ │ ├── McpForUnityToolAttribute.cs.meta │ │ ├── MenuItems │ │ │ ├── ManageMenuItem.cs │ │ │ ├── ManageMenuItem.cs.meta │ │ │ ├── MenuItemExecutor.cs │ │ │ ├── MenuItemExecutor.cs.meta │ │ │ ├── MenuItemsReader.cs │ │ │ └── MenuItemsReader.cs.meta │ │ ├── MenuItems.meta │ │ ├── Prefabs │ │ │ ├── ManagePrefabs.cs │ │ │ └── ManagePrefabs.cs.meta │ │ ├── Prefabs.meta │ │ ├── ReadConsole.cs │ │ └── ReadConsole.cs.meta │ ├── Tools.meta │ ├── Windows │ │ ├── ManualConfigEditorWindow.cs │ │ ├── ManualConfigEditorWindow.cs.meta │ │ ├── MCPForUnityEditorWindow.cs │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ ├── VSCodeManualSetupWindow.cs │ │ └── VSCodeManualSetupWindow.cs.meta │ └── Windows.meta ├── Editor.meta ├── package.json ├── package.json.meta ├── README.md ├── README.md.meta ├── Runtime │ ├── MCPForUnity.Runtime.asmdef │ ├── MCPForUnity.Runtime.asmdef.meta │ ├── Serialization │ │ ├── UnityTypeConverters.cs │ │ └── UnityTypeConverters.cs.meta │ └── Serialization.meta ├── Runtime.meta └── UnityMcpServer~ └── src ├── __init__.py ├── config.py ├── Dockerfile ├── port_discovery.py ├── pyproject.toml ├── pyrightconfig.json ├── registry │ ├── __init__.py │ └── tool_registry.py ├── reload_sentinel.py ├── server_version.txt ├── server.py ├── telemetry_decorator.py ├── telemetry.py ├── test_telemetry.py ├── tools │ ├── __init__.py │ ├── manage_asset.py │ ├── manage_editor.py │ ├── manage_gameobject.py │ ├── manage_menu_item.py │ ├── manage_prefabs.py │ ├── manage_scene.py │ ├── manage_script.py │ ├── manage_shader.py │ ├── read_console.py │ ├── resource_tools.py │ └── script_apply_edits.py ├── unity_connection.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from unity_connection import send_command_with_retry 6 | 7 | 8 | @mcp_for_unity_tool( 9 | description="Bridge for prefab management commands (stage control and creation)." 10 | ) 11 | def manage_prefabs( 12 | ctx: Context, 13 | action: Annotated[Literal[ 14 | "open_stage", 15 | "close_stage", 16 | "save_open_stage", 17 | "create_from_gameobject", 18 | ], "Manage prefabs (stage control and creation)."], 19 | prefab_path: Annotated[str, 20 | "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, 21 | mode: Annotated[str, 22 | "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, 23 | save_before_close: Annotated[bool, 24 | "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, 25 | target: Annotated[str, 26 | "Scene GameObject name required for create_from_gameobject"] | None = None, 27 | allow_overwrite: Annotated[bool, 28 | "Allow replacing an existing prefab at the same path"] | None = None, 29 | search_inactive: Annotated[bool, 30 | "Include inactive objects when resolving the target name"] | None = None, 31 | ) -> dict[str, Any]: 32 | ctx.info(f"Processing manage_prefabs: {action}") 33 | try: 34 | params: dict[str, Any] = {"action": action} 35 | 36 | if prefab_path: 37 | params["prefabPath"] = prefab_path 38 | if mode: 39 | params["mode"] = mode 40 | if save_before_close is not None: 41 | params["saveBeforeClose"] = bool(save_before_close) 42 | if target: 43 | params["target"] = target 44 | if allow_overwrite is not None: 45 | params["allowOverwrite"] = bool(allow_overwrite) 46 | if search_inactive is not None: 47 | params["searchInactive"] = bool(search_inactive) 48 | response = send_command_with_retry("manage_prefabs", params) 49 | 50 | if isinstance(response, dict) and response.get("success"): 51 | return { 52 | "success": True, 53 | "message": response.get("message", "Prefab operation successful."), 54 | "data": response.get("data"), 55 | } 56 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 57 | except Exception as exc: 58 | return {"success": False, "message": f"Python error managing prefabs: {exc}"} 59 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from unity_connection import send_command_with_retry 6 | 7 | 8 | @mcp_for_unity_tool( 9 | description="Bridge for prefab management commands (stage control and creation)." 10 | ) 11 | def manage_prefabs( 12 | ctx: Context, 13 | action: Annotated[Literal[ 14 | "open_stage", 15 | "close_stage", 16 | "save_open_stage", 17 | "create_from_gameobject", 18 | ], "Manage prefabs (stage control and creation)."], 19 | prefab_path: Annotated[str, 20 | "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, 21 | mode: Annotated[str, 22 | "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, 23 | save_before_close: Annotated[bool, 24 | "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, 25 | target: Annotated[str, 26 | "Scene GameObject name required for create_from_gameobject"] | None = None, 27 | allow_overwrite: Annotated[bool, 28 | "Allow replacing an existing prefab at the same path"] | None = None, 29 | search_inactive: Annotated[bool, 30 | "Include inactive objects when resolving the target name"] | None = None, 31 | ) -> dict[str, Any]: 32 | ctx.info(f"Processing manage_prefabs: {action}") 33 | try: 34 | params: dict[str, Any] = {"action": action} 35 | 36 | if prefab_path: 37 | params["prefabPath"] = prefab_path 38 | if mode: 39 | params["mode"] = mode 40 | if save_before_close is not None: 41 | params["saveBeforeClose"] = bool(save_before_close) 42 | if target: 43 | params["target"] = target 44 | if allow_overwrite is not None: 45 | params["allowOverwrite"] = bool(allow_overwrite) 46 | if search_inactive is not None: 47 | params["searchInactive"] = bool(search_inactive) 48 | response = send_command_with_retry("manage_prefabs", params) 49 | 50 | if isinstance(response, dict) and response.get("success"): 51 | return { 52 | "success": True, 53 | "message": response.get("message", "Prefab operation successful."), 54 | "data": response.get("data"), 55 | } 56 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 57 | except Exception as exc: 58 | return {"success": False, "message": f"Python error managing prefabs: {exc}"} 59 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py: -------------------------------------------------------------------------------- ```python 1 | import base64 2 | from typing import Annotated, Any, Literal 3 | 4 | from mcp.server.fastmcp import Context 5 | from registry import mcp_for_unity_tool 6 | from unity_connection import send_command_with_retry 7 | 8 | 9 | @mcp_for_unity_tool( 10 | description="Manages shader scripts in Unity (create, read, update, delete)." 11 | ) 12 | def manage_shader( 13 | ctx: Context, 14 | action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], 15 | name: Annotated[str, "Shader name (no .cs extension)"], 16 | path: Annotated[str, "Asset path (default: \"Assets/\")"], 17 | contents: Annotated[str, 18 | "Shader code for 'create'/'update'"] | None = None, 19 | ) -> dict[str, Any]: 20 | ctx.info(f"Processing manage_shader: {action}") 21 | try: 22 | # Prepare parameters for Unity 23 | params = { 24 | "action": action, 25 | "name": name, 26 | "path": path, 27 | } 28 | 29 | # Base64 encode the contents if they exist to avoid JSON escaping issues 30 | if contents is not None: 31 | if action in ['create', 'update']: 32 | # Encode content for safer transmission 33 | params["encodedContents"] = base64.b64encode( 34 | contents.encode('utf-8')).decode('utf-8') 35 | params["contentsEncoded"] = True 36 | else: 37 | params["contents"] = contents 38 | 39 | # Remove None values so they don't get sent as null 40 | params = {k: v for k, v in params.items() if v is not None} 41 | 42 | # Send command via centralized retry helper 43 | response = send_command_with_retry("manage_shader", params) 44 | 45 | # Process response from Unity 46 | if isinstance(response, dict) and response.get("success"): 47 | # If the response contains base64 encoded content, decode it 48 | if response.get("data", {}).get("contentsEncoded"): 49 | decoded_contents = base64.b64decode( 50 | response["data"]["encodedContents"]).decode('utf-8') 51 | response["data"]["contents"] = decoded_contents 52 | del response["data"]["encodedContents"] 53 | del response["data"]["contentsEncoded"] 54 | 55 | return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} 56 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 57 | 58 | except Exception as e: 59 | # Handle Python-side errors (e.g., connection issues) 60 | return {"success": False, "message": f"Python error managing shader: {str(e)}"} 61 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py: -------------------------------------------------------------------------------- ```python 1 | import base64 2 | from typing import Annotated, Any, Literal 3 | 4 | from mcp.server.fastmcp import Context 5 | from registry import mcp_for_unity_tool 6 | from unity_connection import send_command_with_retry 7 | 8 | 9 | @mcp_for_unity_tool( 10 | description="Manages shader scripts in Unity (create, read, update, delete)." 11 | ) 12 | def manage_shader( 13 | ctx: Context, 14 | action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], 15 | name: Annotated[str, "Shader name (no .cs extension)"], 16 | path: Annotated[str, "Asset path (default: \"Assets/\")"], 17 | contents: Annotated[str, 18 | "Shader code for 'create'/'update'"] | None = None, 19 | ) -> dict[str, Any]: 20 | ctx.info(f"Processing manage_shader: {action}") 21 | try: 22 | # Prepare parameters for Unity 23 | params = { 24 | "action": action, 25 | "name": name, 26 | "path": path, 27 | } 28 | 29 | # Base64 encode the contents if they exist to avoid JSON escaping issues 30 | if contents is not None: 31 | if action in ['create', 'update']: 32 | # Encode content for safer transmission 33 | params["encodedContents"] = base64.b64encode( 34 | contents.encode('utf-8')).decode('utf-8') 35 | params["contentsEncoded"] = True 36 | else: 37 | params["contents"] = contents 38 | 39 | # Remove None values so they don't get sent as null 40 | params = {k: v for k, v in params.items() if v is not None} 41 | 42 | # Send command via centralized retry helper 43 | response = send_command_with_retry("manage_shader", params) 44 | 45 | # Process response from Unity 46 | if isinstance(response, dict) and response.get("success"): 47 | # If the response contains base64 encoded content, decode it 48 | if response.get("data", {}).get("contentsEncoded"): 49 | decoded_contents = base64.b64decode( 50 | response["data"]["encodedContents"]).decode('utf-8') 51 | response["data"]["contents"] = decoded_contents 52 | del response["data"]["encodedContents"] 53 | del response["data"]["contentsEncoded"] 54 | 55 | return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} 56 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 57 | 58 | except Exception as e: 59 | # Handle Python-side errors (e.g., connection issues) 60 | return {"success": False, "message": f"Python error managing shader: {str(e)}"} 61 | ``` -------------------------------------------------------------------------------- /tests/test_logging_stdout.py: -------------------------------------------------------------------------------- ```python 1 | import ast 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | 7 | # locate server src dynamically to avoid hardcoded layout assumptions 8 | ROOT = Path(__file__).resolve().parents[1] 9 | candidates = [ 10 | ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", 11 | ROOT / "UnityMcpServer~" / "src", 12 | ] 13 | SRC = next((p for p in candidates if p.exists()), None) 14 | if SRC is None: 15 | searched = "\n".join(str(p) for p in candidates) 16 | pytest.skip( 17 | "MCP for Unity server source not found. Tried:\n" + searched, 18 | allow_module_level=True, 19 | ) 20 | 21 | 22 | @pytest.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file") 23 | def test_no_stdout_output_from_tools(): 24 | pass 25 | 26 | 27 | def test_no_print_statements_in_codebase(): 28 | """Ensure no stray print/sys.stdout writes remain in server source.""" 29 | offenders = [] 30 | syntax_errors = [] 31 | for py_file in SRC.rglob("*.py"): 32 | # Skip virtual envs and third-party packages if they exist under SRC 33 | parts = set(py_file.parts) 34 | if ".venv" in parts or "site-packages" in parts: 35 | continue 36 | try: 37 | text = py_file.read_text(encoding="utf-8", errors="strict") 38 | except UnicodeDecodeError: 39 | # Be tolerant of encoding edge cases in source tree without silently dropping bytes 40 | text = py_file.read_text(encoding="utf-8", errors="replace") 41 | try: 42 | tree = ast.parse(text, filename=str(py_file)) 43 | except SyntaxError: 44 | syntax_errors.append(py_file.relative_to(SRC)) 45 | continue 46 | 47 | class StdoutVisitor(ast.NodeVisitor): 48 | def __init__(self): 49 | self.hit = False 50 | 51 | def visit_Call(self, node: ast.Call): 52 | # print(...) 53 | if isinstance(node.func, ast.Name) and node.func.id == "print": 54 | self.hit = True 55 | # sys.stdout.write(...) 56 | if isinstance(node.func, ast.Attribute) and node.func.attr == "write": 57 | val = node.func.value 58 | if isinstance(val, ast.Attribute) and val.attr == "stdout": 59 | if isinstance(val.value, ast.Name) and val.value.id == "sys": 60 | self.hit = True 61 | self.generic_visit(node) 62 | 63 | v = StdoutVisitor() 64 | v.visit(tree) 65 | if v.hit: 66 | offenders.append(py_file.relative_to(SRC)) 67 | assert not syntax_errors, "syntax errors in: " + \ 68 | ", ".join(str(e) for e in syntax_errors) 69 | assert not offenders, "stdout writes found in: " + \ 70 | ", ".join(str(o) for o in offenders) 71 | ``` -------------------------------------------------------------------------------- /tests/test_telemetry_queue_worker.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import pathlib 3 | import importlib.util 4 | import types 5 | import threading 6 | import time 7 | import queue as q 8 | 9 | 10 | ROOT = pathlib.Path(__file__).resolve().parents[1] 11 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 12 | sys.path.insert(0, str(SRC)) 13 | 14 | # Stub mcp.server.fastmcp to satisfy imports without the full dependency 15 | mcp_pkg = types.ModuleType("mcp") 16 | server_pkg = types.ModuleType("mcp.server") 17 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 18 | 19 | 20 | class _Dummy: 21 | pass 22 | 23 | 24 | fastmcp_pkg.FastMCP = _Dummy 25 | fastmcp_pkg.Context = _Dummy 26 | server_pkg.fastmcp = fastmcp_pkg 27 | mcp_pkg.server = server_pkg 28 | sys.modules.setdefault("mcp", mcp_pkg) 29 | sys.modules.setdefault("mcp.server", server_pkg) 30 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 31 | 32 | 33 | def _load_module(path: pathlib.Path, name: str): 34 | spec = importlib.util.spec_from_file_location(name, path) 35 | mod = importlib.util.module_from_spec(spec) 36 | spec.loader.exec_module(mod) 37 | return mod 38 | 39 | 40 | telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod") 41 | 42 | 43 | def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog): 44 | caplog.set_level("DEBUG") 45 | 46 | collector = telemetry.TelemetryCollector() 47 | # Force-enable telemetry regardless of env settings from conftest 48 | collector.config.enabled = True 49 | 50 | # Wake existing worker once so it observes the new queue on the next loop 51 | collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": -1}) 52 | # Replace queue with tiny one to trigger backpressure quickly 53 | small_q = q.Queue(maxsize=2) 54 | collector._queue = small_q 55 | # Give the worker a moment to switch queues 56 | time.sleep(0.02) 57 | 58 | # Make sends slow to build backlog and exercise worker 59 | def slow_send(self, rec): 60 | time.sleep(0.05) 61 | 62 | collector._send_telemetry = types.MethodType(slow_send, collector) 63 | 64 | # Fire many events quickly; record() should not block even when queue fills 65 | start = time.perf_counter() 66 | for i in range(50): 67 | collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": i}) 68 | elapsed_ms = (time.perf_counter() - start) * 1000.0 69 | 70 | # Should be fast despite backpressure (non-blocking enqueue or drop) 71 | assert elapsed_ms < 80.0 72 | 73 | # Allow worker to process some 74 | time.sleep(0.3) 75 | 76 | # Verify drops were logged (queue full backpressure) 77 | dropped_logs = [ 78 | m for m in caplog.messages if "Telemetry queue full; dropping" in m] 79 | assert len(dropped_logs) >= 1 80 | 81 | # Ensure only one worker thread exists and is alive 82 | assert collector._worker.is_alive() 83 | worker_threads = [ 84 | t for t in threading.enumerate() if t is collector._worker] 85 | assert len(worker_threads) == 1 86 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/ManualConfigJsonBuilderTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using Newtonsoft.Json.Linq; 2 | using NUnit.Framework; 3 | using MCPForUnity.Editor.Helpers; 4 | using MCPForUnity.Editor.Models; 5 | 6 | namespace MCPForUnityTests.Editor.Windows 7 | { 8 | public class ManualConfigJsonBuilderTests 9 | { 10 | [Test] 11 | public void VSCode_ManualJson_HasServers_NoEnv_NoDisabled() 12 | { 13 | var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; 14 | string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); 15 | 16 | var root = JObject.Parse(json); 17 | var unity = (JObject)root.SelectToken("servers.unityMCP"); 18 | Assert.NotNull(unity, "Expected servers.unityMCP node"); 19 | Assert.AreEqual("/usr/bin/uv", (string)unity["command"]); 20 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/path/to/server", "server.py" }, unity["args"].ToObject<string[]>()); 21 | Assert.AreEqual("stdio", (string)unity["type"], "VSCode should include type=stdio"); 22 | Assert.IsNull(unity["env"], "env should not be added for VSCode"); 23 | Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode"); 24 | } 25 | 26 | [Test] 27 | public void Windsurf_ManualJson_HasMcpServersEnv_DisabledFalse() 28 | { 29 | var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; 30 | string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); 31 | 32 | var root = JObject.Parse(json); 33 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 34 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 35 | Assert.NotNull(unity["env"], "env should be included"); 36 | Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be added for Windsurf"); 37 | Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients"); 38 | } 39 | 40 | [Test] 41 | public void Cursor_ManualJson_HasMcpServers_NoEnv_NoDisabled() 42 | { 43 | var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; 44 | string json = ConfigJsonBuilder.BuildManualConfigJson("/usr/bin/uv", "/path/to/server", client); 45 | 46 | var root = JObject.Parse(json); 47 | var unity = (JObject)root.SelectToken("mcpServers.unityMCP"); 48 | Assert.NotNull(unity, "Expected mcpServers.unityMCP node"); 49 | Assert.IsNull(unity["env"], "env should not be added for Cursor"); 50 | Assert.IsNull(unity["disabled"], "disabled should not be added for Cursor"); 51 | Assert.IsNull(unity["type"], "type should not be added for non-VSCode clients"); 52 | } 53 | } 54 | } 55 | ``` -------------------------------------------------------------------------------- /tests/test_resources_api.py: -------------------------------------------------------------------------------- ```python 1 | from tools.resource_tools import register_resource_tools # type: ignore 2 | import pytest 3 | 4 | 5 | import sys 6 | from pathlib import Path 7 | import pytest 8 | import types 9 | 10 | # locate server src dynamically to avoid hardcoded layout assumptions 11 | ROOT = Path(__file__).resolve().parents[1] 12 | candidates = [ 13 | ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", 14 | ROOT / "UnityMcpServer~" / "src", 15 | ] 16 | SRC = next((p for p in candidates if p.exists()), None) 17 | if SRC is None: 18 | searched = "\n".join(str(p) for p in candidates) 19 | pytest.skip( 20 | "MCP for Unity server source not found. Tried:\n" + searched, 21 | allow_module_level=True, 22 | ) 23 | sys.path.insert(0, str(SRC)) 24 | 25 | 26 | class DummyMCP: 27 | def __init__(self): 28 | self._tools = {} 29 | 30 | def tool(self, *args, **kwargs): # accept kwargs like description 31 | def deco(fn): 32 | self._tools[fn.__name__] = fn 33 | return fn 34 | return deco 35 | 36 | 37 | @pytest.fixture() 38 | def resource_tools(): 39 | mcp = DummyMCP() 40 | register_resource_tools(mcp) 41 | return mcp._tools 42 | 43 | 44 | def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, monkeypatch): 45 | # Create fake project structure 46 | proj = tmp_path 47 | assets = proj / "Assets" / "Scripts" 48 | assets.mkdir(parents=True) 49 | (assets / "A.cs").write_text("// a", encoding="utf-8") 50 | (assets / "B.txt").write_text("b", encoding="utf-8") 51 | outside = tmp_path / "Outside.cs" 52 | outside.write_text("// outside", encoding="utf-8") 53 | # Symlink attempting to escape 54 | sneaky_link = assets / "link_out" 55 | try: 56 | sneaky_link.symlink_to(outside) 57 | except Exception: 58 | # Some platforms may not allow symlinks in tests; ignore 59 | pass 60 | 61 | list_resources = resource_tools["list_resources"] 62 | # Only .cs under Assets should be listed 63 | import asyncio 64 | resp = asyncio.get_event_loop().run_until_complete( 65 | list_resources(ctx=None, pattern="*.cs", under="Assets", 66 | limit=50, project_root=str(proj)) 67 | ) 68 | assert resp["success"] is True 69 | uris = resp["data"]["uris"] 70 | assert any(u.endswith("Assets/Scripts/A.cs") for u in uris) 71 | assert not any(u.endswith("B.txt") for u in uris) 72 | assert not any(u.endswith("Outside.cs") for u in uris) 73 | 74 | 75 | def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): 76 | proj = tmp_path 77 | # under points outside Assets 78 | list_resources = resource_tools["list_resources"] 79 | import asyncio 80 | resp = asyncio.get_event_loop().run_until_complete( 81 | list_resources(ctx=None, pattern="*.cs", under="..", 82 | limit=10, project_root=str(proj)) 83 | ) 84 | assert resp["success"] is False 85 | assert "Assets" in resp.get( 86 | "error", "") or "under project root" in resp.get("error", "") 87 | ``` -------------------------------------------------------------------------------- /tests/test_telemetry_subaction.py: -------------------------------------------------------------------------------- ```python 1 | import importlib 2 | 3 | 4 | def _get_decorator_module(): 5 | # Import the telemetry_decorator module from the MCP for Unity server src 6 | mod = importlib.import_module( 7 | "MCPForUnity.UnityMcpServer~.src.telemetry_decorator") 8 | return mod 9 | 10 | 11 | def test_subaction_extracted_from_keyword(monkeypatch): 12 | td = _get_decorator_module() 13 | 14 | captured = {} 15 | 16 | def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None): 17 | captured["tool_name"] = tool_name 18 | captured["success"] = success 19 | captured["error"] = error 20 | captured["sub_action"] = sub_action 21 | 22 | # Silence milestones/logging in test 23 | monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage) 24 | monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None) 25 | monkeypatch.setattr(td, "_decorator_log_count", 999) 26 | 27 | def dummy_tool(ctx, action: str, name: str = ""): 28 | return {"success": True, "name": name} 29 | 30 | wrapped = td.telemetry_tool("manage_scene")(dummy_tool) 31 | 32 | resp = wrapped(None, action="get_hierarchy", name="Sample") 33 | assert resp["success"] is True 34 | assert captured["tool_name"] == "manage_scene" 35 | assert captured["success"] is True 36 | assert captured["error"] is None 37 | assert captured["sub_action"] == "get_hierarchy" 38 | 39 | 40 | def test_subaction_extracted_from_positionals(monkeypatch): 41 | td = _get_decorator_module() 42 | 43 | captured = {} 44 | 45 | def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None): 46 | captured["tool_name"] = tool_name 47 | captured["sub_action"] = sub_action 48 | 49 | monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage) 50 | monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None) 51 | monkeypatch.setattr(td, "_decorator_log_count", 999) 52 | 53 | def dummy_tool(ctx, action: str, name: str = ""): 54 | return True 55 | 56 | wrapped = td.telemetry_tool("manage_scene")(dummy_tool) 57 | 58 | _ = wrapped(None, "save", "MyScene") 59 | assert captured["tool_name"] == "manage_scene" 60 | assert captured["sub_action"] == "save" 61 | 62 | 63 | def test_subaction_none_when_not_present(monkeypatch): 64 | td = _get_decorator_module() 65 | 66 | captured = {} 67 | 68 | def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None): 69 | captured["tool_name"] = tool_name 70 | captured["sub_action"] = sub_action 71 | 72 | monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage) 73 | monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None) 74 | monkeypatch.setattr(td, "_decorator_log_count", 999) 75 | 76 | def dummy_tool_without_action(ctx, name: str): 77 | return 123 78 | 79 | wrapped = td.telemetry_tool("apply_text_edits")(dummy_tool_without_action) 80 | _ = wrapped(None, name="X") 81 | assert captured["tool_name"] == "apply_text_edits" 82 | assert captured["sub_action"] is None 83 | ``` -------------------------------------------------------------------------------- /tests/test_edit_strict_and_warnings.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import pathlib 3 | import importlib.util 4 | import types 5 | 6 | 7 | ROOT = pathlib.Path(__file__).resolve().parents[1] 8 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 9 | sys.path.insert(0, str(SRC)) 10 | 11 | # stub mcp.server.fastmcp 12 | mcp_pkg = types.ModuleType("mcp") 13 | server_pkg = types.ModuleType("mcp.server") 14 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 15 | 16 | 17 | class _Dummy: 18 | pass 19 | 20 | 21 | fastmcp_pkg.FastMCP = _Dummy 22 | fastmcp_pkg.Context = _Dummy 23 | server_pkg.fastmcp = fastmcp_pkg 24 | mcp_pkg.server = server_pkg 25 | sys.modules.setdefault("mcp", mcp_pkg) 26 | sys.modules.setdefault("mcp.server", server_pkg) 27 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 28 | 29 | 30 | def _load(path: pathlib.Path, name: str): 31 | spec = importlib.util.spec_from_file_location(name, path) 32 | mod = importlib.util.module_from_spec(spec) 33 | spec.loader.exec_module(mod) 34 | return mod 35 | 36 | 37 | manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3") 38 | 39 | 40 | class DummyMCP: 41 | def __init__(self): self.tools = {} 42 | 43 | def tool(self, *args, **kwargs): 44 | def deco(fn): self.tools[fn.__name__] = fn; return fn 45 | return deco 46 | 47 | 48 | def setup_tools(): 49 | mcp = DummyMCP() 50 | manage_script.register_manage_script_tools(mcp) 51 | return mcp.tools 52 | 53 | 54 | def test_explicit_zero_based_normalized_warning(monkeypatch): 55 | tools = setup_tools() 56 | apply_edits = tools["apply_text_edits"] 57 | 58 | def fake_send(cmd, params): 59 | # Simulate Unity path returning minimal success 60 | return {"success": True} 61 | 62 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 63 | 64 | # Explicit fields given as 0-based (invalid); SDK should normalize and warn 65 | edits = [{"startLine": 0, "startCol": 0, 66 | "endLine": 0, "endCol": 0, "newText": "//x"}] 67 | resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", 68 | edits=edits, precondition_sha256="sha") 69 | 70 | assert resp["success"] is True 71 | data = resp.get("data", {}) 72 | assert "normalizedEdits" in data 73 | assert any( 74 | w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", [])) 75 | ne = data["normalizedEdits"][0] 76 | assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1 77 | 78 | 79 | def test_strict_zero_based_error(monkeypatch): 80 | tools = setup_tools() 81 | apply_edits = tools["apply_text_edits"] 82 | 83 | def fake_send(cmd, params): 84 | return {"success": True} 85 | 86 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 87 | 88 | edits = [{"startLine": 0, "startCol": 0, 89 | "endLine": 0, "endCol": 0, "newText": "//x"}] 90 | resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", 91 | edits=edits, precondition_sha256="sha", strict=True) 92 | assert resp["success"] is False 93 | assert resp.get("code") == "zero_based_explicit_fields" 94 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from telemetry import is_telemetry_enabled, record_tool_usage 6 | from unity_connection import send_command_with_retry 7 | 8 | 9 | @mcp_for_unity_tool( 10 | description="Controls and queries the Unity editor's state and settings" 11 | ) 12 | def manage_editor( 13 | ctx: Context, 14 | action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", 15 | "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], 16 | wait_for_completion: Annotated[bool, 17 | "Optional. If True, waits for certain actions"] | None = None, 18 | tool_name: Annotated[str, 19 | "Tool name when setting active tool"] | None = None, 20 | tag_name: Annotated[str, 21 | "Tag name when adding and removing tags"] | None = None, 22 | layer_name: Annotated[str, 23 | "Layer name when adding and removing layers"] | None = None, 24 | ) -> dict[str, Any]: 25 | ctx.info(f"Processing manage_editor: {action}") 26 | try: 27 | # Diagnostics: quick telemetry checks 28 | if action == "telemetry_status": 29 | return {"success": True, "telemetry_enabled": is_telemetry_enabled()} 30 | 31 | if action == "telemetry_ping": 32 | record_tool_usage("diagnostic_ping", True, 1.0, None) 33 | return {"success": True, "message": "telemetry ping queued"} 34 | # Prepare parameters, removing None values 35 | params = { 36 | "action": action, 37 | "waitForCompletion": wait_for_completion, 38 | "toolName": tool_name, # Corrected parameter name to match C# 39 | "tagName": tag_name, # Pass tag name 40 | "layerName": layer_name, # Pass layer name 41 | # Add other parameters based on the action being performed 42 | # "width": width, 43 | # "height": height, 44 | # etc. 45 | } 46 | params = {k: v for k, v in params.items() if v is not None} 47 | 48 | # Send command using centralized retry helper 49 | response = send_command_with_retry("manage_editor", params) 50 | 51 | # Preserve structured failure data; unwrap success into a friendlier shape 52 | if isinstance(response, dict) and response.get("success"): 53 | return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} 54 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 55 | 56 | except Exception as e: 57 | return {"success": False, "message": f"Python error managing editor: {str(e)}"} 58 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Annotated, Any, Literal 2 | 3 | from mcp.server.fastmcp import Context 4 | from registry import mcp_for_unity_tool 5 | from telemetry import is_telemetry_enabled, record_tool_usage 6 | from unity_connection import send_command_with_retry 7 | 8 | 9 | @mcp_for_unity_tool( 10 | description="Controls and queries the Unity editor's state and settings" 11 | ) 12 | def manage_editor( 13 | ctx: Context, 14 | action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", 15 | "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], 16 | wait_for_completion: Annotated[bool, 17 | "Optional. If True, waits for certain actions"] | None = None, 18 | tool_name: Annotated[str, 19 | "Tool name when setting active tool"] | None = None, 20 | tag_name: Annotated[str, 21 | "Tag name when adding and removing tags"] | None = None, 22 | layer_name: Annotated[str, 23 | "Layer name when adding and removing layers"] | None = None, 24 | ) -> dict[str, Any]: 25 | ctx.info(f"Processing manage_editor: {action}") 26 | try: 27 | # Diagnostics: quick telemetry checks 28 | if action == "telemetry_status": 29 | return {"success": True, "telemetry_enabled": is_telemetry_enabled()} 30 | 31 | if action == "telemetry_ping": 32 | record_tool_usage("diagnostic_ping", True, 1.0, None) 33 | return {"success": True, "message": "telemetry ping queued"} 34 | # Prepare parameters, removing None values 35 | params = { 36 | "action": action, 37 | "waitForCompletion": wait_for_completion, 38 | "toolName": tool_name, # Corrected parameter name to match C# 39 | "tagName": tag_name, # Pass tag name 40 | "layerName": layer_name, # Pass layer name 41 | # Add other parameters based on the action being performed 42 | # "width": width, 43 | # "height": height, 44 | # etc. 45 | } 46 | params = {k: v for k, v in params.items() if v is not None} 47 | 48 | # Send command using centralized retry helper 49 | response = send_command_with_retry("manage_editor", params) 50 | 51 | # Preserve structured failure data; unwrap success into a friendlier shape 52 | if isinstance(response, dict) and response.get("success"): 53 | return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} 54 | return response if isinstance(response, dict) else {"success": False, "message": str(response)} 55 | 56 | except Exception as e: 57 | return {"success": False, "message": f"Python error managing editor: {str(e)}"} 58 | ``` -------------------------------------------------------------------------------- /test_unity_socket_framing.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | import socket 3 | import struct 4 | import json 5 | import sys 6 | 7 | HOST = "127.0.0.1" 8 | PORT = 6400 9 | try: 10 | SIZE_MB = int(sys.argv[1]) 11 | except (IndexError, ValueError): 12 | SIZE_MB = 5 # e.g., 5 or 10 13 | FILL = "R" 14 | MAX_FRAME = 64 * 1024 * 1024 15 | 16 | 17 | def recv_exact(sock, n): 18 | buf = bytearray(n) 19 | view = memoryview(buf) 20 | off = 0 21 | while off < n: 22 | r = sock.recv_into(view[off:]) 23 | if r == 0: 24 | raise RuntimeError("socket closed") 25 | off += r 26 | return bytes(buf) 27 | 28 | 29 | def is_valid_json(b): 30 | try: 31 | json.loads(b.decode("utf-8")) 32 | return True 33 | except Exception: 34 | return False 35 | 36 | 37 | def recv_legacy_json(sock, timeout=60): 38 | sock.settimeout(timeout) 39 | chunks = [] 40 | while True: 41 | chunk = sock.recv(65536) 42 | if not chunk: 43 | data = b"".join(chunks) 44 | if not data: 45 | raise RuntimeError("no data, socket closed") 46 | return data 47 | chunks.append(chunk) 48 | data = b"".join(chunks) 49 | if data.strip() == b"ping": 50 | return data 51 | if is_valid_json(data): 52 | return data 53 | 54 | 55 | def main(): 56 | # Cap filler to stay within framing limit (reserve small overhead for JSON) 57 | safe_max = max(1, MAX_FRAME - 4096) 58 | filler_len = min(SIZE_MB * 1024 * 1024, safe_max) 59 | body = { 60 | "type": "read_console", 61 | "params": { 62 | "action": "get", 63 | "types": ["all"], 64 | "count": 1000, 65 | "format": "detailed", 66 | "includeStacktrace": True, 67 | "filterText": FILL * filler_len 68 | } 69 | } 70 | body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") 71 | 72 | with socket.create_connection((HOST, PORT), timeout=5) as s: 73 | s.settimeout(2) 74 | # Read optional greeting 75 | try: 76 | greeting = s.recv(256) 77 | except Exception: 78 | greeting = b"" 79 | greeting_text = greeting.decode("ascii", errors="ignore").strip() 80 | print(f"Greeting: {greeting_text or '(none)'}") 81 | 82 | framing = "FRAMING=1" in greeting_text 83 | print(f"Using framing? {framing}") 84 | 85 | s.settimeout(120) 86 | if framing: 87 | header = struct.pack(">Q", len(body_bytes)) 88 | s.sendall(header + body_bytes) 89 | resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] 90 | print(f"Response framed length: {resp_len}") 91 | MAX_RESP = MAX_FRAME 92 | if resp_len <= 0 or resp_len > MAX_RESP: 93 | raise RuntimeError( 94 | f"invalid framed length: {resp_len} (max {MAX_RESP})") 95 | resp = recv_exact(s, resp_len) 96 | else: 97 | s.sendall(body_bytes) 98 | resp = recv_legacy_json(s) 99 | 100 | print(f"Response bytes: {len(resp)}") 101 | print(f"Response head: {resp[:120].decode('utf-8', 'ignore')}") 102 | 103 | 104 | if __name__ == "__main__": 105 | main() 106 | ``` -------------------------------------------------------------------------------- /tests/test_read_console_truncate.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import pathlib 3 | import importlib.util 4 | import types 5 | 6 | ROOT = pathlib.Path(__file__).resolve().parents[1] 7 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 8 | sys.path.insert(0, str(SRC)) 9 | 10 | # stub mcp.server.fastmcp 11 | mcp_pkg = types.ModuleType("mcp") 12 | server_pkg = types.ModuleType("mcp.server") 13 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 14 | 15 | 16 | class _Dummy: 17 | pass 18 | 19 | 20 | fastmcp_pkg.FastMCP = _Dummy 21 | fastmcp_pkg.Context = _Dummy 22 | server_pkg.fastmcp = fastmcp_pkg 23 | mcp_pkg.server = server_pkg 24 | sys.modules.setdefault("mcp", mcp_pkg) 25 | sys.modules.setdefault("mcp.server", server_pkg) 26 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 27 | 28 | 29 | def _load_module(path: pathlib.Path, name: str): 30 | spec = importlib.util.spec_from_file_location(name, path) 31 | mod = importlib.util.module_from_spec(spec) 32 | spec.loader.exec_module(mod) 33 | return mod 34 | 35 | 36 | read_console_mod = _load_module( 37 | SRC / "tools" / "read_console.py", "read_console_mod") 38 | 39 | 40 | class DummyMCP: 41 | def __init__(self): 42 | self.tools = {} 43 | 44 | def tool(self, *args, **kwargs): 45 | def deco(fn): 46 | self.tools[fn.__name__] = fn 47 | return fn 48 | return deco 49 | 50 | 51 | def setup_tools(): 52 | mcp = DummyMCP() 53 | read_console_mod.register_read_console_tools(mcp) 54 | return mcp.tools 55 | 56 | 57 | def test_read_console_full_default(monkeypatch): 58 | tools = setup_tools() 59 | read_console = tools["read_console"] 60 | 61 | captured = {} 62 | 63 | def fake_send(cmd, params): 64 | captured["params"] = params 65 | return { 66 | "success": True, 67 | "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]}, 68 | } 69 | 70 | monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) 71 | monkeypatch.setattr( 72 | read_console_mod, "get_unity_connection", lambda: object()) 73 | 74 | resp = read_console(ctx=None, count=10) 75 | assert resp == { 76 | "success": True, 77 | "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]}, 78 | } 79 | assert captured["params"]["count"] == 10 80 | assert captured["params"]["includeStacktrace"] is True 81 | 82 | 83 | def test_read_console_truncated(monkeypatch): 84 | tools = setup_tools() 85 | read_console = tools["read_console"] 86 | 87 | captured = {} 88 | 89 | def fake_send(cmd, params): 90 | captured["params"] = params 91 | return { 92 | "success": True, 93 | "data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]}, 94 | } 95 | 96 | monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) 97 | monkeypatch.setattr( 98 | read_console_mod, "get_unity_connection", lambda: object()) 99 | 100 | resp = read_console(ctx=None, count=10, include_stacktrace=False) 101 | assert resp == {"success": True, "data": { 102 | "lines": [{"level": "error", "message": "oops"}]}} 103 | assert captured["params"]["includeStacktrace"] is False 104 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/IPathResolverService.cs: -------------------------------------------------------------------------------- ```csharp 1 | namespace MCPForUnity.Editor.Services 2 | { 3 | /// <summary> 4 | /// Service for resolving paths to required tools and supporting user overrides 5 | /// </summary> 6 | public interface IPathResolverService 7 | { 8 | /// <summary> 9 | /// Gets the MCP server path (respects override if set) 10 | /// </summary> 11 | /// <returns>Path to the MCP server directory containing server.py, or null if not found</returns> 12 | string GetMcpServerPath(); 13 | 14 | /// <summary> 15 | /// Gets the UV package manager path (respects override if set) 16 | /// </summary> 17 | /// <returns>Path to the uv executable, or null if not found</returns> 18 | string GetUvPath(); 19 | 20 | /// <summary> 21 | /// Gets the Claude CLI path (respects override if set) 22 | /// </summary> 23 | /// <returns>Path to the claude executable, or null if not found</returns> 24 | string GetClaudeCliPath(); 25 | 26 | /// <summary> 27 | /// Checks if Python is detected on the system 28 | /// </summary> 29 | /// <returns>True if Python is found</returns> 30 | bool IsPythonDetected(); 31 | 32 | /// <summary> 33 | /// Checks if UV is detected on the system 34 | /// </summary> 35 | /// <returns>True if UV is found</returns> 36 | bool IsUvDetected(); 37 | 38 | /// <summary> 39 | /// Checks if Claude CLI is detected on the system 40 | /// </summary> 41 | /// <returns>True if Claude CLI is found</returns> 42 | bool IsClaudeCliDetected(); 43 | 44 | /// <summary> 45 | /// Sets an override for the MCP server path 46 | /// </summary> 47 | /// <param name="path">Path to override with</param> 48 | void SetMcpServerOverride(string path); 49 | 50 | /// <summary> 51 | /// Sets an override for the UV path 52 | /// </summary> 53 | /// <param name="path">Path to override with</param> 54 | void SetUvPathOverride(string path); 55 | 56 | /// <summary> 57 | /// Sets an override for the Claude CLI path 58 | /// </summary> 59 | /// <param name="path">Path to override with</param> 60 | void SetClaudeCliPathOverride(string path); 61 | 62 | /// <summary> 63 | /// Clears the MCP server path override 64 | /// </summary> 65 | void ClearMcpServerOverride(); 66 | 67 | /// <summary> 68 | /// Clears the UV path override 69 | /// </summary> 70 | void ClearUvPathOverride(); 71 | 72 | /// <summary> 73 | /// Clears the Claude CLI path override 74 | /// </summary> 75 | void ClearClaudeCliPathOverride(); 76 | 77 | /// <summary> 78 | /// Gets whether a MCP server path override is active 79 | /// </summary> 80 | bool HasMcpServerOverride { get; } 81 | 82 | /// <summary> 83 | /// Gets whether a UV path override is active 84 | /// </summary> 85 | bool HasUvPathOverride { get; } 86 | 87 | /// <summary> 88 | /// Gets whether a Claude CLI path override is active 89 | /// </summary> 90 | bool HasClaudeCliPathOverride { get; } 91 | } 92 | } 93 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources/GetMenuItemsTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using NUnit.Framework; 2 | using Newtonsoft.Json.Linq; 3 | using MCPForUnity.Editor.Resources.MenuItems; 4 | using System; 5 | using System.Linq; 6 | 7 | namespace MCPForUnityTests.Editor.Resources.MenuItems 8 | { 9 | public class GetMenuItemsTests 10 | { 11 | private static JObject ToJO(object o) => JObject.FromObject(o); 12 | 13 | [Test] 14 | public void NoSearch_ReturnsSuccessAndArray() 15 | { 16 | var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false }); 17 | var jo = ToJO(res); 18 | Assert.IsTrue((bool)jo["success"], "Expected success true"); 19 | Assert.IsNotNull(jo["data"], "Expected data field present"); 20 | Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); 21 | 22 | // Validate list is sorted ascending when there are multiple items 23 | var arr = (JArray)jo["data"]; 24 | if (arr.Count >= 2) 25 | { 26 | var original = arr.Select(t => (string)t).ToList(); 27 | var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList(); 28 | CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending"); 29 | } 30 | } 31 | 32 | [Test] 33 | public void SearchNoMatch_ReturnsEmpty() 34 | { 35 | var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "___unlikely___term___" }); 36 | var jo = ToJO(res); 37 | Assert.IsTrue((bool)jo["success"], "Expected success true"); 38 | Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); 39 | Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term"); 40 | } 41 | 42 | [Test] 43 | public void SearchMatchesExistingItem_ReturnsContainingItem() 44 | { 45 | // Get the full list first 46 | var listRes = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false }); 47 | var listJo = ToJO(listRes); 48 | if (listJo["data"] is JArray arr && arr.Count > 0) 49 | { 50 | var first = (string)arr[0]; 51 | // Use a mid-substring (case-insensitive) to avoid edge cases 52 | var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first; 53 | term = term.ToLowerInvariant(); 54 | 55 | var res = GetMenuItems.HandleCommand(new JObject { ["search"] = term, ["refresh"] = false }); 56 | var jo = ToJO(res); 57 | Assert.IsTrue((bool)jo["success"], "Expected success true"); 58 | Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array"); 59 | // Expect at least the original item to be present 60 | var names = ((JArray)jo["data"]).Select(t => (string)t).ToList(); 61 | CollectionAssert.Contains(names, first, "Expected search results to include the sampled item"); 62 | } 63 | else 64 | { 65 | Assert.Pass("No menu items available to perform a content-based search assertion."); 66 | } 67 | } 68 | } 69 | } 70 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace MCPForUnity.Editor.Dependencies.Models 6 | { 7 | /// <summary> 8 | /// Result of a comprehensive dependency check 9 | /// </summary> 10 | [Serializable] 11 | public class DependencyCheckResult 12 | { 13 | /// <summary> 14 | /// List of all dependency statuses checked 15 | /// </summary> 16 | public List<DependencyStatus> Dependencies { get; set; } 17 | 18 | /// <summary> 19 | /// Overall system readiness for MCP operations 20 | /// </summary> 21 | public bool IsSystemReady { get; set; } 22 | 23 | /// <summary> 24 | /// Whether all required dependencies are available 25 | /// </summary> 26 | public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; 27 | 28 | /// <summary> 29 | /// Whether any optional dependencies are missing 30 | /// </summary> 31 | public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; 32 | 33 | /// <summary> 34 | /// Summary message about the dependency state 35 | /// </summary> 36 | public string Summary { get; set; } 37 | 38 | /// <summary> 39 | /// Recommended next steps for the user 40 | /// </summary> 41 | public List<string> RecommendedActions { get; set; } 42 | 43 | /// <summary> 44 | /// Timestamp when this check was performed 45 | /// </summary> 46 | public DateTime CheckedAt { get; set; } 47 | 48 | public DependencyCheckResult() 49 | { 50 | Dependencies = new List<DependencyStatus>(); 51 | RecommendedActions = new List<string>(); 52 | CheckedAt = DateTime.UtcNow; 53 | } 54 | 55 | /// <summary> 56 | /// Get dependencies by availability status 57 | /// </summary> 58 | public List<DependencyStatus> GetMissingDependencies() 59 | { 60 | return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); 61 | } 62 | 63 | /// <summary> 64 | /// Get missing required dependencies 65 | /// </summary> 66 | public List<DependencyStatus> GetMissingRequired() 67 | { 68 | return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); 69 | } 70 | 71 | /// <summary> 72 | /// Generate a user-friendly summary of the dependency state 73 | /// </summary> 74 | public void GenerateSummary() 75 | { 76 | var missing = GetMissingDependencies(); 77 | var missingRequired = GetMissingRequired(); 78 | 79 | if (missing.Count == 0) 80 | { 81 | Summary = "All dependencies are available and ready."; 82 | IsSystemReady = true; 83 | } 84 | else if (missingRequired.Count == 0) 85 | { 86 | Summary = $"System is ready. {missing.Count} optional dependencies are missing."; 87 | IsSystemReady = true; 88 | } 89 | else 90 | { 91 | Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; 92 | IsSystemReady = false; 93 | } 94 | } 95 | } 96 | } 97 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace MCPForUnity.Editor.Dependencies.Models 6 | { 7 | /// <summary> 8 | /// Result of a comprehensive dependency check 9 | /// </summary> 10 | [Serializable] 11 | public class DependencyCheckResult 12 | { 13 | /// <summary> 14 | /// List of all dependency statuses checked 15 | /// </summary> 16 | public List<DependencyStatus> Dependencies { get; set; } 17 | 18 | /// <summary> 19 | /// Overall system readiness for MCP operations 20 | /// </summary> 21 | public bool IsSystemReady { get; set; } 22 | 23 | /// <summary> 24 | /// Whether all required dependencies are available 25 | /// </summary> 26 | public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; 27 | 28 | /// <summary> 29 | /// Whether any optional dependencies are missing 30 | /// </summary> 31 | public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; 32 | 33 | /// <summary> 34 | /// Summary message about the dependency state 35 | /// </summary> 36 | public string Summary { get; set; } 37 | 38 | /// <summary> 39 | /// Recommended next steps for the user 40 | /// </summary> 41 | public List<string> RecommendedActions { get; set; } 42 | 43 | /// <summary> 44 | /// Timestamp when this check was performed 45 | /// </summary> 46 | public DateTime CheckedAt { get; set; } 47 | 48 | public DependencyCheckResult() 49 | { 50 | Dependencies = new List<DependencyStatus>(); 51 | RecommendedActions = new List<string>(); 52 | CheckedAt = DateTime.UtcNow; 53 | } 54 | 55 | /// <summary> 56 | /// Get dependencies by availability status 57 | /// </summary> 58 | public List<DependencyStatus> GetMissingDependencies() 59 | { 60 | return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); 61 | } 62 | 63 | /// <summary> 64 | /// Get missing required dependencies 65 | /// </summary> 66 | public List<DependencyStatus> GetMissingRequired() 67 | { 68 | return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List<DependencyStatus>(); 69 | } 70 | 71 | /// <summary> 72 | /// Generate a user-friendly summary of the dependency state 73 | /// </summary> 74 | public void GenerateSummary() 75 | { 76 | var missing = GetMissingDependencies(); 77 | var missingRequired = GetMissingRequired(); 78 | 79 | if (missing.Count == 0) 80 | { 81 | Summary = "All dependencies are available and ready."; 82 | IsSystemReady = true; 83 | } 84 | else if (missingRequired.Count == 0) 85 | { 86 | Summary = $"System is ready. {missing.Count} optional dependencies are missing."; 87 | IsSystemReady = true; 88 | } 89 | else 90 | { 91 | Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; 92 | IsSystemReady = false; 93 | } 94 | } 95 | } 96 | } 97 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/IClientConfigurationService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using MCPForUnity.Editor.Models; 2 | 3 | namespace MCPForUnity.Editor.Services 4 | { 5 | /// <summary> 6 | /// Service for configuring MCP clients 7 | /// </summary> 8 | public interface IClientConfigurationService 9 | { 10 | /// <summary> 11 | /// Configures a specific MCP client 12 | /// </summary> 13 | /// <param name="client">The client to configure</param> 14 | void ConfigureClient(McpClient client); 15 | 16 | /// <summary> 17 | /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) 18 | /// </summary> 19 | /// <returns>Summary of configuration results</returns> 20 | ClientConfigurationSummary ConfigureAllDetectedClients(); 21 | 22 | /// <summary> 23 | /// Checks the configuration status of a client 24 | /// </summary> 25 | /// <param name="client">The client to check</param> 26 | /// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param> 27 | /// <returns>True if status changed, false otherwise</returns> 28 | bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true); 29 | 30 | /// <summary> 31 | /// Registers MCP for Unity with Claude Code CLI 32 | /// </summary> 33 | void RegisterClaudeCode(); 34 | 35 | /// <summary> 36 | /// Unregisters MCP for Unity from Claude Code CLI 37 | /// </summary> 38 | void UnregisterClaudeCode(); 39 | 40 | /// <summary> 41 | /// Gets the configuration file path for a client 42 | /// </summary> 43 | /// <param name="client">The client</param> 44 | /// <returns>Platform-specific config path</returns> 45 | string GetConfigPath(McpClient client); 46 | 47 | /// <summary> 48 | /// Generates the configuration JSON for a client 49 | /// </summary> 50 | /// <param name="client">The client</param> 51 | /// <returns>JSON configuration string</returns> 52 | string GenerateConfigJson(McpClient client); 53 | 54 | /// <summary> 55 | /// Gets human-readable installation steps for a client 56 | /// </summary> 57 | /// <param name="client">The client</param> 58 | /// <returns>Installation instructions</returns> 59 | string GetInstallationSteps(McpClient client); 60 | } 61 | 62 | /// <summary> 63 | /// Summary of configuration results for multiple clients 64 | /// </summary> 65 | public class ClientConfigurationSummary 66 | { 67 | /// <summary> 68 | /// Number of clients successfully configured 69 | /// </summary> 70 | public int SuccessCount { get; set; } 71 | 72 | /// <summary> 73 | /// Number of clients that failed to configure 74 | /// </summary> 75 | public int FailureCount { get; set; } 76 | 77 | /// <summary> 78 | /// Number of clients skipped (already configured or tool not found) 79 | /// </summary> 80 | public int SkippedCount { get; set; } 81 | 82 | /// <summary> 83 | /// Detailed messages for each client 84 | /// </summary> 85 | public System.Collections.Generic.List<string> Messages { get; set; } = new(); 86 | 87 | /// <summary> 88 | /// Gets a human-readable summary message 89 | /// </summary> 90 | public string GetSummaryMessage() 91 | { 92 | return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped"; 93 | } 94 | } 95 | } 96 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/MCPServiceLocator.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | 3 | namespace MCPForUnity.Editor.Services 4 | { 5 | /// <summary> 6 | /// Service locator for accessing MCP services without dependency injection 7 | /// </summary> 8 | public static class MCPServiceLocator 9 | { 10 | private static IBridgeControlService _bridgeService; 11 | private static IClientConfigurationService _clientService; 12 | private static IPathResolverService _pathService; 13 | private static IPythonToolRegistryService _pythonToolRegistryService; 14 | private static ITestRunnerService _testRunnerService; 15 | private static IToolSyncService _toolSyncService; 16 | private static IPackageUpdateService _packageUpdateService; 17 | 18 | public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); 19 | public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); 20 | public static IPathResolverService Paths => _pathService ??= new PathResolverService(); 21 | public static IPythonToolRegistryService PythonToolRegistry => _pythonToolRegistryService ??= new PythonToolRegistryService(); 22 | public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); 23 | public static IToolSyncService ToolSync => _toolSyncService ??= new ToolSyncService(); 24 | public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); 25 | 26 | /// <summary> 27 | /// Registers a custom implementation for a service (useful for testing) 28 | /// </summary> 29 | /// <typeparam name="T">The service interface type</typeparam> 30 | /// <param name="implementation">The implementation to register</param> 31 | public static void Register<T>(T implementation) where T : class 32 | { 33 | if (implementation is IBridgeControlService b) 34 | _bridgeService = b; 35 | else if (implementation is IClientConfigurationService c) 36 | _clientService = c; 37 | else if (implementation is IPathResolverService p) 38 | _pathService = p; 39 | else if (implementation is IPythonToolRegistryService ptr) 40 | _pythonToolRegistryService = ptr; 41 | else if (implementation is ITestRunnerService t) 42 | _testRunnerService = t; 43 | else if (implementation is IToolSyncService ts) 44 | _toolSyncService = ts; 45 | else if (implementation is IPackageUpdateService pu) 46 | _packageUpdateService = pu; 47 | } 48 | 49 | /// <summary> 50 | /// Resets all services to their default implementations (useful for testing) 51 | /// </summary> 52 | public static void Reset() 53 | { 54 | (_bridgeService as IDisposable)?.Dispose(); 55 | (_clientService as IDisposable)?.Dispose(); 56 | (_pathService as IDisposable)?.Dispose(); 57 | (_pythonToolRegistryService as IDisposable)?.Dispose(); 58 | (_testRunnerService as IDisposable)?.Dispose(); 59 | (_toolSyncService as IDisposable)?.Dispose(); 60 | (_packageUpdateService as IDisposable)?.Dispose(); 61 | 62 | _bridgeService = null; 63 | _clientService = null; 64 | _pathService = null; 65 | _pythonToolRegistryService = null; 66 | _testRunnerService = null; 67 | _toolSyncService = null; 68 | _packageUpdateService = null; 69 | } 70 | } 71 | } 72 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Defines the manage_asset tool for interacting with Unity assets. 3 | """ 4 | import asyncio 5 | from typing import Annotated, Any, Literal 6 | 7 | from mcp.server.fastmcp import Context 8 | from registry import mcp_for_unity_tool 9 | from unity_connection import async_send_command_with_retry 10 | 11 | 12 | @mcp_for_unity_tool( 13 | description="Performs asset operations (import, create, modify, delete, etc.) in Unity." 14 | ) 15 | async def manage_asset( 16 | ctx: Context, 17 | action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], 18 | path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], 19 | asset_type: Annotated[str, 20 | "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, 21 | properties: Annotated[dict[str, Any], 22 | "Dictionary of properties for 'create'/'modify'."] | None = None, 23 | destination: Annotated[str, 24 | "Target path for 'duplicate'/'move'."] | None = None, 25 | generate_preview: Annotated[bool, 26 | "Generate a preview/thumbnail for the asset when supported."] = False, 27 | search_pattern: Annotated[str, 28 | "Search pattern (e.g., '*.prefab')."] | None = None, 29 | filter_type: Annotated[str, "Filter type for search"] | None = None, 30 | filter_date_after: Annotated[str, 31 | "Date after which to filter"] | None = None, 32 | page_size: Annotated[int, "Page size for pagination"] | None = None, 33 | page_number: Annotated[int, "Page number for pagination"] | None = None 34 | ) -> dict[str, Any]: 35 | ctx.info(f"Processing manage_asset: {action}") 36 | # Ensure properties is a dict if None 37 | if properties is None: 38 | properties = {} 39 | 40 | # Coerce numeric inputs defensively 41 | def _coerce_int(value, default=None): 42 | if value is None: 43 | return default 44 | try: 45 | if isinstance(value, bool): 46 | return default 47 | if isinstance(value, int): 48 | return int(value) 49 | s = str(value).strip() 50 | if s.lower() in ("", "none", "null"): 51 | return default 52 | return int(float(s)) 53 | except Exception: 54 | return default 55 | 56 | page_size = _coerce_int(page_size) 57 | page_number = _coerce_int(page_number) 58 | 59 | # Prepare parameters for the C# handler 60 | params_dict = { 61 | "action": action.lower(), 62 | "path": path, 63 | "assetType": asset_type, 64 | "properties": properties, 65 | "destination": destination, 66 | "generatePreview": generate_preview, 67 | "searchPattern": search_pattern, 68 | "filterType": filter_type, 69 | "filterDateAfter": filter_date_after, 70 | "pageSize": page_size, 71 | "pageNumber": page_number 72 | } 73 | 74 | # Remove None values to avoid sending unnecessary nulls 75 | params_dict = {k: v for k, v in params_dict.items() if v is not None} 76 | 77 | # Get the current asyncio event loop 78 | loop = asyncio.get_running_loop() 79 | 80 | # Use centralized async retry helper to avoid blocking the event loop 81 | result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) 82 | # Return the result obtained from Unity 83 | return result if isinstance(result, dict) else {"success": False, "message": str(result)} 84 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Defines the manage_asset tool for interacting with Unity assets. 3 | """ 4 | import asyncio 5 | from typing import Annotated, Any, Literal 6 | 7 | from mcp.server.fastmcp import Context 8 | from registry import mcp_for_unity_tool 9 | from unity_connection import async_send_command_with_retry 10 | 11 | 12 | @mcp_for_unity_tool( 13 | description="Performs asset operations (import, create, modify, delete, etc.) in Unity." 14 | ) 15 | async def manage_asset( 16 | ctx: Context, 17 | action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], 18 | path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], 19 | asset_type: Annotated[str, 20 | "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, 21 | properties: Annotated[dict[str, Any], 22 | "Dictionary of properties for 'create'/'modify'."] | None = None, 23 | destination: Annotated[str, 24 | "Target path for 'duplicate'/'move'."] | None = None, 25 | generate_preview: Annotated[bool, 26 | "Generate a preview/thumbnail for the asset when supported."] = False, 27 | search_pattern: Annotated[str, 28 | "Search pattern (e.g., '*.prefab')."] | None = None, 29 | filter_type: Annotated[str, "Filter type for search"] | None = None, 30 | filter_date_after: Annotated[str, 31 | "Date after which to filter"] | None = None, 32 | page_size: Annotated[int, "Page size for pagination"] | None = None, 33 | page_number: Annotated[int, "Page number for pagination"] | None = None 34 | ) -> dict[str, Any]: 35 | ctx.info(f"Processing manage_asset: {action}") 36 | # Ensure properties is a dict if None 37 | if properties is None: 38 | properties = {} 39 | 40 | # Coerce numeric inputs defensively 41 | def _coerce_int(value, default=None): 42 | if value is None: 43 | return default 44 | try: 45 | if isinstance(value, bool): 46 | return default 47 | if isinstance(value, int): 48 | return int(value) 49 | s = str(value).strip() 50 | if s.lower() in ("", "none", "null"): 51 | return default 52 | return int(float(s)) 53 | except Exception: 54 | return default 55 | 56 | page_size = _coerce_int(page_size) 57 | page_number = _coerce_int(page_number) 58 | 59 | # Prepare parameters for the C# handler 60 | params_dict = { 61 | "action": action.lower(), 62 | "path": path, 63 | "assetType": asset_type, 64 | "properties": properties, 65 | "destination": destination, 66 | "generatePreview": generate_preview, 67 | "searchPattern": search_pattern, 68 | "filterType": filter_type, 69 | "filterDateAfter": filter_date_after, 70 | "pageSize": page_size, 71 | "pageNumber": page_number 72 | } 73 | 74 | # Remove None values to avoid sending unnecessary nulls 75 | params_dict = {k: v for k, v in params_dict.items() if v is not None} 76 | 77 | # Get the current asyncio event loop 78 | loop = asyncio.get_running_loop() 79 | 80 | # Use centralized async retry helper to avoid blocking the event loop 81 | result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) 82 | # Return the result obtained from Unity 83 | return result if isinstance(result, dict) else {"success": False, "message": str(result)} 84 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Newtonsoft.Json.Linq; 5 | using UnityEditor; 6 | using MCPForUnity.Editor.Helpers; 7 | 8 | namespace MCPForUnity.Editor.Tools.MenuItems 9 | { 10 | /// <summary> 11 | /// Provides read/list/exists capabilities for Unity menu items with caching. 12 | /// </summary> 13 | public static class MenuItemsReader 14 | { 15 | private static List<string> _cached; 16 | 17 | [InitializeOnLoadMethod] 18 | private static void Build() => Refresh(); 19 | 20 | /// <summary> 21 | /// Returns the cached list, refreshing if necessary. 22 | /// </summary> 23 | public static IReadOnlyList<string> AllMenuItems() => _cached ??= Refresh(); 24 | 25 | /// <summary> 26 | /// Rebuilds the cached list from reflection. 27 | /// </summary> 28 | private static List<string> Refresh() 29 | { 30 | try 31 | { 32 | var methods = TypeCache.GetMethodsWithAttribute<MenuItem>(); 33 | _cached = methods 34 | // Methods can have multiple [MenuItem] attributes; collect them all 35 | .SelectMany(m => m 36 | .GetCustomAttributes(typeof(MenuItem), false) 37 | .OfType<MenuItem>() 38 | .Select(attr => attr.menuItem)) 39 | .Where(s => !string.IsNullOrEmpty(s)) 40 | .Distinct(StringComparer.Ordinal) // Ensure no duplicates 41 | .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering 42 | .ToList(); 43 | return _cached; 44 | } 45 | catch (Exception e) 46 | { 47 | McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); 48 | _cached = _cached ?? new List<string>(); 49 | return _cached; 50 | } 51 | } 52 | 53 | /// <summary> 54 | /// Returns a list of menu items. Optional 'search' param filters results. 55 | /// </summary> 56 | public static object List(JObject @params) 57 | { 58 | string search = @params["search"]?.ToString(); 59 | bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false; 60 | if (doRefresh || _cached == null) 61 | { 62 | Refresh(); 63 | } 64 | 65 | IEnumerable<string> result = _cached ?? Enumerable.Empty<string>(); 66 | if (!string.IsNullOrEmpty(search)) 67 | { 68 | result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0); 69 | } 70 | 71 | return Response.Success("Menu items retrieved.", result.ToList()); 72 | } 73 | 74 | /// <summary> 75 | /// Checks if a given menu path exists in the cache. 76 | /// </summary> 77 | public static object Exists(JObject @params) 78 | { 79 | string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); 80 | if (string.IsNullOrWhiteSpace(menuPath)) 81 | { 82 | return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); 83 | } 84 | 85 | bool doRefresh = @params["refresh"]?.ToObject<bool>() ?? false; 86 | if (doRefresh || _cached == null) 87 | { 88 | Refresh(); 89 | } 90 | 91 | bool exists = (_cached ?? new List<string>()).Contains(menuPath); 92 | return Response.Success($"Exists check completed for '{menuPath}'.", new { exists }); 93 | } 94 | } 95 | } 96 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for Unity MCP Telemetry System 4 | Run this to verify telemetry is working correctly 5 | """ 6 | 7 | import os 8 | from pathlib import Path 9 | import sys 10 | 11 | # Add src to Python path for imports 12 | sys.path.insert(0, str(Path(__file__).parent)) 13 | 14 | 15 | def test_telemetry_basic(): 16 | """Test basic telemetry functionality""" 17 | # Avoid stdout noise in tests 18 | 19 | try: 20 | from telemetry import ( 21 | get_telemetry, record_telemetry, record_milestone, 22 | RecordType, MilestoneType, is_telemetry_enabled 23 | ) 24 | pass 25 | except ImportError as e: 26 | # Silent failure path for tests 27 | return False 28 | 29 | # Test telemetry enabled status 30 | _ = is_telemetry_enabled() 31 | 32 | # Test basic record 33 | try: 34 | record_telemetry(RecordType.VERSION, { 35 | "version": "3.0.2", 36 | "test_run": True 37 | }) 38 | pass 39 | except Exception as e: 40 | # Silent failure path for tests 41 | return False 42 | 43 | # Test milestone recording 44 | try: 45 | is_first = record_milestone(MilestoneType.FIRST_STARTUP, { 46 | "test_mode": True 47 | }) 48 | _ = is_first 49 | except Exception as e: 50 | # Silent failure path for tests 51 | return False 52 | 53 | # Test telemetry collector 54 | try: 55 | collector = get_telemetry() 56 | _ = collector 57 | except Exception as e: 58 | # Silent failure path for tests 59 | return False 60 | 61 | return True 62 | 63 | 64 | def test_telemetry_disabled(): 65 | """Test telemetry with disabled state""" 66 | # Silent for tests 67 | 68 | # Set environment variable to disable telemetry 69 | os.environ["DISABLE_TELEMETRY"] = "true" 70 | 71 | # Re-import to get fresh config 72 | import importlib 73 | import telemetry 74 | importlib.reload(telemetry) 75 | 76 | from telemetry import is_telemetry_enabled, record_telemetry, RecordType 77 | 78 | _ = is_telemetry_enabled() 79 | 80 | if not is_telemetry_enabled(): 81 | pass 82 | 83 | # Test that records are ignored when disabled 84 | record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) 85 | pass 86 | 87 | return True 88 | else: 89 | pass 90 | return False 91 | 92 | 93 | def test_data_storage(): 94 | """Test data storage functionality""" 95 | # Silent for tests 96 | 97 | try: 98 | from telemetry import get_telemetry 99 | 100 | collector = get_telemetry() 101 | data_dir = collector.config.data_dir 102 | 103 | _ = (data_dir, collector.config.uuid_file, 104 | collector.config.milestones_file) 105 | 106 | # Check if files exist 107 | if collector.config.uuid_file.exists(): 108 | pass 109 | else: 110 | pass 111 | 112 | if collector.config.milestones_file.exists(): 113 | pass 114 | else: 115 | pass 116 | 117 | return True 118 | 119 | except Exception as e: 120 | # Silent failure path for tests 121 | return False 122 | 123 | 124 | def main(): 125 | """Run all telemetry tests""" 126 | # Silent runner for CI 127 | 128 | tests = [ 129 | test_telemetry_basic, 130 | test_data_storage, 131 | test_telemetry_disabled, 132 | ] 133 | 134 | passed = 0 135 | failed = 0 136 | 137 | for test in tests: 138 | try: 139 | if test(): 140 | passed += 1 141 | pass 142 | else: 143 | failed += 1 144 | pass 145 | except Exception as e: 146 | failed += 1 147 | pass 148 | 149 | _ = (passed, failed) 150 | 151 | if failed == 0: 152 | pass 153 | return True 154 | else: 155 | pass 156 | return False 157 | 158 | 159 | if __name__ == "__main__": 160 | success = main() 161 | sys.exit(0 if success else 1) 162 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/test_telemetry.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test script for MCP for Unity Telemetry System 4 | Run this to verify telemetry is working correctly 5 | """ 6 | 7 | import os 8 | from pathlib import Path 9 | import sys 10 | 11 | # Add src to Python path for imports 12 | sys.path.insert(0, str(Path(__file__).parent)) 13 | 14 | 15 | def test_telemetry_basic(): 16 | """Test basic telemetry functionality""" 17 | # Avoid stdout noise in tests 18 | 19 | try: 20 | from telemetry import ( 21 | get_telemetry, record_telemetry, record_milestone, 22 | RecordType, MilestoneType, is_telemetry_enabled 23 | ) 24 | pass 25 | except ImportError as e: 26 | # Silent failure path for tests 27 | return False 28 | 29 | # Test telemetry enabled status 30 | _ = is_telemetry_enabled() 31 | 32 | # Test basic record 33 | try: 34 | record_telemetry(RecordType.VERSION, { 35 | "version": "3.0.2", 36 | "test_run": True 37 | }) 38 | pass 39 | except Exception as e: 40 | # Silent failure path for tests 41 | return False 42 | 43 | # Test milestone recording 44 | try: 45 | is_first = record_milestone(MilestoneType.FIRST_STARTUP, { 46 | "test_mode": True 47 | }) 48 | _ = is_first 49 | except Exception as e: 50 | # Silent failure path for tests 51 | return False 52 | 53 | # Test telemetry collector 54 | try: 55 | collector = get_telemetry() 56 | _ = collector 57 | except Exception as e: 58 | # Silent failure path for tests 59 | return False 60 | 61 | return True 62 | 63 | 64 | def test_telemetry_disabled(): 65 | """Test telemetry with disabled state""" 66 | # Silent for tests 67 | 68 | # Set environment variable to disable telemetry 69 | os.environ["DISABLE_TELEMETRY"] = "true" 70 | 71 | # Re-import to get fresh config 72 | import importlib 73 | import telemetry 74 | importlib.reload(telemetry) 75 | 76 | from telemetry import is_telemetry_enabled, record_telemetry, RecordType 77 | 78 | _ = is_telemetry_enabled() 79 | 80 | if not is_telemetry_enabled(): 81 | pass 82 | 83 | # Test that records are ignored when disabled 84 | record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) 85 | pass 86 | 87 | return True 88 | else: 89 | pass 90 | return False 91 | 92 | 93 | def test_data_storage(): 94 | """Test data storage functionality""" 95 | # Silent for tests 96 | 97 | try: 98 | from telemetry import get_telemetry 99 | 100 | collector = get_telemetry() 101 | data_dir = collector.config.data_dir 102 | 103 | _ = (data_dir, collector.config.uuid_file, 104 | collector.config.milestones_file) 105 | 106 | # Check if files exist 107 | if collector.config.uuid_file.exists(): 108 | pass 109 | else: 110 | pass 111 | 112 | if collector.config.milestones_file.exists(): 113 | pass 114 | else: 115 | pass 116 | 117 | return True 118 | 119 | except Exception as e: 120 | # Silent failure path for tests 121 | return False 122 | 123 | 124 | def main(): 125 | """Run all telemetry tests""" 126 | # Silent runner for CI 127 | 128 | tests = [ 129 | test_telemetry_basic, 130 | test_data_storage, 131 | test_telemetry_disabled, 132 | ] 133 | 134 | passed = 0 135 | failed = 0 136 | 137 | for test in tests: 138 | try: 139 | if test(): 140 | passed += 1 141 | pass 142 | else: 143 | failed += 1 144 | pass 145 | except Exception as e: 146 | failed += 1 147 | pass 148 | 149 | _ = (passed, failed) 150 | 151 | if failed == 0: 152 | pass 153 | return True 154 | else: 155 | pass 156 | return False 157 | 158 | 159 | if __name__ == "__main__": 160 | success = main() 161 | sys.exit(0 if success else 1) 162 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/read_console.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Defines the read_console tool for accessing Unity Editor console messages. 3 | """ 4 | from typing import Annotated, Any, Literal 5 | 6 | from mcp.server.fastmcp import Context 7 | from registry import mcp_for_unity_tool 8 | from unity_connection import send_command_with_retry 9 | 10 | 11 | @mcp_for_unity_tool( 12 | description="Gets messages from or clears the Unity Editor console." 13 | ) 14 | def read_console( 15 | ctx: Context, 16 | action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], 17 | types: Annotated[list[Literal['error', 'warning', 18 | 'log', 'all']], "Message types to get"] | None = None, 19 | count: Annotated[int, "Max messages to return"] | None = None, 20 | filter_text: Annotated[str, "Text filter for messages"] | None = None, 21 | since_timestamp: Annotated[str, 22 | "Get messages after this timestamp (ISO 8601)"] | None = None, 23 | format: Annotated[Literal['plain', 'detailed', 24 | 'json'], "Output format"] | None = None, 25 | include_stacktrace: Annotated[bool, 26 | "Include stack traces in output"] | None = None 27 | ) -> dict[str, Any]: 28 | ctx.info(f"Processing read_console: {action}") 29 | # Set defaults if values are None 30 | action = action if action is not None else 'get' 31 | types = types if types is not None else ['error', 'warning', 'log'] 32 | format = format if format is not None else 'detailed' 33 | include_stacktrace = include_stacktrace if include_stacktrace is not None else True 34 | 35 | # Normalize action if it's a string 36 | if isinstance(action, str): 37 | action = action.lower() 38 | 39 | # Coerce count defensively (string/float -> int) 40 | def _coerce_int(value, default=None): 41 | if value is None: 42 | return default 43 | try: 44 | if isinstance(value, bool): 45 | return default 46 | if isinstance(value, int): 47 | return int(value) 48 | s = str(value).strip() 49 | if s.lower() in ("", "none", "null"): 50 | return default 51 | return int(float(s)) 52 | except Exception: 53 | return default 54 | 55 | count = _coerce_int(count) 56 | 57 | # Prepare parameters for the C# handler 58 | params_dict = { 59 | "action": action, 60 | "types": types, 61 | "count": count, 62 | "filterText": filter_text, 63 | "sinceTimestamp": since_timestamp, 64 | "format": format.lower() if isinstance(format, str) else format, 65 | "includeStacktrace": include_stacktrace 66 | } 67 | 68 | # Remove None values unless it's 'count' (as None might mean 'all') 69 | params_dict = {k: v for k, v in params_dict.items() 70 | if v is not None or k == 'count'} 71 | 72 | # Add count back if it was None, explicitly sending null might be important for C# logic 73 | if 'count' not in params_dict: 74 | params_dict['count'] = None 75 | 76 | # Use centralized retry helper 77 | resp = send_command_with_retry("read_console", params_dict) 78 | if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: 79 | # Strip stacktrace fields from returned lines if present 80 | try: 81 | lines = resp.get("data", {}).get("lines", []) 82 | for line in lines: 83 | if isinstance(line, dict) and "stacktrace" in line: 84 | line.pop("stacktrace", None) 85 | except Exception: 86 | pass 87 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 88 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Defines the read_console tool for accessing Unity Editor console messages. 3 | """ 4 | from typing import Annotated, Any, Literal 5 | 6 | from mcp.server.fastmcp import Context 7 | from registry import mcp_for_unity_tool 8 | from unity_connection import send_command_with_retry 9 | 10 | 11 | @mcp_for_unity_tool( 12 | description="Gets messages from or clears the Unity Editor console." 13 | ) 14 | def read_console( 15 | ctx: Context, 16 | action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], 17 | types: Annotated[list[Literal['error', 'warning', 18 | 'log', 'all']], "Message types to get"] | None = None, 19 | count: Annotated[int, "Max messages to return"] | None = None, 20 | filter_text: Annotated[str, "Text filter for messages"] | None = None, 21 | since_timestamp: Annotated[str, 22 | "Get messages after this timestamp (ISO 8601)"] | None = None, 23 | format: Annotated[Literal['plain', 'detailed', 24 | 'json'], "Output format"] | None = None, 25 | include_stacktrace: Annotated[bool, 26 | "Include stack traces in output"] | None = None 27 | ) -> dict[str, Any]: 28 | ctx.info(f"Processing read_console: {action}") 29 | # Set defaults if values are None 30 | action = action if action is not None else 'get' 31 | types = types if types is not None else ['error', 'warning', 'log'] 32 | format = format if format is not None else 'detailed' 33 | include_stacktrace = include_stacktrace if include_stacktrace is not None else True 34 | 35 | # Normalize action if it's a string 36 | if isinstance(action, str): 37 | action = action.lower() 38 | 39 | # Coerce count defensively (string/float -> int) 40 | def _coerce_int(value, default=None): 41 | if value is None: 42 | return default 43 | try: 44 | if isinstance(value, bool): 45 | return default 46 | if isinstance(value, int): 47 | return int(value) 48 | s = str(value).strip() 49 | if s.lower() in ("", "none", "null"): 50 | return default 51 | return int(float(s)) 52 | except Exception: 53 | return default 54 | 55 | count = _coerce_int(count) 56 | 57 | # Prepare parameters for the C# handler 58 | params_dict = { 59 | "action": action, 60 | "types": types, 61 | "count": count, 62 | "filterText": filter_text, 63 | "sinceTimestamp": since_timestamp, 64 | "format": format.lower() if isinstance(format, str) else format, 65 | "includeStacktrace": include_stacktrace 66 | } 67 | 68 | # Remove None values unless it's 'count' (as None might mean 'all') 69 | params_dict = {k: v for k, v in params_dict.items() 70 | if v is not None or k == 'count'} 71 | 72 | # Add count back if it was None, explicitly sending null might be important for C# logic 73 | if 'count' not in params_dict: 74 | params_dict['count'] = None 75 | 76 | # Use centralized retry helper 77 | resp = send_command_with_retry("read_console", params_dict) 78 | if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: 79 | # Strip stacktrace fields from returned lines if present 80 | try: 81 | lines = resp.get("data", {}).get("lines", []) 82 | for line in lines: 83 | if isinstance(line, dict) and "stacktrace" in line: 84 | line.pop("stacktrace", None) 85 | except Exception: 86 | pass 87 | return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} 88 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Resources/Tests/GetTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Newtonsoft.Json.Linq; 5 | using MCPForUnity.Editor.Helpers; 6 | using MCPForUnity.Editor.Services; 7 | using UnityEditor.TestTools.TestRunner.Api; 8 | 9 | namespace MCPForUnity.Editor.Resources.Tests 10 | { 11 | /// <summary> 12 | /// Provides access to Unity tests from the Test Framework. 13 | /// This is a read-only resource that can be queried by MCP clients. 14 | /// </summary> 15 | [McpForUnityResource("get_tests")] 16 | public static class GetTests 17 | { 18 | public static async Task<object> HandleCommand(JObject @params) 19 | { 20 | McpLog.Info("[GetTests] Retrieving tests for all modes"); 21 | IReadOnlyList<Dictionary<string, string>> result; 22 | 23 | try 24 | { 25 | result = await MCPServiceLocator.Tests.GetTestsAsync(mode: null).ConfigureAwait(true); 26 | } 27 | catch (Exception ex) 28 | { 29 | McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); 30 | return Response.Error("Failed to retrieve tests"); 31 | } 32 | 33 | string message = $"Retrieved {result.Count} tests"; 34 | 35 | return Response.Success(message, result); 36 | } 37 | } 38 | 39 | /// <summary> 40 | /// Provides access to Unity tests for a specific mode (EditMode or PlayMode). 41 | /// This is a read-only resource that can be queried by MCP clients. 42 | /// </summary> 43 | [McpForUnityResource("get_tests_for_mode")] 44 | public static class GetTestsForMode 45 | { 46 | public static async Task<object> HandleCommand(JObject @params) 47 | { 48 | IReadOnlyList<Dictionary<string, string>> result; 49 | string modeStr = @params["mode"]?.ToString(); 50 | if (string.IsNullOrEmpty(modeStr)) 51 | { 52 | return Response.Error("'mode' parameter is required"); 53 | } 54 | 55 | if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) 56 | { 57 | return Response.Error(parseError); 58 | } 59 | 60 | McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value}"); 61 | 62 | try 63 | { 64 | result = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); 65 | } 66 | catch (Exception ex) 67 | { 68 | McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); 69 | return Response.Error("Failed to retrieve tests"); 70 | } 71 | 72 | string message = $"Retrieved {result.Count} {parsedMode.Value} tests"; 73 | return Response.Success(message, result); 74 | } 75 | } 76 | 77 | internal static class ModeParser 78 | { 79 | internal static bool TryParse(string modeStr, out TestMode? mode, out string error) 80 | { 81 | error = null; 82 | mode = null; 83 | 84 | if (string.IsNullOrWhiteSpace(modeStr)) 85 | { 86 | error = "'mode' parameter cannot be empty"; 87 | return false; 88 | } 89 | 90 | if (modeStr.Equals("edit", StringComparison.OrdinalIgnoreCase)) 91 | { 92 | mode = TestMode.EditMode; 93 | return true; 94 | } 95 | 96 | if (modeStr.Equals("play", StringComparison.OrdinalIgnoreCase)) 97 | { 98 | mode = TestMode.PlayMode; 99 | return true; 100 | } 101 | 102 | error = $"Unknown test mode: '{modeStr}'. Use 'edit' or 'play'"; 103 | return false; 104 | } 105 | } 106 | } 107 | ``` -------------------------------------------------------------------------------- /.github/scripts/mark_skipped.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Post-processes a JUnit XML so that "expected"/environmental failures 4 | (e.g., permission prompts, empty MCP resources, or schema hiccups) 5 | are converted to <skipped/>. Leaves real failures intact. 6 | 7 | Usage: 8 | python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml 9 | """ 10 | 11 | from __future__ import annotations 12 | import sys 13 | import os 14 | import re 15 | import xml.etree.ElementTree as ET 16 | 17 | PATTERNS = [ 18 | r"\bpermission\b", 19 | r"\bpermissions\b", 20 | r"\bautoApprove\b", 21 | r"\bapproval\b", 22 | r"\bdenied\b", 23 | r"requested\s+permissions", 24 | r"^MCP resources list is empty$", 25 | r"No MCP resources detected", 26 | r"aggregator.*returned\s*\[\s*\]", 27 | r"Unknown resource:\s*unity://", 28 | r"Input should be a valid dictionary.*ctx", 29 | r"validation error .* ctx", 30 | ] 31 | 32 | 33 | def should_skip(msg: str) -> bool: 34 | if not msg: 35 | return False 36 | msg_l = msg.strip() 37 | for pat in PATTERNS: 38 | if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE): 39 | return True 40 | return False 41 | 42 | 43 | def summarize_counts(ts: ET.Element): 44 | tests = 0 45 | failures = 0 46 | errors = 0 47 | skipped = 0 48 | for case in ts.findall("testcase"): 49 | tests += 1 50 | if case.find("failure") is not None: 51 | failures += 1 52 | if case.find("error") is not None: 53 | errors += 1 54 | if case.find("skipped") is not None: 55 | skipped += 1 56 | return tests, failures, errors, skipped 57 | 58 | 59 | def main(path: str) -> int: 60 | if not os.path.exists(path): 61 | print(f"[mark_skipped] No JUnit at {path}; nothing to do.") 62 | return 0 63 | 64 | try: 65 | tree = ET.parse(path) 66 | except ET.ParseError as e: 67 | print(f"[mark_skipped] Could not parse {path}: {e}") 68 | return 0 69 | 70 | root = tree.getroot() 71 | suites = root.findall("testsuite") if root.tag == "testsuites" else [root] 72 | 73 | changed = False 74 | for ts in suites: 75 | for case in list(ts.findall("testcase")): 76 | nodes = [n for n in list(case) if n.tag in ("failure", "error")] 77 | if not nodes: 78 | continue 79 | # If any node matches skip patterns, convert the whole case to skipped. 80 | first_match_text = None 81 | to_skip = False 82 | for n in nodes: 83 | msg = (n.get("message") or "") + "\n" + (n.text or "") 84 | if should_skip(msg): 85 | first_match_text = ( 86 | n.text or "").strip() or first_match_text 87 | to_skip = True 88 | if to_skip: 89 | for n in nodes: 90 | case.remove(n) 91 | reason = "Marked skipped: environment/permission precondition not met" 92 | skip = ET.SubElement(case, "skipped") 93 | skip.set("message", reason) 94 | skip.text = first_match_text or reason 95 | changed = True 96 | # Recompute tallies per testsuite 97 | tests, failures, errors, skipped = summarize_counts(ts) 98 | ts.set("tests", str(tests)) 99 | ts.set("failures", str(failures)) 100 | ts.set("errors", str(errors)) 101 | ts.set("skipped", str(skipped)) 102 | 103 | if changed: 104 | tree.write(path, encoding="utf-8", xml_declaration=True) 105 | print( 106 | f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") 107 | else: 108 | print(f"[mark_skipped] No environmental failures detected in {path}.") 109 | 110 | return 0 111 | 112 | 113 | if __name__ == "__main__": 114 | target = ( 115 | sys.argv[1] 116 | if len(sys.argv) > 1 117 | else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml") 118 | ) 119 | raise SystemExit(main(target)) 120 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Data/PythonToolsAsset.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using UnityEngine; 5 | 6 | namespace MCPForUnity.Editor.Data 7 | { 8 | /// <summary> 9 | /// Registry of Python tool files to sync to the MCP server. 10 | /// Add your Python files here - they can be stored anywhere in your project. 11 | /// </summary> 12 | [CreateAssetMenu(fileName = "PythonTools", menuName = "MCP For Unity/Python Tools")] 13 | public class PythonToolsAsset : ScriptableObject 14 | { 15 | [Tooltip("Add Python files (.py) to sync to the MCP server. Files can be located anywhere in your project.")] 16 | public List<TextAsset> pythonFiles = new List<TextAsset>(); 17 | 18 | [Header("Sync Options")] 19 | [Tooltip("Use content hashing to detect changes (recommended). If false, always copies on startup.")] 20 | public bool useContentHashing = true; 21 | 22 | [Header("Sync State (Read-only)")] 23 | [Tooltip("Internal tracking - do not modify")] 24 | public List<PythonFileState> fileStates = new List<PythonFileState>(); 25 | 26 | /// <summary> 27 | /// Gets all valid Python files (filters out null/missing references) 28 | /// </summary> 29 | public IEnumerable<TextAsset> GetValidFiles() 30 | { 31 | return pythonFiles.Where(f => f != null); 32 | } 33 | 34 | /// <summary> 35 | /// Checks if a file needs syncing 36 | /// </summary> 37 | public bool NeedsSync(TextAsset file, string currentHash) 38 | { 39 | if (!useContentHashing) return true; // Always sync if hashing disabled 40 | 41 | var state = fileStates.FirstOrDefault(s => s.assetGuid == GetAssetGuid(file)); 42 | return state == null || state.contentHash != currentHash; 43 | } 44 | 45 | /// <summary> 46 | /// Records that a file was synced 47 | /// </summary> 48 | public void RecordSync(TextAsset file, string hash) 49 | { 50 | string guid = GetAssetGuid(file); 51 | var state = fileStates.FirstOrDefault(s => s.assetGuid == guid); 52 | 53 | if (state == null) 54 | { 55 | state = new PythonFileState { assetGuid = guid }; 56 | fileStates.Add(state); 57 | } 58 | 59 | state.contentHash = hash; 60 | state.lastSyncTime = DateTime.UtcNow; 61 | state.fileName = file.name; 62 | } 63 | 64 | /// <summary> 65 | /// Removes state entries for files no longer in the list 66 | /// </summary> 67 | public void CleanupStaleStates() 68 | { 69 | var validGuids = new HashSet<string>(GetValidFiles().Select(GetAssetGuid)); 70 | fileStates.RemoveAll(s => !validGuids.Contains(s.assetGuid)); 71 | } 72 | 73 | private string GetAssetGuid(TextAsset asset) 74 | { 75 | return UnityEditor.AssetDatabase.AssetPathToGUID(UnityEditor.AssetDatabase.GetAssetPath(asset)); 76 | } 77 | 78 | /// <summary> 79 | /// Called when the asset is modified in the Inspector 80 | /// Triggers sync to handle file additions/removals 81 | /// </summary> 82 | private void OnValidate() 83 | { 84 | // Cleanup stale states immediately 85 | CleanupStaleStates(); 86 | 87 | // Trigger sync after a delay to handle file removals 88 | // Delay ensures the asset is saved before sync runs 89 | UnityEditor.EditorApplication.delayCall += () => 90 | { 91 | if (this != null) // Check if asset still exists 92 | { 93 | MCPForUnity.Editor.Helpers.PythonToolSyncProcessor.SyncAllTools(); 94 | } 95 | }; 96 | } 97 | } 98 | 99 | [Serializable] 100 | public class PythonFileState 101 | { 102 | public string assetGuid; 103 | public string fileName; 104 | public string contentHash; 105 | public DateTime lastSyncTime; 106 | } 107 | } ``` -------------------------------------------------------------------------------- /tests/test_manage_script_uri.py: -------------------------------------------------------------------------------- ```python 1 | import tools.manage_script as manage_script # type: ignore 2 | import sys 3 | import types 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | 9 | # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) 10 | ROOT = Path(__file__).resolve().parents[1] 11 | candidates = [ 12 | ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", 13 | ROOT / "UnityMcpServer~" / "src", 14 | ] 15 | SRC = next((p for p in candidates if p.exists()), None) 16 | if SRC is None: 17 | searched = "\n".join(str(p) for p in candidates) 18 | pytest.skip( 19 | "MCP for Unity server source not found. Tried:\n" + searched, 20 | allow_module_level=True, 21 | ) 22 | sys.path.insert(0, str(SRC)) 23 | 24 | # Stub mcp.server.fastmcp to satisfy imports without full package 25 | mcp_pkg = types.ModuleType("mcp") 26 | server_pkg = types.ModuleType("mcp.server") 27 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 28 | 29 | 30 | class _Dummy: 31 | pass 32 | 33 | 34 | fastmcp_pkg.FastMCP = _Dummy 35 | fastmcp_pkg.Context = _Dummy 36 | server_pkg.fastmcp = fastmcp_pkg 37 | mcp_pkg.server = server_pkg 38 | sys.modules.setdefault("mcp", mcp_pkg) 39 | sys.modules.setdefault("mcp.server", server_pkg) 40 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 41 | 42 | 43 | # Import target module after path injection 44 | 45 | 46 | class DummyMCP: 47 | def __init__(self): 48 | self.tools = {} 49 | 50 | def tool(self, *args, **kwargs): # ignore decorator kwargs like description 51 | def _decorator(fn): 52 | self.tools[fn.__name__] = fn 53 | return fn 54 | return _decorator 55 | 56 | 57 | class DummyCtx: # FastMCP Context placeholder 58 | pass 59 | 60 | 61 | def _register_tools(): 62 | mcp = DummyMCP() 63 | manage_script.register_manage_script_tools(mcp) # populates mcp.tools 64 | return mcp.tools 65 | 66 | 67 | def test_split_uri_unity_path(monkeypatch): 68 | tools = _register_tools() 69 | captured = {} 70 | 71 | def fake_send(cmd, params): # capture params and return success 72 | captured['cmd'] = cmd 73 | captured['params'] = params 74 | return {"success": True, "message": "ok"} 75 | 76 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 77 | 78 | fn = tools['apply_text_edits'] 79 | uri = "unity://path/Assets/Scripts/MyScript.cs" 80 | fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) 81 | 82 | assert captured['cmd'] == 'manage_script' 83 | assert captured['params']['name'] == 'MyScript' 84 | assert captured['params']['path'] == 'Assets/Scripts' 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "uri, expected_name, expected_path", 89 | [ 90 | ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", 91 | "Foo Bar", "Assets/Scripts"), 92 | ("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"), 93 | ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", 94 | "Hello", "Assets/Scripts"), 95 | # outside Assets → fall back to normalized dir 96 | ("file:///tmp/Other.cs", "Other", "tmp"), 97 | ], 98 | ) 99 | def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): 100 | tools = _register_tools() 101 | captured = {} 102 | 103 | def fake_send(cmd, params): 104 | captured['cmd'] = cmd 105 | captured['params'] = params 106 | return {"success": True, "message": "ok"} 107 | 108 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 109 | 110 | fn = tools['apply_text_edits'] 111 | fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None) 112 | 113 | assert captured['params']['name'] == expected_name 114 | assert captured['params']['path'] == expected_path 115 | 116 | 117 | def test_split_uri_plain_path(monkeypatch): 118 | tools = _register_tools() 119 | captured = {} 120 | 121 | def fake_send(cmd, params): 122 | captured['params'] = params 123 | return {"success": True, "message": "ok"} 124 | 125 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 126 | 127 | fn = tools['apply_text_edits'] 128 | fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", 129 | edits=[], precondition_sha256=None) 130 | 131 | assert captured['params']['name'] == 'Thing' 132 | assert captured['params']['path'] == 'Assets/Scripts' 133 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using NUnit.Framework; 2 | using MCPForUnity.Editor.Helpers; 3 | 4 | namespace MCPForUnityTests.Editor.Helpers 5 | { 6 | public class CodexConfigHelperTests 7 | { 8 | [Test] 9 | public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully() 10 | { 11 | string toml = string.Join("\n", new[] 12 | { 13 | "[mcp_servers.unityMCP]", 14 | "command = \"uv\"", 15 | "args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]" 16 | }); 17 | 18 | bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); 19 | 20 | Assert.IsTrue(result, "Parser should detect server definition"); 21 | Assert.AreEqual("uv", command); 22 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); 23 | } 24 | 25 | [Test] 26 | public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully() 27 | { 28 | string toml = string.Join("\n", new[] 29 | { 30 | "[mcp_servers.unityMCP]", 31 | "command = \"uv\"", 32 | "args = [", 33 | " \"run\",", 34 | " \"--directory\",", 35 | " \"/abs/path\",", 36 | " \"server.py\",", 37 | "]" 38 | }); 39 | 40 | bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); 41 | 42 | Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma"); 43 | Assert.AreEqual("uv", command); 44 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); 45 | } 46 | 47 | [Test] 48 | public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments() 49 | { 50 | string toml = string.Join("\n", new[] 51 | { 52 | "[mcp_servers.unityMCP]", 53 | "command = \"uv\"", 54 | "args = [", 55 | " \"run\", # launch command", 56 | " \"--directory\",", 57 | " \"/abs/path\",", 58 | " \"server.py\"", 59 | "]" 60 | }); 61 | 62 | bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); 63 | 64 | Assert.IsTrue(result, "Parser should tolerate comments within the array block"); 65 | Assert.AreEqual("uv", command); 66 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); 67 | } 68 | 69 | [Test] 70 | public void TryParseCodexServer_HeaderWithComment_StillDetected() 71 | { 72 | string toml = string.Join("\n", new[] 73 | { 74 | "[mcp_servers.unityMCP] # annotated header", 75 | "command = \"uv\"", 76 | "args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]" 77 | }); 78 | 79 | bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); 80 | 81 | Assert.IsTrue(result, "Parser should recognize section headers even with inline comments"); 82 | Assert.AreEqual("uv", command); 83 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args); 84 | } 85 | 86 | [Test] 87 | public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully() 88 | { 89 | string toml = string.Join("\n", new[] 90 | { 91 | "[mcp_servers.unityMCP]", 92 | "command = 'uv'", 93 | "args = ['run', '--directory', '/Users/O''Connor/codex', 'server.py']" 94 | }); 95 | 96 | bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args); 97 | 98 | Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes"); 99 | Assert.AreEqual("uv", command); 100 | CollectionAssert.AreEqual(new[] { "run", "--directory", "/Users/O'Connor/codex", "server.py" }, args); 101 | } 102 | } 103 | } 104 | ``` -------------------------------------------------------------------------------- /deploy-dev.bat: -------------------------------------------------------------------------------- ``` 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | echo =============================================== 5 | echo MCP for Unity Development Deployment Script 6 | echo =============================================== 7 | echo. 8 | 9 | :: Configuration 10 | set "SCRIPT_DIR=%~dp0" 11 | set "BRIDGE_SOURCE=%SCRIPT_DIR%MCPForUnity" 12 | set "SERVER_SOURCE=%SCRIPT_DIR%MCPForUnity\UnityMcpServer~\src" 13 | set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" 14 | set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" 15 | 16 | :: Get user inputs 17 | echo Please provide the following paths: 18 | echo. 19 | 20 | :: Package cache location 21 | echo Unity Package Cache Location: 22 | echo Example: X:\UnityProject\Library\PackageCache\[email protected] 23 | set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " 24 | 25 | if "%PACKAGE_CACHE_PATH%"=="" ( 26 | echo Error: Package cache path cannot be empty! 27 | pause 28 | exit /b 1 29 | ) 30 | 31 | :: Server installation path (with default) 32 | echo. 33 | echo Server Installation Path: 34 | echo Default: %DEFAULT_SERVER_PATH% 35 | set /p "SERVER_PATH=Enter server path (or press Enter for default): " 36 | if "%SERVER_PATH%"=="" set "SERVER_PATH=%DEFAULT_SERVER_PATH%" 37 | 38 | :: Backup location (with default) 39 | echo. 40 | echo Backup Location: 41 | echo Default: %DEFAULT_BACKUP_DIR% 42 | set /p "BACKUP_DIR=Enter backup directory (or press Enter for default): " 43 | if "%BACKUP_DIR%"=="" set "BACKUP_DIR=%DEFAULT_BACKUP_DIR%" 44 | 45 | :: Validation 46 | echo. 47 | echo =============================================== 48 | echo Validating paths... 49 | echo =============================================== 50 | 51 | if not exist "%BRIDGE_SOURCE%" ( 52 | echo Error: Bridge source not found: %BRIDGE_SOURCE% 53 | pause 54 | exit /b 1 55 | ) 56 | 57 | if not exist "%SERVER_SOURCE%" ( 58 | echo Error: Server source not found: %SERVER_SOURCE% 59 | pause 60 | exit /b 1 61 | ) 62 | 63 | if not exist "%PACKAGE_CACHE_PATH%" ( 64 | echo Error: Package cache path not found: %PACKAGE_CACHE_PATH% 65 | pause 66 | exit /b 1 67 | ) 68 | 69 | if not exist "%SERVER_PATH%" ( 70 | echo Error: Server installation path not found: %SERVER_PATH% 71 | pause 72 | exit /b 1 73 | ) 74 | 75 | :: Create backup directory 76 | if not exist "%BACKUP_DIR%" ( 77 | echo Creating backup directory: %BACKUP_DIR% 78 | mkdir "%BACKUP_DIR%" 79 | ) 80 | 81 | :: Create timestamped backup subdirectory 82 | set "TIMESTAMP=%date:~-4,4%%date:~-10,2%%date:~-7,2%_%time:~0,2%%time:~3,2%%time:~6,2%" 83 | set "TIMESTAMP=%TIMESTAMP: =0%" 84 | set "TIMESTAMP=%TIMESTAMP::=-%" 85 | set "TIMESTAMP=%TIMESTAMP:/=-%" 86 | set "BACKUP_SUBDIR=%BACKUP_DIR%\backup_%TIMESTAMP%" 87 | mkdir "%BACKUP_SUBDIR%" 88 | 89 | echo. 90 | echo =============================================== 91 | echo Starting deployment... 92 | echo =============================================== 93 | 94 | :: Backup original files 95 | echo Creating backup of original files... 96 | if exist "%PACKAGE_CACHE_PATH%\Editor" ( 97 | echo Backing up Unity Bridge files... 98 | xcopy "%PACKAGE_CACHE_PATH%\Editor" "%BACKUP_SUBDIR%\UnityBridge\Editor\" /E /I /Y > nul 99 | if !errorlevel! neq 0 ( 100 | echo Error: Failed to backup Unity Bridge files 101 | pause 102 | exit /b 1 103 | ) 104 | ) 105 | 106 | if exist "%SERVER_PATH%" ( 107 | echo Backing up Python Server files... 108 | xcopy "%SERVER_PATH%\*" "%BACKUP_SUBDIR%\PythonServer\" /E /I /Y > nul 109 | if !errorlevel! neq 0 ( 110 | echo Error: Failed to backup Python Server files 111 | pause 112 | exit /b 1 113 | ) 114 | ) 115 | 116 | :: Deploy Unity Bridge 117 | echo. 118 | echo Deploying Unity Bridge code... 119 | xcopy "%BRIDGE_SOURCE%\Editor\*" "%PACKAGE_CACHE_PATH%\Editor\" /E /Y > nul 120 | if !errorlevel! neq 0 ( 121 | echo Error: Failed to deploy Unity Bridge code 122 | pause 123 | exit /b 1 124 | ) 125 | 126 | :: Deploy Python Server 127 | echo Deploying Python Server code... 128 | xcopy "%SERVER_SOURCE%\*" "%SERVER_PATH%\" /E /Y > nul 129 | if !errorlevel! neq 0 ( 130 | echo Error: Failed to deploy Python Server code 131 | pause 132 | exit /b 1 133 | ) 134 | 135 | :: Success 136 | echo. 137 | echo =============================================== 138 | echo Deployment completed successfully! 139 | echo =============================================== 140 | echo. 141 | echo Backup created at: %BACKUP_SUBDIR% 142 | echo. 143 | echo Next steps: 144 | echo 1. Restart Unity Editor to load new Bridge code 145 | echo 2. Restart any MCP clients to use new Server code 146 | echo 3. Use restore-dev.bat to rollback if needed 147 | echo. 148 | pause ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Helpers/PackageDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using UnityEditor; 2 | using UnityEngine; 3 | 4 | namespace MCPForUnity.Editor.Helpers 5 | { 6 | /// <summary> 7 | /// Auto-runs legacy/older install detection on package load/update (log-only). 8 | /// Runs once per embedded server version using an EditorPrefs version-scoped key. 9 | /// </summary> 10 | [InitializeOnLoad] 11 | public static class PackageDetector 12 | { 13 | private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; 14 | 15 | static PackageDetector() 16 | { 17 | try 18 | { 19 | string pkgVer = ReadPackageVersionOrFallback(); 20 | string key = DetectOnceFlagKeyPrefix + pkgVer; 21 | 22 | // Always force-run if legacy roots exist or canonical install is missing 23 | bool legacyPresent = LegacyRootsExist(); 24 | bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); 25 | 26 | if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) 27 | { 28 | // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. 29 | EditorApplication.delayCall += () => 30 | { 31 | string error = null; 32 | System.Exception capturedEx = null; 33 | try 34 | { 35 | // Ensure any UnityEditor API usage inside runs on the main thread 36 | ServerInstaller.EnsureServerInstalled(); 37 | } 38 | catch (System.Exception ex) 39 | { 40 | error = ex.Message; 41 | capturedEx = ex; 42 | } 43 | 44 | // Unity APIs must stay on main thread 45 | try { EditorPrefs.SetBool(key, true); } catch { } 46 | // Ensure prefs cleanup happens on main thread 47 | try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } 48 | try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } 49 | 50 | if (!string.IsNullOrEmpty(error)) 51 | { 52 | McpLog.Info($"Server check: {error}. Download via Window > MCP For Unity if needed.", always: false); 53 | } 54 | }; 55 | } 56 | } 57 | catch { /* ignore */ } 58 | } 59 | 60 | private static string ReadEmbeddedVersionOrFallback() 61 | { 62 | try 63 | { 64 | if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) 65 | { 66 | var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); 67 | if (System.IO.File.Exists(p)) 68 | return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); 69 | } 70 | } 71 | catch { } 72 | return "unknown"; 73 | } 74 | 75 | private static string ReadPackageVersionOrFallback() 76 | { 77 | try 78 | { 79 | var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); 80 | if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; 81 | } 82 | catch { } 83 | // Fallback to embedded server version if package info unavailable 84 | return ReadEmbeddedVersionOrFallback(); 85 | } 86 | 87 | private static bool LegacyRootsExist() 88 | { 89 | try 90 | { 91 | string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; 92 | string[] roots = 93 | { 94 | System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), 95 | System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") 96 | }; 97 | foreach (var r in roots) 98 | { 99 | try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } 100 | } 101 | } 102 | catch { } 103 | return false; 104 | } 105 | } 106 | } 107 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Helpers/PackageDetector.cs: -------------------------------------------------------------------------------- ```csharp 1 | using UnityEditor; 2 | using UnityEngine; 3 | 4 | namespace MCPForUnity.Editor.Helpers 5 | { 6 | /// <summary> 7 | /// Auto-runs legacy/older install detection on package load/update (log-only). 8 | /// Runs once per embedded server version using an EditorPrefs version-scoped key. 9 | /// </summary> 10 | [InitializeOnLoad] 11 | public static class PackageDetector 12 | { 13 | private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; 14 | 15 | static PackageDetector() 16 | { 17 | try 18 | { 19 | string pkgVer = ReadPackageVersionOrFallback(); 20 | string key = DetectOnceFlagKeyPrefix + pkgVer; 21 | 22 | // Always force-run if legacy roots exist or canonical install is missing 23 | bool legacyPresent = LegacyRootsExist(); 24 | bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); 25 | 26 | if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) 27 | { 28 | // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. 29 | EditorApplication.delayCall += () => 30 | { 31 | string error = null; 32 | System.Exception capturedEx = null; 33 | try 34 | { 35 | // Ensure any UnityEditor API usage inside runs on the main thread 36 | ServerInstaller.EnsureServerInstalled(); 37 | } 38 | catch (System.Exception ex) 39 | { 40 | error = ex.Message; 41 | capturedEx = ex; 42 | } 43 | 44 | // Unity APIs must stay on main thread 45 | try { EditorPrefs.SetBool(key, true); } catch { } 46 | // Ensure prefs cleanup happens on main thread 47 | try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } 48 | try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } 49 | 50 | if (!string.IsNullOrEmpty(error)) 51 | { 52 | Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); 53 | // Alternatively: Debug.LogException(capturedEx); 54 | } 55 | }; 56 | } 57 | } 58 | catch { /* ignore */ } 59 | } 60 | 61 | private static string ReadEmbeddedVersionOrFallback() 62 | { 63 | try 64 | { 65 | if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) 66 | { 67 | var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); 68 | if (System.IO.File.Exists(p)) 69 | return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); 70 | } 71 | } 72 | catch { } 73 | return "unknown"; 74 | } 75 | 76 | private static string ReadPackageVersionOrFallback() 77 | { 78 | try 79 | { 80 | var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); 81 | if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; 82 | } 83 | catch { } 84 | // Fallback to embedded server version if package info unavailable 85 | return ReadEmbeddedVersionOrFallback(); 86 | } 87 | 88 | private static bool LegacyRootsExist() 89 | { 90 | try 91 | { 92 | string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; 93 | string[] roots = 94 | { 95 | System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), 96 | System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") 97 | }; 98 | foreach (var r in roots) 99 | { 100 | try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } 101 | } 102 | } 103 | catch { } 104 | return false; 105 | } 106 | } 107 | } 108 | ``` -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Bump Version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version_bump: 7 | description: "Version bump type" 8 | type: choice 9 | options: 10 | - patch 11 | - minor 12 | - major 13 | default: patch 14 | required: true 15 | 16 | jobs: 17 | bump: 18 | name: "Bump version and tag" 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Compute new version 29 | id: compute 30 | shell: bash 31 | run: | 32 | set -euo pipefail 33 | BUMP="${{ inputs.version_bump }}" 34 | CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") 35 | echo "Current version: $CURRENT_VERSION" 36 | 37 | IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION" 38 | case "$BUMP" in 39 | major) 40 | ((MA+=1)); MI=0; PA=0 41 | ;; 42 | minor) 43 | ((MI+=1)); PA=0 44 | ;; 45 | patch) 46 | ((PA+=1)) 47 | ;; 48 | *) 49 | echo "Unknown version_bump: $BUMP" >&2 50 | exit 1 51 | ;; 52 | esac 53 | 54 | NEW_VERSION="$MA.$MI.$PA" 55 | echo "New version: $NEW_VERSION" 56 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 57 | echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" 58 | 59 | - name: Update files to new version 60 | env: 61 | NEW_VERSION: ${{ steps.compute.outputs.new_version }} 62 | shell: bash 63 | run: | 64 | set -euo pipefail 65 | 66 | echo "Updating MCPForUnity/package.json to $NEW_VERSION" 67 | jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp 68 | mv MCPForUnity/package.json.tmp MCPForUnity/package.json 69 | 70 | echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" 71 | sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" 72 | 73 | echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" 74 | echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" 75 | 76 | - name: Commit and push changes 77 | env: 78 | NEW_VERSION: ${{ steps.compute.outputs.new_version }} 79 | shell: bash 80 | run: | 81 | set -euo pipefail 82 | git config user.name "GitHub Actions" 83 | git config user.email "[email protected]" 84 | git add MCPForUnity/package.json "MCPForUnity/UnityMcpServer~/src/pyproject.toml" "MCPForUnity/UnityMcpServer~/src/server_version.txt" 85 | if git diff --cached --quiet; then 86 | echo "No version changes to commit." 87 | else 88 | git commit -m "chore: bump version to ${NEW_VERSION}" 89 | fi 90 | 91 | BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" 92 | echo "Pushing to branch: $BRANCH" 93 | git push origin "$BRANCH" 94 | 95 | - name: Create and push tag 96 | env: 97 | NEW_VERSION: ${{ steps.compute.outputs.new_version }} 98 | shell: bash 99 | run: | 100 | set -euo pipefail 101 | TAG="v${NEW_VERSION}" 102 | echo "Preparing to create tag $TAG" 103 | 104 | if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then 105 | echo "Tag $TAG already exists on remote. Skipping tag creation." 106 | exit 0 107 | fi 108 | 109 | git tag -a "$TAG" -m "Version ${NEW_VERSION}" 110 | git push origin "$TAG" 111 | 112 | - name: Package server for release 113 | env: 114 | NEW_VERSION: ${{ steps.compute.outputs.new_version }} 115 | shell: bash 116 | run: | 117 | set -euo pipefail 118 | cd MCPForUnity 119 | zip -r ../mcp-for-unity-server-v${NEW_VERSION}.zip UnityMcpServer~ 120 | cd .. 121 | ls -lh mcp-for-unity-server-v${NEW_VERSION}.zip 122 | echo "Server package created: mcp-for-unity-server-v${NEW_VERSION}.zip" 123 | 124 | - name: Create GitHub release with server artifact 125 | env: 126 | NEW_VERSION: ${{ steps.compute.outputs.new_version }} 127 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 128 | shell: bash 129 | run: | 130 | set -euo pipefail 131 | TAG="v${NEW_VERSION}" 132 | 133 | # Create release 134 | gh release create "$TAG" \ 135 | --title "v${NEW_VERSION}" \ 136 | --notes "Release v${NEW_VERSION}" \ 137 | "mcp-for-unity-server-v${NEW_VERSION}.zip#MCP Server v${NEW_VERSION}" 138 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Telemetry decorator for Unity MCP tools 3 | """ 4 | 5 | import functools 6 | import inspect 7 | import logging 8 | import time 9 | from typing import Callable, Any 10 | 11 | from telemetry import record_tool_usage, record_milestone, MilestoneType 12 | 13 | _log = logging.getLogger("unity-mcp-telemetry") 14 | _decorator_log_count = 0 15 | 16 | 17 | def telemetry_tool(tool_name: str): 18 | """Decorator to add telemetry tracking to MCP tools""" 19 | def decorator(func: Callable) -> Callable: 20 | @functools.wraps(func) 21 | def _sync_wrapper(*args, **kwargs) -> Any: 22 | start_time = time.time() 23 | success = False 24 | error = None 25 | # Extract sub-action (e.g., 'get_hierarchy') from bound args when available 26 | sub_action = None 27 | try: 28 | sig = inspect.signature(func) 29 | bound = sig.bind_partial(*args, **kwargs) 30 | bound.apply_defaults() 31 | sub_action = bound.arguments.get("action") 32 | except Exception: 33 | sub_action = None 34 | try: 35 | global _decorator_log_count 36 | if _decorator_log_count < 10: 37 | _log.info(f"telemetry_decorator sync: tool={tool_name}") 38 | _decorator_log_count += 1 39 | result = func(*args, **kwargs) 40 | success = True 41 | action_val = sub_action or kwargs.get("action") 42 | try: 43 | if tool_name == "manage_script" and action_val == "create": 44 | record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) 45 | elif tool_name.startswith("manage_scene"): 46 | record_milestone( 47 | MilestoneType.FIRST_SCENE_MODIFICATION) 48 | record_milestone(MilestoneType.FIRST_TOOL_USAGE) 49 | except Exception: 50 | _log.debug("milestone emit failed", exc_info=True) 51 | return result 52 | except Exception as e: 53 | error = str(e) 54 | raise 55 | finally: 56 | duration_ms = (time.time() - start_time) * 1000 57 | try: 58 | record_tool_usage(tool_name, success, 59 | duration_ms, error, sub_action=sub_action) 60 | except Exception: 61 | _log.debug("record_tool_usage failed", exc_info=True) 62 | 63 | @functools.wraps(func) 64 | async def _async_wrapper(*args, **kwargs) -> Any: 65 | start_time = time.time() 66 | success = False 67 | error = None 68 | # Extract sub-action (e.g., 'get_hierarchy') from bound args when available 69 | sub_action = None 70 | try: 71 | sig = inspect.signature(func) 72 | bound = sig.bind_partial(*args, **kwargs) 73 | bound.apply_defaults() 74 | sub_action = bound.arguments.get("action") 75 | except Exception: 76 | sub_action = None 77 | try: 78 | global _decorator_log_count 79 | if _decorator_log_count < 10: 80 | _log.info(f"telemetry_decorator async: tool={tool_name}") 81 | _decorator_log_count += 1 82 | result = await func(*args, **kwargs) 83 | success = True 84 | action_val = sub_action or kwargs.get("action") 85 | try: 86 | if tool_name == "manage_script" and action_val == "create": 87 | record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) 88 | elif tool_name.startswith("manage_scene"): 89 | record_milestone( 90 | MilestoneType.FIRST_SCENE_MODIFICATION) 91 | record_milestone(MilestoneType.FIRST_TOOL_USAGE) 92 | except Exception: 93 | _log.debug("milestone emit failed", exc_info=True) 94 | return result 95 | except Exception as e: 96 | error = str(e) 97 | raise 98 | finally: 99 | duration_ms = (time.time() - start_time) * 1000 100 | try: 101 | record_tool_usage(tool_name, success, 102 | duration_ms, error, sub_action=sub_action) 103 | except Exception: 104 | _log.debug("record_tool_usage failed", exc_info=True) 105 | 106 | return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper 107 | return decorator 108 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/CommandRegistry.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Text.RegularExpressions; 6 | using MCPForUnity.Editor.Helpers; 7 | using Newtonsoft.Json.Linq; 8 | 9 | namespace MCPForUnity.Editor.Tools 10 | { 11 | /// <summary> 12 | /// Registry for all MCP command handlers via reflection. 13 | /// </summary> 14 | public static class CommandRegistry 15 | { 16 | private static readonly Dictionary<string, Func<JObject, object>> _handlers = new(); 17 | private static bool _initialized = false; 18 | 19 | /// <summary> 20 | /// Initialize and auto-discover all tools marked with [McpForUnityTool] 21 | /// </summary> 22 | public static void Initialize() 23 | { 24 | if (_initialized) return; 25 | 26 | AutoDiscoverTools(); 27 | _initialized = true; 28 | } 29 | 30 | /// <summary> 31 | /// Convert PascalCase or camelCase to snake_case 32 | /// </summary> 33 | private static string ToSnakeCase(string name) 34 | { 35 | if (string.IsNullOrEmpty(name)) return name; 36 | 37 | // Insert underscore before uppercase letters (except first) 38 | var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); 39 | var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); 40 | return s2.ToLower(); 41 | } 42 | 43 | /// <summary> 44 | /// Auto-discover all types with [McpForUnityTool] attribute 45 | /// </summary> 46 | private static void AutoDiscoverTools() 47 | { 48 | try 49 | { 50 | var toolTypes = AppDomain.CurrentDomain.GetAssemblies() 51 | .Where(a => !a.IsDynamic) 52 | .SelectMany(a => 53 | { 54 | try { return a.GetTypes(); } 55 | catch { return new Type[0]; } 56 | }) 57 | .Where(t => t.GetCustomAttribute<McpForUnityToolAttribute>() != null); 58 | 59 | foreach (var type in toolTypes) 60 | { 61 | RegisterToolType(type); 62 | } 63 | 64 | McpLog.Info($"Auto-discovered {_handlers.Count} tools"); 65 | } 66 | catch (Exception ex) 67 | { 68 | McpLog.Error($"Failed to auto-discover MCP tools: {ex.Message}"); 69 | } 70 | } 71 | 72 | private static void RegisterToolType(Type type) 73 | { 74 | var attr = type.GetCustomAttribute<McpForUnityToolAttribute>(); 75 | 76 | // Get command name (explicit or auto-generated) 77 | string commandName = attr.CommandName; 78 | if (string.IsNullOrEmpty(commandName)) 79 | { 80 | commandName = ToSnakeCase(type.Name); 81 | } 82 | 83 | // Check for duplicate command names 84 | if (_handlers.ContainsKey(commandName)) 85 | { 86 | McpLog.Warn( 87 | $"Duplicate command name '{commandName}' detected. " + 88 | $"Tool {type.Name} will override previously registered handler." 89 | ); 90 | } 91 | 92 | // Find HandleCommand method 93 | var method = type.GetMethod( 94 | "HandleCommand", 95 | BindingFlags.Public | BindingFlags.Static, 96 | null, 97 | new[] { typeof(JObject) }, 98 | null 99 | ); 100 | 101 | if (method == null) 102 | { 103 | McpLog.Warn( 104 | $"MCP tool {type.Name} is marked with [McpForUnityTool] " + 105 | $"but has no public static HandleCommand(JObject) method" 106 | ); 107 | return; 108 | } 109 | 110 | try 111 | { 112 | var handler = (Func<JObject, object>)Delegate.CreateDelegate( 113 | typeof(Func<JObject, object>), 114 | method 115 | ); 116 | _handlers[commandName] = handler; 117 | } 118 | catch (Exception ex) 119 | { 120 | McpLog.Error($"Failed to register tool {type.Name}: {ex.Message}"); 121 | } 122 | } 123 | 124 | /// <summary> 125 | /// Get a command handler by name 126 | /// </summary> 127 | public static Func<JObject, object> GetHandler(string commandName) 128 | { 129 | if (!_handlers.TryGetValue(commandName, out var handler)) 130 | { 131 | throw new InvalidOperationException( 132 | $"Unknown or unsupported command type: {commandName}" 133 | ); 134 | } 135 | return handler; 136 | } 137 | } 138 | } 139 | ``` -------------------------------------------------------------------------------- /TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PythonToolRegistryServiceTests.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using NUnit.Framework; 4 | using UnityEngine; 5 | using MCPForUnity.Editor.Data; 6 | using MCPForUnity.Editor.Services; 7 | 8 | namespace MCPForUnityTests.Editor.Services 9 | { 10 | public class PythonToolRegistryServiceTests 11 | { 12 | private PythonToolRegistryService _service; 13 | 14 | [SetUp] 15 | public void SetUp() 16 | { 17 | _service = new PythonToolRegistryService(); 18 | } 19 | 20 | [Test] 21 | public void GetAllRegistries_ReturnsEmptyList_WhenNoPythonToolsAssetsExist() 22 | { 23 | var registries = _service.GetAllRegistries().ToList(); 24 | 25 | // Note: This might find assets in the test project, so we just verify it doesn't throw 26 | Assert.IsNotNull(registries, "Should return a non-null list"); 27 | } 28 | 29 | [Test] 30 | public void NeedsSync_ReturnsTrue_WhenHashingDisabled() 31 | { 32 | var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 33 | asset.useContentHashing = false; 34 | 35 | var textAsset = new TextAsset("print('test')"); 36 | 37 | bool needsSync = _service.NeedsSync(asset, textAsset); 38 | 39 | Assert.IsTrue(needsSync, "Should always need sync when hashing is disabled"); 40 | 41 | Object.DestroyImmediate(asset); 42 | } 43 | 44 | [Test] 45 | public void NeedsSync_ReturnsTrue_WhenFileNotPreviouslySynced() 46 | { 47 | var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 48 | asset.useContentHashing = true; 49 | 50 | var textAsset = new TextAsset("print('test')"); 51 | 52 | bool needsSync = _service.NeedsSync(asset, textAsset); 53 | 54 | Assert.IsTrue(needsSync, "Should need sync for new file"); 55 | 56 | Object.DestroyImmediate(asset); 57 | } 58 | 59 | [Test] 60 | public void NeedsSync_ReturnsFalse_WhenHashMatches() 61 | { 62 | var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 63 | asset.useContentHashing = true; 64 | 65 | var textAsset = new TextAsset("print('test')"); 66 | 67 | // First sync 68 | _service.RecordSync(asset, textAsset); 69 | 70 | // Check if needs sync again 71 | bool needsSync = _service.NeedsSync(asset, textAsset); 72 | 73 | Assert.IsFalse(needsSync, "Should not need sync when hash matches"); 74 | 75 | Object.DestroyImmediate(asset); 76 | } 77 | 78 | [Test] 79 | public void RecordSync_StoresFileState() 80 | { 81 | var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 82 | var textAsset = new TextAsset("print('test')"); 83 | 84 | _service.RecordSync(asset, textAsset); 85 | 86 | Assert.AreEqual(1, asset.fileStates.Count, "Should have one file state recorded"); 87 | Assert.IsNotNull(asset.fileStates[0].contentHash, "Hash should be stored"); 88 | Assert.IsNotNull(asset.fileStates[0].assetGuid, "GUID should be stored"); 89 | 90 | Object.DestroyImmediate(asset); 91 | } 92 | 93 | [Test] 94 | public void RecordSync_UpdatesExistingState_WhenFileAlreadyRecorded() 95 | { 96 | var asset = ScriptableObject.CreateInstance<PythonToolsAsset>(); 97 | var textAsset = new TextAsset("print('test')"); 98 | 99 | // Record twice 100 | _service.RecordSync(asset, textAsset); 101 | var firstHash = asset.fileStates[0].contentHash; 102 | 103 | _service.RecordSync(asset, textAsset); 104 | 105 | Assert.AreEqual(1, asset.fileStates.Count, "Should still have only one state"); 106 | Assert.AreEqual(firstHash, asset.fileStates[0].contentHash, "Hash should remain the same"); 107 | 108 | Object.DestroyImmediate(asset); 109 | } 110 | 111 | [Test] 112 | public void ComputeHash_ReturnsSameHash_ForSameContent() 113 | { 114 | var textAsset1 = new TextAsset("print('hello')"); 115 | var textAsset2 = new TextAsset("print('hello')"); 116 | 117 | string hash1 = _service.ComputeHash(textAsset1); 118 | string hash2 = _service.ComputeHash(textAsset2); 119 | 120 | Assert.AreEqual(hash1, hash2, "Same content should produce same hash"); 121 | } 122 | 123 | [Test] 124 | public void ComputeHash_ReturnsDifferentHash_ForDifferentContent() 125 | { 126 | var textAsset1 = new TextAsset("print('hello')"); 127 | var textAsset2 = new TextAsset("print('world')"); 128 | 129 | string hash1 = _service.ComputeHash(textAsset1); 130 | string hash2 = _service.ComputeHash(textAsset2); 131 | 132 | Assert.AreNotEqual(hash1, hash2, "Different content should produce different hash"); 133 | } 134 | } 135 | } 136 | ``` -------------------------------------------------------------------------------- /restore-dev.bat: -------------------------------------------------------------------------------- ``` 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | echo =============================================== 5 | echo MCP for Unity Development Restore Script 6 | echo =============================================== 7 | echo. 8 | echo Note: The Python server is bundled under MCPForUnity\UnityMcpServer~ in the package. 9 | echo This script restores your installed server path from backups, not the repo copy. 10 | echo. 11 | 12 | :: Configuration 13 | set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" 14 | set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" 15 | 16 | :: Get user inputs 17 | echo Please provide the following paths: 18 | echo. 19 | 20 | :: Package cache location 21 | echo Unity Package Cache Location: 22 | echo Example: X:\UnityProject\Library\PackageCache\[email protected] 23 | set /p "PACKAGE_CACHE_PATH=Enter Unity package cache path: " 24 | 25 | if "%PACKAGE_CACHE_PATH%"=="" ( 26 | echo Error: Package cache path cannot be empty! 27 | pause 28 | exit /b 1 29 | ) 30 | 31 | :: Server installation path (with default) 32 | echo. 33 | echo Server Installation Path: 34 | echo Default: %DEFAULT_SERVER_PATH% 35 | set /p "SERVER_PATH=Enter server path (or press Enter for default): " 36 | if "%SERVER_PATH%"=="" set "SERVER_PATH=%DEFAULT_SERVER_PATH%" 37 | 38 | :: Backup location (with default) 39 | echo. 40 | echo Backup Location: 41 | echo Default: %DEFAULT_BACKUP_DIR% 42 | set /p "BACKUP_DIR=Enter backup directory (or press Enter for default): " 43 | if "%BACKUP_DIR%"=="" set "BACKUP_DIR=%DEFAULT_BACKUP_DIR%" 44 | 45 | :: List available backups 46 | echo. 47 | echo =============================================== 48 | echo Available backups: 49 | echo =============================================== 50 | set "counter=0" 51 | for /d %%d in ("%BACKUP_DIR%\backup_*") do ( 52 | set /a counter+=1 53 | set "backup!counter!=%%d" 54 | echo !counter!. %%~nxd 55 | ) 56 | 57 | if %counter%==0 ( 58 | echo No backups found in %BACKUP_DIR% 59 | pause 60 | exit /b 1 61 | ) 62 | 63 | echo. 64 | set /p "choice=Select backup to restore (1-%counter%): " 65 | 66 | :: Validate choice 67 | if "%choice%"=="" goto :invalid_choice 68 | if %choice% lss 1 goto :invalid_choice 69 | if %choice% gtr %counter% goto :invalid_choice 70 | 71 | set "SELECTED_BACKUP=!backup%choice%!" 72 | echo. 73 | echo Selected backup: %SELECTED_BACKUP% 74 | 75 | :: Validation 76 | echo. 77 | echo =============================================== 78 | echo Validating paths... 79 | echo =============================================== 80 | 81 | if not exist "%SELECTED_BACKUP%" ( 82 | echo Error: Selected backup not found: %SELECTED_BACKUP% 83 | pause 84 | exit /b 1 85 | ) 86 | 87 | if not exist "%PACKAGE_CACHE_PATH%" ( 88 | echo Error: Package cache path not found: %PACKAGE_CACHE_PATH% 89 | pause 90 | exit /b 1 91 | ) 92 | 93 | if not exist "%SERVER_PATH%" ( 94 | echo Error: Server installation path not found: %SERVER_PATH% 95 | pause 96 | exit /b 1 97 | ) 98 | 99 | :: Confirm restore 100 | echo. 101 | echo =============================================== 102 | echo WARNING: This will overwrite current files! 103 | echo =============================================== 104 | echo Restoring from: %SELECTED_BACKUP% 105 | echo Unity Bridge target: %PACKAGE_CACHE_PATH%\Editor 106 | echo Python Server target: %SERVER_PATH% 107 | echo. 108 | set /p "confirm=Continue with restore? (y/N): " 109 | if /i not "%confirm%"=="y" ( 110 | echo Restore cancelled. 111 | pause 112 | exit /b 0 113 | ) 114 | 115 | echo. 116 | echo =============================================== 117 | echo Starting restore... 118 | echo =============================================== 119 | 120 | :: Restore Unity Bridge 121 | if exist "%SELECTED_BACKUP%\UnityBridge\Editor" ( 122 | echo Restoring Unity Bridge files... 123 | rd /s /q "%PACKAGE_CACHE_PATH%\Editor" 2>nul 124 | xcopy "%SELECTED_BACKUP%\UnityBridge\Editor\*" "%PACKAGE_CACHE_PATH%\Editor\" /E /I /Y > nul 125 | if !errorlevel! neq 0 ( 126 | echo Error: Failed to restore Unity Bridge files 127 | pause 128 | exit /b 1 129 | ) 130 | ) else ( 131 | echo Warning: No Unity Bridge backup found, skipping... 132 | ) 133 | 134 | :: Restore Python Server 135 | if exist "%SELECTED_BACKUP%\PythonServer" ( 136 | echo Restoring Python Server files... 137 | rd /s /q "%SERVER_PATH%" 2>nul 138 | mkdir "%SERVER_PATH%" 139 | xcopy "%SELECTED_BACKUP%\PythonServer\*" "%SERVER_PATH%\" /E /I /Y > nul 140 | if !errorlevel! neq 0 ( 141 | echo Error: Failed to restore Python Server files 142 | pause 143 | exit /b 1 144 | ) 145 | ) else ( 146 | echo Warning: No Python Server backup found, skipping... 147 | ) 148 | 149 | :: Success 150 | echo. 151 | echo =============================================== 152 | echo Restore completed successfully! 153 | echo =============================================== 154 | echo. 155 | echo Next steps: 156 | echo 1. Restart Unity Editor to load restored Bridge code 157 | echo 2. Restart any MCP clients to use restored Server code 158 | echo. 159 | pause 160 | exit /b 0 161 | 162 | :invalid_choice 163 | echo Invalid choice. Please enter a number between 1 and %counter%. 164 | pause 165 | exit /b 1 ``` -------------------------------------------------------------------------------- /tests/test_edit_normalization_and_noop.py: -------------------------------------------------------------------------------- ```python 1 | import sys 2 | import pathlib 3 | import importlib.util 4 | import types 5 | 6 | 7 | ROOT = pathlib.Path(__file__).resolve().parents[1] 8 | SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" 9 | sys.path.insert(0, str(SRC)) 10 | 11 | # stub mcp.server.fastmcp 12 | mcp_pkg = types.ModuleType("mcp") 13 | server_pkg = types.ModuleType("mcp.server") 14 | fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") 15 | 16 | 17 | class _Dummy: 18 | pass 19 | 20 | 21 | fastmcp_pkg.FastMCP = _Dummy 22 | fastmcp_pkg.Context = _Dummy 23 | server_pkg.fastmcp = fastmcp_pkg 24 | mcp_pkg.server = server_pkg 25 | sys.modules.setdefault("mcp", mcp_pkg) 26 | sys.modules.setdefault("mcp.server", server_pkg) 27 | sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) 28 | 29 | 30 | def _load(path: pathlib.Path, name: str): 31 | spec = importlib.util.spec_from_file_location(name, path) 32 | mod = importlib.util.module_from_spec(spec) 33 | spec.loader.exec_module(mod) 34 | return mod 35 | 36 | 37 | manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") 38 | manage_script_edits = _load( 39 | SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") 40 | 41 | 42 | class DummyMCP: 43 | def __init__(self): self.tools = {} 44 | 45 | def tool(self, *args, **kwargs): 46 | def deco(fn): self.tools[fn.__name__] = fn; return fn 47 | return deco 48 | 49 | 50 | def setup_tools(): 51 | mcp = DummyMCP() 52 | manage_script.register_manage_script_tools(mcp) 53 | return mcp.tools 54 | 55 | 56 | def test_normalizes_lsp_and_index_ranges(monkeypatch): 57 | tools = setup_tools() 58 | apply = tools["apply_text_edits"] 59 | calls = [] 60 | 61 | def fake_send(cmd, params): 62 | calls.append(params) 63 | return {"success": True} 64 | 65 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 66 | 67 | # LSP-style 68 | edits = [{ 69 | "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, 70 | "newText": "// lsp\n" 71 | }] 72 | apply(None, uri="unity://path/Assets/Scripts/F.cs", 73 | edits=edits, precondition_sha256="x") 74 | p = calls[-1] 75 | e = p["edits"][0] 76 | assert e["startLine"] == 11 and e["startCol"] == 3 77 | 78 | # Index pair 79 | calls.clear() 80 | edits = [{"range": [0, 0], "text": "// idx\n"}] 81 | # fake read to provide contents length 82 | 83 | def fake_read(cmd, params): 84 | if params.get("action") == "read": 85 | return {"success": True, "data": {"contents": "hello\n"}} 86 | return {"success": True} 87 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) 88 | apply(None, uri="unity://path/Assets/Scripts/F.cs", 89 | edits=edits, precondition_sha256="x") 90 | # last call is apply_text_edits 91 | 92 | 93 | def test_noop_evidence_shape(monkeypatch): 94 | tools = setup_tools() 95 | apply = tools["apply_text_edits"] 96 | # Route response from Unity indicating no-op 97 | 98 | def fake_send(cmd, params): 99 | return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} 100 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 101 | 102 | resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[ 103 | {"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x") 104 | assert resp["success"] is True 105 | assert resp.get("data", {}).get("no_op") is True 106 | 107 | 108 | def test_atomic_multi_span_and_relaxed(monkeypatch): 109 | tools_text = setup_tools() 110 | apply_text = tools_text["apply_text_edits"] 111 | tools_struct = DummyMCP() 112 | manage_script_edits.register_manage_script_edits_tools(tools_struct) 113 | # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through 114 | sent = {} 115 | 116 | def fake_send(cmd, params): 117 | if params.get("action") == "read": 118 | return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} 119 | sent.setdefault("calls", []).append(params) 120 | return {"success": True} 121 | monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) 122 | 123 | edits = [ 124 | {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, 125 | {"startLine": 3, "startCol": 2, "endLine": 3, 126 | "endCol": 2, "newText": "// tail\n"} 127 | ] 128 | resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, 129 | precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) 130 | assert resp["success"] is True 131 | # Last manage_script call should include options with applyMode atomic and validate relaxed 132 | last = sent["calls"][-1] 133 | assert last.get("options", {}).get("applyMode") == "atomic" 134 | assert last.get("options", {}).get("validate") == "relaxed" 135 | ```