This is page 7 of 19. 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 │ │ │ │ │ └── MaterialMeshInstantiationTests.cs │ │ │ │ ├── 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 -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/telemetry.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Privacy-focused, anonymous telemetry system for Unity MCP 3 | Inspired by Onyx's telemetry implementation with Unity-specific adaptations 4 | 5 | Fire-and-forget telemetry sender with a single background worker. 6 | - No context/thread-local propagation to avoid re-entrancy into tool resolution. 7 | - Small network timeouts to prevent stalls. 8 | """ 9 | 10 | import contextlib 11 | from dataclasses import dataclass 12 | from enum import Enum 13 | import importlib 14 | import json 15 | import logging 16 | import os 17 | from pathlib import Path 18 | import platform 19 | import queue 20 | import sys 21 | import threading 22 | import time 23 | from typing import Optional, Dict, Any 24 | from urllib.parse import urlparse 25 | import uuid 26 | 27 | import tomli 28 | 29 | try: 30 | import httpx 31 | HAS_HTTPX = True 32 | except ImportError: 33 | httpx = None # type: ignore 34 | HAS_HTTPX = False 35 | 36 | 37 | def get_package_version() -> str: 38 | """ 39 | Open pyproject.toml and parse version 40 | We use the tomli library instead of tomllib to support Python 3.10 41 | """ 42 | with open("pyproject.toml", "rb") as f: 43 | data = tomli.load(f) 44 | return data["project"]["version"] 45 | 46 | 47 | MCP_VERSION = get_package_version() 48 | 49 | logger = logging.getLogger("unity-mcp-telemetry") 50 | 51 | 52 | class RecordType(str, Enum): 53 | """Types of telemetry records we collect""" 54 | VERSION = "version" 55 | STARTUP = "startup" 56 | USAGE = "usage" 57 | LATENCY = "latency" 58 | FAILURE = "failure" 59 | TOOL_EXECUTION = "tool_execution" 60 | UNITY_CONNECTION = "unity_connection" 61 | CLIENT_CONNECTION = "client_connection" 62 | 63 | 64 | class MilestoneType(str, Enum): 65 | """Major user journey milestones""" 66 | FIRST_STARTUP = "first_startup" 67 | FIRST_TOOL_USAGE = "first_tool_usage" 68 | FIRST_SCRIPT_CREATION = "first_script_creation" 69 | FIRST_SCENE_MODIFICATION = "first_scene_modification" 70 | MULTIPLE_SESSIONS = "multiple_sessions" 71 | DAILY_ACTIVE_USER = "daily_active_user" 72 | WEEKLY_ACTIVE_USER = "weekly_active_user" 73 | 74 | 75 | @dataclass 76 | class TelemetryRecord: 77 | """Structure for telemetry data""" 78 | record_type: RecordType 79 | timestamp: float 80 | customer_uuid: str 81 | session_id: str 82 | data: Dict[str, Any] 83 | milestone: Optional[MilestoneType] = None 84 | 85 | 86 | class TelemetryConfig: 87 | """Telemetry configuration""" 88 | 89 | def __init__(self): 90 | # Prefer config file, then allow env overrides 91 | server_config = None 92 | for modname in ( 93 | "UnityMcpBridge.UnityMcpServer~.src.config", 94 | "UnityMcpBridge.UnityMcpServer.src.config", 95 | "src.config", 96 | "config", 97 | ): 98 | try: 99 | mod = importlib.import_module(modname) 100 | server_config = getattr(mod, "config", None) 101 | if server_config is not None: 102 | break 103 | except Exception: 104 | continue 105 | 106 | # Determine enabled flag: config -> env DISABLE_* opt-out 107 | cfg_enabled = True if server_config is None else bool( 108 | getattr(server_config, "telemetry_enabled", True)) 109 | self.enabled = cfg_enabled and not self._is_disabled() 110 | 111 | # Telemetry endpoint (Cloud Run default; override via env) 112 | cfg_default = None if server_config is None else getattr( 113 | server_config, "telemetry_endpoint", None) 114 | default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" 115 | self.default_endpoint = default_ep 116 | self.endpoint = self._validated_endpoint( 117 | os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), 118 | default_ep, 119 | ) 120 | try: 121 | logger.info( 122 | "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", 123 | self.endpoint, 124 | default_ep, 125 | os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "<unset>" 126 | ) 127 | except Exception: 128 | pass 129 | 130 | # Local storage for UUID and milestones 131 | self.data_dir = self._get_data_directory() 132 | self.uuid_file = self.data_dir / "customer_uuid.txt" 133 | self.milestones_file = self.data_dir / "milestones.json" 134 | 135 | # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT 136 | try: 137 | self.timeout = float(os.environ.get( 138 | "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) 139 | except Exception: 140 | self.timeout = 1.5 141 | try: 142 | logger.info("Telemetry timeout=%.2fs", self.timeout) 143 | except Exception: 144 | pass 145 | 146 | # Session tracking 147 | self.session_id = str(uuid.uuid4()) 148 | 149 | def _is_disabled(self) -> bool: 150 | """Check if telemetry is disabled via environment variables""" 151 | disable_vars = [ 152 | "DISABLE_TELEMETRY", 153 | "UNITY_MCP_DISABLE_TELEMETRY", 154 | "MCP_DISABLE_TELEMETRY" 155 | ] 156 | 157 | for var in disable_vars: 158 | if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): 159 | return True 160 | return False 161 | 162 | def _get_data_directory(self) -> Path: 163 | """Get directory for storing telemetry data""" 164 | if os.name == 'nt': # Windows 165 | base_dir = Path(os.environ.get( 166 | 'APPDATA', Path.home() / 'AppData' / 'Roaming')) 167 | elif os.name == 'posix': # macOS/Linux 168 | if 'darwin' in os.uname().sysname.lower(): # macOS 169 | base_dir = Path.home() / 'Library' / 'Application Support' 170 | else: # Linux 171 | base_dir = Path(os.environ.get('XDG_DATA_HOME', 172 | Path.home() / '.local' / 'share')) 173 | else: 174 | base_dir = Path.home() / '.unity-mcp' 175 | 176 | data_dir = base_dir / 'UnityMCP' 177 | data_dir.mkdir(parents=True, exist_ok=True) 178 | return data_dir 179 | 180 | def _validated_endpoint(self, candidate: str, fallback: str) -> str: 181 | """Validate telemetry endpoint URL scheme; allow only http/https. 182 | Falls back to the provided default on error. 183 | """ 184 | try: 185 | parsed = urlparse(candidate) 186 | if parsed.scheme not in ("https", "http"): 187 | raise ValueError(f"Unsupported scheme: {parsed.scheme}") 188 | # Basic sanity: require network location and path 189 | if not parsed.netloc: 190 | raise ValueError("Missing netloc in endpoint") 191 | # Reject localhost/loopback endpoints in production to avoid accidental local overrides 192 | host = parsed.hostname or "" 193 | if host in ("localhost", "127.0.0.1", "::1"): 194 | raise ValueError( 195 | "Localhost endpoints are not allowed for telemetry") 196 | return candidate 197 | except Exception as e: 198 | logger.debug( 199 | f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}", 200 | exc_info=True, 201 | ) 202 | return fallback 203 | 204 | 205 | class TelemetryCollector: 206 | """Main telemetry collection class""" 207 | 208 | def __init__(self): 209 | self.config = TelemetryConfig() 210 | self._customer_uuid: Optional[str] = None 211 | self._milestones: Dict[str, Dict[str, Any]] = {} 212 | self._lock: threading.Lock = threading.Lock() 213 | # Bounded queue with single background worker (records only; no context propagation) 214 | self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) 215 | # Load persistent data before starting worker so first events have UUID 216 | self._load_persistent_data() 217 | self._worker: threading.Thread = threading.Thread( 218 | target=self._worker_loop, daemon=True) 219 | self._worker.start() 220 | 221 | def _load_persistent_data(self): 222 | """Load UUID and milestones from disk""" 223 | # Load customer UUID 224 | try: 225 | if self.config.uuid_file.exists(): 226 | self._customer_uuid = self.config.uuid_file.read_text( 227 | encoding="utf-8").strip() or str(uuid.uuid4()) 228 | else: 229 | self._customer_uuid = str(uuid.uuid4()) 230 | try: 231 | self.config.uuid_file.write_text( 232 | self._customer_uuid, encoding="utf-8") 233 | if os.name == "posix": 234 | os.chmod(self.config.uuid_file, 0o600) 235 | except OSError as e: 236 | logger.debug( 237 | f"Failed to persist customer UUID: {e}", exc_info=True) 238 | except OSError as e: 239 | logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) 240 | self._customer_uuid = str(uuid.uuid4()) 241 | 242 | # Load milestones (failure here must not affect UUID) 243 | try: 244 | if self.config.milestones_file.exists(): 245 | content = self.config.milestones_file.read_text( 246 | encoding="utf-8") 247 | self._milestones = json.loads(content) or {} 248 | if not isinstance(self._milestones, dict): 249 | self._milestones = {} 250 | except (OSError, json.JSONDecodeError, ValueError) as e: 251 | logger.debug(f"Failed to load milestones: {e}", exc_info=True) 252 | self._milestones = {} 253 | 254 | def _save_milestones(self): 255 | """Save milestones to disk. Caller must hold self._lock.""" 256 | try: 257 | self.config.milestones_file.write_text( 258 | json.dumps(self._milestones, indent=2), 259 | encoding="utf-8", 260 | ) 261 | except OSError as e: 262 | logger.warning(f"Failed to save milestones: {e}", exc_info=True) 263 | 264 | def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: 265 | """Record a milestone event, returns True if this is the first occurrence""" 266 | if not self.config.enabled: 267 | return False 268 | milestone_key = milestone.value 269 | with self._lock: 270 | if milestone_key in self._milestones: 271 | return False # Already recorded 272 | milestone_data = { 273 | "timestamp": time.time(), 274 | "data": data or {}, 275 | } 276 | self._milestones[milestone_key] = milestone_data 277 | self._save_milestones() 278 | 279 | # Also send as telemetry record 280 | self.record( 281 | record_type=RecordType.USAGE, 282 | data={"milestone": milestone_key, **(data or {})}, 283 | milestone=milestone 284 | ) 285 | 286 | return True 287 | 288 | def record(self, 289 | record_type: RecordType, 290 | data: Dict[str, Any], 291 | milestone: Optional[MilestoneType] = None): 292 | """Record a telemetry event (async, non-blocking)""" 293 | if not self.config.enabled: 294 | return 295 | 296 | # Allow fallback sender when httpx is unavailable (no early return) 297 | 298 | record = TelemetryRecord( 299 | record_type=record_type, 300 | timestamp=time.time(), 301 | customer_uuid=self._customer_uuid or "unknown", 302 | session_id=self.config.session_id, 303 | data=data, 304 | milestone=milestone 305 | ) 306 | # Enqueue for background worker (non-blocking). Drop on backpressure. 307 | try: 308 | self._queue.put_nowait(record) 309 | except queue.Full: 310 | logger.debug("Telemetry queue full; dropping %s", 311 | record.record_type) 312 | 313 | def _worker_loop(self): 314 | """Background worker that serializes telemetry sends.""" 315 | while True: 316 | rec = self._queue.get() 317 | try: 318 | # Run sender directly; do not reuse caller context/thread-locals 319 | self._send_telemetry(rec) 320 | except Exception: 321 | logger.debug("Telemetry worker send failed", exc_info=True) 322 | finally: 323 | with contextlib.suppress(Exception): 324 | self._queue.task_done() 325 | 326 | def _send_telemetry(self, record: TelemetryRecord): 327 | """Send telemetry data to endpoint""" 328 | try: 329 | # System fingerprint (top-level remains concise; details stored in data JSON) 330 | _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows' 331 | _source = sys.platform # 'darwin' | 'linux' | 'win32' 332 | _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})" 333 | _python_version = platform.python_version() 334 | 335 | # Enrich data JSON so BigQuery stores detailed fields without schema change 336 | enriched_data = dict(record.data or {}) 337 | enriched_data.setdefault("platform_detail", _platform_detail) 338 | enriched_data.setdefault("python_version", _python_version) 339 | 340 | payload = { 341 | "record": record.record_type.value, 342 | "timestamp": record.timestamp, 343 | "customer_uuid": record.customer_uuid, 344 | "session_id": record.session_id, 345 | "data": enriched_data, 346 | "version": MCP_VERSION, 347 | "platform": _platform, 348 | "source": _source, 349 | } 350 | 351 | if record.milestone: 352 | payload["milestone"] = record.milestone.value 353 | 354 | # Prefer httpx when available; otherwise fall back to urllib 355 | if httpx: 356 | with httpx.Client(timeout=self.config.timeout) as client: 357 | # Re-validate endpoint at send time to handle dynamic changes 358 | endpoint = self.config._validated_endpoint( 359 | self.config.endpoint, self.config.default_endpoint) 360 | response = client.post(endpoint, json=payload) 361 | if 200 <= response.status_code < 300: 362 | logger.debug(f"Telemetry sent: {record.record_type}") 363 | else: 364 | logger.warning( 365 | f"Telemetry failed: HTTP {response.status_code}") 366 | else: 367 | import urllib.request 368 | import urllib.error 369 | data_bytes = json.dumps(payload).encode("utf-8") 370 | endpoint = self.config._validated_endpoint( 371 | self.config.endpoint, self.config.default_endpoint) 372 | req = urllib.request.Request( 373 | endpoint, 374 | data=data_bytes, 375 | headers={"Content-Type": "application/json"}, 376 | method="POST", 377 | ) 378 | try: 379 | with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: 380 | if 200 <= resp.getcode() < 300: 381 | logger.debug( 382 | f"Telemetry sent (urllib): {record.record_type}") 383 | else: 384 | logger.warning( 385 | f"Telemetry failed (urllib): HTTP {resp.getcode()}") 386 | except urllib.error.URLError as ue: 387 | logger.warning(f"Telemetry send failed (urllib): {ue}") 388 | 389 | except Exception as e: 390 | # Never let telemetry errors interfere with app functionality 391 | logger.debug(f"Telemetry send failed: {e}") 392 | 393 | 394 | # Global telemetry instance 395 | _telemetry_collector: Optional[TelemetryCollector] = None 396 | 397 | 398 | def get_telemetry() -> TelemetryCollector: 399 | """Get the global telemetry collector instance""" 400 | global _telemetry_collector 401 | if _telemetry_collector is None: 402 | _telemetry_collector = TelemetryCollector() 403 | return _telemetry_collector 404 | 405 | 406 | def record_telemetry(record_type: RecordType, 407 | data: Dict[str, Any], 408 | milestone: Optional[MilestoneType] = None): 409 | """Convenience function to record telemetry""" 410 | get_telemetry().record(record_type, data, milestone) 411 | 412 | 413 | def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: 414 | """Convenience function to record a milestone""" 415 | return get_telemetry().record_milestone(milestone, data) 416 | 417 | 418 | def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None): 419 | """Record tool usage telemetry 420 | 421 | Args: 422 | tool_name: Name of the tool invoked (e.g., 'manage_scene'). 423 | success: Whether the tool completed successfully. 424 | duration_ms: Execution duration in milliseconds. 425 | error: Optional error message (truncated if present). 426 | sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy'). 427 | """ 428 | data = { 429 | "tool_name": tool_name, 430 | "success": success, 431 | "duration_ms": round(duration_ms, 2) 432 | } 433 | 434 | if sub_action is not None: 435 | try: 436 | data["sub_action"] = str(sub_action) 437 | except Exception: 438 | # Ensure telemetry is never disruptive 439 | data["sub_action"] = "unknown" 440 | 441 | if error: 442 | data["error"] = str(error)[:200] # Limit error message length 443 | 444 | record_telemetry(RecordType.TOOL_EXECUTION, data) 445 | 446 | 447 | def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): 448 | """Record latency telemetry""" 449 | data = { 450 | "operation": operation, 451 | "duration_ms": round(duration_ms, 2) 452 | } 453 | 454 | if metadata: 455 | data.update(metadata) 456 | 457 | record_telemetry(RecordType.LATENCY, data) 458 | 459 | 460 | def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): 461 | """Record failure telemetry""" 462 | data = { 463 | "component": component, 464 | "error": str(error)[:500] # Limit error message length 465 | } 466 | 467 | if metadata: 468 | data.update(metadata) 469 | 470 | record_telemetry(RecordType.FAILURE, data) 471 | 472 | 473 | def is_telemetry_enabled() -> bool: 474 | """Check if telemetry is enabled""" 475 | return get_telemetry().config.enabled 476 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Resource wrapper tools so clients that do not expose MCP resources primitives 3 | can still list and read files via normal tools. These call into the same 4 | safe path logic (re-implemented here to avoid importing server.py). 5 | """ 6 | import fnmatch 7 | import hashlib 8 | import os 9 | from pathlib import Path 10 | import re 11 | from typing import Annotated, Any 12 | from urllib.parse import urlparse, unquote 13 | 14 | from mcp.server.fastmcp import Context 15 | 16 | from registry import mcp_for_unity_tool 17 | from unity_connection import send_command_with_retry 18 | 19 | 20 | def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: 21 | """Safely coerce various inputs (str/float/etc.) to an int. 22 | Returns default on failure; clamps to minimum when provided. 23 | """ 24 | if value is None: 25 | return default 26 | try: 27 | # Avoid treating booleans as ints implicitly 28 | if isinstance(value, bool): 29 | return default 30 | if isinstance(value, int): 31 | result = int(value) 32 | else: 33 | s = str(value).strip() 34 | if s.lower() in ("", "none", "null"): 35 | return default 36 | # Allow "10.0" or similar inputs 37 | result = int(float(s)) 38 | if minimum is not None and result < minimum: 39 | return minimum 40 | return result 41 | except Exception: 42 | return default 43 | 44 | 45 | def _resolve_project_root(override: str | None) -> Path: 46 | # 1) Explicit override 47 | if override: 48 | pr = Path(override).expanduser().resolve() 49 | if (pr / "Assets").exists(): 50 | return pr 51 | # 2) Environment 52 | env = os.environ.get("UNITY_PROJECT_ROOT") 53 | if env: 54 | env_path = Path(env).expanduser() 55 | # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir 56 | pr = (Path.cwd( 57 | ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() 58 | if (pr / "Assets").exists(): 59 | return pr 60 | # 3) Ask Unity via manage_editor.get_project_root 61 | try: 62 | resp = send_command_with_retry( 63 | "manage_editor", {"action": "get_project_root"}) 64 | if isinstance(resp, dict) and resp.get("success"): 65 | pr = Path(resp.get("data", {}).get( 66 | "projectRoot", "")).expanduser().resolve() 67 | if pr and (pr / "Assets").exists(): 68 | return pr 69 | except Exception: 70 | pass 71 | 72 | # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) 73 | cur = Path.cwd().resolve() 74 | for _ in range(6): 75 | if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): 76 | return cur 77 | if cur.parent == cur: 78 | break 79 | cur = cur.parent 80 | # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings 81 | try: 82 | import os as _os 83 | root = Path.cwd().resolve() 84 | max_depth = 3 85 | for dirpath, dirnames, _ in _os.walk(root): 86 | rel = Path(dirpath).resolve() 87 | try: 88 | depth = len(rel.relative_to(root).parts) 89 | except Exception: 90 | # Unrelated mount/permission edge; skip deeper traversal 91 | dirnames[:] = [] 92 | continue 93 | if depth > max_depth: 94 | # Prune deeper traversal 95 | dirnames[:] = [] 96 | continue 97 | if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): 98 | return rel 99 | except Exception: 100 | pass 101 | # 6) Fallback: CWD 102 | return Path.cwd().resolve() 103 | 104 | 105 | def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: 106 | raw: str | None = None 107 | if uri.startswith("unity://path/"): 108 | raw = uri[len("unity://path/"):] 109 | elif uri.startswith("file://"): 110 | parsed = urlparse(uri) 111 | raw = unquote(parsed.path or "") 112 | # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. 113 | try: 114 | import os as _os 115 | if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): 116 | raw = raw[1:] 117 | # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share 118 | if _os.name == "nt" and parsed.netloc: 119 | raw = f"//{parsed.netloc}{raw}" 120 | except Exception: 121 | pass 122 | elif uri.startswith("Assets/"): 123 | raw = uri 124 | if raw is None: 125 | return None 126 | # Normalize separators early 127 | raw = raw.replace("\\", "/") 128 | p = (project / raw).resolve() 129 | try: 130 | p.relative_to(project) 131 | except ValueError: 132 | return None 133 | return p 134 | 135 | 136 | @mcp_for_unity_tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) 137 | async def list_resources( 138 | ctx: Context, 139 | pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", 140 | under: Annotated[str, 141 | "Folder under project root, default is Assets"] = "Assets", 142 | limit: Annotated[int, "Page limit"] = 200, 143 | project_root: Annotated[str, "Project path"] | None = None, 144 | ) -> dict[str, Any]: 145 | ctx.info(f"Processing list_resources: {pattern}") 146 | try: 147 | project = _resolve_project_root(project_root) 148 | base = (project / under).resolve() 149 | try: 150 | base.relative_to(project) 151 | except ValueError: 152 | return {"success": False, "error": "Base path must be under project root"} 153 | # Enforce listing only under Assets 154 | try: 155 | base.relative_to(project / "Assets") 156 | except ValueError: 157 | return {"success": False, "error": "Listing is restricted to Assets/"} 158 | 159 | matches: list[str] = [] 160 | limit_int = _coerce_int(limit, default=200, minimum=1) 161 | for p in base.rglob("*"): 162 | if not p.is_file(): 163 | continue 164 | # Resolve symlinks and ensure the real path stays under project/Assets 165 | try: 166 | rp = p.resolve() 167 | rp.relative_to(project / "Assets") 168 | except Exception: 169 | continue 170 | # Enforce .cs extension regardless of provided pattern 171 | if p.suffix.lower() != ".cs": 172 | continue 173 | if pattern and not fnmatch.fnmatch(p.name, pattern): 174 | continue 175 | rel = p.relative_to(project).as_posix() 176 | matches.append(f"unity://path/{rel}") 177 | if len(matches) >= max(1, limit_int): 178 | break 179 | 180 | # Always include the canonical spec resource so NL clients can discover it 181 | if "unity://spec/script-edits" not in matches: 182 | matches.append("unity://spec/script-edits") 183 | 184 | return {"success": True, "data": {"uris": matches, "count": len(matches)}} 185 | except Exception as e: 186 | return {"success": False, "error": str(e)} 187 | 188 | 189 | @mcp_for_unity_tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) 190 | async def read_resource( 191 | ctx: Context, 192 | uri: Annotated[str, "The resource URI to read under Assets/"], 193 | start_line: Annotated[int, 194 | "The starting line number (0-based)"] | None = None, 195 | line_count: Annotated[int, 196 | "The number of lines to read"] | None = None, 197 | head_bytes: Annotated[int, 198 | "The number of bytes to read from the start of the file"] | None = None, 199 | tail_lines: Annotated[int, 200 | "The number of lines to read from the end of the file"] | None = None, 201 | project_root: Annotated[str, 202 | "The project root directory"] | None = None, 203 | request: Annotated[str, "The request ID"] | None = None, 204 | ) -> dict[str, Any]: 205 | ctx.info(f"Processing read_resource: {uri}") 206 | try: 207 | # Serve the canonical spec directly when requested (allow bare or with scheme) 208 | if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): 209 | spec_json = ( 210 | '{\n' 211 | ' "name": "Unity MCP - Script Edits v1",\n' 212 | ' "target_tool": "script_apply_edits",\n' 213 | ' "canonical_rules": {\n' 214 | ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' 215 | ' "never_use": ["new_method","anchor_method","content","newText"],\n' 216 | ' "defaults": {\n' 217 | ' "className": "\u2190 server will default to \'name\' when omitted",\n' 218 | ' "position": "end"\n' 219 | ' }\n' 220 | ' },\n' 221 | ' "ops": [\n' 222 | ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' 223 | ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' 224 | ' {"op":"delete_method","required":["className","methodName"]},\n' 225 | ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' 226 | ' ],\n' 227 | ' "apply_text_edits_recipe": {\n' 228 | ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' 229 | ' "step2_apply": {\n' 230 | ' "tool": "manage_script",\n' 231 | ' "args": {\n' 232 | ' "action": "apply_text_edits",\n' 233 | ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' 234 | ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' 235 | ' "precondition_sha256": "<sha-from-step1>",\n' 236 | ' "options": {"refresh": "immediate", "validate": "standard"}\n' 237 | ' }\n' 238 | ' },\n' 239 | ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' 240 | ' },\n' 241 | ' "examples": [\n' 242 | ' {\n' 243 | ' "title": "Replace a method",\n' 244 | ' "args": {\n' 245 | ' "name": "SmartReach",\n' 246 | ' "path": "Assets/Scripts/Interaction",\n' 247 | ' "edits": [\n' 248 | ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' 249 | ' ],\n' 250 | ' "options": { "validate": "standard", "refresh": "immediate" }\n' 251 | ' }\n' 252 | ' },\n' 253 | ' {\n' 254 | ' "title": "Insert a method after another",\n' 255 | ' "args": {\n' 256 | ' "name": "SmartReach",\n' 257 | ' "path": "Assets/Scripts/Interaction",\n' 258 | ' "edits": [\n' 259 | ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' 260 | ' ]\n' 261 | ' }\n' 262 | ' }\n' 263 | ' ]\n' 264 | '}\n' 265 | ) 266 | sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() 267 | return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} 268 | 269 | project = _resolve_project_root(project_root) 270 | p = _resolve_safe_path_from_uri(uri, project) 271 | if not p or not p.exists() or not p.is_file(): 272 | return {"success": False, "error": f"Resource not found: {uri}"} 273 | try: 274 | p.relative_to(project / "Assets") 275 | except ValueError: 276 | return {"success": False, "error": "Read restricted to Assets/"} 277 | # Natural-language convenience: request like "last 120 lines", "first 200 lines", 278 | # "show 40 lines around MethodName", etc. 279 | if request: 280 | req = request.strip().lower() 281 | m = re.search(r"last\s+(\d+)\s+lines", req) 282 | if m: 283 | tail_lines = int(m.group(1)) 284 | m = re.search(r"first\s+(\d+)\s+lines", req) 285 | if m: 286 | start_line = 1 287 | line_count = int(m.group(1)) 288 | m = re.search(r"first\s+(\d+)\s*bytes", req) 289 | if m: 290 | head_bytes = int(m.group(1)) 291 | m = re.search( 292 | r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) 293 | if m: 294 | window = int(m.group(1)) 295 | method = m.group(2) 296 | # naive search for method header to get a line number 297 | text_all = p.read_text(encoding="utf-8") 298 | lines_all = text_all.splitlines() 299 | pat = re.compile( 300 | rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) 301 | hit_line = None 302 | for i, line in enumerate(lines_all, start=1): 303 | if pat.search(line): 304 | hit_line = i 305 | break 306 | if hit_line: 307 | half = max(1, window // 2) 308 | start_line = max(1, hit_line - half) 309 | line_count = window 310 | 311 | # Coerce numeric inputs defensively (string/float -> int) 312 | start_line = _coerce_int(start_line) 313 | line_count = _coerce_int(line_count) 314 | head_bytes = _coerce_int(head_bytes, minimum=1) 315 | tail_lines = _coerce_int(tail_lines, minimum=1) 316 | 317 | # Compute SHA over full file contents (metadata-only default) 318 | full_bytes = p.read_bytes() 319 | full_sha = hashlib.sha256(full_bytes).hexdigest() 320 | 321 | # Selection only when explicitly requested via windowing args or request text hints 322 | selection_requested = bool(head_bytes or tail_lines or ( 323 | start_line is not None and line_count is not None) or request) 324 | if selection_requested: 325 | # Mutually exclusive windowing options precedence: 326 | # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text 327 | if head_bytes and head_bytes > 0: 328 | raw = full_bytes[: head_bytes] 329 | text = raw.decode("utf-8", errors="replace") 330 | else: 331 | text = full_bytes.decode("utf-8", errors="replace") 332 | if tail_lines is not None and tail_lines > 0: 333 | lines = text.splitlines() 334 | n = max(0, tail_lines) 335 | text = "\n".join(lines[-n:]) 336 | elif start_line is not None and line_count is not None and line_count >= 0: 337 | lines = text.splitlines() 338 | s = max(0, start_line - 1) 339 | e = min(len(lines), s + line_count) 340 | text = "\n".join(lines[s:e]) 341 | return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} 342 | else: 343 | # Default: metadata only 344 | return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} 345 | except Exception as e: 346 | return {"success": False, "error": str(e)} 347 | 348 | 349 | @mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") 350 | async def find_in_file( 351 | ctx: Context, 352 | uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], 353 | pattern: Annotated[str, "The regex pattern to search for"], 354 | ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, 355 | project_root: Annotated[str, 356 | "The project root directory"] | None = None, 357 | max_results: Annotated[int, 358 | "Cap results to avoid huge payloads"] = 200, 359 | ) -> dict[str, Any]: 360 | ctx.info(f"Processing find_in_file: {uri}") 361 | try: 362 | project = _resolve_project_root(project_root) 363 | p = _resolve_safe_path_from_uri(uri, project) 364 | if not p or not p.exists() or not p.is_file(): 365 | return {"success": False, "error": f"Resource not found: {uri}"} 366 | 367 | text = p.read_text(encoding="utf-8") 368 | flags = re.MULTILINE 369 | if ignore_case: 370 | flags |= re.IGNORECASE 371 | rx = re.compile(pattern, flags) 372 | 373 | results = [] 374 | max_results_int = _coerce_int(max_results, default=200, minimum=1) 375 | lines = text.splitlines() 376 | for i, line in enumerate(lines, start=1): 377 | m = rx.search(line) 378 | if m: 379 | start_col = m.start() + 1 # 1-based 380 | end_col = m.end() + 1 # 1-based, end exclusive 381 | results.append({ 382 | "startLine": i, 383 | "startCol": start_col, 384 | "endLine": i, 385 | "endCol": end_col, 386 | }) 387 | if max_results_int and len(results) >= max_results_int: 388 | break 389 | 390 | return {"success": True, "data": {"matches": results, "count": len(results)}} 391 | except Exception as e: 392 | return {"success": False, "error": str(e)} 393 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Resource wrapper tools so clients that do not expose MCP resources primitives 3 | can still list and read files via normal tools. These call into the same 4 | safe path logic (re-implemented here to avoid importing server.py). 5 | """ 6 | import fnmatch 7 | import hashlib 8 | import os 9 | from pathlib import Path 10 | import re 11 | from typing import Annotated, Any 12 | from urllib.parse import urlparse, unquote 13 | 14 | from mcp.server.fastmcp import Context 15 | 16 | from registry import mcp_for_unity_tool 17 | from unity_connection import send_command_with_retry 18 | 19 | 20 | def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: 21 | """Safely coerce various inputs (str/float/etc.) to an int. 22 | Returns default on failure; clamps to minimum when provided. 23 | """ 24 | if value is None: 25 | return default 26 | try: 27 | # Avoid treating booleans as ints implicitly 28 | if isinstance(value, bool): 29 | return default 30 | if isinstance(value, int): 31 | result = int(value) 32 | else: 33 | s = str(value).strip() 34 | if s.lower() in ("", "none", "null"): 35 | return default 36 | # Allow "10.0" or similar inputs 37 | result = int(float(s)) 38 | if minimum is not None and result < minimum: 39 | return minimum 40 | return result 41 | except Exception: 42 | return default 43 | 44 | 45 | def _resolve_project_root(override: str | None) -> Path: 46 | # 1) Explicit override 47 | if override: 48 | pr = Path(override).expanduser().resolve() 49 | if (pr / "Assets").exists(): 50 | return pr 51 | # 2) Environment 52 | env = os.environ.get("UNITY_PROJECT_ROOT") 53 | if env: 54 | env_path = Path(env).expanduser() 55 | # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir 56 | pr = (Path.cwd( 57 | ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() 58 | if (pr / "Assets").exists(): 59 | return pr 60 | # 3) Ask Unity via manage_editor.get_project_root 61 | try: 62 | resp = send_command_with_retry( 63 | "manage_editor", {"action": "get_project_root"}) 64 | if isinstance(resp, dict) and resp.get("success"): 65 | pr = Path(resp.get("data", {}).get( 66 | "projectRoot", "")).expanduser().resolve() 67 | if pr and (pr / "Assets").exists(): 68 | return pr 69 | except Exception: 70 | pass 71 | 72 | # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) 73 | cur = Path.cwd().resolve() 74 | for _ in range(6): 75 | if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): 76 | return cur 77 | if cur.parent == cur: 78 | break 79 | cur = cur.parent 80 | # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings 81 | try: 82 | import os as _os 83 | root = Path.cwd().resolve() 84 | max_depth = 3 85 | for dirpath, dirnames, _ in _os.walk(root): 86 | rel = Path(dirpath).resolve() 87 | try: 88 | depth = len(rel.relative_to(root).parts) 89 | except Exception: 90 | # Unrelated mount/permission edge; skip deeper traversal 91 | dirnames[:] = [] 92 | continue 93 | if depth > max_depth: 94 | # Prune deeper traversal 95 | dirnames[:] = [] 96 | continue 97 | if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): 98 | return rel 99 | except Exception: 100 | pass 101 | # 6) Fallback: CWD 102 | return Path.cwd().resolve() 103 | 104 | 105 | def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: 106 | raw: str | None = None 107 | if uri.startswith("unity://path/"): 108 | raw = uri[len("unity://path/"):] 109 | elif uri.startswith("file://"): 110 | parsed = urlparse(uri) 111 | raw = unquote(parsed.path or "") 112 | # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. 113 | try: 114 | import os as _os 115 | if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): 116 | raw = raw[1:] 117 | # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share 118 | if _os.name == "nt" and parsed.netloc: 119 | raw = f"//{parsed.netloc}{raw}" 120 | except Exception: 121 | pass 122 | elif uri.startswith("Assets/"): 123 | raw = uri 124 | if raw is None: 125 | return None 126 | # Normalize separators early 127 | raw = raw.replace("\\", "/") 128 | p = (project / raw).resolve() 129 | try: 130 | p.relative_to(project) 131 | except ValueError: 132 | return None 133 | return p 134 | 135 | 136 | @mcp_for_unity_tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) 137 | async def list_resources( 138 | ctx: Context, 139 | pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", 140 | under: Annotated[str, 141 | "Folder under project root, default is Assets"] = "Assets", 142 | limit: Annotated[int, "Page limit"] = 200, 143 | project_root: Annotated[str, "Project path"] | None = None, 144 | ) -> dict[str, Any]: 145 | ctx.info(f"Processing list_resources: {pattern}") 146 | try: 147 | project = _resolve_project_root(project_root) 148 | base = (project / under).resolve() 149 | try: 150 | base.relative_to(project) 151 | except ValueError: 152 | return {"success": False, "error": "Base path must be under project root"} 153 | # Enforce listing only under Assets 154 | try: 155 | base.relative_to(project / "Assets") 156 | except ValueError: 157 | return {"success": False, "error": "Listing is restricted to Assets/"} 158 | 159 | matches: list[str] = [] 160 | limit_int = _coerce_int(limit, default=200, minimum=1) 161 | for p in base.rglob("*"): 162 | if not p.is_file(): 163 | continue 164 | # Resolve symlinks and ensure the real path stays under project/Assets 165 | try: 166 | rp = p.resolve() 167 | rp.relative_to(project / "Assets") 168 | except Exception: 169 | continue 170 | # Enforce .cs extension regardless of provided pattern 171 | if p.suffix.lower() != ".cs": 172 | continue 173 | if pattern and not fnmatch.fnmatch(p.name, pattern): 174 | continue 175 | rel = p.relative_to(project).as_posix() 176 | matches.append(f"unity://path/{rel}") 177 | if len(matches) >= max(1, limit_int): 178 | break 179 | 180 | # Always include the canonical spec resource so NL clients can discover it 181 | if "unity://spec/script-edits" not in matches: 182 | matches.append("unity://spec/script-edits") 183 | 184 | return {"success": True, "data": {"uris": matches, "count": len(matches)}} 185 | except Exception as e: 186 | return {"success": False, "error": str(e)} 187 | 188 | 189 | @mcp_for_unity_tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) 190 | async def read_resource( 191 | ctx: Context, 192 | uri: Annotated[str, "The resource URI to read under Assets/"], 193 | start_line: Annotated[int, 194 | "The starting line number (0-based)"] | None = None, 195 | line_count: Annotated[int, 196 | "The number of lines to read"] | None = None, 197 | head_bytes: Annotated[int, 198 | "The number of bytes to read from the start of the file"] | None = None, 199 | tail_lines: Annotated[int, 200 | "The number of lines to read from the end of the file"] | None = None, 201 | project_root: Annotated[str, 202 | "The project root directory"] | None = None, 203 | request: Annotated[str, "The request ID"] | None = None, 204 | ) -> dict[str, Any]: 205 | ctx.info(f"Processing read_resource: {uri}") 206 | try: 207 | # Serve the canonical spec directly when requested (allow bare or with scheme) 208 | if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): 209 | spec_json = ( 210 | '{\n' 211 | ' "name": "MCP for Unity - Script Edits v1",\n' 212 | ' "target_tool": "script_apply_edits",\n' 213 | ' "canonical_rules": {\n' 214 | ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' 215 | ' "never_use": ["new_method","anchor_method","content","newText"],\n' 216 | ' "defaults": {\n' 217 | ' "className": "\u2190 server will default to \'name\' when omitted",\n' 218 | ' "position": "end"\n' 219 | ' }\n' 220 | ' },\n' 221 | ' "ops": [\n' 222 | ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' 223 | ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' 224 | ' {"op":"delete_method","required":["className","methodName"]},\n' 225 | ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' 226 | ' ],\n' 227 | ' "apply_text_edits_recipe": {\n' 228 | ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' 229 | ' "step2_apply": {\n' 230 | ' "tool": "manage_script",\n' 231 | ' "args": {\n' 232 | ' "action": "apply_text_edits",\n' 233 | ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' 234 | ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' 235 | ' "precondition_sha256": "<sha-from-step1>",\n' 236 | ' "options": {"refresh": "immediate", "validate": "standard"}\n' 237 | ' }\n' 238 | ' },\n' 239 | ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' 240 | ' },\n' 241 | ' "examples": [\n' 242 | ' {\n' 243 | ' "title": "Replace a method",\n' 244 | ' "args": {\n' 245 | ' "name": "SmartReach",\n' 246 | ' "path": "Assets/Scripts/Interaction",\n' 247 | ' "edits": [\n' 248 | ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' 249 | ' ],\n' 250 | ' "options": { "validate": "standard", "refresh": "immediate" }\n' 251 | ' }\n' 252 | ' },\n' 253 | ' {\n' 254 | ' "title": "Insert a method after another",\n' 255 | ' "args": {\n' 256 | ' "name": "SmartReach",\n' 257 | ' "path": "Assets/Scripts/Interaction",\n' 258 | ' "edits": [\n' 259 | ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' 260 | ' ]\n' 261 | ' }\n' 262 | ' }\n' 263 | ' ]\n' 264 | '}\n' 265 | ) 266 | sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() 267 | return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} 268 | 269 | project = _resolve_project_root(project_root) 270 | p = _resolve_safe_path_from_uri(uri, project) 271 | if not p or not p.exists() or not p.is_file(): 272 | return {"success": False, "error": f"Resource not found: {uri}"} 273 | try: 274 | p.relative_to(project / "Assets") 275 | except ValueError: 276 | return {"success": False, "error": "Read restricted to Assets/"} 277 | # Natural-language convenience: request like "last 120 lines", "first 200 lines", 278 | # "show 40 lines around MethodName", etc. 279 | if request: 280 | req = request.strip().lower() 281 | m = re.search(r"last\s+(\d+)\s+lines", req) 282 | if m: 283 | tail_lines = int(m.group(1)) 284 | m = re.search(r"first\s+(\d+)\s+lines", req) 285 | if m: 286 | start_line = 1 287 | line_count = int(m.group(1)) 288 | m = re.search(r"first\s+(\d+)\s*bytes", req) 289 | if m: 290 | head_bytes = int(m.group(1)) 291 | m = re.search( 292 | r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) 293 | if m: 294 | window = int(m.group(1)) 295 | method = m.group(2) 296 | # naive search for method header to get a line number 297 | text_all = p.read_text(encoding="utf-8") 298 | lines_all = text_all.splitlines() 299 | pat = re.compile( 300 | rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) 301 | hit_line = None 302 | for i, line in enumerate(lines_all, start=1): 303 | if pat.search(line): 304 | hit_line = i 305 | break 306 | if hit_line: 307 | half = max(1, window // 2) 308 | start_line = max(1, hit_line - half) 309 | line_count = window 310 | 311 | # Coerce numeric inputs defensively (string/float -> int) 312 | start_line = _coerce_int(start_line) 313 | line_count = _coerce_int(line_count) 314 | head_bytes = _coerce_int(head_bytes, minimum=1) 315 | tail_lines = _coerce_int(tail_lines, minimum=1) 316 | 317 | # Compute SHA over full file contents (metadata-only default) 318 | full_bytes = p.read_bytes() 319 | full_sha = hashlib.sha256(full_bytes).hexdigest() 320 | 321 | # Selection only when explicitly requested via windowing args or request text hints 322 | selection_requested = bool(head_bytes or tail_lines or ( 323 | start_line is not None and line_count is not None) or request) 324 | if selection_requested: 325 | # Mutually exclusive windowing options precedence: 326 | # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text 327 | if head_bytes and head_bytes > 0: 328 | raw = full_bytes[: head_bytes] 329 | text = raw.decode("utf-8", errors="replace") 330 | else: 331 | text = full_bytes.decode("utf-8", errors="replace") 332 | if tail_lines is not None and tail_lines > 0: 333 | lines = text.splitlines() 334 | n = max(0, tail_lines) 335 | text = "\n".join(lines[-n:]) 336 | elif start_line is not None and line_count is not None and line_count >= 0: 337 | lines = text.splitlines() 338 | s = max(0, start_line - 1) 339 | e = min(len(lines), s + line_count) 340 | text = "\n".join(lines[s:e]) 341 | return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} 342 | else: 343 | # Default: metadata only 344 | return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} 345 | except Exception as e: 346 | return {"success": False, "error": str(e)} 347 | 348 | 349 | @mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") 350 | async def find_in_file( 351 | ctx: Context, 352 | uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], 353 | pattern: Annotated[str, "The regex pattern to search for"], 354 | ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, 355 | project_root: Annotated[str, 356 | "The project root directory"] | None = None, 357 | max_results: Annotated[int, 358 | "Cap results to avoid huge payloads"] = 200, 359 | ) -> dict[str, Any]: 360 | ctx.info(f"Processing find_in_file: {uri}") 361 | try: 362 | project = _resolve_project_root(project_root) 363 | p = _resolve_safe_path_from_uri(uri, project) 364 | if not p or not p.exists() or not p.is_file(): 365 | return {"success": False, "error": f"Resource not found: {uri}"} 366 | 367 | text = p.read_text(encoding="utf-8") 368 | flags = re.MULTILINE 369 | if ignore_case: 370 | flags |= re.IGNORECASE 371 | rx = re.compile(pattern, flags) 372 | 373 | results = [] 374 | max_results_int = _coerce_int(max_results, default=200, minimum=1) 375 | lines = text.splitlines() 376 | for i, line in enumerate(lines, start=1): 377 | m = rx.search(line) 378 | if m: 379 | start_col = m.start() + 1 # 1-based 380 | end_col = m.end() + 1 # 1-based, end exclusive 381 | results.append({ 382 | "startLine": i, 383 | "startCol": start_col, 384 | "endLine": i, 385 | "endCol": end_col, 386 | }) 387 | if max_results_int and len(results) >= max_results_int: 388 | break 389 | 390 | return {"success": True, "data": {"matches": results, "count": len(results)}} 391 | except Exception as e: 392 | return {"success": False, "error": str(e)} 393 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/telemetry.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Privacy-focused, anonymous telemetry system for MCP for Unity 3 | Inspired by Onyx's telemetry implementation with Unity-specific adaptations 4 | 5 | Fire-and-forget telemetry sender with a single background worker. 6 | - No context/thread-local propagation to avoid re-entrancy into tool resolution. 7 | - Small network timeouts to prevent stalls. 8 | """ 9 | 10 | import contextlib 11 | from dataclasses import dataclass 12 | from enum import Enum 13 | import importlib 14 | import json 15 | import logging 16 | import os 17 | from pathlib import Path 18 | import platform 19 | import queue 20 | import sys 21 | import threading 22 | import time 23 | from typing import Any 24 | from urllib.parse import urlparse 25 | import uuid 26 | 27 | import tomli 28 | 29 | try: 30 | import httpx 31 | HAS_HTTPX = True 32 | except ImportError: 33 | httpx = None # type: ignore 34 | HAS_HTTPX = False 35 | 36 | logger = logging.getLogger("unity-mcp-telemetry") 37 | 38 | 39 | def get_package_version() -> str: 40 | """ 41 | Open pyproject.toml and parse version 42 | We use the tomli library instead of tomllib to support Python 3.10 43 | """ 44 | with open("pyproject.toml", "rb") as f: 45 | data = tomli.load(f) 46 | return data["project"]["version"] 47 | 48 | 49 | MCP_VERSION = get_package_version() 50 | 51 | 52 | class RecordType(str, Enum): 53 | """Types of telemetry records we collect""" 54 | VERSION = "version" 55 | STARTUP = "startup" 56 | USAGE = "usage" 57 | LATENCY = "latency" 58 | FAILURE = "failure" 59 | RESOURCE_RETRIEVAL = "resource_retrieval" 60 | TOOL_EXECUTION = "tool_execution" 61 | UNITY_CONNECTION = "unity_connection" 62 | CLIENT_CONNECTION = "client_connection" 63 | 64 | 65 | class MilestoneType(str, Enum): 66 | """Major user journey milestones""" 67 | FIRST_STARTUP = "first_startup" 68 | FIRST_TOOL_USAGE = "first_tool_usage" 69 | FIRST_SCRIPT_CREATION = "first_script_creation" 70 | FIRST_SCENE_MODIFICATION = "first_scene_modification" 71 | MULTIPLE_SESSIONS = "multiple_sessions" 72 | DAILY_ACTIVE_USER = "daily_active_user" 73 | WEEKLY_ACTIVE_USER = "weekly_active_user" 74 | 75 | 76 | @dataclass 77 | class TelemetryRecord: 78 | """Structure for telemetry data""" 79 | record_type: RecordType 80 | timestamp: float 81 | customer_uuid: str 82 | session_id: str 83 | data: dict[str, Any] 84 | milestone: MilestoneType | None = None 85 | 86 | 87 | class TelemetryConfig: 88 | """Telemetry configuration""" 89 | 90 | def __init__(self): 91 | """ 92 | Prefer config file, then allow env overrides 93 | """ 94 | server_config = None 95 | for modname in ( 96 | "MCPForUnity.UnityMcpServer~.src.config", 97 | "MCPForUnity.UnityMcpServer.src.config", 98 | "src.config", 99 | "config", 100 | ): 101 | try: 102 | mod = importlib.import_module(modname) 103 | server_config = getattr(mod, "config", None) 104 | if server_config is not None: 105 | break 106 | except Exception: 107 | continue 108 | 109 | # Determine enabled flag: config -> env DISABLE_* opt-out 110 | cfg_enabled = True if server_config is None else bool( 111 | getattr(server_config, "telemetry_enabled", True)) 112 | self.enabled = cfg_enabled and not self._is_disabled() 113 | 114 | # Telemetry endpoint (Cloud Run default; override via env) 115 | cfg_default = None if server_config is None else getattr( 116 | server_config, "telemetry_endpoint", None) 117 | default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" 118 | self.default_endpoint = default_ep 119 | self.endpoint = self._validated_endpoint( 120 | os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), 121 | default_ep, 122 | ) 123 | try: 124 | logger.info( 125 | "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", 126 | self.endpoint, 127 | default_ep, 128 | os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "<unset>" 129 | ) 130 | except Exception: 131 | pass 132 | 133 | # Local storage for UUID and milestones 134 | self.data_dir = self._get_data_directory() 135 | self.uuid_file = self.data_dir / "customer_uuid.txt" 136 | self.milestones_file = self.data_dir / "milestones.json" 137 | 138 | # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT 139 | try: 140 | self.timeout = float(os.environ.get( 141 | "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) 142 | except Exception: 143 | self.timeout = 1.5 144 | try: 145 | logger.info("Telemetry timeout=%.2fs", self.timeout) 146 | except Exception: 147 | pass 148 | 149 | # Session tracking 150 | self.session_id = str(uuid.uuid4()) 151 | 152 | def _is_disabled(self) -> bool: 153 | """Check if telemetry is disabled via environment variables""" 154 | disable_vars = [ 155 | "DISABLE_TELEMETRY", 156 | "UNITY_MCP_DISABLE_TELEMETRY", 157 | "MCP_DISABLE_TELEMETRY" 158 | ] 159 | 160 | for var in disable_vars: 161 | if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): 162 | return True 163 | return False 164 | 165 | def _get_data_directory(self) -> Path: 166 | """Get directory for storing telemetry data""" 167 | if os.name == 'nt': # Windows 168 | base_dir = Path(os.environ.get( 169 | 'APPDATA', Path.home() / 'AppData' / 'Roaming')) 170 | elif os.name == 'posix': # macOS/Linux 171 | if 'darwin' in os.uname().sysname.lower(): # macOS 172 | base_dir = Path.home() / 'Library' / 'Application Support' 173 | else: # Linux 174 | base_dir = Path(os.environ.get('XDG_DATA_HOME', 175 | Path.home() / '.local' / 'share')) 176 | else: 177 | base_dir = Path.home() / '.unity-mcp' 178 | 179 | data_dir = base_dir / 'UnityMCP' 180 | data_dir.mkdir(parents=True, exist_ok=True) 181 | return data_dir 182 | 183 | def _validated_endpoint(self, candidate: str, fallback: str) -> str: 184 | """Validate telemetry endpoint URL scheme; allow only http/https. 185 | Falls back to the provided default on error. 186 | """ 187 | try: 188 | parsed = urlparse(candidate) 189 | if parsed.scheme not in ("https", "http"): 190 | raise ValueError(f"Unsupported scheme: {parsed.scheme}") 191 | # Basic sanity: require network location and path 192 | if not parsed.netloc: 193 | raise ValueError("Missing netloc in endpoint") 194 | # Reject localhost/loopback endpoints in production to avoid accidental local overrides 195 | host = parsed.hostname or "" 196 | if host in ("localhost", "127.0.0.1", "::1"): 197 | raise ValueError( 198 | "Localhost endpoints are not allowed for telemetry") 199 | return candidate 200 | except Exception as e: 201 | logger.debug( 202 | f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}", 203 | exc_info=True, 204 | ) 205 | return fallback 206 | 207 | 208 | class TelemetryCollector: 209 | """Main telemetry collection class""" 210 | 211 | def __init__(self): 212 | self.config = TelemetryConfig() 213 | self._customer_uuid: str | None = None 214 | self._milestones: dict[str, dict[str, Any]] = {} 215 | self._lock: threading.Lock = threading.Lock() 216 | # Bounded queue with single background worker (records only; no context propagation) 217 | self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) 218 | # Load persistent data before starting worker so first events have UUID 219 | self._load_persistent_data() 220 | self._worker: threading.Thread = threading.Thread( 221 | target=self._worker_loop, daemon=True) 222 | self._worker.start() 223 | 224 | def _load_persistent_data(self): 225 | """Load UUID and milestones from disk""" 226 | # Load customer UUID 227 | try: 228 | if self.config.uuid_file.exists(): 229 | self._customer_uuid = self.config.uuid_file.read_text( 230 | encoding="utf-8").strip() or str(uuid.uuid4()) 231 | else: 232 | self._customer_uuid = str(uuid.uuid4()) 233 | try: 234 | self.config.uuid_file.write_text( 235 | self._customer_uuid, encoding="utf-8") 236 | if os.name == "posix": 237 | os.chmod(self.config.uuid_file, 0o600) 238 | except OSError as e: 239 | logger.debug( 240 | f"Failed to persist customer UUID: {e}", exc_info=True) 241 | except OSError as e: 242 | logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) 243 | self._customer_uuid = str(uuid.uuid4()) 244 | 245 | # Load milestones (failure here must not affect UUID) 246 | try: 247 | if self.config.milestones_file.exists(): 248 | content = self.config.milestones_file.read_text( 249 | encoding="utf-8") 250 | self._milestones = json.loads(content) or {} 251 | if not isinstance(self._milestones, dict): 252 | self._milestones = {} 253 | except (OSError, json.JSONDecodeError, ValueError) as e: 254 | logger.debug(f"Failed to load milestones: {e}", exc_info=True) 255 | self._milestones = {} 256 | 257 | def _save_milestones(self): 258 | """Save milestones to disk. Caller must hold self._lock.""" 259 | try: 260 | self.config.milestones_file.write_text( 261 | json.dumps(self._milestones, indent=2), 262 | encoding="utf-8", 263 | ) 264 | except OSError as e: 265 | logger.warning(f"Failed to save milestones: {e}", exc_info=True) 266 | 267 | def record_milestone(self, milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool: 268 | """Record a milestone event, returns True if this is the first occurrence""" 269 | if not self.config.enabled: 270 | return False 271 | milestone_key = milestone.value 272 | with self._lock: 273 | if milestone_key in self._milestones: 274 | return False # Already recorded 275 | milestone_data = { 276 | "timestamp": time.time(), 277 | "data": data or {}, 278 | } 279 | self._milestones[milestone_key] = milestone_data 280 | self._save_milestones() 281 | 282 | # Also send as telemetry record 283 | self.record( 284 | record_type=RecordType.USAGE, 285 | data={"milestone": milestone_key, **(data or {})}, 286 | milestone=milestone 287 | ) 288 | 289 | return True 290 | 291 | def record(self, 292 | record_type: RecordType, 293 | data: dict[str, Any], 294 | milestone: MilestoneType | None = None): 295 | """Record a telemetry event (async, non-blocking)""" 296 | if not self.config.enabled: 297 | return 298 | 299 | # Allow fallback sender when httpx is unavailable (no early return) 300 | 301 | record = TelemetryRecord( 302 | record_type=record_type, 303 | timestamp=time.time(), 304 | customer_uuid=self._customer_uuid or "unknown", 305 | session_id=self.config.session_id, 306 | data=data, 307 | milestone=milestone 308 | ) 309 | # Enqueue for background worker (non-blocking). Drop on backpressure. 310 | try: 311 | self._queue.put_nowait(record) 312 | except queue.Full: 313 | logger.debug("Telemetry queue full; dropping %s", 314 | record.record_type) 315 | 316 | def _worker_loop(self): 317 | """Background worker that serializes telemetry sends.""" 318 | while True: 319 | rec = self._queue.get() 320 | try: 321 | # Run sender directly; do not reuse caller context/thread-locals 322 | self._send_telemetry(rec) 323 | except Exception: 324 | logger.debug("Telemetry worker send failed", exc_info=True) 325 | finally: 326 | with contextlib.suppress(Exception): 327 | self._queue.task_done() 328 | 329 | def _send_telemetry(self, record: TelemetryRecord): 330 | """Send telemetry data to endpoint""" 331 | try: 332 | # System fingerprint (top-level remains concise; details stored in data JSON) 333 | _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows' 334 | _source = sys.platform # 'darwin' | 'linux' | 'win32' 335 | _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})" 336 | _python_version = platform.python_version() 337 | 338 | # Enrich data JSON so BigQuery stores detailed fields without schema change 339 | enriched_data = dict(record.data or {}) 340 | enriched_data.setdefault("platform_detail", _platform_detail) 341 | enriched_data.setdefault("python_version", _python_version) 342 | 343 | payload = { 344 | "record": record.record_type.value, 345 | "timestamp": record.timestamp, 346 | "customer_uuid": record.customer_uuid, 347 | "session_id": record.session_id, 348 | "data": enriched_data, 349 | "version": MCP_VERSION, 350 | "platform": _platform, 351 | "source": _source, 352 | } 353 | 354 | if record.milestone: 355 | payload["milestone"] = record.milestone.value 356 | 357 | # Prefer httpx when available; otherwise fall back to urllib 358 | if httpx: 359 | with httpx.Client(timeout=self.config.timeout) as client: 360 | # Re-validate endpoint at send time to handle dynamic changes 361 | endpoint = self.config._validated_endpoint( 362 | self.config.endpoint, self.config.default_endpoint) 363 | response = client.post(endpoint, json=payload) 364 | if 200 <= response.status_code < 300: 365 | logger.debug(f"Telemetry sent: {record.record_type}") 366 | else: 367 | logger.warning( 368 | f"Telemetry failed: HTTP {response.status_code}") 369 | else: 370 | import urllib.request 371 | import urllib.error 372 | data_bytes = json.dumps(payload).encode("utf-8") 373 | endpoint = self.config._validated_endpoint( 374 | self.config.endpoint, self.config.default_endpoint) 375 | req = urllib.request.Request( 376 | endpoint, 377 | data=data_bytes, 378 | headers={"Content-Type": "application/json"}, 379 | method="POST", 380 | ) 381 | try: 382 | with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: 383 | if 200 <= resp.getcode() < 300: 384 | logger.debug( 385 | f"Telemetry sent (urllib): {record.record_type}") 386 | else: 387 | logger.warning( 388 | f"Telemetry failed (urllib): HTTP {resp.getcode()}") 389 | except urllib.error.URLError as ue: 390 | logger.warning(f"Telemetry send failed (urllib): {ue}") 391 | 392 | except Exception as e: 393 | # Never let telemetry errors interfere with app functionality 394 | logger.debug(f"Telemetry send failed: {e}") 395 | 396 | 397 | # Global telemetry instance 398 | _telemetry_collector: TelemetryCollector | None = None 399 | 400 | 401 | def get_telemetry() -> TelemetryCollector: 402 | """Get the global telemetry collector instance""" 403 | global _telemetry_collector 404 | if _telemetry_collector is None: 405 | _telemetry_collector = TelemetryCollector() 406 | return _telemetry_collector 407 | 408 | 409 | def record_telemetry(record_type: RecordType, 410 | data: dict[str, Any], 411 | milestone: MilestoneType | None = None): 412 | """Convenience function to record telemetry""" 413 | get_telemetry().record(record_type, data, milestone) 414 | 415 | 416 | def record_milestone(milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool: 417 | """Convenience function to record a milestone""" 418 | return get_telemetry().record_milestone(milestone, data) 419 | 420 | 421 | def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: str | None = None, sub_action: str | None = None): 422 | """Record tool usage telemetry 423 | 424 | Args: 425 | tool_name: Name of the tool invoked (e.g., 'manage_scene'). 426 | success: Whether the tool completed successfully. 427 | duration_ms: Execution duration in milliseconds. 428 | error: Optional error message (truncated if present). 429 | sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy'). 430 | """ 431 | data = { 432 | "tool_name": tool_name, 433 | "success": success, 434 | "duration_ms": round(duration_ms, 2) 435 | } 436 | 437 | if sub_action is not None: 438 | try: 439 | data["sub_action"] = str(sub_action) 440 | except Exception: 441 | # Ensure telemetry is never disruptive 442 | data["sub_action"] = "unknown" 443 | 444 | if error: 445 | data["error"] = str(error)[:200] # Limit error message length 446 | 447 | record_telemetry(RecordType.TOOL_EXECUTION, data) 448 | 449 | 450 | def record_resource_usage(resource_name: str, success: bool, duration_ms: float, error: str | None = None): 451 | """Record resource usage telemetry 452 | 453 | Args: 454 | resource_name: Name of the resource invoked (e.g., 'get_tests'). 455 | success: Whether the resource completed successfully. 456 | duration_ms: Execution duration in milliseconds. 457 | error: Optional error message (truncated if present). 458 | """ 459 | data = { 460 | "resource_name": resource_name, 461 | "success": success, 462 | "duration_ms": round(duration_ms, 2) 463 | } 464 | 465 | if error: 466 | data["error"] = str(error)[:200] # Limit error message length 467 | 468 | record_telemetry(RecordType.RESOURCE_RETRIEVAL, data) 469 | 470 | 471 | def record_latency(operation: str, duration_ms: float, metadata: dict[str, Any] | None = None): 472 | """Record latency telemetry""" 473 | data = { 474 | "operation": operation, 475 | "duration_ms": round(duration_ms, 2) 476 | } 477 | 478 | if metadata: 479 | data.update(metadata) 480 | 481 | record_telemetry(RecordType.LATENCY, data) 482 | 483 | 484 | def record_failure(component: str, error: str, metadata: dict[str, Any] | None = None): 485 | """Record failure telemetry""" 486 | data = { 487 | "component": component, 488 | "error": str(error)[:500] # Limit error message length 489 | } 490 | 491 | if metadata: 492 | data.update(metadata) 493 | 494 | record_telemetry(RecordType.FAILURE, data) 495 | 496 | 497 | def is_telemetry_enabled() -> bool: 498 | """Check if telemetry is enabled""" 499 | return get_telemetry().config.enabled 500 | ``` -------------------------------------------------------------------------------- /MCPForUnity/UnityMcpServer~/src/unity_connection.py: -------------------------------------------------------------------------------- ```python 1 | from config import config 2 | import contextlib 3 | from dataclasses import dataclass 4 | import errno 5 | import json 6 | import logging 7 | from pathlib import Path 8 | from port_discovery import PortDiscovery 9 | import random 10 | import socket 11 | import struct 12 | import threading 13 | import time 14 | from typing import Any, Dict 15 | 16 | from models import MCPResponse 17 | 18 | 19 | # Configure logging using settings from config 20 | logging.basicConfig( 21 | level=getattr(logging, config.log_level), 22 | format=config.log_format 23 | ) 24 | logger = logging.getLogger("mcp-for-unity-server") 25 | 26 | # Module-level lock to guard global connection initialization 27 | _connection_lock = threading.Lock() 28 | 29 | # Maximum allowed framed payload size (64 MiB) 30 | FRAMED_MAX = 64 * 1024 * 1024 31 | 32 | 33 | @dataclass 34 | class UnityConnection: 35 | """Manages the socket connection to the Unity Editor.""" 36 | host: str = config.unity_host 37 | port: int = None # Will be set dynamically 38 | sock: socket.socket = None # Socket for Unity communication 39 | use_framing: bool = False # Negotiated per-connection 40 | 41 | def __post_init__(self): 42 | """Set port from discovery if not explicitly provided""" 43 | if self.port is None: 44 | self.port = PortDiscovery.discover_unity_port() 45 | self._io_lock = threading.Lock() 46 | self._conn_lock = threading.Lock() 47 | 48 | def connect(self) -> bool: 49 | """Establish a connection to the Unity Editor.""" 50 | if self.sock: 51 | return True 52 | with self._conn_lock: 53 | if self.sock: 54 | return True 55 | try: 56 | # Bounded connect to avoid indefinite blocking 57 | connect_timeout = float( 58 | getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) 59 | self.sock = socket.create_connection( 60 | (self.host, self.port), connect_timeout) 61 | # Disable Nagle's algorithm to reduce small RPC latency 62 | with contextlib.suppress(Exception): 63 | self.sock.setsockopt( 64 | socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 65 | logger.debug(f"Connected to Unity at {self.host}:{self.port}") 66 | 67 | # Strict handshake: require FRAMING=1 68 | try: 69 | require_framing = getattr(config, "require_framing", True) 70 | timeout = float(getattr(config, "handshake_timeout", 1.0)) 71 | self.sock.settimeout(timeout) 72 | buf = bytearray() 73 | deadline = time.monotonic() + timeout 74 | while time.monotonic() < deadline and len(buf) < 512: 75 | try: 76 | chunk = self.sock.recv(256) 77 | if not chunk: 78 | break 79 | buf.extend(chunk) 80 | if b"\n" in buf: 81 | break 82 | except socket.timeout: 83 | break 84 | text = bytes(buf).decode('ascii', errors='ignore').strip() 85 | 86 | if 'FRAMING=1' in text: 87 | self.use_framing = True 88 | logger.debug( 89 | 'MCP for Unity handshake received: FRAMING=1 (strict)') 90 | else: 91 | if require_framing: 92 | # Best-effort plain-text advisory for legacy peers 93 | with contextlib.suppress(Exception): 94 | self.sock.sendall( 95 | b'MCP for Unity requires FRAMING=1\n') 96 | raise ConnectionError( 97 | f'MCP for Unity requires FRAMING=1, got: {text!r}') 98 | else: 99 | self.use_framing = False 100 | logger.warning( 101 | 'MCP for Unity handshake missing FRAMING=1; proceeding in legacy mode by configuration') 102 | finally: 103 | self.sock.settimeout(config.connection_timeout) 104 | return True 105 | except Exception as e: 106 | logger.error(f"Failed to connect to Unity: {str(e)}") 107 | try: 108 | if self.sock: 109 | self.sock.close() 110 | except Exception: 111 | pass 112 | self.sock = None 113 | return False 114 | 115 | def disconnect(self): 116 | """Close the connection to the Unity Editor.""" 117 | if self.sock: 118 | try: 119 | self.sock.close() 120 | except Exception as e: 121 | logger.error(f"Error disconnecting from Unity: {str(e)}") 122 | finally: 123 | self.sock = None 124 | 125 | def _read_exact(self, sock: socket.socket, count: int) -> bytes: 126 | data = bytearray() 127 | while len(data) < count: 128 | chunk = sock.recv(count - len(data)) 129 | if not chunk: 130 | raise ConnectionError( 131 | "Connection closed before reading expected bytes") 132 | data.extend(chunk) 133 | return bytes(data) 134 | 135 | def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: 136 | """Receive a complete response from Unity, handling chunked data.""" 137 | if self.use_framing: 138 | try: 139 | # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive 140 | heartbeat_count = 0 141 | deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) 142 | while True: 143 | header = self._read_exact(sock, 8) 144 | payload_len = struct.unpack('>Q', header)[0] 145 | if payload_len == 0: 146 | # Heartbeat/no-op frame: consume and continue waiting for a data frame 147 | logger.debug("Received heartbeat frame (length=0)") 148 | heartbeat_count += 1 149 | if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: 150 | # Treat as empty successful response to match C# server behavior 151 | logger.debug( 152 | "Heartbeat threshold reached; returning empty response") 153 | return b"" 154 | continue 155 | if payload_len > FRAMED_MAX: 156 | raise ValueError( 157 | f"Invalid framed length: {payload_len}") 158 | payload = self._read_exact(sock, payload_len) 159 | logger.debug( 160 | f"Received framed response ({len(payload)} bytes)") 161 | return payload 162 | except socket.timeout as e: 163 | logger.warning("Socket timeout during framed receive") 164 | raise TimeoutError("Timeout receiving Unity response") from e 165 | except Exception as e: 166 | logger.error(f"Error during framed receive: {str(e)}") 167 | raise 168 | 169 | chunks = [] 170 | # Respect the socket's currently configured timeout 171 | try: 172 | while True: 173 | chunk = sock.recv(buffer_size) 174 | if not chunk: 175 | if not chunks: 176 | raise Exception( 177 | "Connection closed before receiving data") 178 | break 179 | chunks.append(chunk) 180 | 181 | # Process the data received so far 182 | data = b''.join(chunks) 183 | decoded_data = data.decode('utf-8') 184 | 185 | # Check if we've received a complete response 186 | try: 187 | # Special case for ping-pong 188 | if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): 189 | logger.debug("Received ping response") 190 | return data 191 | 192 | # Handle escaped quotes in the content 193 | if '"content":' in decoded_data: 194 | # Find the content field and its value 195 | content_start = decoded_data.find('"content":') + 9 196 | content_end = decoded_data.rfind('"', content_start) 197 | if content_end > content_start: 198 | # Replace escaped quotes in content with regular quotes 199 | content = decoded_data[content_start:content_end] 200 | content = content.replace('\\"', '"') 201 | decoded_data = decoded_data[:content_start] + \ 202 | content + decoded_data[content_end:] 203 | 204 | # Validate JSON format 205 | json.loads(decoded_data) 206 | 207 | # If we get here, we have valid JSON 208 | logger.info( 209 | f"Received complete response ({len(data)} bytes)") 210 | return data 211 | except json.JSONDecodeError: 212 | # We haven't received a complete valid JSON response yet 213 | continue 214 | except Exception as e: 215 | logger.warning( 216 | f"Error processing response chunk: {str(e)}") 217 | # Continue reading more chunks as this might not be the complete response 218 | continue 219 | except socket.timeout: 220 | logger.warning("Socket timeout during receive") 221 | raise Exception("Timeout receiving Unity response") 222 | except Exception as e: 223 | logger.error(f"Error during receive: {str(e)}") 224 | raise 225 | 226 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: 227 | """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" 228 | # Defensive guard: catch empty/placeholder invocations early 229 | if not command_type: 230 | raise ValueError("MCP call missing command_type") 231 | if params is None: 232 | return MCPResponse(success=False, error="MCP call received with no parameters (client placeholder?)") 233 | attempts = max(config.max_retries, 5) 234 | base_backoff = max(0.5, config.retry_delay) 235 | 236 | def read_status_file() -> dict | None: 237 | try: 238 | status_files = sorted(Path.home().joinpath( 239 | '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) 240 | if not status_files: 241 | return None 242 | latest = status_files[0] 243 | with latest.open('r') as f: 244 | return json.load(f) 245 | except Exception: 246 | return None 247 | 248 | last_short_timeout = None 249 | 250 | # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely 251 | try: 252 | status = read_status_file() 253 | if status and (status.get('reloading') or status.get('reason') == 'reloading'): 254 | return MCPResponse( 255 | success=False, 256 | error="Unity domain reload in progress, please try again shortly", 257 | data={"state": "reloading", "retry_after_ms": int( 258 | config.reload_retry_ms)} 259 | ) 260 | except Exception: 261 | pass 262 | 263 | for attempt in range(attempts + 1): 264 | try: 265 | # Ensure connected (handshake occurs within connect()) 266 | if not self.sock and not self.connect(): 267 | raise Exception("Could not connect to Unity") 268 | 269 | # Build payload 270 | if command_type == 'ping': 271 | payload = b'ping' 272 | else: 273 | command = {"type": command_type, "params": params or {}} 274 | payload = json.dumps( 275 | command, ensure_ascii=False).encode('utf-8') 276 | 277 | # Send/receive are serialized to protect the shared socket 278 | with self._io_lock: 279 | mode = 'framed' if self.use_framing else 'legacy' 280 | with contextlib.suppress(Exception): 281 | logger.debug( 282 | "send %d bytes; mode=%s; head=%s", 283 | len(payload), 284 | mode, 285 | (payload[:32]).decode('utf-8', 'ignore'), 286 | ) 287 | if self.use_framing: 288 | header = struct.pack('>Q', len(payload)) 289 | self.sock.sendall(header) 290 | self.sock.sendall(payload) 291 | else: 292 | self.sock.sendall(payload) 293 | 294 | # During retry bursts use a short receive timeout and ensure restoration 295 | restore_timeout = None 296 | if attempt > 0 and last_short_timeout is None: 297 | restore_timeout = self.sock.gettimeout() 298 | self.sock.settimeout(1.0) 299 | try: 300 | response_data = self.receive_full_response(self.sock) 301 | with contextlib.suppress(Exception): 302 | logger.debug("recv %d bytes; mode=%s", 303 | len(response_data), mode) 304 | finally: 305 | if restore_timeout is not None: 306 | self.sock.settimeout(restore_timeout) 307 | last_short_timeout = None 308 | 309 | # Parse 310 | if command_type == 'ping': 311 | resp = json.loads(response_data.decode('utf-8')) 312 | if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': 313 | return {"message": "pong"} 314 | raise Exception("Ping unsuccessful") 315 | 316 | resp = json.loads(response_data.decode('utf-8')) 317 | if resp.get('status') == 'error': 318 | err = resp.get('error') or resp.get( 319 | 'message', 'Unknown Unity error') 320 | raise Exception(err) 321 | return resp.get('result', {}) 322 | except Exception as e: 323 | logger.warning( 324 | f"Unity communication attempt {attempt+1} failed: {e}") 325 | try: 326 | if self.sock: 327 | self.sock.close() 328 | finally: 329 | self.sock = None 330 | 331 | # Re-discover port each time 332 | try: 333 | new_port = PortDiscovery.discover_unity_port() 334 | if new_port != self.port: 335 | logger.info( 336 | f"Unity port changed {self.port} -> {new_port}") 337 | self.port = new_port 338 | except Exception as de: 339 | logger.debug(f"Port discovery failed: {de}") 340 | 341 | if attempt < attempts: 342 | # Heartbeat-aware, jittered backoff 343 | status = read_status_file() 344 | # Base exponential backoff 345 | backoff = base_backoff * (2 ** attempt) 346 | # Decorrelated jitter multiplier 347 | jitter = random.uniform(0.1, 0.3) 348 | 349 | # Fast‑retry for transient socket failures 350 | fast_error = isinstance( 351 | e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) 352 | if not fast_error: 353 | try: 354 | err_no = getattr(e, 'errno', None) 355 | fast_error = err_no in ( 356 | errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) 357 | except Exception: 358 | pass 359 | 360 | # Cap backoff depending on state 361 | if status and status.get('reloading'): 362 | cap = 0.8 363 | elif fast_error: 364 | cap = 0.25 365 | else: 366 | cap = 3.0 367 | 368 | sleep_s = min(cap, jitter * (2 ** attempt)) 369 | time.sleep(sleep_s) 370 | continue 371 | raise 372 | 373 | 374 | # Global Unity connection 375 | _unity_connection = None 376 | 377 | 378 | def get_unity_connection() -> UnityConnection: 379 | """Retrieve or establish a persistent Unity connection. 380 | 381 | Note: Do NOT ping on every retrieval to avoid connection storms. Rely on 382 | send_command() exceptions to detect broken sockets and reconnect there. 383 | """ 384 | global _unity_connection 385 | if _unity_connection is not None: 386 | return _unity_connection 387 | 388 | # Double-checked locking to avoid concurrent socket creation 389 | with _connection_lock: 390 | if _unity_connection is not None: 391 | return _unity_connection 392 | logger.info("Creating new Unity connection") 393 | _unity_connection = UnityConnection() 394 | if not _unity_connection.connect(): 395 | _unity_connection = None 396 | raise ConnectionError( 397 | "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") 398 | logger.info("Connected to Unity on startup") 399 | return _unity_connection 400 | 401 | 402 | # ----------------------------- 403 | # Centralized retry helpers 404 | # ----------------------------- 405 | 406 | def _is_reloading_response(resp: dict) -> bool: 407 | """Return True if the Unity response indicates the editor is reloading.""" 408 | if not isinstance(resp, dict): 409 | return False 410 | if resp.get("state") == "reloading": 411 | return True 412 | message_text = (resp.get("message") or resp.get("error") or "").lower() 413 | return "reload" in message_text 414 | 415 | 416 | def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: 417 | """Send a command via the shared connection, waiting politely through Unity reloads. 418 | 419 | Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the 420 | structured failure if retries are exhausted. 421 | """ 422 | conn = get_unity_connection() 423 | if max_retries is None: 424 | max_retries = getattr(config, "reload_max_retries", 40) 425 | if retry_ms is None: 426 | retry_ms = getattr(config, "reload_retry_ms", 250) 427 | 428 | response = conn.send_command(command_type, params) 429 | retries = 0 430 | while _is_reloading_response(response) and retries < max_retries: 431 | delay_ms = int(response.get("retry_after_ms", retry_ms) 432 | ) if isinstance(response, dict) else retry_ms 433 | time.sleep(max(0.0, delay_ms / 1000.0)) 434 | retries += 1 435 | response = conn.send_command(command_type, params) 436 | return response 437 | 438 | 439 | async def async_send_command_with_retry(command_type: str, params: dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> dict[str, Any] | MCPResponse: 440 | """Async wrapper that runs the blocking retry helper in a thread pool.""" 441 | try: 442 | import asyncio # local import to avoid mandatory asyncio dependency for sync callers 443 | if loop is None: 444 | loop = asyncio.get_running_loop() 445 | return await loop.run_in_executor( 446 | None, 447 | lambda: send_command_with_retry( 448 | command_type, params, max_retries=max_retries, retry_ms=retry_ms), 449 | ) 450 | except Exception as e: 451 | return MCPResponse(success=False, error=str(e)) 452 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/UnityMcpServer~/src/unity_connection.py: -------------------------------------------------------------------------------- ```python 1 | from config import config 2 | import contextlib 3 | from dataclasses import dataclass 4 | import errno 5 | import json 6 | import logging 7 | from pathlib import Path 8 | from port_discovery import PortDiscovery 9 | import random 10 | import socket 11 | import struct 12 | import threading 13 | import time 14 | from typing import Any, Dict 15 | 16 | 17 | # Configure logging using settings from config 18 | logging.basicConfig( 19 | level=getattr(logging, config.log_level), 20 | format=config.log_format 21 | ) 22 | logger = logging.getLogger("mcp-for-unity-server") 23 | 24 | # Module-level lock to guard global connection initialization 25 | _connection_lock = threading.Lock() 26 | 27 | # Maximum allowed framed payload size (64 MiB) 28 | FRAMED_MAX = 64 * 1024 * 1024 29 | 30 | 31 | @dataclass 32 | class UnityConnection: 33 | """Manages the socket connection to the Unity Editor.""" 34 | host: str = config.unity_host 35 | port: int = None # Will be set dynamically 36 | sock: socket.socket = None # Socket for Unity communication 37 | use_framing: bool = False # Negotiated per-connection 38 | 39 | def __post_init__(self): 40 | """Set port from discovery if not explicitly provided""" 41 | if self.port is None: 42 | self.port = PortDiscovery.discover_unity_port() 43 | self._io_lock = threading.Lock() 44 | self._conn_lock = threading.Lock() 45 | 46 | def connect(self) -> bool: 47 | """Establish a connection to the Unity Editor.""" 48 | if self.sock: 49 | return True 50 | with self._conn_lock: 51 | if self.sock: 52 | return True 53 | try: 54 | # Bounded connect to avoid indefinite blocking 55 | connect_timeout = float( 56 | getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) 57 | self.sock = socket.create_connection( 58 | (self.host, self.port), connect_timeout) 59 | # Disable Nagle's algorithm to reduce small RPC latency 60 | with contextlib.suppress(Exception): 61 | self.sock.setsockopt( 62 | socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 63 | logger.debug(f"Connected to Unity at {self.host}:{self.port}") 64 | 65 | # Strict handshake: require FRAMING=1 66 | try: 67 | require_framing = getattr(config, "require_framing", True) 68 | timeout = float(getattr(config, "handshake_timeout", 1.0)) 69 | self.sock.settimeout(timeout) 70 | buf = bytearray() 71 | deadline = time.monotonic() + timeout 72 | while time.monotonic() < deadline and len(buf) < 512: 73 | try: 74 | chunk = self.sock.recv(256) 75 | if not chunk: 76 | break 77 | buf.extend(chunk) 78 | if b"\n" in buf: 79 | break 80 | except socket.timeout: 81 | break 82 | text = bytes(buf).decode('ascii', errors='ignore').strip() 83 | 84 | if 'FRAMING=1' in text: 85 | self.use_framing = True 86 | logger.debug( 87 | 'Unity MCP handshake received: FRAMING=1 (strict)') 88 | else: 89 | if require_framing: 90 | # Best-effort plain-text advisory for legacy peers 91 | with contextlib.suppress(Exception): 92 | self.sock.sendall( 93 | b'Unity MCP requires FRAMING=1\n') 94 | raise ConnectionError( 95 | f'Unity MCP requires FRAMING=1, got: {text!r}') 96 | else: 97 | self.use_framing = False 98 | logger.warning( 99 | 'Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') 100 | finally: 101 | self.sock.settimeout(config.connection_timeout) 102 | return True 103 | except Exception as e: 104 | logger.error(f"Failed to connect to Unity: {str(e)}") 105 | try: 106 | if self.sock: 107 | self.sock.close() 108 | except Exception: 109 | pass 110 | self.sock = None 111 | return False 112 | 113 | def disconnect(self): 114 | """Close the connection to the Unity Editor.""" 115 | if self.sock: 116 | try: 117 | self.sock.close() 118 | except Exception as e: 119 | logger.error(f"Error disconnecting from Unity: {str(e)}") 120 | finally: 121 | self.sock = None 122 | 123 | def _read_exact(self, sock: socket.socket, count: int) -> bytes: 124 | data = bytearray() 125 | while len(data) < count: 126 | chunk = sock.recv(count - len(data)) 127 | if not chunk: 128 | raise ConnectionError( 129 | "Connection closed before reading expected bytes") 130 | data.extend(chunk) 131 | return bytes(data) 132 | 133 | def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: 134 | """Receive a complete response from Unity, handling chunked data.""" 135 | if self.use_framing: 136 | try: 137 | # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive 138 | heartbeat_count = 0 139 | deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) 140 | while True: 141 | header = self._read_exact(sock, 8) 142 | payload_len = struct.unpack('>Q', header)[0] 143 | if payload_len == 0: 144 | # Heartbeat/no-op frame: consume and continue waiting for a data frame 145 | logger.debug("Received heartbeat frame (length=0)") 146 | heartbeat_count += 1 147 | if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: 148 | # Treat as empty successful response to match C# server behavior 149 | logger.debug( 150 | "Heartbeat threshold reached; returning empty response") 151 | return b"" 152 | continue 153 | if payload_len > FRAMED_MAX: 154 | raise ValueError( 155 | f"Invalid framed length: {payload_len}") 156 | payload = self._read_exact(sock, payload_len) 157 | logger.debug( 158 | f"Received framed response ({len(payload)} bytes)") 159 | return payload 160 | except socket.timeout as e: 161 | logger.warning("Socket timeout during framed receive") 162 | raise TimeoutError("Timeout receiving Unity response") from e 163 | except Exception as e: 164 | logger.error(f"Error during framed receive: {str(e)}") 165 | raise 166 | 167 | chunks = [] 168 | # Respect the socket's currently configured timeout 169 | try: 170 | while True: 171 | chunk = sock.recv(buffer_size) 172 | if not chunk: 173 | if not chunks: 174 | raise Exception( 175 | "Connection closed before receiving data") 176 | break 177 | chunks.append(chunk) 178 | 179 | # Process the data received so far 180 | data = b''.join(chunks) 181 | decoded_data = data.decode('utf-8') 182 | 183 | # Check if we've received a complete response 184 | try: 185 | # Special case for ping-pong 186 | if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): 187 | logger.debug("Received ping response") 188 | return data 189 | 190 | # Handle escaped quotes in the content 191 | if '"content":' in decoded_data: 192 | # Find the content field and its value 193 | content_start = decoded_data.find('"content":') + 9 194 | content_end = decoded_data.rfind('"', content_start) 195 | if content_end > content_start: 196 | # Replace escaped quotes in content with regular quotes 197 | content = decoded_data[content_start:content_end] 198 | content = content.replace('\\"', '"') 199 | decoded_data = decoded_data[:content_start] + \ 200 | content + decoded_data[content_end:] 201 | 202 | # Validate JSON format 203 | json.loads(decoded_data) 204 | 205 | # If we get here, we have valid JSON 206 | logger.info( 207 | f"Received complete response ({len(data)} bytes)") 208 | return data 209 | except json.JSONDecodeError: 210 | # We haven't received a complete valid JSON response yet 211 | continue 212 | except Exception as e: 213 | logger.warning( 214 | f"Error processing response chunk: {str(e)}") 215 | # Continue reading more chunks as this might not be the complete response 216 | continue 217 | except socket.timeout: 218 | logger.warning("Socket timeout during receive") 219 | raise Exception("Timeout receiving Unity response") 220 | except Exception as e: 221 | logger.error(f"Error during receive: {str(e)}") 222 | raise 223 | 224 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: 225 | """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" 226 | # Defensive guard: catch empty/placeholder invocations early 227 | if not command_type: 228 | raise ValueError("MCP call missing command_type") 229 | if params is None: 230 | # Return a fast, structured error that clients can display without hanging 231 | return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"} 232 | attempts = max(config.max_retries, 5) 233 | base_backoff = max(0.5, config.retry_delay) 234 | 235 | def read_status_file() -> dict | None: 236 | try: 237 | status_files = sorted(Path.home().joinpath( 238 | '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) 239 | if not status_files: 240 | return None 241 | latest = status_files[0] 242 | with latest.open('r') as f: 243 | return json.load(f) 244 | except Exception: 245 | return None 246 | 247 | last_short_timeout = None 248 | 249 | # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely 250 | try: 251 | status = read_status_file() 252 | if status and (status.get('reloading') or status.get('reason') == 'reloading'): 253 | return { 254 | "success": False, 255 | "state": "reloading", 256 | "retry_after_ms": int(config.reload_retry_ms), 257 | "error": "Unity domain reload in progress", 258 | "message": "Unity is reloading scripts; please retry shortly" 259 | } 260 | except Exception: 261 | pass 262 | 263 | for attempt in range(attempts + 1): 264 | try: 265 | # Ensure connected (handshake occurs within connect()) 266 | if not self.sock and not self.connect(): 267 | raise Exception("Could not connect to Unity") 268 | 269 | # Build payload 270 | if command_type == 'ping': 271 | payload = b'ping' 272 | else: 273 | command = {"type": command_type, "params": params or {}} 274 | payload = json.dumps( 275 | command, ensure_ascii=False).encode('utf-8') 276 | 277 | # Send/receive are serialized to protect the shared socket 278 | with self._io_lock: 279 | mode = 'framed' if self.use_framing else 'legacy' 280 | with contextlib.suppress(Exception): 281 | logger.debug( 282 | "send %d bytes; mode=%s; head=%s", 283 | len(payload), 284 | mode, 285 | (payload[:32]).decode('utf-8', 'ignore'), 286 | ) 287 | if self.use_framing: 288 | header = struct.pack('>Q', len(payload)) 289 | self.sock.sendall(header) 290 | self.sock.sendall(payload) 291 | else: 292 | self.sock.sendall(payload) 293 | 294 | # During retry bursts use a short receive timeout and ensure restoration 295 | restore_timeout = None 296 | if attempt > 0 and last_short_timeout is None: 297 | restore_timeout = self.sock.gettimeout() 298 | self.sock.settimeout(1.0) 299 | try: 300 | response_data = self.receive_full_response(self.sock) 301 | with contextlib.suppress(Exception): 302 | logger.debug("recv %d bytes; mode=%s", 303 | len(response_data), mode) 304 | finally: 305 | if restore_timeout is not None: 306 | self.sock.settimeout(restore_timeout) 307 | last_short_timeout = None 308 | 309 | # Parse 310 | if command_type == 'ping': 311 | resp = json.loads(response_data.decode('utf-8')) 312 | if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': 313 | return {"message": "pong"} 314 | raise Exception("Ping unsuccessful") 315 | 316 | resp = json.loads(response_data.decode('utf-8')) 317 | if resp.get('status') == 'error': 318 | err = resp.get('error') or resp.get( 319 | 'message', 'Unknown Unity error') 320 | raise Exception(err) 321 | return resp.get('result', {}) 322 | except Exception as e: 323 | logger.warning( 324 | f"Unity communication attempt {attempt+1} failed: {e}") 325 | try: 326 | if self.sock: 327 | self.sock.close() 328 | finally: 329 | self.sock = None 330 | 331 | # Re-discover port each time 332 | try: 333 | new_port = PortDiscovery.discover_unity_port() 334 | if new_port != self.port: 335 | logger.info( 336 | f"Unity port changed {self.port} -> {new_port}") 337 | self.port = new_port 338 | except Exception as de: 339 | logger.debug(f"Port discovery failed: {de}") 340 | 341 | if attempt < attempts: 342 | # Heartbeat-aware, jittered backoff 343 | status = read_status_file() 344 | # Base exponential backoff 345 | backoff = base_backoff * (2 ** attempt) 346 | # Decorrelated jitter multiplier 347 | jitter = random.uniform(0.1, 0.3) 348 | 349 | # Fast‑retry for transient socket failures 350 | fast_error = isinstance( 351 | e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) 352 | if not fast_error: 353 | try: 354 | err_no = getattr(e, 'errno', None) 355 | fast_error = err_no in ( 356 | errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) 357 | except Exception: 358 | pass 359 | 360 | # Cap backoff depending on state 361 | if status and status.get('reloading'): 362 | cap = 0.8 363 | elif fast_error: 364 | cap = 0.25 365 | else: 366 | cap = 3.0 367 | 368 | sleep_s = min(cap, jitter * (2 ** attempt)) 369 | time.sleep(sleep_s) 370 | continue 371 | raise 372 | 373 | 374 | # Global Unity connection 375 | _unity_connection = None 376 | 377 | 378 | def get_unity_connection() -> UnityConnection: 379 | """Retrieve or establish a persistent Unity connection. 380 | 381 | Note: Do NOT ping on every retrieval to avoid connection storms. Rely on 382 | send_command() exceptions to detect broken sockets and reconnect there. 383 | """ 384 | global _unity_connection 385 | if _unity_connection is not None: 386 | return _unity_connection 387 | 388 | # Double-checked locking to avoid concurrent socket creation 389 | with _connection_lock: 390 | if _unity_connection is not None: 391 | return _unity_connection 392 | logger.info("Creating new Unity connection") 393 | _unity_connection = UnityConnection() 394 | if not _unity_connection.connect(): 395 | _unity_connection = None 396 | raise ConnectionError( 397 | "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") 398 | logger.info("Connected to Unity on startup") 399 | return _unity_connection 400 | 401 | 402 | # ----------------------------- 403 | # Centralized retry helpers 404 | # ----------------------------- 405 | 406 | def _is_reloading_response(resp: dict) -> bool: 407 | """Return True if the Unity response indicates the editor is reloading.""" 408 | if not isinstance(resp, dict): 409 | return False 410 | if resp.get("state") == "reloading": 411 | return True 412 | message_text = (resp.get("message") or resp.get("error") or "").lower() 413 | return "reload" in message_text 414 | 415 | 416 | def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: 417 | """Send a command via the shared connection, waiting politely through Unity reloads. 418 | 419 | Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the 420 | structured failure if retries are exhausted. 421 | """ 422 | conn = get_unity_connection() 423 | if max_retries is None: 424 | max_retries = getattr(config, "reload_max_retries", 40) 425 | if retry_ms is None: 426 | retry_ms = getattr(config, "reload_retry_ms", 250) 427 | 428 | response = conn.send_command(command_type, params) 429 | retries = 0 430 | while _is_reloading_response(response) and retries < max_retries: 431 | delay_ms = int(response.get("retry_after_ms", retry_ms) 432 | ) if isinstance(response, dict) else retry_ms 433 | time.sleep(max(0.0, delay_ms / 1000.0)) 434 | retries += 1 435 | response = conn.send_command(command_type, params) 436 | return response 437 | 438 | 439 | async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: 440 | """Async wrapper that runs the blocking retry helper in a thread pool.""" 441 | try: 442 | import asyncio # local import to avoid mandatory asyncio dependency for sync callers 443 | if loop is None: 444 | loop = asyncio.get_running_loop() 445 | return await loop.run_in_executor( 446 | None, 447 | lambda: send_command_with_retry( 448 | command_type, params, max_retries=max_retries, retry_ms=retry_ms), 449 | ) 450 | except Exception as e: 451 | # Return a structured error dict for consistency with other responses 452 | return {"success": False, "error": f"Python async retry helper failed: {str(e)}"} 453 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Tools/ManageScene.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditor.SceneManagement; 8 | using UnityEngine; 9 | using UnityEngine.SceneManagement; 10 | using MCPForUnity.Editor.Helpers; // For Response class 11 | 12 | namespace MCPForUnity.Editor.Tools 13 | { 14 | /// <summary> 15 | /// Handles scene management operations like loading, saving, creating, and querying hierarchy. 16 | /// </summary> 17 | [McpForUnityTool("manage_scene")] 18 | public static class ManageScene 19 | { 20 | private sealed class SceneCommand 21 | { 22 | public string action { get; set; } = string.Empty; 23 | public string name { get; set; } = string.Empty; 24 | public string path { get; set; } = string.Empty; 25 | public int? buildIndex { get; set; } 26 | } 27 | 28 | private static SceneCommand ToSceneCommand(JObject p) 29 | { 30 | if (p == null) return new SceneCommand(); 31 | int? BI(JToken t) 32 | { 33 | if (t == null || t.Type == JTokenType.Null) return null; 34 | var s = t.ToString().Trim(); 35 | if (s.Length == 0) return null; 36 | if (int.TryParse(s, out var i)) return i; 37 | if (double.TryParse(s, out var d)) return (int)d; 38 | return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null; 39 | } 40 | return new SceneCommand 41 | { 42 | action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), 43 | name = p["name"]?.ToString() ?? string.Empty, 44 | path = p["path"]?.ToString() ?? string.Empty, 45 | buildIndex = BI(p["buildIndex"] ?? p["build_index"]) 46 | }; 47 | } 48 | 49 | /// <summary> 50 | /// Main handler for scene management actions. 51 | /// </summary> 52 | public static object HandleCommand(JObject @params) 53 | { 54 | try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } 55 | var cmd = ToSceneCommand(@params); 56 | string action = cmd.action; 57 | string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; 58 | string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ 59 | int? buildIndex = cmd.buildIndex; 60 | // bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension 61 | 62 | // Ensure path is relative to Assets/, removing any leading "Assets/" 63 | string relativeDir = path ?? string.Empty; 64 | if (!string.IsNullOrEmpty(relativeDir)) 65 | { 66 | relativeDir = relativeDir.Replace('\\', '/').Trim('/'); 67 | if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 68 | { 69 | relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); 70 | } 71 | } 72 | 73 | // Apply default *after* sanitizing, using the original path variable for the check 74 | if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness 75 | { 76 | relativeDir = "Scenes"; // Default relative directory 77 | } 78 | 79 | if (string.IsNullOrEmpty(action)) 80 | { 81 | return Response.Error("Action parameter is required."); 82 | } 83 | 84 | string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; 85 | // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName 86 | string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) 87 | string fullPath = string.IsNullOrEmpty(sceneFileName) 88 | ? null 89 | : Path.Combine(fullPathDir, sceneFileName); 90 | // Ensure relativePath always starts with "Assets/" and uses forward slashes 91 | string relativePath = string.IsNullOrEmpty(sceneFileName) 92 | ? null 93 | : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); 94 | 95 | // Ensure directory exists for 'create' 96 | if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) 97 | { 98 | try 99 | { 100 | Directory.CreateDirectory(fullPathDir); 101 | } 102 | catch (Exception e) 103 | { 104 | return Response.Error( 105 | $"Could not create directory '{fullPathDir}': {e.Message}" 106 | ); 107 | } 108 | } 109 | 110 | // Route action 111 | try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } 112 | switch (action) 113 | { 114 | case "create": 115 | if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) 116 | return Response.Error( 117 | "'name' and 'path' parameters are required for 'create' action." 118 | ); 119 | return CreateScene(fullPath, relativePath); 120 | case "load": 121 | // Loading can be done by path/name or build index 122 | if (!string.IsNullOrEmpty(relativePath)) 123 | return LoadScene(relativePath); 124 | else if (buildIndex.HasValue) 125 | return LoadScene(buildIndex.Value); 126 | else 127 | return Response.Error( 128 | "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." 129 | ); 130 | case "save": 131 | // Save current scene, optionally to a new path 132 | return SaveScene(fullPath, relativePath); 133 | case "get_hierarchy": 134 | try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } 135 | var gh = GetSceneHierarchy(); 136 | try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } 137 | return gh; 138 | case "get_active": 139 | try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } 140 | var ga = GetActiveSceneInfo(); 141 | try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } 142 | return ga; 143 | case "get_build_settings": 144 | return GetBuildSettingsScenes(); 145 | // Add cases for modifying build settings, additive loading, unloading etc. 146 | default: 147 | return Response.Error( 148 | $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." 149 | ); 150 | } 151 | } 152 | 153 | private static object CreateScene(string fullPath, string relativePath) 154 | { 155 | if (File.Exists(fullPath)) 156 | { 157 | return Response.Error($"Scene already exists at '{relativePath}'."); 158 | } 159 | 160 | try 161 | { 162 | // Create a new empty scene 163 | Scene newScene = EditorSceneManager.NewScene( 164 | NewSceneSetup.EmptyScene, 165 | NewSceneMode.Single 166 | ); 167 | // Save it to the specified path 168 | bool saved = EditorSceneManager.SaveScene(newScene, relativePath); 169 | 170 | if (saved) 171 | { 172 | AssetDatabase.Refresh(); // Ensure Unity sees the new scene file 173 | return Response.Success( 174 | $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", 175 | new { path = relativePath } 176 | ); 177 | } 178 | else 179 | { 180 | // If SaveScene fails, it might leave an untitled scene open. 181 | // Optionally try to close it, but be cautious. 182 | return Response.Error($"Failed to save new scene to '{relativePath}'."); 183 | } 184 | } 185 | catch (Exception e) 186 | { 187 | return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); 188 | } 189 | } 190 | 191 | private static object LoadScene(string relativePath) 192 | { 193 | if ( 194 | !File.Exists( 195 | Path.Combine( 196 | Application.dataPath.Substring( 197 | 0, 198 | Application.dataPath.Length - "Assets".Length 199 | ), 200 | relativePath 201 | ) 202 | ) 203 | ) 204 | { 205 | return Response.Error($"Scene file not found at '{relativePath}'."); 206 | } 207 | 208 | // Check for unsaved changes in the current scene 209 | if (EditorSceneManager.GetActiveScene().isDirty) 210 | { 211 | // Optionally prompt the user or save automatically before loading 212 | return Response.Error( 213 | "Current scene has unsaved changes. Please save or discard changes before loading a new scene." 214 | ); 215 | // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); 216 | // if (!saveOK) return Response.Error("Load cancelled by user."); 217 | } 218 | 219 | try 220 | { 221 | EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); 222 | return Response.Success( 223 | $"Scene '{relativePath}' loaded successfully.", 224 | new 225 | { 226 | path = relativePath, 227 | name = Path.GetFileNameWithoutExtension(relativePath), 228 | } 229 | ); 230 | } 231 | catch (Exception e) 232 | { 233 | return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); 234 | } 235 | } 236 | 237 | private static object LoadScene(int buildIndex) 238 | { 239 | if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) 240 | { 241 | return Response.Error( 242 | $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." 243 | ); 244 | } 245 | 246 | // Check for unsaved changes 247 | if (EditorSceneManager.GetActiveScene().isDirty) 248 | { 249 | return Response.Error( 250 | "Current scene has unsaved changes. Please save or discard changes before loading a new scene." 251 | ); 252 | } 253 | 254 | try 255 | { 256 | string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); 257 | EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); 258 | return Response.Success( 259 | $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", 260 | new 261 | { 262 | path = scenePath, 263 | name = Path.GetFileNameWithoutExtension(scenePath), 264 | buildIndex = buildIndex, 265 | } 266 | ); 267 | } 268 | catch (Exception e) 269 | { 270 | return Response.Error( 271 | $"Error loading scene with build index {buildIndex}: {e.Message}" 272 | ); 273 | } 274 | } 275 | 276 | private static object SaveScene(string fullPath, string relativePath) 277 | { 278 | try 279 | { 280 | Scene currentScene = EditorSceneManager.GetActiveScene(); 281 | if (!currentScene.IsValid()) 282 | { 283 | return Response.Error("No valid scene is currently active to save."); 284 | } 285 | 286 | bool saved; 287 | string finalPath = currentScene.path; // Path where it was last saved or will be saved 288 | 289 | if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) 290 | { 291 | // Save As... 292 | // Ensure directory exists 293 | string dir = Path.GetDirectoryName(fullPath); 294 | if (!Directory.Exists(dir)) 295 | Directory.CreateDirectory(dir); 296 | 297 | saved = EditorSceneManager.SaveScene(currentScene, relativePath); 298 | finalPath = relativePath; 299 | } 300 | else 301 | { 302 | // Save (overwrite existing or save untitled) 303 | if (string.IsNullOrEmpty(currentScene.path)) 304 | { 305 | // Scene is untitled, needs a path 306 | return Response.Error( 307 | "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." 308 | ); 309 | } 310 | saved = EditorSceneManager.SaveScene(currentScene); 311 | } 312 | 313 | if (saved) 314 | { 315 | AssetDatabase.Refresh(); 316 | return Response.Success( 317 | $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", 318 | new { path = finalPath, name = currentScene.name } 319 | ); 320 | } 321 | else 322 | { 323 | return Response.Error($"Failed to save scene '{currentScene.name}'."); 324 | } 325 | } 326 | catch (Exception e) 327 | { 328 | return Response.Error($"Error saving scene: {e.Message}"); 329 | } 330 | } 331 | 332 | private static object GetActiveSceneInfo() 333 | { 334 | try 335 | { 336 | try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } 337 | Scene activeScene = EditorSceneManager.GetActiveScene(); 338 | try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } 339 | if (!activeScene.IsValid()) 340 | { 341 | return Response.Error("No active scene found."); 342 | } 343 | 344 | var sceneInfo = new 345 | { 346 | name = activeScene.name, 347 | path = activeScene.path, 348 | buildIndex = activeScene.buildIndex, // -1 if not in build settings 349 | isDirty = activeScene.isDirty, 350 | isLoaded = activeScene.isLoaded, 351 | rootCount = activeScene.rootCount, 352 | }; 353 | 354 | return Response.Success("Retrieved active scene information.", sceneInfo); 355 | } 356 | catch (Exception e) 357 | { 358 | try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } 359 | return Response.Error($"Error getting active scene info: {e.Message}"); 360 | } 361 | } 362 | 363 | private static object GetBuildSettingsScenes() 364 | { 365 | try 366 | { 367 | var scenes = new List<object>(); 368 | for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) 369 | { 370 | var scene = EditorBuildSettings.scenes[i]; 371 | scenes.Add( 372 | new 373 | { 374 | path = scene.path, 375 | guid = scene.guid.ToString(), 376 | enabled = scene.enabled, 377 | buildIndex = i, // Actual build index considering only enabled scenes might differ 378 | } 379 | ); 380 | } 381 | return Response.Success("Retrieved scenes from Build Settings.", scenes); 382 | } 383 | catch (Exception e) 384 | { 385 | return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); 386 | } 387 | } 388 | 389 | private static object GetSceneHierarchy() 390 | { 391 | try 392 | { 393 | try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } 394 | Scene activeScene = EditorSceneManager.GetActiveScene(); 395 | try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } 396 | if (!activeScene.IsValid() || !activeScene.isLoaded) 397 | { 398 | return Response.Error( 399 | "No valid and loaded scene is active to get hierarchy from." 400 | ); 401 | } 402 | 403 | try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } 404 | GameObject[] rootObjects = activeScene.GetRootGameObjects(); 405 | try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } 406 | var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); 407 | 408 | var resp = Response.Success( 409 | $"Retrieved hierarchy for scene '{activeScene.name}'.", 410 | hierarchy 411 | ); 412 | try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } 413 | return resp; 414 | } 415 | catch (Exception e) 416 | { 417 | try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } 418 | return Response.Error($"Error getting scene hierarchy: {e.Message}"); 419 | } 420 | } 421 | 422 | /// <summary> 423 | /// Recursively builds a data representation of a GameObject and its children. 424 | /// </summary> 425 | private static object GetGameObjectDataRecursive(GameObject go) 426 | { 427 | if (go == null) 428 | return null; 429 | 430 | var childrenData = new List<object>(); 431 | foreach (Transform child in go.transform) 432 | { 433 | childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); 434 | } 435 | 436 | var gameObjectData = new Dictionary<string, object> 437 | { 438 | { "name", go.name }, 439 | { "activeSelf", go.activeSelf }, 440 | { "activeInHierarchy", go.activeInHierarchy }, 441 | { "tag", go.tag }, 442 | { "layer", go.layer }, 443 | { "isStatic", go.isStatic }, 444 | { "instanceID", go.GetInstanceID() }, // Useful unique identifier 445 | { 446 | "transform", 447 | new 448 | { 449 | position = new 450 | { 451 | x = go.transform.localPosition.x, 452 | y = go.transform.localPosition.y, 453 | z = go.transform.localPosition.z, 454 | }, 455 | rotation = new 456 | { 457 | x = go.transform.localRotation.eulerAngles.x, 458 | y = go.transform.localRotation.eulerAngles.y, 459 | z = go.transform.localRotation.eulerAngles.z, 460 | }, // Euler for simplicity 461 | scale = new 462 | { 463 | x = go.transform.localScale.x, 464 | y = go.transform.localScale.y, 465 | z = go.transform.localScale.z, 466 | }, 467 | } 468 | }, 469 | { "children", childrenData }, 470 | }; 471 | 472 | return gameObjectData; 473 | } 474 | } 475 | } 476 | ``` -------------------------------------------------------------------------------- /UnityMcpBridge/Editor/Tools/ManageScene.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using Newtonsoft.Json.Linq; 6 | using UnityEditor; 7 | using UnityEditor.SceneManagement; 8 | using UnityEngine; 9 | using UnityEngine.SceneManagement; 10 | using MCPForUnity.Editor.Helpers; // For Response class 11 | 12 | namespace MCPForUnity.Editor.Tools 13 | { 14 | /// <summary> 15 | /// Handles scene management operations like loading, saving, creating, and querying hierarchy. 16 | /// </summary> 17 | [McpForUnityTool("manage_scene")] 18 | public static class ManageScene 19 | { 20 | private sealed class SceneCommand 21 | { 22 | public string action { get; set; } = string.Empty; 23 | public string name { get; set; } = string.Empty; 24 | public string path { get; set; } = string.Empty; 25 | public int? buildIndex { get; set; } 26 | } 27 | 28 | private static SceneCommand ToSceneCommand(JObject p) 29 | { 30 | if (p == null) return new SceneCommand(); 31 | int? BI(JToken t) 32 | { 33 | if (t == null || t.Type == JTokenType.Null) return null; 34 | var s = t.ToString().Trim(); 35 | if (s.Length == 0) return null; 36 | if (int.TryParse(s, out var i)) return i; 37 | if (double.TryParse(s, out var d)) return (int)d; 38 | return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null; 39 | } 40 | return new SceneCommand 41 | { 42 | action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), 43 | name = p["name"]?.ToString() ?? string.Empty, 44 | path = p["path"]?.ToString() ?? string.Empty, 45 | buildIndex = BI(p["buildIndex"] ?? p["build_index"]) 46 | }; 47 | } 48 | 49 | /// <summary> 50 | /// Main handler for scene management actions. 51 | /// </summary> 52 | public static object HandleCommand(JObject @params) 53 | { 54 | try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } 55 | var cmd = ToSceneCommand(@params); 56 | string action = cmd.action; 57 | string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; 58 | string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ 59 | int? buildIndex = cmd.buildIndex; 60 | // bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension 61 | 62 | // Ensure path is relative to Assets/, removing any leading "Assets/" 63 | string relativeDir = path ?? string.Empty; 64 | if (!string.IsNullOrEmpty(relativeDir)) 65 | { 66 | relativeDir = relativeDir.Replace('\\', '/').Trim('/'); 67 | if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) 68 | { 69 | relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); 70 | } 71 | } 72 | 73 | // Apply default *after* sanitizing, using the original path variable for the check 74 | if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness 75 | { 76 | relativeDir = "Scenes"; // Default relative directory 77 | } 78 | 79 | if (string.IsNullOrEmpty(action)) 80 | { 81 | return Response.Error("Action parameter is required."); 82 | } 83 | 84 | string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; 85 | // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName 86 | string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) 87 | string fullPath = string.IsNullOrEmpty(sceneFileName) 88 | ? null 89 | : Path.Combine(fullPathDir, sceneFileName); 90 | // Ensure relativePath always starts with "Assets/" and uses forward slashes 91 | string relativePath = string.IsNullOrEmpty(sceneFileName) 92 | ? null 93 | : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); 94 | 95 | // Ensure directory exists for 'create' 96 | if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) 97 | { 98 | try 99 | { 100 | Directory.CreateDirectory(fullPathDir); 101 | } 102 | catch (Exception e) 103 | { 104 | return Response.Error( 105 | $"Could not create directory '{fullPathDir}': {e.Message}" 106 | ); 107 | } 108 | } 109 | 110 | // Route action 111 | try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } 112 | switch (action) 113 | { 114 | case "create": 115 | if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) 116 | return Response.Error( 117 | "'name' and 'path' parameters are required for 'create' action." 118 | ); 119 | return CreateScene(fullPath, relativePath); 120 | case "load": 121 | // Loading can be done by path/name or build index 122 | if (!string.IsNullOrEmpty(relativePath)) 123 | return LoadScene(relativePath); 124 | else if (buildIndex.HasValue) 125 | return LoadScene(buildIndex.Value); 126 | else 127 | return Response.Error( 128 | "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." 129 | ); 130 | case "save": 131 | // Save current scene, optionally to a new path 132 | return SaveScene(fullPath, relativePath); 133 | case "get_hierarchy": 134 | try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } 135 | var gh = GetSceneHierarchy(); 136 | try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } 137 | return gh; 138 | case "get_active": 139 | try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } 140 | var ga = GetActiveSceneInfo(); 141 | try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } 142 | return ga; 143 | case "get_build_settings": 144 | return GetBuildSettingsScenes(); 145 | // Add cases for modifying build settings, additive loading, unloading etc. 146 | default: 147 | return Response.Error( 148 | $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." 149 | ); 150 | } 151 | } 152 | 153 | private static object CreateScene(string fullPath, string relativePath) 154 | { 155 | if (File.Exists(fullPath)) 156 | { 157 | return Response.Error($"Scene already exists at '{relativePath}'."); 158 | } 159 | 160 | try 161 | { 162 | // Create a new empty scene 163 | Scene newScene = EditorSceneManager.NewScene( 164 | NewSceneSetup.EmptyScene, 165 | NewSceneMode.Single 166 | ); 167 | // Save it to the specified path 168 | bool saved = EditorSceneManager.SaveScene(newScene, relativePath); 169 | 170 | if (saved) 171 | { 172 | AssetDatabase.Refresh(); // Ensure Unity sees the new scene file 173 | return Response.Success( 174 | $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", 175 | new { path = relativePath } 176 | ); 177 | } 178 | else 179 | { 180 | // If SaveScene fails, it might leave an untitled scene open. 181 | // Optionally try to close it, but be cautious. 182 | return Response.Error($"Failed to save new scene to '{relativePath}'."); 183 | } 184 | } 185 | catch (Exception e) 186 | { 187 | return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); 188 | } 189 | } 190 | 191 | private static object LoadScene(string relativePath) 192 | { 193 | if ( 194 | !File.Exists( 195 | Path.Combine( 196 | Application.dataPath.Substring( 197 | 0, 198 | Application.dataPath.Length - "Assets".Length 199 | ), 200 | relativePath 201 | ) 202 | ) 203 | ) 204 | { 205 | return Response.Error($"Scene file not found at '{relativePath}'."); 206 | } 207 | 208 | // Check for unsaved changes in the current scene 209 | if (EditorSceneManager.GetActiveScene().isDirty) 210 | { 211 | // Optionally prompt the user or save automatically before loading 212 | return Response.Error( 213 | "Current scene has unsaved changes. Please save or discard changes before loading a new scene." 214 | ); 215 | // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); 216 | // if (!saveOK) return Response.Error("Load cancelled by user."); 217 | } 218 | 219 | try 220 | { 221 | EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); 222 | return Response.Success( 223 | $"Scene '{relativePath}' loaded successfully.", 224 | new 225 | { 226 | path = relativePath, 227 | name = Path.GetFileNameWithoutExtension(relativePath), 228 | } 229 | ); 230 | } 231 | catch (Exception e) 232 | { 233 | return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); 234 | } 235 | } 236 | 237 | private static object LoadScene(int buildIndex) 238 | { 239 | if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) 240 | { 241 | return Response.Error( 242 | $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." 243 | ); 244 | } 245 | 246 | // Check for unsaved changes 247 | if (EditorSceneManager.GetActiveScene().isDirty) 248 | { 249 | return Response.Error( 250 | "Current scene has unsaved changes. Please save or discard changes before loading a new scene." 251 | ); 252 | } 253 | 254 | try 255 | { 256 | string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); 257 | EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); 258 | return Response.Success( 259 | $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", 260 | new 261 | { 262 | path = scenePath, 263 | name = Path.GetFileNameWithoutExtension(scenePath), 264 | buildIndex = buildIndex, 265 | } 266 | ); 267 | } 268 | catch (Exception e) 269 | { 270 | return Response.Error( 271 | $"Error loading scene with build index {buildIndex}: {e.Message}" 272 | ); 273 | } 274 | } 275 | 276 | private static object SaveScene(string fullPath, string relativePath) 277 | { 278 | try 279 | { 280 | Scene currentScene = EditorSceneManager.GetActiveScene(); 281 | if (!currentScene.IsValid()) 282 | { 283 | return Response.Error("No valid scene is currently active to save."); 284 | } 285 | 286 | bool saved; 287 | string finalPath = currentScene.path; // Path where it was last saved or will be saved 288 | 289 | if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) 290 | { 291 | // Save As... 292 | // Ensure directory exists 293 | string dir = Path.GetDirectoryName(fullPath); 294 | if (!Directory.Exists(dir)) 295 | Directory.CreateDirectory(dir); 296 | 297 | saved = EditorSceneManager.SaveScene(currentScene, relativePath); 298 | finalPath = relativePath; 299 | } 300 | else 301 | { 302 | // Save (overwrite existing or save untitled) 303 | if (string.IsNullOrEmpty(currentScene.path)) 304 | { 305 | // Scene is untitled, needs a path 306 | return Response.Error( 307 | "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." 308 | ); 309 | } 310 | saved = EditorSceneManager.SaveScene(currentScene); 311 | } 312 | 313 | if (saved) 314 | { 315 | AssetDatabase.Refresh(); 316 | return Response.Success( 317 | $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", 318 | new { path = finalPath, name = currentScene.name } 319 | ); 320 | } 321 | else 322 | { 323 | return Response.Error($"Failed to save scene '{currentScene.name}'."); 324 | } 325 | } 326 | catch (Exception e) 327 | { 328 | return Response.Error($"Error saving scene: {e.Message}"); 329 | } 330 | } 331 | 332 | private static object GetActiveSceneInfo() 333 | { 334 | try 335 | { 336 | try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } 337 | Scene activeScene = EditorSceneManager.GetActiveScene(); 338 | try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } 339 | if (!activeScene.IsValid()) 340 | { 341 | return Response.Error("No active scene found."); 342 | } 343 | 344 | var sceneInfo = new 345 | { 346 | name = activeScene.name, 347 | path = activeScene.path, 348 | buildIndex = activeScene.buildIndex, // -1 if not in build settings 349 | isDirty = activeScene.isDirty, 350 | isLoaded = activeScene.isLoaded, 351 | rootCount = activeScene.rootCount, 352 | }; 353 | 354 | return Response.Success("Retrieved active scene information.", sceneInfo); 355 | } 356 | catch (Exception e) 357 | { 358 | try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } 359 | return Response.Error($"Error getting active scene info: {e.Message}"); 360 | } 361 | } 362 | 363 | private static object GetBuildSettingsScenes() 364 | { 365 | try 366 | { 367 | var scenes = new List<object>(); 368 | for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) 369 | { 370 | var scene = EditorBuildSettings.scenes[i]; 371 | scenes.Add( 372 | new 373 | { 374 | path = scene.path, 375 | guid = scene.guid.ToString(), 376 | enabled = scene.enabled, 377 | buildIndex = i, // Actual build index considering only enabled scenes might differ 378 | } 379 | ); 380 | } 381 | return Response.Success("Retrieved scenes from Build Settings.", scenes); 382 | } 383 | catch (Exception e) 384 | { 385 | return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); 386 | } 387 | } 388 | 389 | private static object GetSceneHierarchy() 390 | { 391 | try 392 | { 393 | try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } 394 | Scene activeScene = EditorSceneManager.GetActiveScene(); 395 | try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } 396 | if (!activeScene.IsValid() || !activeScene.isLoaded) 397 | { 398 | return Response.Error( 399 | "No valid and loaded scene is active to get hierarchy from." 400 | ); 401 | } 402 | 403 | try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } 404 | GameObject[] rootObjects = activeScene.GetRootGameObjects(); 405 | try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } 406 | var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); 407 | 408 | var resp = Response.Success( 409 | $"Retrieved hierarchy for scene '{activeScene.name}'.", 410 | hierarchy 411 | ); 412 | try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } 413 | return resp; 414 | } 415 | catch (Exception e) 416 | { 417 | try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } 418 | return Response.Error($"Error getting scene hierarchy: {e.Message}"); 419 | } 420 | } 421 | 422 | /// <summary> 423 | /// Recursively builds a data representation of a GameObject and its children. 424 | /// </summary> 425 | private static object GetGameObjectDataRecursive(GameObject go) 426 | { 427 | if (go == null) 428 | return null; 429 | 430 | var childrenData = new List<object>(); 431 | foreach (Transform child in go.transform) 432 | { 433 | childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); 434 | } 435 | 436 | var gameObjectData = new Dictionary<string, object> 437 | { 438 | { "name", go.name }, 439 | { "activeSelf", go.activeSelf }, 440 | { "activeInHierarchy", go.activeInHierarchy }, 441 | { "tag", go.tag }, 442 | { "layer", go.layer }, 443 | { "isStatic", go.isStatic }, 444 | { "instanceID", go.GetInstanceID() }, // Useful unique identifier 445 | { 446 | "transform", 447 | new 448 | { 449 | position = new 450 | { 451 | x = go.transform.localPosition.x, 452 | y = go.transform.localPosition.y, 453 | z = go.transform.localPosition.z, 454 | }, 455 | rotation = new 456 | { 457 | x = go.transform.localRotation.eulerAngles.x, 458 | y = go.transform.localRotation.eulerAngles.y, 459 | z = go.transform.localRotation.eulerAngles.z, 460 | }, // Euler for simplicity 461 | scale = new 462 | { 463 | x = go.transform.localScale.x, 464 | y = go.transform.localScale.y, 465 | z = go.transform.localScale.z, 466 | }, 467 | } 468 | }, 469 | { "children", childrenData }, 470 | }; 471 | 472 | return gameObjectData; 473 | } 474 | } 475 | } 476 | ``` -------------------------------------------------------------------------------- /MCPForUnity/Editor/Services/ClientConfigurationService.cs: -------------------------------------------------------------------------------- ```csharp 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using MCPForUnity.Editor.Data; 6 | using MCPForUnity.Editor.Helpers; 7 | using MCPForUnity.Editor.Models; 8 | using Newtonsoft.Json; 9 | using UnityEditor; 10 | using UnityEngine; 11 | 12 | namespace MCPForUnity.Editor.Services 13 | { 14 | /// <summary> 15 | /// Implementation of client configuration service 16 | /// </summary> 17 | public class ClientConfigurationService : IClientConfigurationService 18 | { 19 | private readonly Data.McpClients mcpClients = new(); 20 | 21 | public void ConfigureClient(McpClient client) 22 | { 23 | try 24 | { 25 | string configPath = McpConfigurationHelper.GetClientConfigPath(client); 26 | McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); 27 | 28 | string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); 29 | 30 | if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) 31 | { 32 | throw new InvalidOperationException("Server not found. Please use manual configuration or set server path in Advanced Settings."); 33 | } 34 | 35 | string result = client.mcpType == McpTypes.Codex 36 | ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) 37 | : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); 38 | 39 | if (result == "Configured successfully") 40 | { 41 | client.SetStatus(McpStatus.Configured); 42 | Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: {client.name} configured successfully"); 43 | } 44 | else 45 | { 46 | Debug.LogWarning($"Configuration completed with message: {result}"); 47 | } 48 | 49 | CheckClientStatus(client); 50 | } 51 | catch (Exception ex) 52 | { 53 | Debug.LogError($"Failed to configure {client.name}: {ex.Message}"); 54 | throw; 55 | } 56 | } 57 | 58 | public ClientConfigurationSummary ConfigureAllDetectedClients() 59 | { 60 | var summary = new ClientConfigurationSummary(); 61 | var pathService = MCPServiceLocator.Paths; 62 | 63 | foreach (var client in mcpClients.clients) 64 | { 65 | try 66 | { 67 | // Skip if already configured 68 | CheckClientStatus(client, attemptAutoRewrite: false); 69 | if (client.status == McpStatus.Configured) 70 | { 71 | summary.SkippedCount++; 72 | summary.Messages.Add($"✓ {client.name}: Already configured"); 73 | continue; 74 | } 75 | 76 | // Check if required tools are available 77 | if (client.mcpType == McpTypes.ClaudeCode) 78 | { 79 | if (!pathService.IsClaudeCliDetected()) 80 | { 81 | summary.SkippedCount++; 82 | summary.Messages.Add($"➜ {client.name}: Claude CLI not found"); 83 | continue; 84 | } 85 | 86 | RegisterClaudeCode(); 87 | summary.SuccessCount++; 88 | summary.Messages.Add($"✓ {client.name}: Registered successfully"); 89 | } 90 | else 91 | { 92 | // Other clients require UV 93 | if (!pathService.IsUvDetected()) 94 | { 95 | summary.SkippedCount++; 96 | summary.Messages.Add($"➜ {client.name}: UV not found"); 97 | continue; 98 | } 99 | 100 | ConfigureClient(client); 101 | summary.SuccessCount++; 102 | summary.Messages.Add($"✓ {client.name}: Configured successfully"); 103 | } 104 | } 105 | catch (Exception ex) 106 | { 107 | summary.FailureCount++; 108 | summary.Messages.Add($"⚠ {client.name}: {ex.Message}"); 109 | } 110 | } 111 | 112 | return summary; 113 | } 114 | 115 | public bool CheckClientStatus(McpClient client, bool attemptAutoRewrite = true) 116 | { 117 | var previousStatus = client.status; 118 | 119 | try 120 | { 121 | // Special handling for Claude Code 122 | if (client.mcpType == McpTypes.ClaudeCode) 123 | { 124 | CheckClaudeCodeConfiguration(client); 125 | return client.status != previousStatus; 126 | } 127 | 128 | string configPath = McpConfigurationHelper.GetClientConfigPath(client); 129 | 130 | if (!File.Exists(configPath)) 131 | { 132 | client.SetStatus(McpStatus.NotConfigured); 133 | return client.status != previousStatus; 134 | } 135 | 136 | string configJson = File.ReadAllText(configPath); 137 | string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); 138 | 139 | // Check configuration based on client type 140 | string[] args = null; 141 | bool configExists = false; 142 | 143 | switch (client.mcpType) 144 | { 145 | case McpTypes.VSCode: 146 | dynamic vsConfig = JsonConvert.DeserializeObject(configJson); 147 | if (vsConfig?.servers?.unityMCP != null) 148 | { 149 | args = vsConfig.servers.unityMCP.args.ToObject<string[]>(); 150 | configExists = true; 151 | } 152 | else if (vsConfig?.mcp?.servers?.unityMCP != null) 153 | { 154 | args = vsConfig.mcp.servers.unityMCP.args.ToObject<string[]>(); 155 | configExists = true; 156 | } 157 | break; 158 | 159 | case McpTypes.Codex: 160 | if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) 161 | { 162 | args = codexArgs; 163 | configExists = true; 164 | } 165 | break; 166 | 167 | default: 168 | McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); 169 | if (standardConfig?.mcpServers?.unityMCP != null) 170 | { 171 | args = standardConfig.mcpServers.unityMCP.args; 172 | configExists = true; 173 | } 174 | break; 175 | } 176 | 177 | if (configExists) 178 | { 179 | string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); 180 | bool matches = !string.IsNullOrEmpty(configuredDir) && 181 | McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); 182 | 183 | if (matches) 184 | { 185 | client.SetStatus(McpStatus.Configured); 186 | } 187 | else if (attemptAutoRewrite) 188 | { 189 | // Attempt auto-rewrite if path mismatch detected 190 | try 191 | { 192 | string rewriteResult = client.mcpType == McpTypes.Codex 193 | ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, client) 194 | : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); 195 | 196 | if (rewriteResult == "Configured successfully") 197 | { 198 | bool debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); 199 | if (debugLogsEnabled) 200 | { 201 | McpLog.Info($"Auto-updated MCP config for '{client.name}' to new path: {pythonDir}", always: false); 202 | } 203 | client.SetStatus(McpStatus.Configured); 204 | } 205 | else 206 | { 207 | client.SetStatus(McpStatus.IncorrectPath); 208 | } 209 | } 210 | catch 211 | { 212 | client.SetStatus(McpStatus.IncorrectPath); 213 | } 214 | } 215 | else 216 | { 217 | client.SetStatus(McpStatus.IncorrectPath); 218 | } 219 | } 220 | else 221 | { 222 | client.SetStatus(McpStatus.MissingConfig); 223 | } 224 | } 225 | catch (Exception ex) 226 | { 227 | client.SetStatus(McpStatus.Error, ex.Message); 228 | } 229 | 230 | return client.status != previousStatus; 231 | } 232 | 233 | public void RegisterClaudeCode() 234 | { 235 | var pathService = MCPServiceLocator.Paths; 236 | string pythonDir = pathService.GetMcpServerPath(); 237 | 238 | if (string.IsNullOrEmpty(pythonDir)) 239 | { 240 | throw new InvalidOperationException("Cannot register: Python directory not found"); 241 | } 242 | 243 | string claudePath = pathService.GetClaudeCliPath(); 244 | if (string.IsNullOrEmpty(claudePath)) 245 | { 246 | throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); 247 | } 248 | 249 | string uvPath = pathService.GetUvPath() ?? "uv"; 250 | string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; 251 | string projectDir = Path.GetDirectoryName(Application.dataPath); 252 | 253 | string pathPrepend = null; 254 | if (Application.platform == RuntimePlatform.OSXEditor) 255 | { 256 | pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; 257 | } 258 | else if (Application.platform == RuntimePlatform.LinuxEditor) 259 | { 260 | pathPrepend = "/usr/local/bin:/usr/bin:/bin"; 261 | } 262 | 263 | // Add the directory containing Claude CLI to PATH (for node/nvm scenarios) 264 | try 265 | { 266 | string claudeDir = Path.GetDirectoryName(claudePath); 267 | if (!string.IsNullOrEmpty(claudeDir)) 268 | { 269 | pathPrepend = string.IsNullOrEmpty(pathPrepend) 270 | ? claudeDir 271 | : $"{claudeDir}:{pathPrepend}"; 272 | } 273 | } 274 | catch { } 275 | 276 | if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) 277 | { 278 | string combined = ($"{stdout}\n{stderr}") ?? string.Empty; 279 | if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) 280 | { 281 | Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP for Unity already registered with Claude Code."); 282 | } 283 | else 284 | { 285 | throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); 286 | } 287 | return; 288 | } 289 | 290 | Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: Successfully registered with Claude Code."); 291 | 292 | // Update status 293 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 294 | if (claudeClient != null) 295 | { 296 | CheckClaudeCodeConfiguration(claudeClient); 297 | } 298 | } 299 | 300 | public void UnregisterClaudeCode() 301 | { 302 | var pathService = MCPServiceLocator.Paths; 303 | string claudePath = pathService.GetClaudeCliPath(); 304 | 305 | if (string.IsNullOrEmpty(claudePath)) 306 | { 307 | throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); 308 | } 309 | 310 | string projectDir = Path.GetDirectoryName(Application.dataPath); 311 | string pathPrepend = Application.platform == RuntimePlatform.OSXEditor 312 | ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" 313 | : null; 314 | 315 | // Check if UnityMCP server exists (fixed - only check for "UnityMCP") 316 | bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); 317 | 318 | if (!serverExists) 319 | { 320 | // Nothing to unregister 321 | var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 322 | if (claudeClient != null) 323 | { 324 | claudeClient.SetStatus(McpStatus.NotConfigured); 325 | } 326 | Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: No MCP for Unity server found - already unregistered."); 327 | return; 328 | } 329 | 330 | // Remove the server 331 | if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) 332 | { 333 | Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCP server successfully unregistered from Claude Code."); 334 | } 335 | else 336 | { 337 | throw new InvalidOperationException($"Failed to unregister: {stderr}"); 338 | } 339 | 340 | // Update status 341 | var client = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); 342 | if (client != null) 343 | { 344 | client.SetStatus(McpStatus.NotConfigured); 345 | CheckClaudeCodeConfiguration(client); 346 | } 347 | } 348 | 349 | public string GetConfigPath(McpClient client) 350 | { 351 | // Claude Code is managed via CLI, not config files 352 | if (client.mcpType == McpTypes.ClaudeCode) 353 | { 354 | return "Not applicable (managed via Claude CLI)"; 355 | } 356 | 357 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 358 | return client.windowsConfigPath; 359 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 360 | return client.macConfigPath; 361 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 362 | return client.linuxConfigPath; 363 | 364 | return "Unknown"; 365 | } 366 | 367 | public string GenerateConfigJson(McpClient client) 368 | { 369 | string pythonDir = MCPServiceLocator.Paths.GetMcpServerPath(); 370 | string uvPath = MCPServiceLocator.Paths.GetUvPath(); 371 | 372 | // Claude Code uses CLI commands, not JSON config 373 | if (client.mcpType == McpTypes.ClaudeCode) 374 | { 375 | if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) 376 | { 377 | return "# Error: Configuration not available - check paths in Advanced Settings"; 378 | } 379 | 380 | // Show the actual command that RegisterClaudeCode() uses 381 | string registerCommand = $"claude mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; 382 | 383 | return "# Register the MCP server with Claude Code:\n" + 384 | $"{registerCommand}\n\n" + 385 | "# Unregister the MCP server:\n" + 386 | "claude mcp remove UnityMCP\n\n" + 387 | "# List registered servers:\n" + 388 | "claude mcp list # Only works when claude is run in the project's directory"; 389 | } 390 | 391 | if (string.IsNullOrEmpty(pythonDir) || string.IsNullOrEmpty(uvPath)) 392 | return "{ \"error\": \"Configuration not available - check paths in Advanced Settings\" }"; 393 | 394 | try 395 | { 396 | if (client.mcpType == McpTypes.Codex) 397 | { 398 | return CodexConfigHelper.BuildCodexServerBlock(uvPath, 399 | McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)); 400 | } 401 | else 402 | { 403 | return ConfigJsonBuilder.BuildManualConfigJson(uvPath, pythonDir, client); 404 | } 405 | } 406 | catch (Exception ex) 407 | { 408 | return $"{{ \"error\": \"{ex.Message}\" }}"; 409 | } 410 | } 411 | 412 | public string GetInstallationSteps(McpClient client) 413 | { 414 | string baseSteps = client.mcpType switch 415 | { 416 | McpTypes.ClaudeDesktop => 417 | "1. Open Claude Desktop\n" + 418 | "2. Go to Settings > Developer > Edit Config\n" + 419 | " OR open the config file at the path above\n" + 420 | "3. Paste the configuration JSON\n" + 421 | "4. Save and restart Claude Desktop", 422 | 423 | McpTypes.Cursor => 424 | "1. Open Cursor\n" + 425 | "2. Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\n" + 426 | " OR open the config file at the path above\n" + 427 | "3. Paste the configuration JSON\n" + 428 | "4. Save and restart Cursor", 429 | 430 | McpTypes.Windsurf => 431 | "1. Open Windsurf\n" + 432 | "2. Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\n" + 433 | " OR open the config file at the path above\n" + 434 | "3. Paste the configuration JSON\n" + 435 | "4. Save and restart Windsurf", 436 | 437 | McpTypes.VSCode => 438 | "1. Ensure VSCode and GitHub Copilot extension are installed\n" + 439 | "2. Open or create mcp.json at the path above\n" + 440 | "3. Paste the configuration JSON\n" + 441 | "4. Save and restart VSCode", 442 | 443 | McpTypes.Kiro => 444 | "1. Open Kiro\n" + 445 | "2. Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\n" + 446 | " OR open the config file at the path above\n" + 447 | "3. Paste the configuration JSON\n" + 448 | "4. Save and restart Kiro", 449 | 450 | McpTypes.Codex => 451 | "1. Run 'codex config edit' in a terminal\n" + 452 | " OR open the config file at the path above\n" + 453 | "2. Paste the configuration TOML\n" + 454 | "3. Save and restart Codex", 455 | 456 | McpTypes.ClaudeCode => 457 | "1. Ensure Claude CLI is installed\n" + 458 | "2. Use the Register button to register automatically\n" + 459 | " OR manually run: claude mcp add UnityMCP\n" + 460 | "3. Restart Claude Code", 461 | 462 | _ => "Configuration steps not available for this client." 463 | }; 464 | 465 | return baseSteps; 466 | } 467 | 468 | private void CheckClaudeCodeConfiguration(McpClient client) 469 | { 470 | try 471 | { 472 | string configPath = McpConfigurationHelper.GetClientConfigPath(client); 473 | 474 | if (!File.Exists(configPath)) 475 | { 476 | client.SetStatus(McpStatus.NotConfigured); 477 | return; 478 | } 479 | 480 | string configJson = File.ReadAllText(configPath); 481 | dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); 482 | 483 | if (claudeConfig?.mcpServers != null) 484 | { 485 | var servers = claudeConfig.mcpServers; 486 | // Only check for UnityMCP (fixed - removed candidate hacks) 487 | if (servers.UnityMCP != null) 488 | { 489 | client.SetStatus(McpStatus.Configured); 490 | return; 491 | } 492 | } 493 | 494 | client.SetStatus(McpStatus.NotConfigured); 495 | } 496 | catch (Exception ex) 497 | { 498 | client.SetStatus(McpStatus.Error, ex.Message); 499 | } 500 | } 501 | } 502 | } 503 | ```