#
tokens: 49550/50000 9/264 files (page 7/19)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 7/19FirstPrevNextLast