From 7243d5823b437f52e667a43d022988a9502a2581 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:49:17 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .dockerignore | 6 + .github/ISSUE_TEMPLATE/bug_report.yml | 122 + .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/feature_request.yml | 63 + .github/actions/publish-docker/action.yml | 111 + .github/actions/publish-pypi/action.yml | 22 + .github/pull_request_template.md | 43 + .github/scripts/mark_skipped.py | 119 + .github/workflows/beta-release.yml | 241 + .github/workflows/claude-nl-suite.yml | 1674 ++ .github/workflows/docs-deploy.yml | 97 + .github/workflows/docs-generate.yml | 72 + .github/workflows/e2e-bridge.yml | 194 + .github/workflows/github-repo-stats.yml | 20 + .github/workflows/python-tests.yml | 81 + .github/workflows/release.yml | 557 + .github/workflows/stats.yml | 25 + .github/workflows/sync-releases.yml | 78 + .github/workflows/unity-tests.yml | 265 + .gitignore | 75 + .mcpbignore | 82 + CLAUDE.md | 200 + CODE_OF_CONDUCT.md | 75 + CONTRIBUTING.md | 71 + .../ManageRuntimeCompilation.cs | 528 + .../ManageRuntimeCompilation.cs.meta | 2 + .../RoslynRuntimeCompiler.cs | 1210 + .../RoslynRuntimeCompiler.cs.meta | 2 + LICENSE | 21 + MCPForUnity/Editor.meta | 8 + MCPForUnity/Editor/AssemblyInfo.cs | 3 + MCPForUnity/Editor/AssemblyInfo.cs.meta | 11 + MCPForUnity/Editor/Clients.meta | 8 + MCPForUnity/Editor/Clients/Configurators.meta | 8 + .../Configurators/AntigravityConfigurator.cs | 59 + .../AntigravityConfigurator.cs.meta | 11 + .../AntigravityIdeConfigurator.cs | 52 + .../AntigravityIdeConfigurator.cs.meta | 11 + .../Configurators/CherryStudioConfigurator.cs | 94 + .../CherryStudioConfigurator.cs.meta | 11 + .../Configurators/ClaudeCodeConfigurator.cs | 37 + .../ClaudeCodeConfigurator.cs.meta | 11 + .../ClaudeDesktopConfigurator.cs | 43 + .../ClaudeDesktopConfigurator.cs.meta | 11 + .../Configurators/ClineConfigurator.cs | 30 + .../Configurators/ClineConfigurator.cs.meta | 11 + .../Configurators/CodeBuddyCliConfigurator.cs | 29 + .../CodeBuddyCliConfigurator.cs.meta | 11 + .../Configurators/CodexConfigurator.cs | 34 + .../Configurators/CodexConfigurator.cs.meta | 11 + .../Configurators/CopilotCliConfigurator.cs | 27 + .../CopilotCliConfigurator.cs.meta | 11 + .../Configurators/CursorConfigurator.cs | 27 + .../Configurators/CursorConfigurator.cs.meta | 11 + .../Configurators/GeminiCliConfigurator.cs | 30 + .../GeminiCliConfigurator.cs.meta | 11 + .../Configurators/KiloCodeConfigurator.cs | 38 + .../KiloCodeConfigurator.cs.meta | 11 + .../Configurators/KimiCodeConfigurator.cs | 40 + .../KimiCodeConfigurator.cs.meta | 11 + .../Clients/Configurators/KiroConfigurator.cs | 29 + .../Configurators/KiroConfigurator.cs.meta | 11 + .../Configurators/OpenClawConfigurator.cs | 398 + .../OpenClawConfigurator.cs.meta | 11 + .../Configurators/OpenCodeConfigurator.cs | 224 + .../OpenCodeConfigurator.cs.meta | 11 + .../Configurators/QwenCodeConfigurator.cs | 43 + .../QwenCodeConfigurator.cs.meta | 11 + .../Configurators/RiderConfigurator.cs | 29 + .../Configurators/RiderConfigurator.cs.meta | 11 + .../Clients/Configurators/TraeConfigurator.cs | 30 + .../Configurators/TraeConfigurator.cs.meta | 11 + .../Configurators/VSCodeConfigurator.cs | 28 + .../Configurators/VSCodeConfigurator.cs.meta | 11 + .../VSCodeInsidersConfigurator.cs | 28 + .../VSCodeInsidersConfigurator.cs.meta | 11 + .../Configurators/WindsurfConfigurator.cs | 30 + .../WindsurfConfigurator.cs.meta | 11 + .../Editor/Clients/IMcpClientConfigurator.cs | 77 + .../Clients/IMcpClientConfigurator.cs.meta | 11 + .../Clients/McpClientConfiguratorBase.cs | 1473 ++ .../Clients/McpClientConfiguratorBase.cs.meta | 11 + .../Editor/Clients/McpClientRegistry.cs | 60 + .../Editor/Clients/McpClientRegistry.cs.meta | 11 + MCPForUnity/Editor/Constants.meta | 8 + MCPForUnity/Editor/Constants/AuthConstants.cs | 10 + .../Editor/Constants/AuthConstants.cs.meta | 11 + .../Editor/Constants/EditorPrefKeys.cs | 85 + .../Editor/Constants/EditorPrefKeys.cs.meta | 11 + MCPForUnity/Editor/Constants/HealthStatus.cs | 14 + .../Editor/Constants/HealthStatus.cs.meta | 11 + MCPForUnity/Editor/Constants/ProductInfo.cs | 9 + .../Editor/Constants/ProductInfo.cs.meta | 11 + MCPForUnity/Editor/Dependencies.meta | 8 + .../Editor/Dependencies/DependencyManager.cs | 143 + .../Dependencies/DependencyManager.cs.meta | 11 + MCPForUnity/Editor/Dependencies/Models.meta | 8 + .../Models/DependencyCheckResult.cs | 96 + .../Models/DependencyCheckResult.cs.meta | 11 + .../Dependencies/Models/DependencyStatus.cs | 65 + .../Models/DependencyStatus.cs.meta | 11 + .../Dependencies/PlatformDetectors.meta | 8 + .../PlatformDetectors/IPlatformDetector.cs | 45 + .../IPlatformDetector.cs.meta | 11 + .../LinuxPlatformDetector.cs | 207 + .../LinuxPlatformDetector.cs.meta | 11 + .../MacOSPlatformDetector.cs | 206 + .../MacOSPlatformDetector.cs.meta | 11 + .../PlatformDetectors/PlatformDetectorBase.cs | 137 + .../PlatformDetectorBase.cs.meta | 11 + .../WindowsPlatformDetector.cs | 297 + .../WindowsPlatformDetector.cs.meta | 11 + .../Editor/Dependencies/UvInstaller.cs | 93 + .../Editor/Dependencies/UvInstaller.cs.meta | 11 + MCPForUnity/Editor/External.meta | 8 + MCPForUnity/Editor/External/Tommy.cs | 2138 ++ MCPForUnity/Editor/External/Tommy.cs.meta | 11 + MCPForUnity/Editor/Helpers.meta | 8 + MCPForUnity/Editor/Helpers/AssetGenPaths.cs | 90 + .../Editor/Helpers/AssetGenPaths.cs.meta | 11 + MCPForUnity/Editor/Helpers/AssetGenPrefs.cs | 73 + .../Editor/Helpers/AssetGenPrefs.cs.meta | 11 + .../Editor/Helpers/AssetPathUtility.cs | 668 + .../Editor/Helpers/AssetPathUtility.cs.meta | 11 + .../Editor/Helpers/BlenderDetection.cs | 80 + .../Editor/Helpers/BlenderDetection.cs.meta | 11 + .../Editor/Helpers/CodexConfigHelper.cs | 316 + .../Editor/Helpers/CodexConfigHelper.cs.meta | 11 + MCPForUnity/Editor/Helpers/ComponentOps.cs | 997 + .../Editor/Helpers/ComponentOps.cs.meta | 11 + .../Editor/Helpers/ConfigJsonBuilder.cs | 187 + .../Editor/Helpers/ConfigJsonBuilder.cs.meta | 11 + .../Helpers/EditorWindowScreenshotUtility.cs | 427 + .../EditorWindowScreenshotUtility.cs.meta | 11 + MCPForUnity/Editor/Helpers/ExecPath.cs | 332 + MCPForUnity/Editor/Helpers/ExecPath.cs.meta | 11 + .../Editor/Helpers/GameObjectLookup.cs | 375 + .../Editor/Helpers/GameObjectLookup.cs.meta | 11 + .../Editor/Helpers/GameObjectSerializer.cs | 747 + .../Helpers/GameObjectSerializer.cs.meta | 11 + .../Editor/Helpers/HttpEndpointUtility.cs | 358 + .../Helpers/HttpEndpointUtility.cs.meta | 11 + MCPForUnity/Editor/Helpers/MaterialOps.cs | 397 + .../Editor/Helpers/MaterialOps.cs.meta | 11 + .../Editor/Helpers/McpConfigurationHelper.cs | 283 + .../Helpers/McpConfigurationHelper.cs.meta | 11 + .../Editor/Helpers/McpJobStateStore.cs | 62 + .../Editor/Helpers/McpJobStateStore.cs.meta | 11 + MCPForUnity/Editor/Helpers/McpLog.cs | 53 + MCPForUnity/Editor/Helpers/McpLog.cs.meta | 11 + MCPForUnity/Editor/Helpers/McpLogRecord.cs | 117 + .../Editor/Helpers/McpLogRecord.cs.meta | 11 + MCPForUnity/Editor/Helpers/ObjectResolver.cs | 202 + .../Editor/Helpers/ObjectResolver.cs.meta | 11 + MCPForUnity/Editor/Helpers/Pagination.cs | 149 + MCPForUnity/Editor/Helpers/Pagination.cs.meta | 11 + MCPForUnity/Editor/Helpers/ParamCoercion.cs | 394 + .../Editor/Helpers/ParamCoercion.cs.meta | 11 + MCPForUnity/Editor/Helpers/PortManager.cs | 347 + .../Editor/Helpers/PortManager.cs.meta | 11 + .../Editor/Helpers/PrefabUtilityHelper.cs | 228 + .../Helpers/PrefabUtilityHelper.cs.meta | 11 + .../Editor/Helpers/ProjectIdentityUtility.cs | 260 + .../Helpers/ProjectIdentityUtility.cs.meta | 11 + .../Editor/Helpers/PropertyConversion.cs | 93 + .../Editor/Helpers/PropertyConversion.cs.meta | 11 + .../Editor/Helpers/RenderPipelineUtility.cs | 445 + .../Helpers/RenderPipelineUtility.cs.meta | 11 + MCPForUnity/Editor/Helpers/RendererHelpers.cs | 321 + .../Editor/Helpers/RendererHelpers.cs.meta | 12 + MCPForUnity/Editor/Helpers/Response.cs | 108 + MCPForUnity/Editor/Helpers/Response.cs.meta | 11 + .../Editor/Helpers/ScreenshotPreferences.cs | 49 + .../Helpers/ScreenshotPreferences.cs.meta | 11 + .../Editor/Helpers/StringCaseUtility.cs | 73 + .../Editor/Helpers/StringCaseUtility.cs.meta | 11 + MCPForUnity/Editor/Helpers/TelemetryHelper.cs | 226 + .../Editor/Helpers/TelemetryHelper.cs.meta | 11 + MCPForUnity/Editor/Helpers/TextureOps.cs | 162 + MCPForUnity/Editor/Helpers/TextureOps.cs.meta | 11 + MCPForUnity/Editor/Helpers/ToolParams.cs | 255 + MCPForUnity/Editor/Helpers/ToolParams.cs.meta | 11 + .../Editor/Helpers/UnityJsonSerializer.cs | 33 + .../Helpers/UnityJsonSerializer.cs.meta | 11 + .../Editor/Helpers/UnityTypeResolver.cs | 263 + .../Editor/Helpers/UnityTypeResolver.cs.meta | 11 + MCPForUnity/Editor/Helpers/VectorParsing.cs | 731 + .../Editor/Helpers/VectorParsing.cs.meta | 11 + MCPForUnity/Editor/MCPForUnity.Editor.asmdef | 25 + .../Editor/MCPForUnity.Editor.asmdef.meta | 7 + MCPForUnity/Editor/McpCiBoot.cs | 21 + MCPForUnity/Editor/McpCiBoot.cs.meta | 11 + MCPForUnity/Editor/MenuItems.meta | 8 + .../Editor/MenuItems/MCPForUnityMenu.cs | 30 + .../Editor/MenuItems/MCPForUnityMenu.cs.meta | 11 + MCPForUnity/Editor/Migrations.meta | 8 + .../Migrations/LegacyServerSrcMigration.cs | 80 + .../LegacyServerSrcMigration.cs.meta | 11 + .../Migrations/StdIoVersionMigration.cs | 154 + .../Migrations/StdIoVersionMigration.cs.meta | 11 + MCPForUnity/Editor/Models.meta | 8 + MCPForUnity/Editor/Models/Command.cs | 21 + MCPForUnity/Editor/Models/Command.cs.meta | 11 + MCPForUnity/Editor/Models/MCPConfigServer.cs | 23 + .../Editor/Models/MCPConfigServer.cs.meta | 11 + MCPForUnity/Editor/Models/MCPConfigServers.cs | 12 + .../Editor/Models/MCPConfigServers.cs.meta | 11 + MCPForUnity/Editor/Models/McpClient.cs | 62 + MCPForUnity/Editor/Models/McpClient.cs.meta | 11 + MCPForUnity/Editor/Models/McpConfig.cs | 12 + MCPForUnity/Editor/Models/McpConfig.cs.meta | 11 + MCPForUnity/Editor/Models/McpStatus.cs | 31 + MCPForUnity/Editor/Models/McpStatus.cs.meta | 11 + MCPForUnity/Editor/Resources.meta | 8 + MCPForUnity/Editor/Resources/Editor.meta | 8 + .../Editor/Resources/Editor/ActiveTool.cs | 64 + .../Resources/Editor/ActiveTool.cs.meta | 11 + .../Editor/Resources/Editor/EditorState.cs | 27 + .../Resources/Editor/EditorState.cs.meta | 11 + .../Editor/Resources/Editor/GetPrefabStage.cs | 43 + .../Resources/Editor/GetPrefabStage.cs.meta | 11 + .../Editor/Resources/Editor/Selection.cs | 53 + .../Editor/Resources/Editor/Selection.cs.meta | 11 + .../Editor/Resources/Editor/ToolStates.cs | 81 + .../Resources/Editor/ToolStates.cs.meta | 11 + .../Editor/Resources/Editor/Windows.cs | 60 + .../Editor/Resources/Editor/Windows.cs.meta | 11 + .../Resources/McpForUnityResourceAttribute.cs | 42 + .../McpForUnityResourceAttribute.cs.meta | 11 + MCPForUnity/Editor/Resources/MenuItems.meta | 8 + .../Resources/MenuItems/GetMenuItems.cs | 71 + .../Resources/MenuItems/GetMenuItems.cs.meta | 11 + MCPForUnity/Editor/Resources/Project.meta | 8 + .../Editor/Resources/Project/Layers.cs | 39 + .../Editor/Resources/Project/Layers.cs.meta | 11 + .../Editor/Resources/Project/ProjectInfo.cs | 96 + .../Resources/Project/ProjectInfo.cs.meta | 11 + MCPForUnity/Editor/Resources/Project/Tags.cs | 27 + .../Editor/Resources/Project/Tags.cs.meta | 11 + MCPForUnity/Editor/Resources/Scene.meta | 8 + .../Editor/Resources/Scene/CamerasResource.cs | 27 + .../Resources/Scene/CamerasResource.cs.meta | 11 + .../Resources/Scene/GameObjectResource.cs | 285 + .../Scene/GameObjectResource.cs.meta | 11 + .../Scene/RendererFeaturesResource.cs | 24 + .../Scene/RendererFeaturesResource.cs.meta | 11 + .../Resources/Scene/RenderingStatsResource.cs | 24 + .../Scene/RenderingStatsResource.cs.meta | 11 + .../Editor/Resources/Scene/VolumesResource.cs | 24 + .../Resources/Scene/VolumesResource.cs.meta | 11 + MCPForUnity/Editor/Resources/Tests.meta | 8 + .../Editor/Resources/Tests/GetTests.cs | 217 + .../Editor/Resources/Tests/GetTests.cs.meta | 11 + MCPForUnity/Editor/Security.meta | 8 + .../Editor/Security/SecureKeyStore.meta | 8 + .../SecureKeyStore/EncryptedFileKeyStore.cs | 220 + .../EncryptedFileKeyStore.cs.meta | 11 + .../Security/SecureKeyStore/EnvKeyOverride.cs | 26 + .../SecureKeyStore/EnvKeyOverride.cs.meta | 11 + .../SecureKeyStore/ISecureKeyStore.cs | 25 + .../SecureKeyStore/ISecureKeyStore.cs.meta | 11 + .../SecureKeyStore/LinuxSecretToolKeyStore.cs | 103 + .../LinuxSecretToolKeyStore.cs.meta | 11 + .../SecureKeyStore/MacKeychainKeyStore.cs | 67 + .../MacKeychainKeyStore.cs.meta | 11 + .../Security/SecureKeyStore/SecretRedactor.cs | 36 + .../SecureKeyStore/SecretRedactor.cs.meta | 11 + .../Security/SecureKeyStore/SecureKeyStore.cs | 65 + .../SecureKeyStore/SecureKeyStore.cs.meta | 11 + .../SecureKeyStore/SecureKeyStoreConstants.cs | 15 + .../SecureKeyStoreConstants.cs.meta | 11 + .../WindowsCredentialKeyStore.cs | 106 + .../WindowsCredentialKeyStore.cs.meta | 11 + MCPForUnity/Editor/Services.meta | 8 + MCPForUnity/Editor/Services/AssetGen.meta | 8 + .../Services/AssetGen/AssetGenJobManager.cs | 533 + .../AssetGen/AssetGenJobManager.cs.meta | 11 + .../Editor/Services/AssetGen/Http.meta | 8 + .../AssetGen/Http/FakeHttpTransport.cs | 55 + .../AssetGen/Http/FakeHttpTransport.cs.meta | 11 + .../Services/AssetGen/Http/HttpRequestSpec.cs | 18 + .../AssetGen/Http/HttpRequestSpec.cs.meta | 11 + .../Services/AssetGen/Http/HttpResult.cs | 19 + .../Services/AssetGen/Http/HttpResult.cs.meta | 11 + .../Services/AssetGen/Http/IHttpTransport.cs | 15 + .../AssetGen/Http/IHttpTransport.cs.meta | 11 + .../AssetGen/Http/UnityWebRequestTransport.cs | 80 + .../Http/UnityWebRequestTransport.cs.meta | 11 + .../Editor/Services/AssetGen/Import.meta | 8 + .../AssetGen/Import/ImageImportPipeline.cs | 63 + .../Import/ImageImportPipeline.cs.meta | 11 + .../AssetGen/Import/ModelImportPipeline.cs | 247 + .../Import/ModelImportPipeline.cs.meta | 11 + .../AssetGen/Import/SafeZipExtractor.cs | 74 + .../AssetGen/Import/SafeZipExtractor.cs.meta | 11 + .../Editor/Services/AssetGen/Providers.meta | 8 + .../AssetGen/Providers/AssetGenProviders.cs | 78 + .../Providers/AssetGenProviders.cs.meta | 11 + .../Services/AssetGen/Providers/FalAdapter.cs | 162 + .../AssetGen/Providers/FalAdapter.cs.meta | 11 + .../AssetGen/Providers/IProviderAdapters.cs | 35 + .../Providers/IProviderAdapters.cs.meta | 11 + .../Services/AssetGen/Providers/LocalImage.cs | 75 + .../AssetGen/Providers/LocalImage.cs.meta | 11 + .../AssetGen/Providers/MeshyAdapter.cs | 208 + .../AssetGen/Providers/MeshyAdapter.cs.meta | 11 + .../AssetGen/Providers/OpenRouterAdapter.cs | 155 + .../Providers/OpenRouterAdapter.cs.meta | 11 + .../AssetGen/Providers/ProviderHttp.cs | 28 + .../AssetGen/Providers/ProviderHttp.cs.meta | 11 + .../AssetGen/Providers/ProviderModels.cs | 75 + .../AssetGen/Providers/ProviderModels.cs.meta | 11 + .../AssetGen/Providers/SketchfabAdapter.cs | 96 + .../Providers/SketchfabAdapter.cs.meta | 11 + .../AssetGen/Providers/TripoAdapter.cs | 222 + .../AssetGen/Providers/TripoAdapter.cs.meta | 11 + .../Editor/Services/BridgeControlService.cs | 157 + .../Services/BridgeControlService.cs.meta | 11 + .../Services/ClientConfigurationService.cs | 146 + .../ClientConfigurationService.cs.meta | 11 + .../Services/EditorConfigurationCache.cs | 321 + .../Services/EditorConfigurationCache.cs.meta | 11 + .../Services/EditorPrefsWindowService.cs | 54 + .../Services/EditorPrefsWindowService.cs.meta | 11 + .../Editor/Services/EditorStateCache.cs | 573 + .../Editor/Services/EditorStateCache.cs.meta | 11 + .../Editor/Services/HttpAutoStartHandler.cs | 357 + .../Services/HttpAutoStartHandler.cs.meta | 11 + .../Services/HttpBridgeReloadHandler.cs | 223 + .../Services/HttpBridgeReloadHandler.cs.meta | 11 + .../Editor/Services/IBridgeControlService.cs | 82 + .../Services/IBridgeControlService.cs.meta | 11 + .../Services/IClientConfigurationService.cs | 69 + .../IClientConfigurationService.cs.meta | 11 + .../Services/IPackageDeploymentService.cs | 29 + .../IPackageDeploymentService.cs.meta | 11 + .../Editor/Services/IPackageUpdateService.cs | 89 + .../Services/IPackageUpdateService.cs.meta | 11 + .../Editor/Services/IPathResolverService.cs | 77 + .../Services/IPathResolverService.cs.meta | 11 + .../Editor/Services/IPlatformService.cs | 20 + .../Editor/Services/IPlatformService.cs.meta | 11 + .../Services/IResourceDiscoveryService.cs | 53 + .../IResourceDiscoveryService.cs.meta | 11 + .../Services/IServerManagementService.cs | 89 + .../Services/IServerManagementService.cs.meta | 11 + .../Editor/Services/ITestRunnerService.cs | 52 + .../Services/ITestRunnerService.cs.meta | 11 + .../Editor/Services/IToolDiscoveryService.cs | 72 + .../Services/IToolDiscoveryService.cs.meta | 11 + .../Editor/Services/MCPServiceLocator.cs | 98 + .../Editor/Services/MCPServiceLocator.cs.meta | 11 + .../Services/McpEditorShutdownCleanup.cs | 71 + .../Services/McpEditorShutdownCleanup.cs.meta | 11 + .../Services/PackageDeploymentService.cs | 304 + .../Services/PackageDeploymentService.cs.meta | 11 + .../Editor/Services/PackageJobManager.cs | 345 + .../Editor/Services/PackageJobManager.cs.meta | 11 + .../Editor/Services/PackageUpdateService.cs | 451 + .../Services/PackageUpdateService.cs.meta | 11 + .../Editor/Services/PathResolverService.cs | 317 + .../Services/PathResolverService.cs.meta | 11 + .../Editor/Services/PlatformService.cs | 31 + .../Editor/Services/PlatformService.cs.meta | 11 + .../Services/ResourceDiscoveryService.cs | 167 + .../Services/ResourceDiscoveryService.cs.meta | 11 + MCPForUnity/Editor/Services/Server.meta | 8 + .../Editor/Services/Server/IPidFileManager.cs | 94 + .../Services/Server/IPidFileManager.cs.meta | 11 + .../Services/Server/IProcessDetector.cs | 55 + .../Services/Server/IProcessDetector.cs.meta | 11 + .../Services/Server/IProcessTerminator.cs | 18 + .../Server/IProcessTerminator.cs.meta | 11 + .../Services/Server/IServerCommandBuilder.cs | 39 + .../Server/IServerCommandBuilder.cs.meta | 11 + .../Services/Server/ITerminalLauncher.cs | 34 + .../Services/Server/ITerminalLauncher.cs.meta | 11 + .../Editor/Services/Server/PidFileManager.cs | 275 + .../Services/Server/PidFileManager.cs.meta | 11 + .../Editor/Services/Server/ProcessDetector.cs | 268 + .../Services/Server/ProcessDetector.cs.meta | 11 + .../Services/Server/ProcessTerminator.cs | 89 + .../Services/Server/ProcessTerminator.cs.meta | 11 + .../Services/Server/ServerCommandBuilder.cs | 133 + .../Server/ServerCommandBuilder.cs.meta | 11 + .../Services/Server/TerminalLauncher.cs | 191 + .../Services/Server/TerminalLauncher.cs.meta | 11 + .../Services/ServerManagementService.cs | 1081 + .../Services/ServerManagementService.cs.meta | 11 + .../Editor/Services/StartupConfigRewrite.cs | 96 + .../Services/StartupConfigRewrite.cs.meta | 11 + .../Services/StdioBridgeReloadHandler.cs | 209 + .../Services/StdioBridgeReloadHandler.cs.meta | 11 + MCPForUnity/Editor/Services/TestJobManager.cs | 688 + .../Editor/Services/TestJobManager.cs.meta | 13 + MCPForUnity/Editor/Services/TestRunStatus.cs | 62 + .../Editor/Services/TestRunStatus.cs.meta | 11 + .../Editor/Services/TestRunnerNoThrottle.cs | 150 + .../Services/TestRunnerNoThrottle.cs.meta | 11 + .../Editor/Services/TestRunnerService.cs | 759 + .../Editor/Services/TestRunnerService.cs.meta | 11 + .../Editor/Services/ToolDiscoveryService.cs | 262 + .../Services/ToolDiscoveryService.cs.meta | 11 + MCPForUnity/Editor/Services/Transport.meta | 8 + .../Services/Transport/IMcpTransportClient.cs | 19 + .../Transport/IMcpTransportClient.cs.meta | 11 + .../Transport/TransportCommandDispatcher.cs | 489 + .../TransportCommandDispatcher.cs.meta | 11 + .../Services/Transport/TransportManager.cs | 216 + .../Transport/TransportManager.cs.meta | 11 + .../Services/Transport/TransportState.cs | 52 + .../Services/Transport/TransportState.cs.meta | 11 + .../Editor/Services/Transport/Transports.meta | 8 + .../Transport/Transports/StdioBridgeHost.cs | 1134 + .../Transports/StdioBridgeHost.cs.meta | 11 + .../Transports/StdioTransportClient.cs | 89 + .../Transports/StdioTransportClient.cs.meta | 11 + .../Transports/WebSocketTransportClient.cs | 895 + .../WebSocketTransportClient.cs.meta | 11 + MCPForUnity/Editor/Setup.meta | 8 + .../Editor/Setup/McpForUnitySkillInstaller.cs | 268 + .../Setup/McpForUnitySkillInstaller.cs.meta | 11 + MCPForUnity/Editor/Setup/RoslynInstaller.cs | 174 + .../Editor/Setup/RoslynInstaller.cs.meta | 11 + .../Editor/Setup/SetupWindowService.cs | 110 + .../Editor/Setup/SetupWindowService.cs.meta | 11 + MCPForUnity/Editor/Setup/SkillSyncService.cs | 816 + .../Editor/Setup/SkillSyncService.cs.meta | 11 + MCPForUnity/Editor/Tools.meta | 8 + MCPForUnity/Editor/Tools/Animation.meta | 8 + .../Editor/Tools/Animation/AnimatorControl.cs | 224 + .../Tools/Animation/AnimatorControl.cs.meta | 11 + .../Editor/Tools/Animation/AnimatorRead.cs | 152 + .../Tools/Animation/AnimatorRead.cs.meta | 11 + .../Editor/Tools/Animation/ClipCreate.cs | 637 + .../Editor/Tools/Animation/ClipCreate.cs.meta | 11 + .../Editor/Tools/Animation/ClipPresets.cs | 401 + .../Tools/Animation/ClipPresets.cs.meta | 11 + .../Tools/Animation/ControllerBlendTrees.cs | 255 + .../Animation/ControllerBlendTrees.cs.meta | 11 + .../Tools/Animation/ControllerCreate.cs | 458 + .../Tools/Animation/ControllerCreate.cs.meta | 11 + .../Tools/Animation/ControllerLayers.cs | 202 + .../Tools/Animation/ControllerLayers.cs.meta | 11 + .../Editor/Tools/Animation/ManageAnimation.cs | 253 + .../Tools/Animation/ManageAnimation.cs.meta | 11 + MCPForUnity/Editor/Tools/AssetGen.meta | 8 + .../Editor/Tools/AssetGen/GenerateImage.cs | 151 + .../Tools/AssetGen/GenerateImage.cs.meta | 11 + .../Editor/Tools/AssetGen/GenerateModel.cs | 147 + .../Tools/AssetGen/GenerateModel.cs.meta | 11 + .../Editor/Tools/AssetGen/ImportModel.cs | 171 + .../Editor/Tools/AssetGen/ImportModel.cs.meta | 11 + .../Editor/Tools/AssetGen/ImportModelFile.cs | 108 + .../Tools/AssetGen/ImportModelFile.cs.meta | 11 + MCPForUnity/Editor/Tools/BatchExecute.cs | 235 + MCPForUnity/Editor/Tools/BatchExecute.cs.meta | 11 + MCPForUnity/Editor/Tools/Build.meta | 8 + MCPForUnity/Editor/Tools/Build/BuildJob.cs | 189 + .../Editor/Tools/Build/BuildJob.cs.meta | 11 + MCPForUnity/Editor/Tools/Build/BuildRunner.cs | 234 + .../Editor/Tools/Build/BuildRunner.cs.meta | 11 + .../Editor/Tools/Build/BuildSettingsHelper.cs | 94 + .../Tools/Build/BuildSettingsHelper.cs.meta | 11 + .../Editor/Tools/Build/BuildTargetMapping.cs | 165 + .../Tools/Build/BuildTargetMapping.cs.meta | 11 + MCPForUnity/Editor/Tools/Cameras.meta | 8 + .../Editor/Tools/Cameras/CameraConfigure.cs | 412 + .../Tools/Cameras/CameraConfigure.cs.meta | 11 + .../Editor/Tools/Cameras/CameraControl.cs | 315 + .../Tools/Cameras/CameraControl.cs.meta | 11 + .../Editor/Tools/Cameras/CameraCreate.cs | 251 + .../Editor/Tools/Cameras/CameraCreate.cs.meta | 11 + .../Editor/Tools/Cameras/CameraHelpers.cs | 270 + .../Tools/Cameras/CameraHelpers.cs.meta | 11 + .../Editor/Tools/Cameras/ManageCamera.cs | 134 + .../Editor/Tools/Cameras/ManageCamera.cs.meta | 11 + MCPForUnity/Editor/Tools/CommandRegistry.cs | 495 + .../Editor/Tools/CommandRegistry.cs.meta | 11 + MCPForUnity/Editor/Tools/ExecuteCode.cs | 709 + MCPForUnity/Editor/Tools/ExecuteCode.cs.meta | 11 + MCPForUnity/Editor/Tools/ExecuteMenuItem.cs | 53 + .../Editor/Tools/ExecuteMenuItem.cs.meta | 11 + MCPForUnity/Editor/Tools/FindGameObjects.cs | 81 + .../Editor/Tools/FindGameObjects.cs.meta | 11 + MCPForUnity/Editor/Tools/GameObjects.meta | 8 + .../Tools/GameObjects/ComponentResolver.cs | 142 + .../GameObjects/ComponentResolver.cs.meta | 11 + .../GameObjects/GameObjectComponentHelpers.cs | 431 + .../GameObjectComponentHelpers.cs.meta | 11 + .../Tools/GameObjects/GameObjectCreate.cs | 338 + .../GameObjects/GameObjectCreate.cs.meta | 11 + .../Tools/GameObjects/GameObjectDelete.cs | 49 + .../GameObjects/GameObjectDelete.cs.meta | 11 + .../Tools/GameObjects/GameObjectDuplicate.cs | 87 + .../GameObjects/GameObjectDuplicate.cs.meta | 11 + .../Tools/GameObjects/GameObjectHandlers.cs | 22 + .../GameObjects/GameObjectHandlers.cs.meta | 11 + .../Tools/GameObjects/GameObjectLookAt.cs | 64 + .../GameObjects/GameObjectLookAt.cs.meta | 11 + .../Tools/GameObjects/GameObjectModify.cs | 310 + .../GameObjects/GameObjectModify.cs.meta | 11 + .../GameObjects/GameObjectMoveRelative.cs | 119 + .../GameObjectMoveRelative.cs.meta | 11 + .../Tools/GameObjects/ManageGameObject.cs | 117 + .../GameObjects/ManageGameObject.cs.meta | 11 + .../GameObjects/ManageGameObjectCommon.cs | 232 + .../ManageGameObjectCommon.cs.meta | 11 + MCPForUnity/Editor/Tools/GetTestJob.cs | 36 + MCPForUnity/Editor/Tools/GetTestJob.cs.meta | 13 + MCPForUnity/Editor/Tools/Graphics.meta | 8 + .../Editor/Tools/Graphics/GraphicsHelpers.cs | 268 + .../Tools/Graphics/GraphicsHelpers.cs.meta | 11 + .../Editor/Tools/Graphics/LightBakingOps.cs | 585 + .../Tools/Graphics/LightBakingOps.cs.meta | 11 + .../Editor/Tools/Graphics/ManageGraphics.cs | 175 + .../Tools/Graphics/ManageGraphics.cs.meta | 11 + .../Tools/Graphics/RenderPipelineOps.cs | 286 + .../Tools/Graphics/RenderPipelineOps.cs.meta | 11 + .../Tools/Graphics/RendererFeatureOps.cs | 577 + .../Tools/Graphics/RendererFeatureOps.cs.meta | 11 + .../Tools/Graphics/RenderingStatsOps.cs | 171 + .../Tools/Graphics/RenderingStatsOps.cs.meta | 11 + .../Editor/Tools/Graphics/SkyboxOps.cs | 497 + .../Editor/Tools/Graphics/SkyboxOps.cs.meta | 11 + .../Editor/Tools/Graphics/VolumeOps.cs | 705 + .../Editor/Tools/Graphics/VolumeOps.cs.meta | 11 + MCPForUnity/Editor/Tools/JsonUtil.cs | 31 + MCPForUnity/Editor/Tools/JsonUtil.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageAsset.cs | 1121 + MCPForUnity/Editor/Tools/ManageAsset.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageBuild.cs | 589 + MCPForUnity/Editor/Tools/ManageBuild.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageComponents.cs | 423 + .../Editor/Tools/ManageComponents.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageEditor.cs | 471 + MCPForUnity/Editor/Tools/ManageEditor.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageMaterial.cs | 718 + .../Editor/Tools/ManageMaterial.cs.meta | 11 + MCPForUnity/Editor/Tools/ManagePackages.cs | 737 + .../Editor/Tools/ManagePackages.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageScene.cs | 2141 ++ MCPForUnity/Editor/Tools/ManageScene.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageScript.cs | 3027 +++ MCPForUnity/Editor/Tools/ManageScript.cs.meta | 11 + .../Editor/Tools/ManageScriptableObject.cs | 1501 ++ .../Tools/ManageScriptableObject.cs.meta | 14 + MCPForUnity/Editor/Tools/ManageShader.cs | 348 + MCPForUnity/Editor/Tools/ManageShader.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageTexture.cs | 1126 + .../Editor/Tools/ManageTexture.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageUI.cs | 1912 ++ MCPForUnity/Editor/Tools/ManageUI.cs.meta | 11 + .../Editor/Tools/McpForUnityToolAttribute.cs | 122 + .../Tools/McpForUnityToolAttribute.cs.meta | 11 + MCPForUnity/Editor/Tools/Physics.meta | 8 + .../Tools/Physics/CollisionMatrixOps.cs | 128 + .../Tools/Physics/CollisionMatrixOps.cs.meta | 10 + MCPForUnity/Editor/Tools/Physics/JointOps.cs | 527 + .../Editor/Tools/Physics/JointOps.cs.meta | 10 + .../Editor/Tools/Physics/ManagePhysics.cs | 108 + .../Tools/Physics/ManagePhysics.cs.meta | 10 + .../Editor/Tools/Physics/PhysicsForceOps.cs | 278 + .../Tools/Physics/PhysicsForceOps.cs.meta | 10 + .../Tools/Physics/PhysicsMaterialOps.cs | 533 + .../Tools/Physics/PhysicsMaterialOps.cs.meta | 10 + .../Editor/Tools/Physics/PhysicsQueryOps.cs | 807 + .../Tools/Physics/PhysicsQueryOps.cs.meta | 10 + .../Tools/Physics/PhysicsRigidbodyOps.cs | 437 + .../Tools/Physics/PhysicsRigidbodyOps.cs.meta | 10 + .../Tools/Physics/PhysicsSettingsOps.cs | 306 + .../Tools/Physics/PhysicsSettingsOps.cs.meta | 10 + .../Tools/Physics/PhysicsSimulationOps.cs | 179 + .../Physics/PhysicsSimulationOps.cs.meta | 10 + .../Tools/Physics/PhysicsValidationOps.cs | 308 + .../Physics/PhysicsValidationOps.cs.meta | 10 + MCPForUnity/Editor/Tools/Prefabs.meta | 8 + .../Editor/Tools/Prefabs/ManagePrefabs.cs | 1419 ++ .../Tools/Prefabs/ManagePrefabs.cs.meta | 11 + MCPForUnity/Editor/Tools/ProBuilder.meta | 8 + .../Tools/ProBuilder/ManageProBuilder.cs | 2523 ++ .../Tools/ProBuilder/ManageProBuilder.cs.meta | 11 + .../Tools/ProBuilder/ProBuilderMeshUtils.cs | 278 + .../ProBuilder/ProBuilderMeshUtils.cs.meta | 11 + .../Tools/ProBuilder/ProBuilderSmoothing.cs | 97 + .../ProBuilder/ProBuilderSmoothing.cs.meta | 11 + MCPForUnity/Editor/Tools/Profiler.meta | 8 + .../Editor/Tools/Profiler/ManageProfiler.cs | 85 + .../Tools/Profiler/ManageProfiler.cs.meta | 11 + .../Editor/Tools/Profiler/Operations.meta | 8 + .../Tools/Profiler/Operations/CounterOps.cs | 155 + .../Profiler/Operations/CounterOps.cs.meta | 11 + .../Profiler/Operations/FrameDebuggerOps.cs | 260 + .../Operations/FrameDebuggerOps.cs.meta | 11 + .../Profiler/Operations/FrameTimingOps.cs | 56 + .../Operations/FrameTimingOps.cs.meta | 11 + .../Profiler/Operations/MemorySnapshotOps.cs | 219 + .../Operations/MemorySnapshotOps.cs.meta | 11 + .../Profiler/Operations/ObjectMemoryOps.cs | 55 + .../Operations/ObjectMemoryOps.cs.meta | 11 + .../Tools/Profiler/Operations/SessionOps.cs | 102 + .../Profiler/Operations/SessionOps.cs.meta | 11 + MCPForUnity/Editor/Tools/ReadConsole.cs | 586 + MCPForUnity/Editor/Tools/ReadConsole.cs.meta | 11 + MCPForUnity/Editor/Tools/RefreshUnity.cs | 171 + MCPForUnity/Editor/Tools/RefreshUnity.cs.meta | 11 + MCPForUnity/Editor/Tools/RunTests.cs | 98 + MCPForUnity/Editor/Tools/RunTests.cs.meta | 11 + MCPForUnity/Editor/Tools/UnityReflect.cs | 821 + MCPForUnity/Editor/Tools/UnityReflect.cs.meta | 2 + MCPForUnity/Editor/Tools/Vfx.meta | 8 + MCPForUnity/Editor/Tools/Vfx/LineCreate.cs | 220 + .../Editor/Tools/Vfx/LineCreate.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/LineRead.cs | 52 + MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/LineWrite.cs | 189 + .../Editor/Tools/Vfx/LineWrite.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs | 414 + .../Editor/Tools/Vfx/ManageVFX.cs.meta | 13 + .../Editor/Tools/Vfx/ManageVfxCommon.cs | 50 + .../Editor/Tools/Vfx/ManageVfxCommon.cs.meta | 11 + .../Editor/Tools/Vfx/ParticleCommon.cs | 90 + .../Editor/Tools/Vfx/ParticleCommon.cs.meta | 11 + .../Editor/Tools/Vfx/ParticleControl.cs | 231 + .../Editor/Tools/Vfx/ParticleControl.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs | 153 + .../Editor/Tools/Vfx/ParticleRead.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs | 282 + .../Editor/Tools/Vfx/ParticleWrite.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/TrailControl.cs | 32 + .../Editor/Tools/Vfx/TrailControl.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/TrailRead.cs | 49 + .../Editor/Tools/Vfx/TrailRead.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs | 130 + .../Editor/Tools/Vfx/TrailWrite.cs.meta | 11 + .../Editor/Tools/Vfx/VfxGraphAssets.cs | 529 + .../Editor/Tools/Vfx/VfxGraphAssets.cs.meta | 11 + .../Editor/Tools/Vfx/VfxGraphCommon.cs | 29 + .../Editor/Tools/Vfx/VfxGraphCommon.cs.meta | 11 + .../Editor/Tools/Vfx/VfxGraphControl.cs | 89 + .../Editor/Tools/Vfx/VfxGraphControl.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs | 47 + .../Editor/Tools/Vfx/VfxGraphRead.cs.meta | 11 + MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs | 310 + .../Editor/Tools/Vfx/VfxGraphWrite.cs.meta | 11 + MCPForUnity/Editor/Windows.meta | 8 + MCPForUnity/Editor/Windows/Components.meta | 8 + .../Editor/Windows/Components/Advanced.meta | 8 + .../Components/Advanced/McpAdvancedSection.cs | 680 + .../Advanced/McpAdvancedSection.cs.meta | 11 + .../Advanced/McpAdvancedSection.uxml | 90 + .../Advanced/McpAdvancedSection.uxml.meta | 10 + .../Editor/Windows/Components/AssetGen.meta | 8 + .../Components/AssetGen/McpAssetGenSection.cs | 376 + .../AssetGen/McpAssetGenSection.cs.meta | 11 + .../AssetGen/McpAssetGenSection.uxml | 28 + .../AssetGen/McpAssetGenSection.uxml.meta | 10 + .../Editor/Windows/Components/Branding.meta | 8 + .../Windows/Components/Branding/OceanMark.cs | 163 + .../Components/Branding/OceanMark.cs.meta | 11 + .../Windows/Components/ClientConfig.meta | 8 + .../ClientConfig/McpClientConfigSection.cs | 864 + .../McpClientConfigSection.cs.meta | 11 + .../ClientConfig/McpClientConfigSection.uxml | 51 + .../McpClientConfigSection.uxml.meta | 10 + .../Editor/Windows/Components/Common.uss | 706 + .../Editor/Windows/Components/Common.uss.meta | 11 + .../Editor/Windows/Components/Connection.meta | 8 + .../Connection/McpConnectionSection.cs | 1261 + .../Connection/McpConnectionSection.cs.meta | 11 + .../Connection/McpConnectionSection.uxml | 57 + .../Connection/McpConnectionSection.uxml.meta | 10 + .../Editor/Windows/Components/Resources.meta | 8 + .../Resources/McpResourcesSection.cs | 250 + .../Resources/McpResourcesSection.cs.meta | 11 + .../Resources/McpResourcesSection.uxml | 17 + .../Resources/McpResourcesSection.uxml.meta | 10 + .../Editor/Windows/Components/Tools.meta | 8 + .../Components/Tools/McpToolsSection.cs | 803 + .../Components/Tools/McpToolsSection.cs.meta | 11 + .../Components/Tools/McpToolsSection.uxml | 24 + .../Tools/McpToolsSection.uxml.meta | 10 + .../Editor/Windows/Components/Validation.meta | 8 + .../Validation/McpValidationSection.cs | 79 + .../Validation/McpValidationSection.cs.meta | 11 + .../Validation/McpValidationSection.uxml | 13 + .../Validation/McpValidationSection.uxml.meta | 10 + MCPForUnity/Editor/Windows/EditorPrefs.meta | 8 + .../Windows/EditorPrefs/EditorPrefItem.uxml | 21 + .../EditorPrefs/EditorPrefItem.uxml.meta | 10 + .../Windows/EditorPrefs/EditorPrefsWindow.cs | 420 + .../EditorPrefs/EditorPrefsWindow.cs.meta | 11 + .../Windows/EditorPrefs/EditorPrefsWindow.uss | 225 + .../EditorPrefs/EditorPrefsWindow.uss.meta | 11 + .../EditorPrefs/EditorPrefsWindow.uxml | 30 + .../EditorPrefs/EditorPrefsWindow.uxml.meta | 10 + .../Editor/Windows/MCPForUnityEditorWindow.cs | 1108 + .../Windows/MCPForUnityEditorWindow.cs.meta | 11 + .../Windows/MCPForUnityEditorWindow.uss | 156 + .../Windows/MCPForUnityEditorWindow.uss.meta | 11 + .../Windows/MCPForUnityEditorWindow.uxml | 40 + .../Windows/MCPForUnityEditorWindow.uxml.meta | 10 + MCPForUnity/Editor/Windows/MCPSetupWindow.cs | 375 + .../Editor/Windows/MCPSetupWindow.cs.meta | 11 + MCPForUnity/Editor/Windows/MCPSetupWindow.uss | 143 + .../Editor/Windows/MCPSetupWindow.uss.meta | 11 + .../Editor/Windows/MCPSetupWindow.uxml | 78 + .../Editor/Windows/MCPSetupWindow.uxml.meta | 10 + MCPForUnity/README.md | 97 + MCPForUnity/README.md.meta | 7 + MCPForUnity/Runtime.meta | 8 + MCPForUnity/Runtime/AssemblyInfo.cs | 5 + MCPForUnity/Runtime/AssemblyInfo.cs.meta | 11 + MCPForUnity/Runtime/Helpers.meta | 8 + .../Runtime/Helpers/ScreenshotUtility.cs | 782 + .../Runtime/Helpers/ScreenshotUtility.cs.meta | 11 + .../Runtime/Helpers/UnityAssembliesCompat.cs | 121 + .../Helpers/UnityAssembliesCompat.cs.meta | 2 + .../Runtime/Helpers/UnityCompatShims.cs | 41 + .../Runtime/Helpers/UnityCompatShims.cs.meta | 2 + .../Runtime/Helpers/UnityFindObjectsCompat.cs | 148 + .../Helpers/UnityFindObjectsCompat.cs.meta | 11 + .../Runtime/Helpers/UnityObjectIdCompat.cs | 75 + .../Helpers/UnityObjectIdCompat.cs.meta | 2 + .../Runtime/Helpers/UnityPhysicsCompat.cs | 261 + .../Helpers/UnityPhysicsCompat.cs.meta | 2 + .../Runtime/MCPForUnity.Runtime.asmdef | 14 + .../Runtime/MCPForUnity.Runtime.asmdef.meta | 7 + MCPForUnity/Runtime/Serialization.meta | 8 + .../Serialization/UnityTypeConverters.cs | 519 + .../Serialization/UnityTypeConverters.cs.meta | 11 + MCPForUnity/package-icon.png | Bin 0 -> 3988 bytes MCPForUnity/package-icon.png.meta | 98 + MCPForUnity/package.json | 34 + MCPForUnity/package.json.meta | 7 + README.md | 114 + README.wehub.md | 7 + SECURITY.md | 51 + Server/DOCKER_OVERVIEW.md | 136 + Server/Dockerfile | 36 + Server/LICENSE | 21 + Server/README.md | 290 + Server/__init__.py | 0 Server/pyproject.toml | 94 + Server/pyrightconfig.json | 11 + Server/src/__init__.py | 1 + Server/src/cli/CLI_USAGE_GUIDE.md | 945 + Server/src/cli/__init__.py | 3 + Server/src/cli/commands/__init__.py | 3 + Server/src/cli/commands/animation.py | 933 + Server/src/cli/commands/asset.py | 280 + Server/src/cli/commands/asset_gen.py | 212 + Server/src/cli/commands/audio.py | 125 + Server/src/cli/commands/batch.py | 171 + Server/src/cli/commands/build.py | 228 + Server/src/cli/commands/camera.py | 554 + Server/src/cli/commands/code.py | 268 + Server/src/cli/commands/component.py | 217 + Server/src/cli/commands/docs.py | 33 + Server/src/cli/commands/editor.py | 517 + Server/src/cli/commands/gameobject.py | 496 + Server/src/cli/commands/graphics.py | 545 + Server/src/cli/commands/instance.py | 93 + Server/src/cli/commands/lighting.py | 123 + Server/src/cli/commands/material.py | 239 + Server/src/cli/commands/packages.py | 236 + Server/src/cli/commands/physics.py | 441 + Server/src/cli/commands/prefab.py | 370 + Server/src/cli/commands/probuilder.py | 715 + Server/src/cli/commands/profiler.py | 170 + Server/src/cli/commands/reflect.py | 72 + Server/src/cli/commands/scene.py | 352 + Server/src/cli/commands/script.py | 222 + Server/src/cli/commands/shader.py | 226 + Server/src/cli/commands/texture.py | 667 + Server/src/cli/commands/tool.py | 58 + Server/src/cli/commands/ui.py | 258 + Server/src/cli/commands/vfx.py | 468 + Server/src/cli/main.py | 295 + Server/src/cli/utils/__init__.py | 31 + Server/src/cli/utils/config.py | 58 + Server/src/cli/utils/confirmation.py | 37 + Server/src/cli/utils/connection.py | 254 + Server/src/cli/utils/constants.py | 23 + Server/src/cli/utils/output.py | 195 + Server/src/cli/utils/parsers.py | 112 + Server/src/cli/utils/suggestions.py | 34 + Server/src/core/__init__.py | 0 Server/src/core/config.py | 69 + Server/src/core/constants.py | 4 + Server/src/core/logging_decorator.py | 37 + Server/src/core/telemetry.py | 551 + Server/src/core/telemetry_decorator.py | 164 + Server/src/main.py | 940 + Server/src/models/__init__.py | 4 + Server/src/models/models.py | 59 + Server/src/models/unity_response.py | 70 + Server/src/services/__init__.py | 0 Server/src/services/api_key_service.py | 235 + Server/src/services/custom_tool_service.py | 548 + Server/src/services/registry/__init__.py | 28 + .../services/registry/resource_registry.py | 53 + Server/src/services/registry/tool_registry.py | 130 + Server/src/services/resources/__init__.py | 109 + Server/src/services/resources/active_tool.py | 48 + Server/src/services/resources/cameras.py | 30 + Server/src/services/resources/custom_tools.py | 59 + Server/src/services/resources/editor_state.py | 309 + Server/src/services/resources/gameobject.py | 243 + Server/src/services/resources/layers.py | 30 + Server/src/services/resources/menu_items.py | 35 + Server/src/services/resources/prefab.py | 192 + Server/src/services/resources/prefab_stage.py | 40 + Server/src/services/resources/project_info.py | 40 + .../services/resources/renderer_features.py | 21 + .../src/services/resources/rendering_stats.py | 21 + Server/src/services/resources/selection.py | 56 + Server/src/services/resources/tags.py | 31 + Server/src/services/resources/tests.py | 88 + Server/src/services/resources/tool_groups.py | 44 + .../src/services/resources/unity_instances.py | 125 + Server/src/services/resources/volumes.py | 20 + Server/src/services/resources/windows.py | 48 + .../state/external_changes_scanner.py | 245 + Server/src/services/tools/__init__.py | 286 + Server/src/services/tools/batch_execute.py | 147 + .../services/tools/debug_request_context.py | 83 + Server/src/services/tools/execute_code.py | 101 + .../src/services/tools/execute_custom_tool.py | 59 + .../src/services/tools/execute_menu_item.py | 32 + Server/src/services/tools/find_gameobjects.py | 116 + Server/src/services/tools/find_in_file.py | 182 + Server/src/services/tools/generate_image.py | 88 + Server/src/services/tools/generate_model.py | 85 + Server/src/services/tools/import_model.py | 84 + .../src/services/tools/import_model_file.py | 72 + Server/src/services/tools/manage_animation.py | 113 + Server/src/services/tools/manage_asset.py | 119 + Server/src/services/tools/manage_build.py | 110 + Server/src/services/tools/manage_camera.py | 203 + .../src/services/tools/manage_components.py | 150 + Server/src/services/tools/manage_editor.py | 58 + .../src/services/tools/manage_gameobject.py | 237 + Server/src/services/tools/manage_graphics.py | 166 + Server/src/services/tools/manage_material.py | 111 + Server/src/services/tools/manage_packages.py | 88 + Server/src/services/tools/manage_physics.py | 262 + Server/src/services/tools/manage_prefabs.py | 228 + .../src/services/tools/manage_probuilder.py | 172 + Server/src/services/tools/manage_profiler.py | 106 + Server/src/services/tools/manage_scene.py | 159 + Server/src/services/tools/manage_script.py | 686 + .../tools/manage_scriptable_object.py | 91 + Server/src/services/tools/manage_shader.py | 72 + Server/src/services/tools/manage_texture.py | 593 + Server/src/services/tools/manage_tools.py | 162 + Server/src/services/tools/manage_ui.py | 295 + Server/src/services/tools/manage_vfx.py | 128 + Server/src/services/tools/preflight.py | 110 + Server/src/services/tools/read_console.py | 148 + Server/src/services/tools/refresh_unity.py | 273 + Server/src/services/tools/run_tests.py | 353 + .../src/services/tools/script_apply_edits.py | 1368 ++ .../src/services/tools/set_active_instance.py | 152 + Server/src/services/tools/unity_docs.py | 841 + Server/src/services/tools/unity_reflect.py | 95 + Server/src/services/tools/utils.py | 572 + Server/src/transport/__init__.py | 0 Server/src/transport/legacy/port_discovery.py | 330 + .../transport/legacy/stdio_port_registry.py | 65 + .../src/transport/legacy/unity_connection.py | 1028 + Server/src/transport/models.py | 68 + Server/src/transport/plugin_hub.py | 1112 + Server/src/transport/plugin_registry.py | 189 + .../transport/unity_instance_middleware.py | 635 + Server/src/transport/unity_transport.py | 113 + Server/src/utils/focus_nudge.py | 656 + Server/src/utils/log_paths.py | 44 + Server/src/utils/module_discovery.py | 55 + Server/tests/__init__.py | 0 Server/tests/conftest.py | 87 + Server/tests/e2e/README.md | 38 + Server/tests/e2e/bridge_smoke.py | 256 + Server/tests/integration/__init__.py | 1 + Server/tests/integration/conftest.py | 132 + .../tests/integration/test_api_key_service.py | 456 + .../integration/test_auth_config_startup.py | 114 + .../integration/test_connection_deadline.py | 110 + .../test_debug_request_context_diagnostics.py | 30 + .../test_domain_reload_resilience.py | 282 + .../test_edit_normalization_and_noop.py | 138 + .../test_edit_strict_and_warnings.py | 66 + .../test_editor_state_v2_contract.py | 59 + .../test_external_changes_scanner.py | 86 + .../integration/test_find_gameobjects.py | 200 + .../integration/test_gameobject_resources.py | 254 + Server/tests/integration/test_get_sha.py | 33 + Server/tests/integration/test_helpers.py | 88 + .../test_improved_anchor_matching.py | 151 + .../integration/test_inline_unity_instance.py | 428 + .../integration/test_instance_autoselect.py | 139 + .../test_instance_routing_comprehensive.py | 446 + .../test_instance_targeting_resolution.py | 88 + .../integration/test_json_parsing_simple.py | 113 + .../tests/integration/test_logging_stdout.py | 69 + .../test_manage_asset_json_parsing.py | 164 + .../test_manage_asset_param_coercion.py | 29 + .../integration/test_manage_components.py | 242 + .../test_manage_gameobject_is_static.py | 135 + .../test_manage_gameobject_look_at.py | 77 + .../test_manage_gameobject_param_coercion.py | 65 + .../test_manage_scene_paging_params.py | 44 + .../integration/test_manage_script_uri.py | 99 + .../test_manage_scriptable_object_tool.py | 134 + .../tests/integration/test_manage_texture.py | 277 + Server/tests/integration/test_manage_ui.py | 428 + .../test_middleware_auth_integration.py | 142 + .../test_multi_user_session_isolation.py | 176 + .../test_plugin_hub_websocket_auth.py | 183 + .../test_plugin_registry_user_isolation.py | 132 + .../integration/test_read_console_truncate.py | 248 + .../integration/test_read_resource_minimal.py | 8 + .../test_refresh_unity_registration.py | 14 + .../test_refresh_unity_retry_recovery.py | 43 + .../tests/integration/test_resolve_user_id.py | 114 + .../tests/integration/test_run_tests_async.py | 116 + .../test_script_apply_edits_local.py | 291 + Server/tests/integration/test_script_tools.py | 176 + .../test_stdio_custom_tool_sync.py | 270 + .../test_telemetry_endpoint_validation.py | 62 + .../test_telemetry_queue_worker.py | 65 + .../integration/test_telemetry_subaction.py | 116 + .../test_tool_signatures_paging.py | 41 + .../integration/test_transport_framing.py | 203 + .../tests/integration/test_transport_smoke.py | 113 + .../test_validate_script_summary.py | 30 + .../integration/test_wait_for_editor_ready.py | 239 + Server/tests/pytest.ini | 8 + Server/tests/test_asset_gen_image.py | 150 + Server/tests/test_asset_gen_import.py | 146 + Server/tests/test_asset_gen_import_file.py | 139 + Server/tests/test_asset_gen_model.py | 189 + Server/tests/test_asset_gen_scaffold.py | 12 + Server/tests/test_cli.py | 1533 ++ .../test_cli_commands_characterization.py | 1051 + ...st_core_infrastructure_characterization.py | 1406 ++ .../test_custom_tool_service_user_scope.py | 106 + Server/tests/test_execute_code.py | 164 + Server/tests/test_focus_nudge.py | 104 + Server/tests/test_log_paths.py | 97 + Server/tests/test_manage_animation.py | 683 + Server/tests/test_manage_build.py | 256 + Server/tests/test_manage_camera.py | 591 + Server/tests/test_manage_editor.py | 97 + Server/tests/test_manage_graphics.py | 960 + Server/tests/test_manage_packages.py | 303 + Server/tests/test_manage_physics.py | 269 + Server/tests/test_manage_prefabs.py | 211 + Server/tests/test_manage_probuilder.py | 672 + Server/tests/test_manage_profiler.py | 296 + Server/tests/test_manage_scene.py | 141 + Server/tests/test_manage_vfx_actions.py | 44 + Server/tests/test_models_characterization.py | 933 + Server/tests/test_param_normalizer.py | 151 + Server/tests/test_plugin_hub_wait_env.py | 35 + .../tests/test_stdio_instance_resolution.py | 100 + Server/tests/test_tool_registry_metadata.py | 64 + Server/tests/test_tool_test_symmetry.py | 75 + .../tests/test_transport_characterization.py | 1683 ++ Server/tests/test_unity_docs.py | 682 + Server/tests/test_unity_reflect.py | 258 + .../tests/test_utilities_characterization.py | 0 Server/uv.lock | 1831 ++ TestProjects/AssetStoreUploads/.gitignore | 106 + .../Assets/Readme.asset.meta | 8 + .../AssetStoreUploads/Assets/Scenes.meta | 8 + .../Assets/Scenes/SampleScene.unity | 407 + .../Assets/Scenes/SampleScene.unity.meta | 7 + .../AssetStoreUploads/Assets/Settings.meta | 8 + .../Settings/SampleSceneProfile.asset.meta | 8 + .../Settings/URP-Balanced-Renderer.asset.meta | 8 + .../Assets/Settings/URP-Balanced.asset.meta | 8 + .../URP-HighFidelity-Renderer.asset.meta | 8 + .../Settings/URP-HighFidelity.asset.meta | 8 + .../URP-Performant-Renderer.asset.meta | 8 + .../Assets/Settings/URP-Performant.asset.meta | 8 + .../Assets/TutorialInfo.meta | 8 + .../Assets/TutorialInfo/Icons.meta | 9 + .../Assets/TutorialInfo/Icons/URP.png | Bin 0 -> 24069 bytes .../Assets/TutorialInfo/Icons/URP.png.meta | 134 + .../Assets/TutorialInfo/Layout.wlt | 654 + .../Assets/TutorialInfo/Layout.wlt.meta | 8 + .../Assets/TutorialInfo/Scripts.meta | 9 + .../Assets/TutorialInfo/Scripts/Editor.meta | 9 + .../Scripts/Editor/ReadmeEditor.cs | 242 + .../Scripts/Editor/ReadmeEditor.cs.meta | 12 + .../Assets/TutorialInfo/Scripts/Readme.cs | 16 + .../TutorialInfo/Scripts/Readme.cs.meta | 12 + ...salRenderPipelineGlobalSettings.asset.meta | 8 + .../com.unity.asset-store-tools/CHANGELOG.md | 324 + .../CHANGELOG.md.meta | 7 + .../com.unity.asset-store-tools/Editor.meta | 8 + .../Editor/Api.meta | 8 + .../Editor/Api/Abstractions.meta | 8 + .../Api/Abstractions/AuthenticationBase.cs | 48 + .../Abstractions/AuthenticationBase.cs.meta | 11 + .../Editor/Api/Abstractions/IAssetStoreApi.cs | 21 + .../Api/Abstractions/IAssetStoreApi.cs.meta | 11 + .../Api/Abstractions/IAssetStoreClient.cs | 18 + .../Abstractions/IAssetStoreClient.cs.meta | 11 + .../Api/Abstractions/IAuthenticationType.cs | 11 + .../Abstractions/IAuthenticationType.cs.meta | 11 + .../Api/Abstractions/IPackageUploader.cs | 12 + .../Api/Abstractions/IPackageUploader.cs.meta | 11 + .../Api/Abstractions/PackageUploaderBase.cs | 59 + .../Abstractions/PackageUploaderBase.cs.meta | 11 + .../Editor/Api/ApiUtility.cs | 76 + .../Editor/Api/ApiUtility.cs.meta | 11 + .../Editor/Api/AssetStoreApi.cs | 268 + .../Editor/Api/AssetStoreApi.cs.meta | 11 + .../Editor/Api/AssetStoreClient.cs | 55 + .../Editor/Api/AssetStoreClient.cs.meta | 11 + .../Editor/Api/CloudTokenAuthentication.cs | 25 + .../Api/CloudTokenAuthentication.cs.meta | 11 + .../Editor/Api/CredentialsAuthentication.cs | 26 + .../Api/CredentialsAuthentication.cs.meta | 11 + .../Editor/Api/Models.meta | 8 + .../Editor/Api/Models/Category.cs | 58 + .../Editor/Api/Models/Category.cs.meta | 11 + .../Editor/Api/Models/Package.cs | 80 + .../Editor/Api/Models/Package.cs.meta | 11 + .../Api/Models/PackageAdditionalData.cs | 41 + .../Api/Models/PackageAdditionalData.cs.meta | 11 + .../Editor/Api/Models/User.cs | 51 + .../Editor/Api/Models/User.cs.meta | 11 + .../Editor/Api/Responses.meta | 8 + .../Api/Responses/AssetStoreResponse.cs | 45 + .../Api/Responses/AssetStoreResponse.cs.meta | 11 + .../AssetStoreToolsVersionResponse.cs | 38 + .../AssetStoreToolsVersionResponse.cs.meta | 11 + .../Api/Responses/AuthenticationResponse.cs | 74 + .../Responses/AuthenticationResponse.cs.meta | 11 + .../Api/Responses/CategoryDataResponse.cs | 43 + .../Responses/CategoryDataResponse.cs.meta | 11 + .../Api/Responses/PackageThumbnailResponse.cs | 31 + .../PackageThumbnailResponse.cs.meta | 11 + ...PackageUploadedUnityVersionDataResponse.cs | 44 + ...geUploadedUnityVersionDataResponse.cs.meta | 11 + .../PackagesAdditionalDataResponse.cs | 59 + .../PackagesAdditionalDataResponse.cs.meta | 11 + .../Api/Responses/PackagesDataResponse.cs | 59 + .../Responses/PackagesDataResponse.cs.meta | 11 + .../Responses/RefreshedPackageDataResponse.cs | 12 + .../RefreshedPackageDataResponse.cs.meta | 11 + .../Editor/Api/Responses/UploadResponse.cs | 28 + .../Api/Responses/UploadResponse.cs.meta | 11 + .../Editor/Api/SessionAuthentication.cs | 25 + .../Editor/Api/SessionAuthentication.cs.meta | 11 + .../Editor/Api/UnityPackageUploader.cs | 99 + .../Editor/Api/UnityPackageUploader.cs.meta | 11 + .../Editor/Api/UploadStatus.cs | 11 + .../Editor/Api/UploadStatus.cs.meta | 11 + .../Editor/AssemblyInfo.cs | 5 + .../Editor/AssemblyInfo.cs.meta | 11 + .../Editor/AssetStoreTools.cs | 82 + .../Editor/AssetStoreTools.cs.meta | 11 + .../Editor/AssetStoreToolsWindow.cs | 23 + .../Editor/AssetStoreToolsWindow.cs.meta | 11 + .../Editor/Constants.cs | 178 + .../Editor/Constants.cs.meta | 11 + .../Editor/Exporter.meta | 8 + .../Editor/Exporter/Abstractions.meta | 8 + .../Exporter/Abstractions/IPackageExporter.cs | 11 + .../Abstractions/IPackageExporter.cs.meta | 11 + .../Exporter/Abstractions/IPreviewInjector.cs | 7 + .../Abstractions/IPreviewInjector.cs.meta | 11 + .../Abstractions/PackageExporterBase.cs | 134 + .../Abstractions/PackageExporterBase.cs.meta | 11 + .../Abstractions/PackageExporterSettings.cs | 7 + .../PackageExporterSettings.cs.meta | 11 + .../Exporter/DefaultExporterSettings.cs | 11 + .../Exporter/DefaultExporterSettings.cs.meta | 11 + .../Editor/Exporter/DefaultPackageExporter.cs | 304 + .../Exporter/DefaultPackageExporter.cs.meta | 11 + .../Editor/Exporter/LegacyExporterSettings.cs | 8 + .../Exporter/LegacyExporterSettings.cs.meta | 11 + .../Editor/Exporter/LegacyPackageExporter.cs | 109 + .../Exporter/LegacyPackageExporter.cs.meta | 11 + .../Editor/Exporter/PackageExporterResult.cs | 13 + .../Exporter/PackageExporterResult.cs.meta | 11 + .../Editor/Exporter/PreviewInjector.cs | 41 + .../Editor/Exporter/PreviewInjector.cs.meta | 11 + .../Editor/Previews.meta | 8 + .../Editor/Previews/Scripts.meta | 8 + .../Editor/Previews/Scripts/Data.meta | 8 + .../Data/CustomPreviewGenerationSettings.cs | 19 + .../CustomPreviewGenerationSettings.cs.meta | 11 + .../Previews/Scripts/Data/FileNameFormat.cs | 9 + .../Scripts/Data/FileNameFormat.cs.meta | 11 + .../Previews/Scripts/Data/GenerationType.cs | 9 + .../Scripts/Data/GenerationType.cs.meta | 11 + .../Data/NativePreviewGenerationSettings.cs | 10 + .../NativePreviewGenerationSettings.cs.meta | 11 + .../Previews/Scripts/Data/PreviewDatabase.cs | 14 + .../Scripts/Data/PreviewDatabase.cs.meta | 11 + .../Previews/Scripts/Data/PreviewFormat.cs | 8 + .../Scripts/Data/PreviewFormat.cs.meta | 11 + .../Scripts/Data/PreviewGenerationResult.cs | 14 + .../Data/PreviewGenerationResult.cs.meta | 11 + .../Scripts/Data/PreviewGenerationSettings.cs | 12 + .../Data/PreviewGenerationSettings.cs.meta | 11 + .../Previews/Scripts/Data/PreviewMetadata.cs | 17 + .../Scripts/Data/PreviewMetadata.cs.meta | 11 + .../Editor/Previews/Scripts/Generators.meta | 8 + .../Previews/Scripts/Generators/Custom.meta | 8 + .../Scripts/Generators/Custom/AudioChannel.cs | 97 + .../Generators/Custom/AudioChannel.cs.meta | 11 + .../Custom/AudioChannelCoordinate.cs | 18 + .../Custom/AudioChannelCoordinate.cs.meta | 11 + .../Generators/Custom/Screenshotters.meta | 8 + .../Screenshotters/ISceneScreenshotter.cs | 12 + .../ISceneScreenshotter.cs.meta | 11 + .../Screenshotters/MaterialScreenshotter.cs | 32 + .../MaterialScreenshotter.cs.meta | 11 + .../Screenshotters/MeshScreenshotter.cs | 33 + .../Screenshotters/MeshScreenshotter.cs.meta | 11 + .../Screenshotters/SceneScreenshotterBase.cs | 124 + .../SceneScreenshotterBase.cs.meta | 11 + .../SceneScreenshotterSettings.cs | 16 + .../SceneScreenshotterSettings.cs.meta | 11 + .../Generators/Custom/TypeGenerators.meta | 8 + .../AudioTypeGeneratorSettings.cs | 15 + .../AudioTypeGeneratorSettings.cs.meta | 11 + .../AudioTypePreviewGenerator.cs | 207 + .../AudioTypePreviewGenerator.cs.meta | 11 + .../TypeGenerators/ITypePreviewGenerator.cs | 16 + .../ITypePreviewGenerator.cs.meta | 11 + .../MaterialTypePreviewGenerator.cs | 85 + .../MaterialTypePreviewGenerator.cs.meta | 11 + .../ModelTypePreviewGenerator.cs | 86 + .../ModelTypePreviewGenerator.cs.meta | 11 + .../PrefabTypePreviewGenerator.cs | 113 + .../PrefabTypePreviewGenerator.cs.meta | 11 + .../TextureTypeGeneratorSettings.cs | 11 + .../TextureTypeGeneratorSettings.cs.meta | 11 + .../TextureTypePreviewGenerator.cs | 116 + .../TextureTypePreviewGenerator.cs.meta | 11 + .../TypeGenerators/TypeGeneratorSettings.cs | 12 + .../TypeGeneratorSettings.cs.meta | 11 + .../TypePreviewGeneratorBase.cs | 126 + .../TypePreviewGeneratorBase.cs.meta | 11 + .../TypePreviewGeneratorFromScene.cs | 111 + .../TypePreviewGeneratorFromScene.cs.meta | 11 + .../TypePreviewGeneratorFromSceneSettings.cs | 9 + ...ePreviewGeneratorFromSceneSettings.cs.meta | 11 + .../Generators/CustomPreviewGenerator.cs | 213 + .../Generators/CustomPreviewGenerator.cs.meta | 11 + .../Scripts/Generators/IPreviewGenerator.cs | 15 + .../Generators/IPreviewGenerator.cs.meta | 11 + .../Generators/NativePreviewGenerator.cs | 362 + .../Generators/NativePreviewGenerator.cs.meta | 11 + .../Generators/PreviewGeneratorBase.cs | 45 + .../Generators/PreviewGeneratorBase.cs.meta | 11 + .../Editor/Previews/Scripts/Services.meta | 8 + .../Previews/Scripts/Services/Caching.meta | 8 + .../Services/Caching/CachingService.cs | 87 + .../Services/Caching/CachingService.cs.meta | 11 + .../Services/Caching/ICachingService.cs | 11 + .../Services/Caching/ICachingService.cs.meta | 11 + .../Scripts/Services/IPreviewService.cs | 4 + .../Scripts/Services/IPreviewService.cs.meta | 11 + .../Services/PreviewServiceProvider.cs | 17 + .../Services/PreviewServiceProvider.cs.meta | 11 + .../Editor/Previews/Scripts/UI.meta | 8 + .../Editor/Previews/Scripts/UI/Data.meta | 8 + .../Previews/Scripts/UI/Data/AssetPreview.cs | 56 + .../Scripts/UI/Data/AssetPreview.cs.meta | 11 + .../Scripts/UI/Data/AssetPreviewCollection.cs | 46 + .../UI/Data/AssetPreviewCollection.cs.meta | 11 + .../Previews/Scripts/UI/Data/IAssetPreview.cs | 13 + .../Scripts/UI/Data/IAssetPreview.cs.meta | 11 + .../UI/Data/IAssetPreviewCollection.cs | 15 + .../UI/Data/IAssetPreviewCollection.cs.meta | 11 + .../UI/Data/IPreviewGeneratorSettings.cs | 27 + .../UI/Data/IPreviewGeneratorSettings.cs.meta | 11 + .../UI/Data/PreviewGeneratorSettings.cs | 212 + .../UI/Data/PreviewGeneratorSettings.cs.meta | 11 + .../Editor/Previews/Scripts/UI/Elements.meta | 8 + .../UI/Elements/AssetPreviewElement.cs | 83 + .../UI/Elements/AssetPreviewElement.cs.meta | 11 + .../Scripts/UI/Elements/GridListElement.cs | 140 + .../UI/Elements/GridListElement.cs.meta | 11 + .../UI/Elements/PreviewCollectionElement.cs | 116 + .../Elements/PreviewCollectionElement.cs.meta | 11 + .../Elements/PreviewGenerateButtonElement.cs | 50 + .../PreviewGenerateButtonElement.cs.meta | 11 + .../Elements/PreviewGeneratorPathsElement.cs | 122 + .../PreviewGeneratorPathsElement.cs.meta | 11 + .../PreviewGeneratorSettingsElement.cs | 99 + .../PreviewGeneratorSettingsElement.cs.meta | 11 + .../PreviewWindowDescriptionElement.cs | 87 + .../PreviewWindowDescriptionElement.cs.meta | 11 + .../Scripts/UI/PreviewGeneratorWindow.cs | 51 + .../Scripts/UI/PreviewGeneratorWindow.cs.meta | 11 + .../Editor/Previews/Scripts/UI/Views.meta | 8 + .../Scripts/UI/Views/PreviewListView.cs | 142 + .../Scripts/UI/Views/PreviewListView.cs.meta | 11 + .../Editor/Previews/Scripts/Utility.meta | 8 + .../Scripts/Utility/GraphicsUtility.cs | 96 + .../Scripts/Utility/GraphicsUtility.cs.meta | 11 + .../Scripts/Utility/PreviewConvertUtility.cs | 72 + .../Utility/PreviewConvertUtility.cs.meta | 11 + .../Scripts/Utility/PreviewSceneUtility.cs | 196 + .../Utility/PreviewSceneUtility.cs.meta | 11 + .../Scripts/Utility/RenderPipeline.cs | 10 + .../Scripts/Utility/RenderPipeline.cs.meta | 11 + .../Scripts/Utility/RenderPipelineUtility.cs | 32 + .../Utility/RenderPipelineUtility.cs.meta | 11 + .../Editor/Previews/Styles.meta | 8 + .../Editor/Previews/Styles/Style.uss | 210 + .../Editor/Previews/Styles/Style.uss.meta | 11 + .../Editor/Previews/Styles/ThemeDark.uss | 67 + .../Editor/Previews/Styles/ThemeDark.uss.meta | 11 + .../Editor/Previews/Styles/ThemeLight.uss | 67 + .../Previews/Styles/ThemeLight.uss.meta | 11 + .../Unity.AssetStoreTools.Editor.asmdef | 36 + .../Unity.AssetStoreTools.Editor.asmdef.meta | 7 + .../Editor/Uploader.meta | 8 + .../Editor/Uploader/Icons.meta | 8 + .../Editor/Uploader/Icons/account-dark.png | Bin 0 -> 337 bytes .../Uploader/Icons/account-dark.png.meta | 123 + .../Editor/Uploader/Icons/account-light.png | Bin 0 -> 387 bytes .../Uploader/Icons/account-light.png.meta | 123 + .../Editor/Uploader/Icons/open-in-browser.png | Bin 0 -> 878 bytes .../Uploader/Icons/open-in-browser.png.meta | 147 + .../Uploader/Icons/publisher-portal-dark.png | Bin 0 -> 33588 bytes .../Icons/publisher-portal-dark.png.meta | 128 + .../Uploader/Icons/publisher-portal-light.png | Bin 0 -> 33281 bytes .../Icons/publisher-portal-light.png.meta | 135 + .../Editor/Uploader/Scripts.meta | 8 + .../Editor/Uploader/Scripts/Data.meta | 8 + .../Uploader/Scripts/Data/Abstractions.meta | 8 + .../Scripts/Data/Abstractions/IPackage.cs | 34 + .../Data/Abstractions/IPackage.cs.meta | 11 + .../Data/Abstractions/IPackageContent.cs | 14 + .../Data/Abstractions/IPackageContent.cs.meta | 11 + .../Data/Abstractions/IPackageGroup.cs | 17 + .../Data/Abstractions/IPackageGroup.cs.meta | 11 + .../Scripts/Data/Abstractions/IWorkflow.cs | 36 + .../Data/Abstractions/IWorkflow.cs.meta | 11 + .../Data/Abstractions/IWorkflowServices.cs | 18 + .../Abstractions/IWorkflowServices.cs.meta | 11 + .../Scripts/Data/Abstractions/WorkflowBase.cs | 253 + .../Data/Abstractions/WorkflowBase.cs.meta | 11 + .../Uploader/Scripts/Data/AssetsWorkflow.cs | 329 + .../Scripts/Data/AssetsWorkflow.cs.meta | 11 + .../Scripts/Data/HybridPackageWorkflow.cs | 251 + .../Data/HybridPackageWorkflow.cs.meta | 11 + .../Editor/Uploader/Scripts/Data/Package.cs | 91 + .../Uploader/Scripts/Data/Package.cs.meta | 11 + .../Uploader/Scripts/Data/PackageContent.cs | 68 + .../Scripts/Data/PackageContent.cs.meta | 11 + .../Uploader/Scripts/Data/PackageGroup.cs | 64 + .../Scripts/Data/PackageGroup.cs.meta | 11 + .../Uploader/Scripts/Data/PackageSorting.cs | 9 + .../Scripts/Data/PackageSorting.cs.meta | 11 + .../Uploader/Scripts/Data/Serialization.meta | 8 + .../Scripts/Data/Serialization/AssetPath.cs | 59 + .../Data/Serialization/AssetPath.cs.meta | 11 + .../Serialization/AssetsWorkflowStateData.cs | 77 + .../AssetsWorkflowStateData.cs.meta | 11 + .../HybridPackageWorkflowState.cs | 41 + .../HybridPackageWorkflowState.cs.meta | 11 + .../UnityPackageWorkflowStateData.cs | 25 + .../UnityPackageWorkflowStateData.cs.meta | 11 + .../Data/Serialization/WorkflowStateData.cs | 68 + .../Serialization/WorkflowStateData.cs.meta | 11 + .../Scripts/Data/UnityPackageWorkflow.cs | 135 + .../Scripts/Data/UnityPackageWorkflow.cs.meta | 11 + .../Uploader/Scripts/Data/WorkflowServices.cs | 53 + .../Scripts/Data/WorkflowServices.cs.meta | 11 + .../Editor/Uploader/Scripts/Services.meta | 8 + .../Uploader/Scripts/Services/Analytics.meta | 8 + .../Services/Analytics/AnalyticsService.cs | 45 + .../Analytics/AnalyticsService.cs.meta | 11 + .../Scripts/Services/Analytics/Data.meta | 8 + .../Analytics/Data/AuthenticationAnalytic.cs | 46 + .../Data/AuthenticationAnalytic.cs.meta | 11 + .../Services/Analytics/Data/BaseAnalytic.cs | 35 + .../Analytics/Data/BaseAnalytic.cs.meta | 11 + .../Analytics/Data/IAssetStoreAnalytic.cs | 16 + .../Data/IAssetStoreAnalytic.cs.meta | 11 + .../Analytics/Data/IAssetStoreAnalyticData.cs | 8 + .../Data/IAssetStoreAnalyticData.cs.meta | 11 + .../Analytics/Data/PackageUploadAnalytic.cs | 72 + .../Data/PackageUploadAnalytic.cs.meta | 11 + .../Data/ValidationResultsSerializer.cs | 91 + .../Data/ValidationResultsSerializer.cs.meta | 11 + .../Services/Analytics/IAnalyticsService.cs | 10 + .../Analytics/IAnalyticsService.cs.meta | 11 + .../Editor/Uploader/Scripts/Services/Api.meta | 8 + .../Services/Api/AuthenticationService.cs | 100 + .../Api/AuthenticationService.cs.meta | 11 + .../Services/Api/IAuthenticationService.cs | 16 + .../Api/IAuthenticationService.cs.meta | 11 + .../Api/IPackageDownloadingService.cs | 16 + .../Api/IPackageDownloadingService.cs.meta | 11 + .../Services/Api/IPackageUploadingService.cs | 16 + .../Api/IPackageUploadingService.cs.meta | 11 + .../Services/Api/PackageDownloadingService.cs | 109 + .../Api/PackageDownloadingService.cs.meta | 11 + .../Services/Api/PackageUploadingService.cs | 103 + .../Api/PackageUploadingService.cs.meta | 11 + .../Uploader/Scripts/Services/Caching.meta | 8 + .../Services/Caching/CachingService.cs | 157 + .../Services/Caching/CachingService.cs.meta | 11 + .../Services/Caching/ICachingService.cs | 25 + .../Services/Caching/ICachingService.cs.meta | 11 + .../Scripts/Services/IUploaderService.cs | 4 + .../Scripts/Services/IUploaderService.cs.meta | 11 + .../Scripts/Services/PackageFactory.meta | 8 + .../PackageFactory/IPackageFactoryService.cs | 18 + .../IPackageFactoryService.cs.meta | 11 + .../PackageFactory/PackageFactoryService.cs | 95 + .../PackageFactoryService.cs.meta | 11 + .../Services/UploaderServiceProvider.cs | 26 + .../Services/UploaderServiceProvider.cs.meta | 11 + .../Editor/Uploader/Scripts/UI.meta | 8 + .../Editor/Uploader/Scripts/UI/Elements.meta | 8 + .../Scripts/UI/Elements/Abstractions.meta | 8 + .../Abstractions/ValidationElementBase.cs | 170 + .../ValidationElementBase.cs.meta | 11 + .../Abstractions/WorkflowElementBase.cs | 151 + .../Abstractions/WorkflowElementBase.cs.meta | 11 + .../Scripts/UI/Elements/AccountToolbar.cs | 102 + .../UI/Elements/AccountToolbar.cs.meta | 11 + .../UI/Elements/AssetsWorkflowElement.cs | 215 + .../UI/Elements/AssetsWorkflowElement.cs.meta | 11 + .../CurrentProjectValidationElement.cs | 31 + .../CurrentProjectValidationElement.cs.meta | 11 + .../ExternalProjectValidationElement.cs | 92 + .../ExternalProjectValidationElement.cs.meta | 11 + .../Elements/HybridPackageWorkflowElement.cs | 116 + .../HybridPackageWorkflowElement.cs.meta | 11 + .../Scripts/UI/Elements/LoadingSpinner.cs | 52 + .../UI/Elements/LoadingSpinner.cs.meta | 11 + .../Elements/MultiToggleSelectionElement.cs | 187 + .../MultiToggleSelectionElement.cs.meta | 11 + .../UI/Elements/PackageContentElement.cs | 137 + .../UI/Elements/PackageContentElement.cs.meta | 11 + .../Scripts/UI/Elements/PackageElement.cs | 215 + .../UI/Elements/PackageElement.cs.meta | 11 + .../UI/Elements/PackageGroupElement.cs | 149 + .../UI/Elements/PackageGroupElement.cs.meta | 11 + .../Scripts/UI/Elements/PackageListToolbar.cs | 58 + .../UI/Elements/PackageListToolbar.cs.meta | 11 + .../UI/Elements/PackageUploadElement.cs | 321 + .../UI/Elements/PackageUploadElement.cs.meta | 11 + .../UI/Elements/PathSelectionElement.cs | 63 + .../UI/Elements/PathSelectionElement.cs.meta | 11 + .../UI/Elements/PreviewGenerationElement.cs | 109 + .../Elements/PreviewGenerationElement.cs.meta | 11 + .../Elements/UnityPackageWorkflowElement.cs | 58 + .../UnityPackageWorkflowElement.cs.meta | 11 + .../Editor/Uploader/Scripts/UI/Views.meta | 8 + .../Uploader/Scripts/UI/Views/LoginView.cs | 284 + .../Scripts/UI/Views/LoginView.cs.meta | 11 + .../Scripts/UI/Views/PackageListView.cs | 249 + .../Scripts/UI/Views/PackageListView.cs.meta | 11 + .../Editor/Uploader/Styles.meta | 8 + .../Editor/Uploader/Styles/LoginView.meta | 8 + .../Uploader/Styles/LoginView/Style.uss | 134 + .../Uploader/Styles/LoginView/Style.uss.meta | 11 + .../Uploader/Styles/LoginView/ThemeDark.uss | 44 + .../Styles/LoginView/ThemeDark.uss.meta | 11 + .../Uploader/Styles/LoginView/ThemeLight.uss | 44 + .../Styles/LoginView/ThemeLight.uss.meta | 11 + .../Uploader/Styles/PackageListView.meta | 8 + .../Uploader/Styles/PackageListView/Style.uss | 485 + .../Styles/PackageListView/Style.uss.meta | 11 + .../Styles/PackageListView/ThemeDark.uss | 238 + .../Styles/PackageListView/ThemeDark.uss.meta | 11 + .../Styles/PackageListView/ThemeLight.uss | 238 + .../PackageListView/ThemeLight.uss.meta | 11 + .../Editor/Uploader/Styles/Style.uss | 74 + .../Editor/Uploader/Styles/Style.uss.meta | 11 + .../Editor/Uploader/Styles/ThemeDark.uss | 9 + .../Editor/Uploader/Styles/ThemeDark.uss.meta | 11 + .../Editor/Uploader/Styles/ThemeLight.uss | 9 + .../Uploader/Styles/ThemeLight.uss.meta | 11 + .../Editor/Uploader/UploaderWindow.cs | 232 + .../Editor/Uploader/UploaderWindow.cs.meta | 11 + .../Editor/Utility.meta | 8 + .../Editor/Utility/ASDebug.cs | 65 + .../Editor/Utility/ASDebug.cs.meta | 11 + .../Editor/Utility/ASToolsPreferences.cs | 134 + .../Editor/Utility/ASToolsPreferences.cs.meta | 11 + .../Editor/Utility/ASToolsUpdater.cs | 250 + .../Editor/Utility/ASToolsUpdater.cs.meta | 11 + .../Editor/Utility/CacheUtil.cs | 266 + .../Editor/Utility/CacheUtil.cs.meta | 11 + .../Editor/Utility/FileUtility.cs | 207 + .../Editor/Utility/FileUtility.cs.meta | 11 + .../Editor/Utility/LegacyToolsRemover.cs | 86 + .../Editor/Utility/LegacyToolsRemover.cs.meta | 11 + .../Editor/Utility/PackageUtility.cs | 170 + .../Editor/Utility/PackageUtility.cs.meta | 11 + .../Editor/Utility/ServiceProvider.cs | 113 + .../Editor/Utility/ServiceProvider.cs.meta | 11 + .../Editor/Utility/StyleSelector.cs | 55 + .../Editor/Utility/StyleSelector.cs.meta | 11 + .../Editor/Utility/Styles.meta | 8 + .../Editor/Utility/Styles/Updater.meta | 8 + .../Editor/Utility/Styles/Updater/Style.uss | 76 + .../Utility/Styles/Updater/Style.uss.meta | 11 + .../Utility/Styles/Updater/ThemeDark.uss | 3 + .../Utility/Styles/Updater/ThemeDark.uss.meta | 11 + .../Utility/Styles/Updater/ThemeLight.uss | 3 + .../Styles/Updater/ThemeLight.uss.meta | 11 + .../Editor/Utility/SymlinkUtil.cs | 67 + .../Editor/Utility/SymlinkUtil.cs.meta | 3 + .../Editor/Validator.meta | 8 + .../Editor/Validator/Icons.meta | 8 + .../Editor/Validator/Icons/error.png | Bin 0 -> 1057 bytes .../Editor/Validator/Icons/error.png.meta | 128 + .../Editor/Validator/Icons/error_d.png | Bin 0 -> 1024 bytes .../Editor/Validator/Icons/error_d.png.meta | 128 + .../Editor/Validator/Icons/success.png | Bin 0 -> 1583 bytes .../Editor/Validator/Icons/success.png.meta | 128 + .../Editor/Validator/Icons/success_d.png | Bin 0 -> 1617 bytes .../Editor/Validator/Icons/success_d.png.meta | 128 + .../Editor/Validator/Icons/undefined.png | Bin 0 -> 1561 bytes .../Editor/Validator/Icons/undefined.png.meta | 128 + .../Editor/Validator/Icons/undefined_d.png | Bin 0 -> 1600 bytes .../Validator/Icons/undefined_d.png.meta | 128 + .../Editor/Validator/Icons/warning.png | Bin 0 -> 1141 bytes .../Editor/Validator/Icons/warning.png.meta | 128 + .../Editor/Validator/Icons/warning_d.png | Bin 0 -> 1185 bytes .../Editor/Validator/Icons/warning_d.png.meta | 128 + .../Editor/Validator/Scripts.meta | 8 + .../Editor/Validator/Scripts/Categories.meta | 3 + .../Scripts/Categories/CategoryEvaluator.cs | 52 + .../Categories/CategoryEvaluator.cs.meta | 3 + .../Scripts/Categories/ValidatorCategory.cs | 38 + .../Categories/ValidatorCategory.cs.meta | 3 + .../Scripts/CurrentProjectValidator.cs | 71 + .../Scripts/CurrentProjectValidator.cs.meta | 11 + .../Editor/Validator/Scripts/Data.meta | 8 + .../Data/CurrentProjectValidationSettings.cs | 33 + .../CurrentProjectValidationSettings.cs.meta | 11 + .../Data/ExternalProjectValidationSettings.cs | 7 + .../ExternalProjectValidationSettings.cs.meta | 11 + .../Scripts/Data/MessageActions.meta | 8 + .../MessageActions/HighlightObjectAction.cs | 31 + .../HighlightObjectAction.cs.meta | 11 + .../Data/MessageActions/IMessageAction.cs | 16 + .../MessageActions/IMessageAction.cs.meta | 11 + .../Data/MessageActions/OpenAssetAction.cs | 38 + .../MessageActions/OpenAssetAction.cs.meta | 11 + .../Validator/Scripts/Data/TestResult.cs | 52 + .../Validator/Scripts/Data/TestResult.cs.meta | 11 + .../Scripts/Data/TestResultMessage.cs | 53 + .../Scripts/Data/TestResultMessage.cs.meta | 11 + .../Scripts/Data/TestResultObject.cs | 35 + .../Scripts/Data/TestResultObject.cs.meta | 11 + .../Scripts/Data/TestResultStatus.cs | 11 + .../Scripts/Data/TestResultStatus.cs.meta | 11 + .../Scripts/Data/ValidationResult.cs | 24 + .../Scripts/Data/ValidationResult.cs.meta | 11 + .../Scripts/Data/ValidationSettings.cs | 7 + .../Scripts/Data/ValidationSettings.cs.meta | 11 + .../Scripts/Data/ValidationStatus.cs | 10 + .../Scripts/Data/ValidationStatus.cs.meta | 11 + .../Validator/Scripts/Data/ValidationType.cs | 8 + .../Scripts/Data/ValidationType.cs.meta | 11 + .../Scripts/ExternalProjectValidator.cs | 259 + .../Scripts/ExternalProjectValidator.cs.meta | 11 + .../Editor/Validator/Scripts/IValidator.cs | 11 + .../Validator/Scripts/IValidator.cs.meta | 11 + .../Editor/Validator/Scripts/Services.meta | 8 + .../Scripts/Services/CachingService.meta | 8 + .../Services/CachingService/CachingService.cs | 55 + .../CachingService/CachingService.cs.meta | 11 + .../CachingService/ICachingService.cs | 11 + .../CachingService/ICachingService.cs.meta | 11 + .../PreviewDatabaseContractResolver.cs | 26 + .../PreviewDatabaseContractResolver.cs.meta | 11 + .../Scripts/Services/IValidatorService.cs | 4 + .../Services/IValidatorService.cs.meta | 11 + .../Scripts/Services/Validation.meta | 8 + .../Services/Validation/Abstractions.meta | 8 + .../Abstractions/IAssetUtilityService.cs | 18 + .../Abstractions/IAssetUtilityService.cs.meta | 11 + .../IFileSignatureUtilityService.cs | 7 + .../IFileSignatureUtilityService.cs.meta | 11 + .../Abstractions/IMeshUtilityService.cs | 10 + .../Abstractions/IMeshUtilityService.cs.meta | 11 + .../Abstractions/IModelUtilityService.cs | 10 + .../Abstractions/IModelUtilityService.cs.meta | 11 + .../Abstractions/ISceneUtilityService.cs | 13 + .../Abstractions/ISceneUtilityService.cs.meta | 11 + .../Abstractions/IScriptUtilityService.cs | 14 + .../IScriptUtilityService.cs.meta | 11 + .../Validation/AssetUtilityService.cs | 216 + .../Validation/AssetUtilityService.cs.meta | 11 + .../Scripts/Services/Validation/Data.meta | 8 + .../Services/Validation/Data/ArchiveType.cs | 19 + .../Validation/Data/ArchiveType.cs.meta | 11 + .../Validation/Data/AssetEnumerator.cs | 84 + .../Validation/Data/AssetEnumerator.cs.meta | 11 + .../Services/Validation/Data/AssetType.cs | 25 + .../Validation/Data/AssetType.cs.meta | 11 + .../Services/Validation/Data/LogEntry.cs | 10 + .../Services/Validation/Data/LogEntry.cs.meta | 11 + .../Validation/FileSignatureUtilityService.cs | 78 + .../FileSignatureUtilityService.cs.meta | 11 + .../Services/Validation/MeshUtilityService.cs | 26 + .../Validation/MeshUtilityService.cs.meta | 11 + .../Validation/ModelUtilityService.cs | 147 + .../Validation/ModelUtilityService.cs.meta | 11 + .../Validation/SceneUtilityService.cs | 26 + .../Validation/SceneUtilityService.cs.meta | 11 + .../Validation/ScriptUtilityService.cs | 658 + .../Validation/ScriptUtilityService.cs.meta | 11 + .../Services/ValidatorServiceProvider.cs | 24 + .../Services/ValidatorServiceProvider.cs.meta | 11 + .../Validator/Scripts/Test Definitions.meta | 8 + .../Scripts/Test Definitions/AutomatedTest.cs | 121 + .../Test Definitions/AutomatedTest.cs.meta | 11 + .../Test Definitions/GenericTestConfig.cs | 7 + .../GenericTestConfig.cs.meta | 11 + .../Scripts/Test Definitions/ITestConfig.cs | 4 + .../Test Definitions/ITestConfig.cs.meta | 11 + .../Scripts/Test Definitions/ITestScript.cs | 9 + .../Test Definitions/ITestScript.cs.meta | 11 + .../Test Definitions/Scriptable Objects.meta | 8 + .../AutomatedTestScriptableObject.cs | 11 + .../AutomatedTestScriptableObject.cs.meta | 11 + .../Scriptable Objects/Editor.meta | 8 + ...ValidationTestScriptableObjectInspector.cs | 196 + ...ationTestScriptableObjectInspector.cs.meta | 11 + .../ValidationTestScriptableObject.cs | 35 + .../ValidationTestScriptableObject.cs.meta | 11 + .../Test Definitions/ValidationTest.cs | 38 + .../Test Definitions/ValidationTest.cs.meta | 11 + .../Validator/Scripts/Test Methods.meta | 8 + .../Scripts/Test Methods/Generic.meta | 8 + .../Generic/CheckAnimationClips.cs | 64 + .../Generic/CheckAnimationClips.cs.meta | 11 + .../Generic/CheckAudioClipping.cs | 128 + .../Generic/CheckAudioClipping.cs.meta | 11 + .../Test Methods/Generic/CheckColliders.cs | 55 + .../Generic/CheckColliders.cs.meta | 11 + .../Generic/CheckCompressedFiles.cs | 121 + .../Generic/CheckCompressedFiles.cs.meta | 11 + .../Test Methods/Generic/CheckEmptyPrefabs.cs | 46 + .../Generic/CheckEmptyPrefabs.cs.meta | 11 + .../Generic/CheckFileMenuNames.cs | 153 + .../Generic/CheckFileMenuNames.cs.meta | 11 + .../Scripts/Test Methods/Generic/CheckLODs.cs | 79 + .../Test Methods/Generic/CheckLODs.cs.meta | 11 + .../Test Methods/Generic/CheckLineEndings.cs | 77 + .../Generic/CheckLineEndings.cs.meta | 11 + .../Test Methods/Generic/CheckMeshPrefabs.cs | 106 + .../Generic/CheckMeshPrefabs.cs.meta | 11 + .../Generic/CheckMissingComponentsinAssets.cs | 66 + .../CheckMissingComponentsinAssets.cs.meta | 11 + .../Generic/CheckMissingComponentsinScenes.cs | 93 + .../CheckMissingComponentsinScenes.cs.meta | 11 + .../Generic/CheckModelImportLogs.cs | 64 + .../Generic/CheckModelImportLogs.cs.meta | 11 + .../Generic/CheckModelOrientation.cs | 71 + .../Generic/CheckModelOrientation.cs.meta | 11 + .../Test Methods/Generic/CheckModelTypes.cs | 58 + .../Generic/CheckModelTypes.cs.meta | 11 + .../Generic/CheckNormalMapTextures.cs | 94 + .../Generic/CheckNormalMapTextures.cs.meta | 11 + .../Generic/CheckPackageNaming.cs | 295 + .../Generic/CheckPackageNaming.cs.meta | 11 + .../Generic/CheckParticleSystems.cs | 76 + .../Generic/CheckParticleSystems.cs.meta | 11 + .../Test Methods/Generic/CheckPathLengths.cs | 98 + .../Generic/CheckPathLengths.cs.meta | 11 + .../Generic/CheckPrefabTransforms.cs | 71 + .../Generic/CheckPrefabTransforms.cs.meta | 11 + .../Generic/CheckScriptCompilation.cs | 30 + .../Generic/CheckScriptCompilation.cs.meta | 11 + .../Generic/CheckShaderCompilation.cs | 63 + .../Generic/CheckShaderCompilation.cs.meta | 11 + .../Generic/CheckTextureDimensions.cs | 57 + .../Generic/CheckTextureDimensions.cs.meta | 11 + .../Generic/CheckTypeNamespaces.cs | 233 + .../Generic/CheckTypeNamespaces.cs.meta | 11 + .../Generic/RemoveExecutableFiles.cs | 38 + .../Generic/RemoveExecutableFiles.cs.meta | 11 + .../Test Methods/Generic/RemoveJPGFiles.cs | 38 + .../Generic/RemoveJPGFiles.cs.meta | 11 + .../Generic/RemoveJavaScriptFiles.cs | 38 + .../Generic/RemoveJavaScriptFiles.cs.meta | 11 + .../Generic/RemoveLossyAudioFiles.cs | 83 + .../Generic/RemoveLossyAudioFiles.cs.meta | 11 + .../Test Methods/Generic/RemoveMixamoFiles.cs | 38 + .../Generic/RemoveMixamoFiles.cs.meta | 11 + .../Generic/RemoveSpeedTreeFiles.cs | 38 + .../Generic/RemoveSpeedTreeFiles.cs.meta | 11 + .../Test Methods/Generic/RemoveVideoFiles.cs | 38 + .../Generic/RemoveVideoFiles.cs.meta | 11 + .../Scripts/Test Methods/UnityPackage.meta | 8 + .../UnityPackage/CheckDemoScenes.cs | 172 + .../UnityPackage/CheckDemoScenes.cs.meta | 11 + .../UnityPackage/CheckDocumentation.cs | 73 + .../UnityPackage/CheckDocumentation.cs.meta | 11 + .../UnityPackage/CheckPackageSize.cs | 69 + .../UnityPackage/CheckPackageSize.cs.meta | 11 + .../CheckProjectTemplateAssets.cs | 217 + .../CheckProjectTemplateAssets.cs.meta | 11 + .../Editor/Validator/Scripts/UI.meta | 8 + .../Editor/Validator/Scripts/UI/Data.meta | 8 + .../Scripts/UI/Data/Abstractions.meta | 8 + .../UI/Data/Abstractions/IValidatorResults.cs | 15 + .../Abstractions/IValidatorResults.cs.meta | 11 + .../Data/Abstractions/IValidatorSettings.cs | 31 + .../Abstractions/IValidatorSettings.cs.meta | 11 + .../UI/Data/Abstractions/IValidatorTest.cs | 15 + .../Data/Abstractions/IValidatorTest.cs.meta | 11 + .../Data/Abstractions/IValidatorTestGroup.cs | 12 + .../Abstractions/IValidatorTestGroup.cs.meta | 11 + .../Scripts/UI/Data/Serialization.meta | 8 + .../Data/Serialization/ValidatorStateData.cs | 28 + .../Serialization/ValidatorStateData.cs.meta | 11 + .../ValidatorStateDataContractResolver.cs | 26 + ...ValidatorStateDataContractResolver.cs.meta | 11 + .../Serialization/ValidatorStateResults.cs | 83 + .../ValidatorStateResults.cs.meta | 11 + .../Serialization/ValidatorStateSettings.cs | 63 + .../ValidatorStateSettings.cs.meta | 11 + .../Scripts/UI/Data/ValidatorResults.cs | 137 + .../Scripts/UI/Data/ValidatorResults.cs.meta | 11 + .../Scripts/UI/Data/ValidatorSettings.cs | 236 + .../Scripts/UI/Data/ValidatorSettings.cs.meta | 11 + .../Scripts/UI/Data/ValidatorTest.cs | 28 + .../Scripts/UI/Data/ValidatorTest.cs.meta | 11 + .../Scripts/UI/Data/ValidatorTestGroup.cs | 18 + .../UI/Data/ValidatorTestGroup.cs.meta | 11 + .../Editor/Validator/Scripts/UI/Elements.meta | 8 + .../UI/Elements/ValidatorButtonElement.cs | 50 + .../Elements/ValidatorButtonElement.cs.meta | 11 + .../Elements/ValidatorDescriptionElement.cs | 114 + .../ValidatorDescriptionElement.cs.meta | 3 + .../UI/Elements/ValidatorPathsElement.cs | 128 + .../UI/Elements/ValidatorPathsElement.cs.meta | 11 + .../UI/Elements/ValidatorResultsElement.cs | 47 + .../Elements/ValidatorResultsElement.cs.meta | 11 + .../UI/Elements/ValidatorSettingsElement.cs | 96 + .../Elements/ValidatorSettingsElement.cs.meta | 11 + .../UI/Elements/ValidatorTestElement.cs | 239 + .../UI/Elements/ValidatorTestElement.cs.meta | 11 + .../UI/Elements/ValidatorTestGroupElement.cs | 102 + .../ValidatorTestGroupElement.cs.meta | 11 + .../Validator/Scripts/UI/ValidatorWindow.cs | 56 + .../Scripts/UI/ValidatorWindow.cs.meta | 11 + .../Editor/Validator/Scripts/UI/Views.meta | 8 + .../Scripts/UI/Views/ValidatorTestsView.cs | 103 + .../UI/Views/ValidatorTestsView.cs.meta | 11 + .../Editor/Validator/Scripts/Utility.meta | 8 + .../Scripts/Utility/ValidatorUtility.cs | 142 + .../Scripts/Utility/ValidatorUtility.cs.meta | 11 + .../Editor/Validator/Scripts/ValidatorBase.cs | 108 + .../Validator/Scripts/ValidatorBase.cs.meta | 11 + .../Editor/Validator/Styles.meta | 8 + .../Editor/Validator/Styles/Style.uss | 337 + .../Editor/Validator/Styles/Style.uss.meta | 11 + .../Editor/Validator/Styles/ThemeDark.uss | 166 + .../Validator/Styles/ThemeDark.uss.meta | 11 + .../Editor/Validator/Styles/ThemeLight.uss | 166 + .../Validator/Styles/ThemeLight.uss.meta | 11 + .../Editor/Validator/Tests.meta | 8 + .../Editor/Validator/Tests/Generic.meta | 8 + .../Generic/Check Animation Clips.asset.meta | 8 + .../Generic/Check Audio Clipping.asset.meta | 8 + .../Tests/Generic/Check Colliders.asset.meta | 8 + .../Generic/Check Compressed Files.asset.meta | 8 + .../Generic/Check Empty Prefabs.asset.meta | 8 + .../Generic/Check File Menu Names.asset.meta | 8 + .../Tests/Generic/Check LODs.asset.meta | 8 + .../Generic/Check Line Endings.asset.meta | 8 + .../Generic/Check Mesh Prefabs.asset.meta | 8 + ...ck Missing Components in Assets.asset.meta | 8 + ...ck Missing Components in Scenes.asset.meta | 8 + .../Check Model Import Logs.asset.meta | 8 + .../Check Model Orientation.asset.meta | 8 + .../Generic/Check Model Types.asset.meta | 8 + .../Check Normal Map Textures.asset.meta | 8 + .../Generic/Check Package Naming.asset.meta | 8 + .../Generic/Check Particle Systems.asset.meta | 8 + .../Generic/Check Path Lengths.asset.meta | 8 + .../Check Prefab Transforms.asset.meta | 8 + .../Check Script Compilation.asset.meta | 8 + .../Check Shader Compilation.asset.meta | 8 + .../Check Texture Dimensions.asset.meta | 8 + .../Generic/Check Type Namespaces.asset.meta | 8 + .../Remove Executable Files.asset.meta | 8 + .../Tests/Generic/Remove JPG Files.asset.meta | 8 + .../Remove JavaScript Files.asset.meta | 8 + .../Remove Lossy Audio Files.asset.meta | 8 + .../Generic/Remove Mixamo Files.asset.meta | 8 + .../Generic/Remove SpeedTree Files.asset.meta | 8 + .../Generic/Remove Video Files.asset.meta | 8 + .../Editor/Validator/Tests/UnityPackage.meta | 8 + .../UnityPackage/Check Demo Scenes.asset.meta | 8 + .../Check Documentation.asset.meta | 8 + .../Check Package Size.asset.meta | 8 + .../Check Project Template Assets.asset.meta | 8 + .../com.unity.asset-store-tools/LICENSE.md | 5 + .../LICENSE.md.meta | 7 + .../com.unity.asset-store-tools/package.json | 11 + .../package.json.meta | 7 + .../AssetStoreUploads/Packages/manifest.json | 45 + .../Packages/packages-lock.json | 417 + .../BurstAotSettings_StandaloneWindows.json | 17 + .../CommonBurstAotSettings.json | 6 + .../ProjectSettings/ProjectVersion.txt | 2 + .../SceneTemplateSettings.json | 167 + TestProjects/UnityMCPTests/.gitignore | 112 + .../UnityMCPTests/Assets/Packages.meta | 8 + TestProjects/UnityMCPTests/Assets/Scenes.meta | 8 + .../Assets/Scenes/SampleScene.unity | 267 + .../Assets/Scenes/SampleScene.unity.meta | 7 + .../Assets/Scenes/Test.unity.meta | 8 + .../Assets/Scenes/Test.unity/Test.unity | 186 + .../Assets/Scenes/Test.unity/Test.unity.meta | 7 + .../UnityMCPTests/Assets/Scripts.meta | 8 + .../UnityMCPTests/Assets/Scripts/Bouncer.cs | 19 + .../Assets/Scripts/Bouncer.cs.meta | 11 + .../UnityMCPTests/Assets/Scripts/Hello.cs | 10 + .../Assets/Scripts/Hello.cs.meta | 2 + .../Scripts/LongUnityScriptClaudeTest.cs | 2045 ++ .../Scripts/LongUnityScriptClaudeTest.cs.meta | 11 + .../Assets/Scripts/TestAsmdef.meta | 8 + .../Scripts/TestAsmdef/CustomComponent.cs | 18 + .../TestAsmdef/CustomComponent.cs.meta | 2 + .../Scripts/TestAsmdef/TestAsmdef.asmdef | 14 + .../Scripts/TestAsmdef/TestAsmdef.asmdef.meta | 7 + .../TestAsmdef/UnityEventTestComponent.cs | 14 + .../UnityEventTestComponent.cs.meta | 11 + TestProjects/UnityMCPTests/Assets/Temp.meta | 8 + TestProjects/UnityMCPTests/Assets/TestMat.mat | 80 + .../UnityMCPTests/Assets/TestMat.mat.meta | 8 + TestProjects/UnityMCPTests/Assets/Tests.meta | 8 + .../UnityMCPTests/Assets/Tests/EditMode.meta | 8 + .../Assets/Tests/EditMode/AssetGen.meta | 8 + .../AssetGen/AssetGenJobManagerTests.cs | 166 + .../AssetGen/AssetGenJobManagerTests.cs.meta | 11 + .../EditMode/AssetGen/AssetGenPrefsTests.cs | 83 + .../AssetGen/AssetGenPrefsTests.cs.meta | 11 + .../AssetGen/AssetGenProvidersTests.cs | 69 + .../AssetGen/AssetGenProvidersTests.cs.meta | 11 + .../AssetGen/BlenderDetectionTests.cs | 40 + .../AssetGen/BlenderDetectionTests.cs.meta | 11 + .../EditMode/AssetGen/EnvKeyOverrideTests.cs | 37 + .../AssetGen/EnvKeyOverrideTests.cs.meta | 11 + .../EditMode/AssetGen/FalAdapterTests.cs | 125 + .../EditMode/AssetGen/FalAdapterTests.cs.meta | 11 + .../EditMode/AssetGen/GenerateImageTests.cs | 177 + .../AssetGen/GenerateImageTests.cs.meta | 11 + .../AssetGen/GenerateModelHandlerTests.cs | 165 + .../GenerateModelHandlerTests.cs.meta | 11 + .../AssetGen/ImportModelFileHandlerTests.cs | 110 + .../ImportModelFileHandlerTests.cs.meta | 11 + .../AssetGen/ImportModelHandlerTests.cs | 119 + .../AssetGen/ImportModelHandlerTests.cs.meta | 11 + .../EditMode/AssetGen/MeshyAdapterTests.cs | 210 + .../AssetGen/MeshyAdapterTests.cs.meta | 11 + .../AssetGen/ModelImportPipelineTests.cs | 62 + .../AssetGen/ModelImportPipelineTests.cs.meta | 11 + .../AssetGen/OpenRouterAdapterTests.cs | 111 + .../AssetGen/OpenRouterAdapterTests.cs.meta | 11 + .../AssetGen/SafeZipExtractorTests.cs | 114 + .../AssetGen/SafeZipExtractorTests.cs.meta | 11 + .../EditMode/AssetGen/SecretRedactorTests.cs | 36 + .../AssetGen/SecretRedactorTests.cs.meta | 11 + .../EditMode/AssetGen/SecureKeyStoreTests.cs | 92 + .../AssetGen/SecureKeyStoreTests.cs.meta | 11 + .../AssetGen/SketchfabAdapterTests.cs | 87 + .../AssetGen/SketchfabAdapterTests.cs.meta | 11 + .../EditMode/AssetGen/TripoAdapterTests.cs | 136 + .../AssetGen/TripoAdapterTests.cs.meta | 11 + .../Assets/Tests/EditMode/Clients.meta | 8 + .../Clients/CheckStatusUrlPropertyTests.cs | 76 + .../CheckStatusUrlPropertyTests.cs.meta | 11 + .../EditMode/Clients/IsInstalledTests.cs | 45 + .../EditMode/Clients/IsInstalledTests.cs.meta | 11 + .../Clients/SupportedTransportsTests.cs | 36 + .../Clients/SupportedTransportsTests.cs.meta | 11 + .../Assets/Tests/EditMode/Helpers.meta | 8 + .../Helpers/AssetPathUtilityOfflineTests.cs | 38 + .../AssetPathUtilityOfflineTests.cs.meta | 11 + .../Helpers/ClientConfigFormatTests.cs | 156 + .../Helpers/ClientConfigFormatTests.cs.meta | 11 + .../Helpers/CodexConfigHelperTests.cs | 595 + .../Helpers/CodexConfigHelperTests.cs.meta | 11 + .../Helpers/Matrix4x4ConverterTests.cs | 134 + .../Helpers/Matrix4x4ConverterTests.cs.meta | 11 + .../Tests/EditMode/Helpers/PaginationTests.cs | 209 + .../EditMode/Helpers/PaginationTests.cs.meta | 11 + .../Tests/EditMode/Helpers/ToolParamsTests.cs | 469 + .../EditMode/Helpers/ToolParamsTests.cs.meta | 11 + .../EditMode/Helpers/WriteToConfigTests.cs | 475 + .../Helpers/WriteToConfigTests.cs.meta | 11 + .../EditMode/MCPForUnityTests.Editor.asmdef | 27 + .../MCPForUnityTests.Editor.asmdef.meta | 7 + .../EditMode/PathResolverClaudeCliTests.cs | 64 + .../PathResolverClaudeCliTests.cs.meta | 11 + .../Assets/Tests/EditMode/Resources.meta | 8 + .../EditMode/Resources/GetMenuItemsTests.cs | 69 + .../Resources/GetMenuItemsTests.cs.meta | 11 + .../Assets/Tests/EditMode/Services.meta | 8 + .../EditMode/Services/Characterization.meta | 8 + ...rManagementServiceCharacterizationTests.cs | 899 + ...gementServiceCharacterizationTests.cs.meta | 11 + .../Services_Characterization.cs | 503 + .../Services_Characterization.cs.meta | 11 + .../Services/ConfigureDetectedClientsTests.cs | 36 + .../ConfigureDetectedClientsTests.cs.meta | 11 + .../Services/EditorConfigurationCacheTests.cs | 264 + .../EditorConfigurationCacheTests.cs.meta | 11 + .../Services/HttpAutoStartHandlerTests.cs | 194 + .../HttpAutoStartHandlerTests.cs.meta | 11 + .../Services/HttpBridgeReloadHandlerTests.cs | 196 + .../HttpBridgeReloadHandlerTests.cs.meta | 11 + .../Services/McpEditorShutdownCleanupTests.cs | 49 + .../McpEditorShutdownCleanupTests.cs.meta | 11 + .../Services/PackageUpdateServiceTests.cs | 357 + .../PackageUpdateServiceTests.cs.meta | 11 + .../EditMode/Services/PortManagerTests.cs | 176 + .../Services/PortManagerTests.cs.meta | 11 + .../Tests/EditMode/Services/Server.meta | 8 + .../Services/Server/PidFileManagerTests.cs | 494 + .../Server/PidFileManagerTests.cs.meta | 11 + .../Services/Server/ProcessDetectorTests.cs | 364 + .../Server/ProcessDetectorTests.cs.meta | 11 + .../Services/Server/ProcessTerminatorTests.cs | 159 + .../Server/ProcessTerminatorTests.cs.meta | 11 + .../Server/ServerCommandBuilderTests.cs | 325 + .../Server/ServerCommandBuilderTests.cs.meta | 11 + .../Services/Server/TerminalLauncherTests.cs | 304 + .../Server/TerminalLauncherTests.cs.meta | 11 + .../Services/StartupConfigRewriteTests.cs | 38 + .../StartupConfigRewriteTests.cs.meta | 11 + .../Services/StdioBridgeReconnectTests.cs | 234 + .../StdioBridgeReconnectTests.cs.meta | 11 + .../TestJobManagerInitTimeoutTests.cs | 168 + .../TestJobManagerInitTimeoutTests.cs.meta | 11 + .../Services/ToolDiscoveryServiceTests.cs | 155 + .../ToolDiscoveryServiceTests.cs.meta | 11 + .../Services/TransportManagerTests.cs | 66 + .../Services/TransportManagerTests.cs.meta | 11 + .../Services/WebSocketTransportClientTests.cs | 186 + .../WebSocketTransportClientTests.cs.meta | 11 + .../Assets/Tests/EditMode/TestUtilities.cs | 182 + .../Tests/EditMode/TestUtilities.cs.meta | 11 + .../Assets/Tests/EditMode/Tools.meta | 8 + .../EditMode/Tools/AIPropertyMatchingTests.cs | 168 + .../Tools/AIPropertyMatchingTests.cs.meta | 11 + .../Tools/BatchExecuteKeyPreservationTests.cs | 166 + .../BatchExecuteKeyPreservationTests.cs.meta | 11 + .../EditMode/Tools/BuildTargetMappingTests.cs | 67 + .../Tools/BuildTargetMappingTests.cs.meta | 11 + .../EditMode/Tools/Characterization.meta | 8 + .../EditorTools_Characterization.cs | 594 + .../EditorTools_Characterization.cs.meta | 11 + .../EditMode/Tools/CommandRegistryTests.cs | 56 + .../Tools/CommandRegistryTests.cs.meta | 11 + .../Tools/ComponentOpsUnityEventTests.cs | 233 + .../Tools/ComponentOpsUnityEventTests.cs.meta | 11 + .../EditMode/Tools/ComponentResolverTests.cs | 145 + .../Tools/ComponentResolverTests.cs.meta | 11 + .../Tools/DomainReloadResilienceTests.cs | 289 + .../Tools/DomainReloadResilienceTests.cs.meta | 11 + .../Tests/EditMode/Tools/ExecuteCodeTests.cs | 382 + .../EditMode/Tools/ExecuteCodeTests.cs.meta | 11 + .../EditMode/Tools/ExecuteMenuItemTests.cs | 39 + .../Tools/ExecuteMenuItemTests.cs.meta | 11 + .../Assets/Tests/EditMode/Tools/Fixtures.meta | 8 + .../ManageScriptableObjectTestDefinition.cs | 27 + ...nageScriptableObjectTestDefinition.cs.meta | 11 + ...anageScriptableObjectTestDefinitionBase.cs | 14 + ...ScriptableObjectTestDefinitionBase.cs.meta | 11 + .../Tools/Fixtures/StressTestSOs.meta | 8 + .../Fixtures/StressTestSOs/ArrayStressSO.cs | 7 + .../StressTestSOs/ArrayStressSO.cs.meta | 11 + .../Fixtures/StressTestSOs/ComplexStressSO.cs | 52 + .../StressTestSOs/ComplexStressSO.cs.meta | 11 + .../Fixtures/StressTestSOs/DeepStressSO.cs | 30 + .../StressTestSOs/DeepStressSO.cs.meta | 11 + .../FusionUnsafeTypeSerializationTests.cs | 68 + ...FusionUnsafeTypeSerializationTests.cs.meta | 11 + .../Tools/GameObjectAPIStressTests.cs | 570 + .../Tools/GameObjectAPIStressTests.cs.meta | 11 + .../GameObjectComponentHelpersErrorTests.cs | 108 + ...meObjectComponentHelpersErrorTests.cs.meta | 11 + .../EditMode/Tools/MCPToolParameterTests.cs | 381 + .../Tools/MCPToolParameterTests.cs.meta | 11 + .../EditMode/Tools/ManageAnimationTests.cs | 1073 + .../Tools/ManageAnimationTests.cs.meta | 11 + .../EditMode/Tools/ManageEditorUndoTests.cs | 50 + .../Tools/ManageEditorUndoTests.cs.meta | 11 + .../Tools/ManageGameObjectCreateTests.cs | 491 + .../Tools/ManageGameObjectCreateTests.cs.meta | 11 + .../Tools/ManageGameObjectDeleteTests.cs | 380 + .../Tools/ManageGameObjectDeleteTests.cs.meta | 11 + .../Tools/ManageGameObjectModifyTests.cs | 456 + .../Tools/ManageGameObjectModifyTests.cs.meta | 11 + .../EditMode/Tools/ManageGameObjectTests.cs | 645 + .../Tools/ManageGameObjectTests.cs.meta | 11 + .../EditMode/Tools/ManageGraphicsTests.cs | 697 + .../Tools/ManageGraphicsTests.cs.meta | 11 + .../Tools/ManageMaterialPropertiesTests.cs | 162 + .../ManageMaterialPropertiesTests.cs.meta | 11 + .../Tools/ManageMaterialReproTests.cs | 72 + .../Tools/ManageMaterialReproTests.cs.meta | 11 + .../Tools/ManageMaterialStressTests.cs | 181 + .../Tools/ManageMaterialStressTests.cs.meta | 11 + .../EditMode/Tools/ManageMaterialTests.cs | 223 + .../Tools/ManageMaterialTests.cs.meta | 11 + .../EditMode/Tools/ManagePhysicsTests.cs | 974 + .../EditMode/Tools/ManagePhysicsTests.cs.meta | 11 + .../EditMode/Tools/ManagePrefabsCrudTests.cs | 1194 + .../Tools/ManagePrefabsCrudTests.cs.meta | 11 + .../EditMode/Tools/ManagePrefabsStageTests.cs | 251 + .../Tools/ManagePrefabsStageTests.cs.meta | 11 + .../EditMode/Tools/ManageProBuilderTests.cs | 861 + .../Tools/ManageProBuilderTests.cs.meta | 11 + .../Tools/ManageSceneHierarchyPagingTests.cs | 205 + .../ManageSceneHierarchyPagingTests.cs.meta | 11 + .../Tools/ManageSceneMultiSceneTests.cs | 97 + .../Tools/ManageSceneMultiSceneTests.cs.meta | 11 + .../Tools/ManageSceneTemplateTests.cs | 25 + .../Tools/ManageSceneTemplateTests.cs.meta | 11 + .../Tools/ManageSceneValidationTests.cs | 32 + .../Tools/ManageSceneValidationTests.cs.meta | 11 + .../Tools/ManageScriptDelimiterTests.cs | 230 + .../Tools/ManageScriptDelimiterTests.cs.meta | 11 + .../Tools/ManageScriptValidationTests.cs | 447 + .../Tools/ManageScriptValidationTests.cs.meta | 2 + .../ManageScriptableObjectStressTests.cs | 1389 ++ .../ManageScriptableObjectStressTests.cs.meta | 11 + .../Tools/ManageScriptableObjectTests.cs | 307 + .../Tools/ManageScriptableObjectTests.cs.meta | 11 + .../Tests/EditMode/Tools/ManageUITests.cs | 903 + .../EditMode/Tools/ManageUITests.cs.meta | 11 + .../Tools/MaterialDirectPropertiesTests.cs | 164 + .../MaterialDirectPropertiesTests.cs.meta | 11 + .../Tools/MaterialMeshInstantiationTests.cs | 202 + .../MaterialMeshInstantiationTests.cs.meta | 11 + .../Tools/MaterialParameterToolTests.cs | 167 + .../Tools/MaterialParameterToolTests.cs.meta | 11 + .../PropertyConversionErrorHandlingTests.cs | 328 + ...opertyConversionErrorHandlingTests.cs.meta | 11 + .../PropertyConversion_ArrayForFloat_Test.cs | 58 + ...pertyConversion_ArrayForFloat_Test.cs.meta | 11 + .../Tests/EditMode/Tools/ReadConsoleTests.cs | 116 + .../EditMode/Tools/ReadConsoleTests.cs.meta | 11 + .../Tests/EditMode/Tools/RunTestsTests.cs | 65 + .../EditMode/Tools/RunTestsTests.cs.meta | 11 + .../Tools/UIDocumentSerializationTests.cs | 247 + .../UIDocumentSerializationTests.cs.meta | 11 + .../Tests/EditMode/Tools/UnityReflectTests.cs | 218 + .../EditMode/Tools/UnityReflectTests.cs.meta | 2 + .../Assets/Tests/EditMode/Transport.meta | 8 + .../StdioTransportClientReadinessTests.cs | 44 + ...StdioTransportClientReadinessTests.cs.meta | 11 + .../Assets/Tests/EditMode/UvInstallerTests.cs | 47 + .../Tests/EditMode/UvInstallerTests.cs.meta | 11 + .../Assets/Tests/EditMode/Windows.meta | 8 + .../EditMode/Windows/Characterization.meta | 8 + .../Windows_Characterization.cs | 700 + .../Windows_Characterization.cs.meta | 11 + ...McpConnectionSectionHealthDebounceTests.cs | 49 + ...nnectionSectionHealthDebounceTests.cs.meta | 11 + ...cpConnectionSectionOrphanDetectionTests.cs | 72 + ...nectionSectionOrphanDetectionTests.cs.meta | 11 + .../UnityMCPTests/Assets/Tests/Editor.meta | 8 + .../UnityMCPTests/Assets/Tests/PlayMode.meta | 8 + .../PlayMode/MCPForUnityTests.PlayMode.asmdef | 21 + .../MCPForUnityTests.PlayMode.asmdef.meta | 7 + .../Tests/PlayMode/PlayModeBasicTests.cs | 86 + .../Tests/PlayMode/PlayModeBasicTests.cs.meta | 11 + .../UnityMCPTests/Assets/UI Toolkit.meta | 8 + .../Assets/UI Toolkit/UnityThemes.meta | 8 + .../UnityThemes/UnityDefaultRuntimeTheme.tss | 1 + .../UnityDefaultRuntimeTheme.tss.meta | 11 + .../UnityMCPTests/Packages/manifest.json | 48 + .../Settings.json | 7 + .../ProjectSettings/ProjectVersion.txt | 2 + .../SceneTemplateSettings.json | 121 + docker-compose.yml | 13 + docs/asset-gen-manual-verification.md | 82 + docs/development/README-DEV-zh.md | 139 + docs/i18n/README-zh.md | 214 + docs/images/advanced-setting.png | Bin 0 -> 88918 bytes docs/images/building_scene.gif | Bin 0 -> 2222269 bytes docs/images/connector.svg | 10 + docs/images/coplay-logo.png | Bin 0 -> 40503 bytes docs/images/logo-header-dark.png | Bin 0 -> 11701 bytes docs/images/logo-header-light.png | Bin 0 -> 11787 bytes docs/images/networking-architecture.png | Bin 0 -> 6951 bytes docs/images/readme_ui.png | Bin 0 -> 709455 bytes docs/images/unity-mcp-ui-v8.6.png | Bin 0 -> 148984 bytes docs/images/v5_01_uninstall.png | Bin 0 -> 542110 bytes docs/images/v5_02_install.png | Bin 0 -> 343474 bytes docs/images/v5_03_open_mcp_window.png | Bin 0 -> 247385 bytes docs/images/v5_04_rebuild_mcp_server.png | Bin 0 -> 417026 bytes docs/images/v5_05_rebuild_success.png | Bin 0 -> 195405 bytes .../images/v6_2_create_python_tools_asset.png | Bin 0 -> 337375 bytes docs/images/v6_2_python_tools_asset.png | Bin 0 -> 411157 bytes docs/images/v6_new_ui_asset_store_version.png | Bin 0 -> 655185 bytes docs/images/v6_new_ui_dark.png | Bin 0 -> 651387 bytes docs/images/v6_new_ui_light.png | Bin 0 -> 625018 bytes docs/wiki/V10.md | 285 + manifest.json | 205 + mcp_source.py | 176 + scripts/validate-nlt-coverage.sh | 12 + tools/UPDATE_DOCS_PROMPT.md | 82 + tools/check-unity-versions.ps1 | 239 + tools/check-unity-versions.sh | 285 + tools/docker_publish.sh | 54 + tools/generate_docs_reference.py | 628 + tools/generate_mcpb.py | 147 + tools/hooks/pre-commit | 42 + tools/hooks/pre-push | 50 + tools/install-hooks.sh | 84 + tools/local_harness.py | 1610 ++ tools/prepare_unity_asset_store_release.py | 182 + tools/pypi_publish.sh | 86 + tools/stress_editor_state.py | 186 + tools/stress_mcp.py | 350 + tools/sync_release_notes.py | 344 + tools/tests/__init__.py | 28 + .../test_build_release_characterization.py | 1796 ++ tools/tests/test_local_harness.py | 830 + tools/unity-versions.json | 28 + tools/update_fork.bat | 17 + tools/update_fork.sh | 8 + tools/update_versions.py | 287 + unity-mcp-skill/SKILL.md | 282 + .../references/probuilder-guide.md | 444 + .../references/resources-reference.md | 645 + unity-mcp-skill/references/tools-reference.md | 1584 ++ unity-mcp-skill/references/workflows.md | 2111 ++ website/.gitignore | 20 + website/README.md | 51 + .../docs/architecture/external-analytics.md | 57 + website/docs/architecture/manage-physics.md | 110 + website/docs/architecture/project-roadmap.md | 82 + website/docs/architecture/python-layers.md | 71 + website/docs/architecture/remote-auth.md | 363 + website/docs/architecture/roadmap.md | 307 + website/docs/architecture/telemetry.md | 172 + website/docs/architecture/transports.md | 98 + website/docs/architecture/unity-compat.md | 65 + website/docs/contributing/dev-setup.md | 349 + website/docs/contributing/docs.md | 151 + website/docs/contributing/releases.md | 111 + website/docs/contributing/testing.md | 116 + website/docs/getting-started/clients.md | 53 + website/docs/getting-started/first-prompt.md | 62 + website/docs/getting-started/index.md | 39 + website/docs/getting-started/install.md | 139 + website/docs/guides/claude-code-cli.md | 72 + website/docs/guides/cli-examples.md | 252 + website/docs/guides/cli.md | 560 + website/docs/guides/client-configurators.md | 300 + website/docs/guides/custom-tools.md | 310 + website/docs/guides/multi-instance.md | 82 + website/docs/guides/remote-server-auth.md | 261 + website/docs/guides/roslyn.md | 54 + website/docs/guides/tool-groups.md | 80 + website/docs/guides/troubleshooting.md | 183 + website/docs/guides/uv-setup.md | 110 + website/docs/migrations/v10.md | 265 + website/docs/migrations/v5.md | 53 + website/docs/migrations/v6.md | 303 + website/docs/migrations/v8.md | 261 + website/docs/reference/cli.md | 96 + website/docs/reference/manifest.md | 69 + website/docs/reference/resources/index.md | 260 + .../reference/tools/animation/_category_.json | 8 + .../docs/reference/tools/animation/index.md | 11 + .../tools/animation/manage_animation.md | 37 + .../reference/tools/asset_gen/_category_.json | 8 + .../tools/asset_gen/generate_image.md | 51 + .../tools/asset_gen/generate_model.md | 50 + .../reference/tools/asset_gen/import_model.md | 50 + .../tools/asset_gen/import_model_file.md | 42 + .../docs/reference/tools/asset_gen/index.md | 14 + .../docs/reference/tools/core/_category_.json | 8 + .../reference/tools/core/apply_text_edits.md | 47 + .../reference/tools/core/batch_execute.md | 100 + .../reference/tools/core/create_script.md | 35 + .../tools/core/debug_request_context.md | 30 + .../reference/tools/core/delete_script.md | 32 + .../tools/core/execute_custom_tool.md | 33 + .../reference/tools/core/execute_menu_item.md | 32 + .../reference/tools/core/find_gameobjects.md | 36 + .../docs/reference/tools/core/find_in_file.md | 36 + website/docs/reference/tools/core/get_sha.md | 32 + website/docs/reference/tools/core/index.md | 40 + .../docs/reference/tools/core/manage_asset.md | 44 + .../docs/reference/tools/core/manage_build.md | 47 + .../reference/tools/core/manage_camera.md | 80 + .../reference/tools/core/manage_components.md | 39 + .../reference/tools/core/manage_editor.md | 35 + .../reference/tools/core/manage_gameobject.md | 126 + .../reference/tools/core/manage_graphics.md | 101 + .../reference/tools/core/manage_material.md | 101 + .../reference/tools/core/manage_packages.md | 59 + .../reference/tools/core/manage_physics.md | 94 + .../reference/tools/core/manage_prefabs.md | 50 + .../docs/reference/tools/core/manage_scene.md | 110 + .../reference/tools/core/manage_script.md | 89 + .../tools/core/manage_script_capabilities.md | 35 + .../docs/reference/tools/core/manage_tools.md | 33 + .../docs/reference/tools/core/read_console.md | 39 + .../reference/tools/core/refresh_unity.md | 35 + .../tools/core/script_apply_edits.md | 186 + .../tools/core/set_active_instance.md | 32 + .../reference/tools/core/validate_script.md | 34 + .../docs/reference/tools/docs/_category_.json | 8 + website/docs/reference/tools/docs/index.md | 12 + .../docs/reference/tools/docs/unity_docs.md | 47 + .../reference/tools/docs/unity_reflect.md | 41 + website/docs/reference/tools/index.md | 91 + .../tools/probuilder/_category_.json | 8 + .../docs/reference/tools/probuilder/index.md | 11 + .../tools/probuilder/manage_probuilder.md | 88 + .../reference/tools/profiling/_category_.json | 8 + .../docs/reference/tools/profiling/index.md | 11 + .../tools/profiling/manage_profiler.md | 65 + .../tools/scripting_ext/_category_.json | 8 + .../tools/scripting_ext/execute_code.md | 37 + .../reference/tools/scripting_ext/index.md | 12 + .../scripting_ext/manage_scriptable_object.md | 39 + .../reference/tools/testing/_category_.json | 8 + .../reference/tools/testing/get_test_job.md | 35 + website/docs/reference/tools/testing/index.md | 12 + .../docs/reference/tools/testing/run_tests.md | 39 + .../docs/reference/tools/ui/_category_.json | 8 + website/docs/reference/tools/ui/index.md | 11 + website/docs/reference/tools/ui/manage_ui.md | 80 + .../docs/reference/tools/vfx/_category_.json | 8 + website/docs/reference/tools/vfx/index.md | 13 + .../docs/reference/tools/vfx/manage_shader.md | 35 + .../reference/tools/vfx/manage_texture.md | 48 + .../docs/reference/tools/vfx/manage_vfx.md | 36 + website/docs/releases.md | 1388 ++ website/docusaurus.config.js | 270 + website/package-lock.json | 19556 ++++++++++++++++ website/package.json | 49 + website/scripts/fetch-stats.mjs | 101 + website/scripts/fetch-stats.test.mjs | 71 + website/scripts/gen-brand-assets.mjs | 34 + website/sidebars.js | 107 + website/src/components/CopyButton/index.js | 72 + .../components/CopyButton/styles.module.css | 42 + .../src/components/HomeArchitecture/index.js | 83 + .../HomeArchitecture/styles.module.css | 215 + website/src/components/HomeCloser/index.js | 72 + .../components/HomeCloser/styles.module.css | 180 + website/src/components/HomeFeatures/index.js | 91 + .../components/HomeFeatures/styles.module.css | 141 + website/src/components/HomeHero/index.js | 102 + .../src/components/HomeHero/styles.module.css | 334 + website/src/components/HomeStats/index.js | 39 + .../components/HomeStats/styles.module.css | 86 + website/src/css/custom.css | 343 + website/src/pages/index.js | 26 + website/static/.nojekyll | 0 website/static/img/android-chrome-192.png | Bin 0 -> 5362 bytes website/static/img/android-chrome-512.png | Bin 0 -> 14260 bytes website/static/img/apple-touch-icon.png | Bin 0 -> 5229 bytes website/static/img/building_scene.gif | Bin 0 -> 2222269 bytes website/static/img/building_scene.mp4 | Bin 0 -> 280587 bytes website/static/img/building_scene.webm | Bin 0 -> 296878 bytes website/static/img/favicon-32.png | Bin 0 -> 1053 bytes website/static/img/favicon.png | Bin 0 -> 1567 bytes website/static/img/github-social-preview.png | Bin 0 -> 672404 bytes website/static/img/logo-mark.svg | 23 + website/static/img/logo.png | Bin 0 -> 461300 bytes .../static/img/networking-architecture.png | Bin 0 -> 6951 bytes website/static/img/package-icon.png | Bin 0 -> 3988 bytes website/static/img/social-card.png | Bin 0 -> 666984 bytes website/static/img/v5_01_uninstall.png | Bin 0 -> 542110 bytes website/static/img/v5_02_install.png | Bin 0 -> 343474 bytes website/static/img/v5_03_open_mcp_window.png | Bin 0 -> 247385 bytes .../static/img/v5_04_rebuild_mcp_server.png | Bin 0 -> 417026 bytes website/static/img/v5_05_rebuild_success.png | Bin 0 -> 195405 bytes .../img/v6_new_ui_asset_store_version.png | Bin 0 -> 655185 bytes website/static/img/v6_new_ui_dark.png | Bin 0 -> 651387 bytes website/static/img/v6_new_ui_light.png | Bin 0 -> 625018 bytes website/static/robots.txt | 4 + 2201 files changed, 257291 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/actions/publish-docker/action.yml create mode 100644 .github/actions/publish-pypi/action.yml create mode 100644 .github/pull_request_template.md create mode 100755 .github/scripts/mark_skipped.py create mode 100644 .github/workflows/beta-release.yml create mode 100644 .github/workflows/claude-nl-suite.yml create mode 100644 .github/workflows/docs-deploy.yml create mode 100644 .github/workflows/docs-generate.yml create mode 100644 .github/workflows/e2e-bridge.yml create mode 100644 .github/workflows/github-repo-stats.yml create mode 100644 .github/workflows/python-tests.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stats.yml create mode 100644 .github/workflows/sync-releases.yml create mode 100644 .github/workflows/unity-tests.yml create mode 100644 .gitignore create mode 100644 .mcpbignore create mode 100644 CLAUDE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs create mode 100644 CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs.meta create mode 100644 CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs create mode 100644 CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs.meta create mode 100644 LICENSE create mode 100644 MCPForUnity/Editor.meta create mode 100644 MCPForUnity/Editor/AssemblyInfo.cs create mode 100644 MCPForUnity/Editor/AssemblyInfo.cs.meta create mode 100644 MCPForUnity/Editor/Clients.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs create mode 100644 MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta create mode 100644 MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs create mode 100644 MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta create mode 100644 MCPForUnity/Editor/Clients/McpClientRegistry.cs create mode 100644 MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta create mode 100644 MCPForUnity/Editor/Constants.meta create mode 100644 MCPForUnity/Editor/Constants/AuthConstants.cs create mode 100644 MCPForUnity/Editor/Constants/AuthConstants.cs.meta create mode 100644 MCPForUnity/Editor/Constants/EditorPrefKeys.cs create mode 100644 MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta create mode 100644 MCPForUnity/Editor/Constants/HealthStatus.cs create mode 100644 MCPForUnity/Editor/Constants/HealthStatus.cs.meta create mode 100644 MCPForUnity/Editor/Constants/ProductInfo.cs create mode 100644 MCPForUnity/Editor/Constants/ProductInfo.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies.meta create mode 100644 MCPForUnity/Editor/Dependencies/DependencyManager.cs create mode 100644 MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/Models.meta create mode 100644 MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs create mode 100644 MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs create mode 100644 MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs create mode 100644 MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta create mode 100644 MCPForUnity/Editor/Dependencies/UvInstaller.cs create mode 100644 MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta create mode 100644 MCPForUnity/Editor/External.meta create mode 100644 MCPForUnity/Editor/External/Tommy.cs create mode 100644 MCPForUnity/Editor/External/Tommy.cs.meta create mode 100644 MCPForUnity/Editor/Helpers.meta create mode 100644 MCPForUnity/Editor/Helpers/AssetGenPaths.cs create mode 100644 MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/AssetGenPrefs.cs create mode 100644 MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/AssetPathUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/BlenderDetection.cs create mode 100644 MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/CodexConfigHelper.cs create mode 100644 MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ComponentOps.cs create mode 100644 MCPForUnity/Editor/Helpers/ComponentOps.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs create mode 100644 MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ExecPath.cs create mode 100644 MCPForUnity/Editor/Helpers/ExecPath.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/GameObjectLookup.cs create mode 100644 MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/GameObjectSerializer.cs create mode 100644 MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/MaterialOps.cs create mode 100644 MCPForUnity/Editor/Helpers/MaterialOps.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs create mode 100644 MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/McpJobStateStore.cs create mode 100644 MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/McpLog.cs create mode 100644 MCPForUnity/Editor/Helpers/McpLog.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/McpLogRecord.cs create mode 100644 MCPForUnity/Editor/Helpers/McpLogRecord.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ObjectResolver.cs create mode 100644 MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/Pagination.cs create mode 100644 MCPForUnity/Editor/Helpers/Pagination.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ParamCoercion.cs create mode 100644 MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/PortManager.cs create mode 100644 MCPForUnity/Editor/Helpers/PortManager.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs create mode 100644 MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/PropertyConversion.cs create mode 100644 MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/RendererHelpers.cs create mode 100644 MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/Response.cs create mode 100644 MCPForUnity/Editor/Helpers/Response.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs create mode 100644 MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/StringCaseUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/TelemetryHelper.cs create mode 100644 MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/TextureOps.cs create mode 100644 MCPForUnity/Editor/Helpers/TextureOps.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/ToolParams.cs create mode 100644 MCPForUnity/Editor/Helpers/ToolParams.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs create mode 100644 MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/UnityTypeResolver.cs create mode 100644 MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta create mode 100644 MCPForUnity/Editor/Helpers/VectorParsing.cs create mode 100644 MCPForUnity/Editor/Helpers/VectorParsing.cs.meta create mode 100644 MCPForUnity/Editor/MCPForUnity.Editor.asmdef create mode 100644 MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta create mode 100644 MCPForUnity/Editor/McpCiBoot.cs create mode 100644 MCPForUnity/Editor/McpCiBoot.cs.meta create mode 100644 MCPForUnity/Editor/MenuItems.meta create mode 100644 MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs create mode 100644 MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta create mode 100644 MCPForUnity/Editor/Migrations.meta create mode 100644 MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs create mode 100644 MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta create mode 100644 MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs create mode 100644 MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta create mode 100644 MCPForUnity/Editor/Models.meta create mode 100644 MCPForUnity/Editor/Models/Command.cs create mode 100644 MCPForUnity/Editor/Models/Command.cs.meta create mode 100644 MCPForUnity/Editor/Models/MCPConfigServer.cs create mode 100644 MCPForUnity/Editor/Models/MCPConfigServer.cs.meta create mode 100644 MCPForUnity/Editor/Models/MCPConfigServers.cs create mode 100644 MCPForUnity/Editor/Models/MCPConfigServers.cs.meta create mode 100644 MCPForUnity/Editor/Models/McpClient.cs create mode 100644 MCPForUnity/Editor/Models/McpClient.cs.meta create mode 100644 MCPForUnity/Editor/Models/McpConfig.cs create mode 100644 MCPForUnity/Editor/Models/McpConfig.cs.meta create mode 100644 MCPForUnity/Editor/Models/McpStatus.cs create mode 100644 MCPForUnity/Editor/Models/McpStatus.cs.meta create mode 100644 MCPForUnity/Editor/Resources.meta create mode 100644 MCPForUnity/Editor/Resources/Editor.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/ActiveTool.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/EditorState.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/Selection.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/Selection.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/ToolStates.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/ToolStates.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Editor/Windows.cs create mode 100644 MCPForUnity/Editor/Resources/Editor/Windows.cs.meta create mode 100644 MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs create mode 100644 MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta create mode 100644 MCPForUnity/Editor/Resources/MenuItems.meta create mode 100644 MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs create mode 100644 MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project.meta create mode 100644 MCPForUnity/Editor/Resources/Project/Layers.cs create mode 100644 MCPForUnity/Editor/Resources/Project/Layers.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project/ProjectInfo.cs create mode 100644 MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Project/Tags.cs create mode 100644 MCPForUnity/Editor/Resources/Project/Tags.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Scene.meta create mode 100644 MCPForUnity/Editor/Resources/Scene/CamerasResource.cs create mode 100644 MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs create mode 100644 MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs create mode 100644 MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs create mode 100644 MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Scene/VolumesResource.cs create mode 100644 MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta create mode 100644 MCPForUnity/Editor/Resources/Tests.meta create mode 100644 MCPForUnity/Editor/Resources/Tests/GetTests.cs create mode 100644 MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta create mode 100644 MCPForUnity/Editor/Security.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs create mode 100644 MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta create mode 100644 MCPForUnity/Editor/Services.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs create mode 100644 MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta create mode 100644 MCPForUnity/Editor/Services/BridgeControlService.cs create mode 100644 MCPForUnity/Editor/Services/BridgeControlService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ClientConfigurationService.cs create mode 100644 MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta create mode 100644 MCPForUnity/Editor/Services/EditorConfigurationCache.cs create mode 100644 MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta create mode 100644 MCPForUnity/Editor/Services/EditorPrefsWindowService.cs create mode 100644 MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta create mode 100644 MCPForUnity/Editor/Services/EditorStateCache.cs create mode 100644 MCPForUnity/Editor/Services/EditorStateCache.cs.meta create mode 100644 MCPForUnity/Editor/Services/HttpAutoStartHandler.cs create mode 100644 MCPForUnity/Editor/Services/HttpAutoStartHandler.cs.meta create mode 100644 MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs create mode 100644 MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta create mode 100644 MCPForUnity/Editor/Services/IBridgeControlService.cs create mode 100644 MCPForUnity/Editor/Services/IBridgeControlService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IClientConfigurationService.cs create mode 100644 MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IPackageDeploymentService.cs create mode 100644 MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IPackageUpdateService.cs create mode 100644 MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IPathResolverService.cs create mode 100644 MCPForUnity/Editor/Services/IPathResolverService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IPlatformService.cs create mode 100644 MCPForUnity/Editor/Services/IPlatformService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IResourceDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IServerManagementService.cs create mode 100644 MCPForUnity/Editor/Services/IServerManagementService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ITestRunnerService.cs create mode 100644 MCPForUnity/Editor/Services/ITestRunnerService.cs.meta create mode 100644 MCPForUnity/Editor/Services/IToolDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/MCPServiceLocator.cs create mode 100644 MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta create mode 100644 MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs create mode 100644 MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta create mode 100644 MCPForUnity/Editor/Services/PackageDeploymentService.cs create mode 100644 MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta create mode 100644 MCPForUnity/Editor/Services/PackageJobManager.cs create mode 100644 MCPForUnity/Editor/Services/PackageJobManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/PackageUpdateService.cs create mode 100644 MCPForUnity/Editor/Services/PackageUpdateService.cs.meta create mode 100644 MCPForUnity/Editor/Services/PathResolverService.cs create mode 100644 MCPForUnity/Editor/Services/PathResolverService.cs.meta create mode 100644 MCPForUnity/Editor/Services/PlatformService.cs create mode 100644 MCPForUnity/Editor/Services/PlatformService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ResourceDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/ResourceDiscoveryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server.meta create mode 100644 MCPForUnity/Editor/Services/Server/IPidFileManager.cs create mode 100644 MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/IProcessDetector.cs create mode 100644 MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/IProcessTerminator.cs create mode 100644 MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs create mode 100644 MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs create mode 100644 MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/PidFileManager.cs create mode 100644 MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/ProcessDetector.cs create mode 100644 MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/ProcessTerminator.cs create mode 100644 MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs create mode 100644 MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta create mode 100644 MCPForUnity/Editor/Services/Server/TerminalLauncher.cs create mode 100644 MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta create mode 100644 MCPForUnity/Editor/Services/ServerManagementService.cs create mode 100644 MCPForUnity/Editor/Services/ServerManagementService.cs.meta create mode 100644 MCPForUnity/Editor/Services/StartupConfigRewrite.cs create mode 100644 MCPForUnity/Editor/Services/StartupConfigRewrite.cs.meta create mode 100644 MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs create mode 100644 MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs.meta create mode 100644 MCPForUnity/Editor/Services/TestJobManager.cs create mode 100644 MCPForUnity/Editor/Services/TestJobManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/TestRunStatus.cs create mode 100644 MCPForUnity/Editor/Services/TestRunStatus.cs.meta create mode 100644 MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs create mode 100644 MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs.meta create mode 100644 MCPForUnity/Editor/Services/TestRunnerService.cs create mode 100644 MCPForUnity/Editor/Services/TestRunnerService.cs.meta create mode 100644 MCPForUnity/Editor/Services/ToolDiscoveryService.cs create mode 100644 MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport.meta create mode 100644 MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportManager.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/TransportState.cs create mode 100644 MCPForUnity/Editor/Services/Transport/TransportState.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs create mode 100644 MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta create mode 100644 MCPForUnity/Editor/Setup.meta create mode 100644 MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs create mode 100644 MCPForUnity/Editor/Setup/McpForUnitySkillInstaller.cs.meta create mode 100644 MCPForUnity/Editor/Setup/RoslynInstaller.cs create mode 100644 MCPForUnity/Editor/Setup/RoslynInstaller.cs.meta create mode 100644 MCPForUnity/Editor/Setup/SetupWindowService.cs create mode 100644 MCPForUnity/Editor/Setup/SetupWindowService.cs.meta create mode 100644 MCPForUnity/Editor/Setup/SkillSyncService.cs create mode 100644 MCPForUnity/Editor/Setup/SkillSyncService.cs.meta create mode 100644 MCPForUnity/Editor/Tools.meta create mode 100644 MCPForUnity/Editor/Tools/Animation.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/AnimatorControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/AnimatorRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ClipCreate.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ClipCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ClipPresets.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ClipPresets.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerBlendTrees.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerLayers.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ControllerLayers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs create mode 100644 MCPForUnity/Editor/Tools/Animation/ManageAnimation.cs.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateImage.cs.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs create mode 100644 MCPForUnity/Editor/Tools/AssetGen/GenerateModel.cs.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs create mode 100644 MCPForUnity/Editor/Tools/AssetGen/ImportModel.cs.meta create mode 100644 MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs create mode 100644 MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs.meta create mode 100644 MCPForUnity/Editor/Tools/BatchExecute.cs create mode 100644 MCPForUnity/Editor/Tools/BatchExecute.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Build.meta create mode 100644 MCPForUnity/Editor/Tools/Build/BuildJob.cs create mode 100644 MCPForUnity/Editor/Tools/Build/BuildJob.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Build/BuildRunner.cs create mode 100644 MCPForUnity/Editor/Tools/Build/BuildRunner.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Build/BuildSettingsHelper.cs create mode 100644 MCPForUnity/Editor/Tools/Build/BuildSettingsHelper.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Build/BuildTargetMapping.cs create mode 100644 MCPForUnity/Editor/Tools/Build/BuildTargetMapping.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraControl.cs create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs create mode 100644 MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs create mode 100644 MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs.meta create mode 100644 MCPForUnity/Editor/Tools/CommandRegistry.cs create mode 100644 MCPForUnity/Editor/Tools/CommandRegistry.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ExecuteCode.cs create mode 100644 MCPForUnity/Editor/Tools/ExecuteCode.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ExecuteMenuItem.cs create mode 100644 MCPForUnity/Editor/Tools/ExecuteMenuItem.cs.meta create mode 100644 MCPForUnity/Editor/Tools/FindGameObjects.cs create mode 100644 MCPForUnity/Editor/Tools/FindGameObjects.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectLookAt.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectLookAt.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GetTestJob.cs create mode 100644 MCPForUnity/Editor/Tools/GetTestJob.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs create mode 100644 MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/JsonUtil.cs create mode 100644 MCPForUnity/Editor/Tools/JsonUtil.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageAsset.cs create mode 100644 MCPForUnity/Editor/Tools/ManageAsset.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageBuild.cs create mode 100644 MCPForUnity/Editor/Tools/ManageBuild.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageComponents.cs create mode 100644 MCPForUnity/Editor/Tools/ManageComponents.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageEditor.cs create mode 100644 MCPForUnity/Editor/Tools/ManageEditor.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageMaterial.cs create mode 100644 MCPForUnity/Editor/Tools/ManageMaterial.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManagePackages.cs create mode 100644 MCPForUnity/Editor/Tools/ManagePackages.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageScene.cs create mode 100644 MCPForUnity/Editor/Tools/ManageScene.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageScript.cs create mode 100644 MCPForUnity/Editor/Tools/ManageScript.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageScriptableObject.cs create mode 100644 MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageShader.cs create mode 100644 MCPForUnity/Editor/Tools/ManageShader.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageTexture.cs create mode 100644 MCPForUnity/Editor/Tools/ManageTexture.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageUI.cs create mode 100644 MCPForUnity/Editor/Tools/ManageUI.cs.meta create mode 100644 MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs create mode 100644 MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/CollisionMatrixOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/CollisionMatrixOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/JointOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/JointOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/ManagePhysics.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/ManagePhysics.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsForceOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsForceOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsMaterialOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsMaterialOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsQueryOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsQueryOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsRigidbodyOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsRigidbodyOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsSettingsOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsSimulationOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsValidationOps.cs create mode 100644 MCPForUnity/Editor/Tools/Physics/PhysicsValidationOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Prefabs.meta create mode 100644 MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs create mode 100644 MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ManageProBuilder.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderMeshUtils.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs create mode 100644 MCPForUnity/Editor/Tools/ProBuilder/ProBuilderSmoothing.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/ManageProfiler.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/CounterOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameDebuggerOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/FrameTimingOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/MemorySnapshotOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/ObjectMemoryOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs create mode 100644 MCPForUnity/Editor/Tools/Profiler/Operations/SessionOps.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ReadConsole.cs create mode 100644 MCPForUnity/Editor/Tools/ReadConsole.cs.meta create mode 100644 MCPForUnity/Editor/Tools/RefreshUnity.cs create mode 100644 MCPForUnity/Editor/Tools/RefreshUnity.cs.meta create mode 100644 MCPForUnity/Editor/Tools/RunTests.cs create mode 100644 MCPForUnity/Editor/Tools/RunTests.cs.meta create mode 100644 MCPForUnity/Editor/Tools/UnityReflect.cs create mode 100644 MCPForUnity/Editor/Tools/UnityReflect.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineCreate.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineRead.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineWrite.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailControl.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailRead.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs create mode 100644 MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta create mode 100644 MCPForUnity/Editor/Windows.meta create mode 100644 MCPForUnity/Editor/Windows/Components.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Advanced.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen.meta create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Branding.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Common.uss create mode 100644 MCPForUnity/Editor/Windows/Components/Common.uss.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Resources.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Resources/McpResourcesSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Resources/McpResourcesSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Resources/McpResourcesSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Resources/McpResourcesSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Tools.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Validation.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Validation/McpValidationSection.cs create mode 100644 MCPForUnity/Editor/Windows/Components/Validation/McpValidationSection.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/Validation/McpValidationSection.uxml create mode 100644 MCPForUnity/Editor/Windows/Components/Validation/McpValidationSection.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs.meta create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefItem.uxml create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefItem.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs.meta create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.uss create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.uss.meta create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.uxml create mode 100644 MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs.meta create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss.meta create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uxml create mode 100644 MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uxml.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.cs create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.cs.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uss create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uss.meta create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uxml create mode 100644 MCPForUnity/Editor/Windows/MCPSetupWindow.uxml.meta create mode 100644 MCPForUnity/README.md create mode 100644 MCPForUnity/README.md.meta create mode 100644 MCPForUnity/Runtime.meta create mode 100644 MCPForUnity/Runtime/AssemblyInfo.cs create mode 100644 MCPForUnity/Runtime/AssemblyInfo.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers.meta create mode 100644 MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs create mode 100644 MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityAssembliesCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityAssembliesCompat.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityCompatShims.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityCompatShims.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityFindObjectsCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityFindObjectsCompat.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityObjectIdCompat.cs.meta create mode 100644 MCPForUnity/Runtime/Helpers/UnityPhysicsCompat.cs create mode 100644 MCPForUnity/Runtime/Helpers/UnityPhysicsCompat.cs.meta create mode 100644 MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef create mode 100644 MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef.meta create mode 100644 MCPForUnity/Runtime/Serialization.meta create mode 100644 MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs create mode 100644 MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs.meta create mode 100644 MCPForUnity/package-icon.png create mode 100644 MCPForUnity/package-icon.png.meta create mode 100644 MCPForUnity/package.json create mode 100644 MCPForUnity/package.json.meta create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 SECURITY.md create mode 100644 Server/DOCKER_OVERVIEW.md create mode 100644 Server/Dockerfile create mode 100644 Server/LICENSE create mode 100644 Server/README.md create mode 100644 Server/__init__.py create mode 100644 Server/pyproject.toml create mode 100644 Server/pyrightconfig.json create mode 100644 Server/src/__init__.py create mode 100644 Server/src/cli/CLI_USAGE_GUIDE.md create mode 100644 Server/src/cli/__init__.py create mode 100644 Server/src/cli/commands/__init__.py create mode 100644 Server/src/cli/commands/animation.py create mode 100644 Server/src/cli/commands/asset.py create mode 100644 Server/src/cli/commands/asset_gen.py create mode 100644 Server/src/cli/commands/audio.py create mode 100644 Server/src/cli/commands/batch.py create mode 100644 Server/src/cli/commands/build.py create mode 100644 Server/src/cli/commands/camera.py create mode 100644 Server/src/cli/commands/code.py create mode 100644 Server/src/cli/commands/component.py create mode 100644 Server/src/cli/commands/docs.py create mode 100644 Server/src/cli/commands/editor.py create mode 100644 Server/src/cli/commands/gameobject.py create mode 100644 Server/src/cli/commands/graphics.py create mode 100644 Server/src/cli/commands/instance.py create mode 100644 Server/src/cli/commands/lighting.py create mode 100644 Server/src/cli/commands/material.py create mode 100644 Server/src/cli/commands/packages.py create mode 100644 Server/src/cli/commands/physics.py create mode 100644 Server/src/cli/commands/prefab.py create mode 100644 Server/src/cli/commands/probuilder.py create mode 100644 Server/src/cli/commands/profiler.py create mode 100644 Server/src/cli/commands/reflect.py create mode 100644 Server/src/cli/commands/scene.py create mode 100644 Server/src/cli/commands/script.py create mode 100644 Server/src/cli/commands/shader.py create mode 100644 Server/src/cli/commands/texture.py create mode 100644 Server/src/cli/commands/tool.py create mode 100644 Server/src/cli/commands/ui.py create mode 100644 Server/src/cli/commands/vfx.py create mode 100644 Server/src/cli/main.py create mode 100644 Server/src/cli/utils/__init__.py create mode 100644 Server/src/cli/utils/config.py create mode 100644 Server/src/cli/utils/confirmation.py create mode 100644 Server/src/cli/utils/connection.py create mode 100644 Server/src/cli/utils/constants.py create mode 100644 Server/src/cli/utils/output.py create mode 100644 Server/src/cli/utils/parsers.py create mode 100644 Server/src/cli/utils/suggestions.py create mode 100644 Server/src/core/__init__.py create mode 100644 Server/src/core/config.py create mode 100644 Server/src/core/constants.py create mode 100644 Server/src/core/logging_decorator.py create mode 100644 Server/src/core/telemetry.py create mode 100644 Server/src/core/telemetry_decorator.py create mode 100644 Server/src/main.py create mode 100644 Server/src/models/__init__.py create mode 100644 Server/src/models/models.py create mode 100644 Server/src/models/unity_response.py create mode 100644 Server/src/services/__init__.py create mode 100644 Server/src/services/api_key_service.py create mode 100644 Server/src/services/custom_tool_service.py create mode 100644 Server/src/services/registry/__init__.py create mode 100644 Server/src/services/registry/resource_registry.py create mode 100644 Server/src/services/registry/tool_registry.py create mode 100644 Server/src/services/resources/__init__.py create mode 100644 Server/src/services/resources/active_tool.py create mode 100644 Server/src/services/resources/cameras.py create mode 100644 Server/src/services/resources/custom_tools.py create mode 100644 Server/src/services/resources/editor_state.py create mode 100644 Server/src/services/resources/gameobject.py create mode 100644 Server/src/services/resources/layers.py create mode 100644 Server/src/services/resources/menu_items.py create mode 100644 Server/src/services/resources/prefab.py create mode 100644 Server/src/services/resources/prefab_stage.py create mode 100644 Server/src/services/resources/project_info.py create mode 100644 Server/src/services/resources/renderer_features.py create mode 100644 Server/src/services/resources/rendering_stats.py create mode 100644 Server/src/services/resources/selection.py create mode 100644 Server/src/services/resources/tags.py create mode 100644 Server/src/services/resources/tests.py create mode 100644 Server/src/services/resources/tool_groups.py create mode 100644 Server/src/services/resources/unity_instances.py create mode 100644 Server/src/services/resources/volumes.py create mode 100644 Server/src/services/resources/windows.py create mode 100644 Server/src/services/state/external_changes_scanner.py create mode 100644 Server/src/services/tools/__init__.py create mode 100644 Server/src/services/tools/batch_execute.py create mode 100644 Server/src/services/tools/debug_request_context.py create mode 100644 Server/src/services/tools/execute_code.py create mode 100644 Server/src/services/tools/execute_custom_tool.py create mode 100644 Server/src/services/tools/execute_menu_item.py create mode 100644 Server/src/services/tools/find_gameobjects.py create mode 100644 Server/src/services/tools/find_in_file.py create mode 100644 Server/src/services/tools/generate_image.py create mode 100644 Server/src/services/tools/generate_model.py create mode 100644 Server/src/services/tools/import_model.py create mode 100644 Server/src/services/tools/import_model_file.py create mode 100644 Server/src/services/tools/manage_animation.py create mode 100644 Server/src/services/tools/manage_asset.py create mode 100644 Server/src/services/tools/manage_build.py create mode 100644 Server/src/services/tools/manage_camera.py create mode 100644 Server/src/services/tools/manage_components.py create mode 100644 Server/src/services/tools/manage_editor.py create mode 100644 Server/src/services/tools/manage_gameobject.py create mode 100644 Server/src/services/tools/manage_graphics.py create mode 100644 Server/src/services/tools/manage_material.py create mode 100644 Server/src/services/tools/manage_packages.py create mode 100644 Server/src/services/tools/manage_physics.py create mode 100644 Server/src/services/tools/manage_prefabs.py create mode 100644 Server/src/services/tools/manage_probuilder.py create mode 100644 Server/src/services/tools/manage_profiler.py create mode 100644 Server/src/services/tools/manage_scene.py create mode 100644 Server/src/services/tools/manage_script.py create mode 100644 Server/src/services/tools/manage_scriptable_object.py create mode 100644 Server/src/services/tools/manage_shader.py create mode 100644 Server/src/services/tools/manage_texture.py create mode 100644 Server/src/services/tools/manage_tools.py create mode 100644 Server/src/services/tools/manage_ui.py create mode 100644 Server/src/services/tools/manage_vfx.py create mode 100644 Server/src/services/tools/preflight.py create mode 100644 Server/src/services/tools/read_console.py create mode 100644 Server/src/services/tools/refresh_unity.py create mode 100644 Server/src/services/tools/run_tests.py create mode 100644 Server/src/services/tools/script_apply_edits.py create mode 100644 Server/src/services/tools/set_active_instance.py create mode 100644 Server/src/services/tools/unity_docs.py create mode 100644 Server/src/services/tools/unity_reflect.py create mode 100644 Server/src/services/tools/utils.py create mode 100644 Server/src/transport/__init__.py create mode 100644 Server/src/transport/legacy/port_discovery.py create mode 100644 Server/src/transport/legacy/stdio_port_registry.py create mode 100644 Server/src/transport/legacy/unity_connection.py create mode 100644 Server/src/transport/models.py create mode 100644 Server/src/transport/plugin_hub.py create mode 100644 Server/src/transport/plugin_registry.py create mode 100644 Server/src/transport/unity_instance_middleware.py create mode 100644 Server/src/transport/unity_transport.py create mode 100644 Server/src/utils/focus_nudge.py create mode 100644 Server/src/utils/log_paths.py create mode 100644 Server/src/utils/module_discovery.py create mode 100644 Server/tests/__init__.py create mode 100644 Server/tests/conftest.py create mode 100644 Server/tests/e2e/README.md create mode 100644 Server/tests/e2e/bridge_smoke.py create mode 100644 Server/tests/integration/__init__.py create mode 100644 Server/tests/integration/conftest.py create mode 100644 Server/tests/integration/test_api_key_service.py create mode 100644 Server/tests/integration/test_auth_config_startup.py create mode 100644 Server/tests/integration/test_connection_deadline.py create mode 100644 Server/tests/integration/test_debug_request_context_diagnostics.py create mode 100644 Server/tests/integration/test_domain_reload_resilience.py create mode 100644 Server/tests/integration/test_edit_normalization_and_noop.py create mode 100644 Server/tests/integration/test_edit_strict_and_warnings.py create mode 100644 Server/tests/integration/test_editor_state_v2_contract.py create mode 100644 Server/tests/integration/test_external_changes_scanner.py create mode 100644 Server/tests/integration/test_find_gameobjects.py create mode 100644 Server/tests/integration/test_gameobject_resources.py create mode 100644 Server/tests/integration/test_get_sha.py create mode 100644 Server/tests/integration/test_helpers.py create mode 100644 Server/tests/integration/test_improved_anchor_matching.py create mode 100644 Server/tests/integration/test_inline_unity_instance.py create mode 100644 Server/tests/integration/test_instance_autoselect.py create mode 100644 Server/tests/integration/test_instance_routing_comprehensive.py create mode 100644 Server/tests/integration/test_instance_targeting_resolution.py create mode 100644 Server/tests/integration/test_json_parsing_simple.py create mode 100644 Server/tests/integration/test_logging_stdout.py create mode 100644 Server/tests/integration/test_manage_asset_json_parsing.py create mode 100644 Server/tests/integration/test_manage_asset_param_coercion.py create mode 100644 Server/tests/integration/test_manage_components.py create mode 100644 Server/tests/integration/test_manage_gameobject_is_static.py create mode 100644 Server/tests/integration/test_manage_gameobject_look_at.py create mode 100644 Server/tests/integration/test_manage_gameobject_param_coercion.py create mode 100644 Server/tests/integration/test_manage_scene_paging_params.py create mode 100644 Server/tests/integration/test_manage_script_uri.py create mode 100644 Server/tests/integration/test_manage_scriptable_object_tool.py create mode 100644 Server/tests/integration/test_manage_texture.py create mode 100644 Server/tests/integration/test_manage_ui.py create mode 100644 Server/tests/integration/test_middleware_auth_integration.py create mode 100644 Server/tests/integration/test_multi_user_session_isolation.py create mode 100644 Server/tests/integration/test_plugin_hub_websocket_auth.py create mode 100644 Server/tests/integration/test_plugin_registry_user_isolation.py create mode 100644 Server/tests/integration/test_read_console_truncate.py create mode 100644 Server/tests/integration/test_read_resource_minimal.py create mode 100644 Server/tests/integration/test_refresh_unity_registration.py create mode 100644 Server/tests/integration/test_refresh_unity_retry_recovery.py create mode 100644 Server/tests/integration/test_resolve_user_id.py create mode 100644 Server/tests/integration/test_run_tests_async.py create mode 100644 Server/tests/integration/test_script_apply_edits_local.py create mode 100644 Server/tests/integration/test_script_tools.py create mode 100644 Server/tests/integration/test_stdio_custom_tool_sync.py create mode 100644 Server/tests/integration/test_telemetry_endpoint_validation.py create mode 100644 Server/tests/integration/test_telemetry_queue_worker.py create mode 100644 Server/tests/integration/test_telemetry_subaction.py create mode 100644 Server/tests/integration/test_tool_signatures_paging.py create mode 100644 Server/tests/integration/test_transport_framing.py create mode 100644 Server/tests/integration/test_transport_smoke.py create mode 100644 Server/tests/integration/test_validate_script_summary.py create mode 100644 Server/tests/integration/test_wait_for_editor_ready.py create mode 100644 Server/tests/pytest.ini create mode 100644 Server/tests/test_asset_gen_image.py create mode 100644 Server/tests/test_asset_gen_import.py create mode 100644 Server/tests/test_asset_gen_import_file.py create mode 100644 Server/tests/test_asset_gen_model.py create mode 100644 Server/tests/test_asset_gen_scaffold.py create mode 100644 Server/tests/test_cli.py create mode 100644 Server/tests/test_cli_commands_characterization.py create mode 100644 Server/tests/test_core_infrastructure_characterization.py create mode 100644 Server/tests/test_custom_tool_service_user_scope.py create mode 100644 Server/tests/test_execute_code.py create mode 100644 Server/tests/test_focus_nudge.py create mode 100644 Server/tests/test_log_paths.py create mode 100644 Server/tests/test_manage_animation.py create mode 100644 Server/tests/test_manage_build.py create mode 100644 Server/tests/test_manage_camera.py create mode 100644 Server/tests/test_manage_editor.py create mode 100644 Server/tests/test_manage_graphics.py create mode 100644 Server/tests/test_manage_packages.py create mode 100644 Server/tests/test_manage_physics.py create mode 100644 Server/tests/test_manage_prefabs.py create mode 100644 Server/tests/test_manage_probuilder.py create mode 100644 Server/tests/test_manage_profiler.py create mode 100644 Server/tests/test_manage_scene.py create mode 100644 Server/tests/test_manage_vfx_actions.py create mode 100644 Server/tests/test_models_characterization.py create mode 100644 Server/tests/test_param_normalizer.py create mode 100644 Server/tests/test_plugin_hub_wait_env.py create mode 100644 Server/tests/test_stdio_instance_resolution.py create mode 100644 Server/tests/test_tool_registry_metadata.py create mode 100644 Server/tests/test_tool_test_symmetry.py create mode 100644 Server/tests/test_transport_characterization.py create mode 100644 Server/tests/test_unity_docs.py create mode 100644 Server/tests/test_unity_reflect.py create mode 100644 Server/tests/test_utilities_characterization.py create mode 100644 Server/uv.lock create mode 100644 TestProjects/AssetStoreUploads/.gitignore create mode 100644 TestProjects/AssetStoreUploads/Assets/Readme.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Scenes.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Scenes/SampleScene.unity create mode 100644 TestProjects/AssetStoreUploads/Assets/Scenes/SampleScene.unity.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/SampleSceneProfile.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-Balanced-Renderer.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-Balanced.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-HighFidelity-Renderer.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-HighFidelity.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-Performant-Renderer.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/Settings/URP-Performant.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Icons.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Icons/URP.png create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Icons/URP.png.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Layout.wlt create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Layout.wlt.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts/Editor.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts/Editor/ReadmeEditor.cs create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts/Editor/ReadmeEditor.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts/Readme.cs create mode 100644 TestProjects/AssetStoreUploads/Assets/TutorialInfo/Scripts/Readme.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Assets/UniversalRenderPipelineGlobalSettings.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/CHANGELOG.md create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/CHANGELOG.md.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/AuthenticationBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/AuthenticationBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAssetStoreApi.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAssetStoreApi.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAssetStoreClient.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAssetStoreClient.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAuthenticationType.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IAuthenticationType.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IPackageUploader.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/IPackageUploader.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/PackageUploaderBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Abstractions/PackageUploaderBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/ApiUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/ApiUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/AssetStoreApi.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/AssetStoreApi.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/AssetStoreClient.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/AssetStoreClient.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/CloudTokenAuthentication.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/CloudTokenAuthentication.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/CredentialsAuthentication.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/CredentialsAuthentication.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/Category.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/Category.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/Package.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/Package.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/PackageAdditionalData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/PackageAdditionalData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/User.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Models/User.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AssetStoreResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AssetStoreResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AssetStoreToolsVersionResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AssetStoreToolsVersionResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AuthenticationResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/AuthenticationResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/CategoryDataResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/CategoryDataResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackageThumbnailResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackageThumbnailResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackageUploadedUnityVersionDataResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackageUploadedUnityVersionDataResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackagesAdditionalDataResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackagesAdditionalDataResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackagesDataResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/PackagesDataResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/RefreshedPackageDataResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/RefreshedPackageDataResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/UploadResponse.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/Responses/UploadResponse.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/SessionAuthentication.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/SessionAuthentication.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/UnityPackageUploader.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/UnityPackageUploader.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/UploadStatus.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Api/UploadStatus.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssemblyInfo.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssemblyInfo.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssetStoreTools.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssetStoreTools.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssetStoreToolsWindow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/AssetStoreToolsWindow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Constants.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Constants.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/IPackageExporter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/IPackageExporter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/IPreviewInjector.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/IPreviewInjector.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/PackageExporterBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/PackageExporterBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/PackageExporterSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/Abstractions/PackageExporterSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/DefaultExporterSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/DefaultExporterSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/DefaultPackageExporter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/DefaultPackageExporter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/LegacyExporterSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/LegacyExporterSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/LegacyPackageExporter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/LegacyPackageExporter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/PackageExporterResult.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/PackageExporterResult.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/PreviewInjector.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Exporter/PreviewInjector.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/CustomPreviewGenerationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/CustomPreviewGenerationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/FileNameFormat.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/FileNameFormat.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/GenerationType.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/GenerationType.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/NativePreviewGenerationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/NativePreviewGenerationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewDatabase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewDatabase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewFormat.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewFormat.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewGenerationResult.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewGenerationResult.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewGenerationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewGenerationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewMetadata.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Data/PreviewMetadata.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/AudioChannel.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/AudioChannel.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/AudioChannelCoordinate.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/AudioChannelCoordinate.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/ISceneScreenshotter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/ISceneScreenshotter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/MaterialScreenshotter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/MaterialScreenshotter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/MeshScreenshotter.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/MeshScreenshotter.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/SceneScreenshotterBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/SceneScreenshotterBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/SceneScreenshotterSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/Screenshotters/SceneScreenshotterSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/AudioTypeGeneratorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/AudioTypeGeneratorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/AudioTypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/AudioTypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/ITypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/ITypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/MaterialTypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/MaterialTypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/ModelTypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/ModelTypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/PrefabTypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/PrefabTypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TextureTypeGeneratorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TextureTypeGeneratorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TextureTypePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TextureTypePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypeGeneratorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypeGeneratorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorFromScene.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorFromScene.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorFromSceneSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/Custom/TypeGenerators/TypePreviewGeneratorFromSceneSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/CustomPreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/CustomPreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/IPreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/IPreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/NativePreviewGenerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/NativePreviewGenerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/PreviewGeneratorBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Generators/PreviewGeneratorBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/Caching.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/Caching/CachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/Caching/CachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/Caching/ICachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/Caching/ICachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/IPreviewService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/IPreviewService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/PreviewServiceProvider.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Services/PreviewServiceProvider.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/AssetPreview.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/AssetPreview.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/AssetPreviewCollection.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/AssetPreviewCollection.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IAssetPreview.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IAssetPreview.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IAssetPreviewCollection.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IAssetPreviewCollection.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IPreviewGeneratorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/IPreviewGeneratorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/PreviewGeneratorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Data/PreviewGeneratorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/AssetPreviewElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/AssetPreviewElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/GridListElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/GridListElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewCollectionElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewCollectionElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGenerateButtonElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGenerateButtonElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGeneratorPathsElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGeneratorPathsElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGeneratorSettingsElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewGeneratorSettingsElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewWindowDescriptionElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Elements/PreviewWindowDescriptionElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/PreviewGeneratorWindow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/PreviewGeneratorWindow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Views.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Views/PreviewListView.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/UI/Views/PreviewListView.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/GraphicsUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/GraphicsUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/PreviewConvertUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/PreviewConvertUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/PreviewSceneUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/PreviewSceneUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/RenderPipeline.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/RenderPipeline.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/RenderPipelineUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Scripts/Utility/RenderPipelineUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Previews/Styles/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Unity.AssetStoreTools.Editor.asmdef create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Unity.AssetStoreTools.Editor.asmdef.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/account-dark.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/account-dark.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/account-light.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/account-light.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/open-in-browser.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/open-in-browser.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/publisher-portal-dark.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/publisher-portal-dark.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/publisher-portal-light.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Icons/publisher-portal-light.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackage.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackage.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackageContent.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackageContent.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackageGroup.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IPackageGroup.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IWorkflow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IWorkflow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IWorkflowServices.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/IWorkflowServices.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/WorkflowBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Abstractions/WorkflowBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/AssetsWorkflow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/AssetsWorkflow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/HybridPackageWorkflow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/HybridPackageWorkflow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Package.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Package.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageContent.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageContent.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageGroup.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageGroup.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageSorting.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/PackageSorting.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/AssetPath.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/AssetPath.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/AssetsWorkflowStateData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/AssetsWorkflowStateData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/HybridPackageWorkflowState.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/HybridPackageWorkflowState.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/UnityPackageWorkflowStateData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/UnityPackageWorkflowStateData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/WorkflowStateData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/Serialization/WorkflowStateData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/UnityPackageWorkflow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/UnityPackageWorkflow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/WorkflowServices.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Data/WorkflowServices.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/AnalyticsService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/AnalyticsService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/AuthenticationAnalytic.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/AuthenticationAnalytic.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/BaseAnalytic.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/BaseAnalytic.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/IAssetStoreAnalytic.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/IAssetStoreAnalytic.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/IAssetStoreAnalyticData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/IAssetStoreAnalyticData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/PackageUploadAnalytic.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/PackageUploadAnalytic.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/ValidationResultsSerializer.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/Data/ValidationResultsSerializer.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/IAnalyticsService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Analytics/IAnalyticsService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/AuthenticationService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/AuthenticationService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IAuthenticationService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IAuthenticationService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IPackageDownloadingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IPackageDownloadingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IPackageUploadingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/IPackageUploadingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/PackageDownloadingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/PackageDownloadingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/PackageUploadingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Api/PackageUploadingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Caching.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Caching/CachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Caching/CachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Caching/ICachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/Caching/ICachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/IUploaderService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/IUploaderService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/PackageFactory.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/PackageFactory/IPackageFactoryService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/PackageFactory/IPackageFactoryService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/PackageFactory/PackageFactoryService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/PackageFactory/PackageFactoryService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/UploaderServiceProvider.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/Services/UploaderServiceProvider.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/Abstractions/ValidationElementBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/Abstractions/ValidationElementBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/Abstractions/WorkflowElementBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/Abstractions/WorkflowElementBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/AccountToolbar.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/AccountToolbar.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/AssetsWorkflowElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/AssetsWorkflowElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/CurrentProjectValidationElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/CurrentProjectValidationElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/ExternalProjectValidationElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/ExternalProjectValidationElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/HybridPackageWorkflowElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/HybridPackageWorkflowElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/LoadingSpinner.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/LoadingSpinner.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/MultiToggleSelectionElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/MultiToggleSelectionElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageContentElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageContentElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageGroupElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageGroupElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageListToolbar.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageListToolbar.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageUploadElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PackageUploadElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PathSelectionElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PathSelectionElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PreviewGenerationElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/PreviewGenerationElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/UnityPackageWorkflowElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Elements/UnityPackageWorkflowElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Views.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Views/LoginView.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Views/LoginView.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Views/PackageListView.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Scripts/UI/Views/PackageListView.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/LoginView/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/PackageListView/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/Styles/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/UploaderWindow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Uploader/UploaderWindow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASDebug.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASDebug.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASToolsPreferences.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASToolsPreferences.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASToolsUpdater.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ASToolsUpdater.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/CacheUtil.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/CacheUtil.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/FileUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/FileUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/LegacyToolsRemover.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/LegacyToolsRemover.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/PackageUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/PackageUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ServiceProvider.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/ServiceProvider.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/StyleSelector.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/StyleSelector.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/Styles/Updater/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/SymlinkUtil.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Utility/SymlinkUtil.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/error.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/error.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/error_d.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/error_d.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/success.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/success.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/success_d.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/success_d.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/undefined.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/undefined.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/undefined_d.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/undefined_d.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/warning.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/warning.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/warning_d.png create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Icons/warning_d.png.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Categories.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Categories/CategoryEvaluator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Categories/CategoryEvaluator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Categories/ValidatorCategory.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Categories/ValidatorCategory.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/CurrentProjectValidator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/CurrentProjectValidator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/CurrentProjectValidationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/CurrentProjectValidationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ExternalProjectValidationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ExternalProjectValidationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/HighlightObjectAction.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/HighlightObjectAction.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/IMessageAction.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/IMessageAction.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/OpenAssetAction.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/MessageActions/OpenAssetAction.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResult.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResult.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultMessage.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultMessage.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultObject.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultObject.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultStatus.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/TestResultStatus.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationResult.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationResult.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationStatus.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationStatus.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationType.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Data/ValidationType.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/ExternalProjectValidator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/ExternalProjectValidator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/IValidator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/IValidator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/CachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/CachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/ICachingService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/ICachingService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/PreviewDatabaseContractResolver.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/CachingService/PreviewDatabaseContractResolver.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/IValidatorService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/IValidatorService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IAssetUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IAssetUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IFileSignatureUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IFileSignatureUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IMeshUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IMeshUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IModelUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IModelUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/ISceneUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/ISceneUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IScriptUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Abstractions/IScriptUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/AssetUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/AssetUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/ArchiveType.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/ArchiveType.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/AssetEnumerator.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/AssetEnumerator.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/AssetType.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/AssetType.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/LogEntry.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/Data/LogEntry.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/FileSignatureUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/FileSignatureUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/MeshUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/MeshUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/ModelUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/ModelUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/SceneUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/SceneUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/ScriptUtilityService.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/Validation/ScriptUtilityService.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/ValidatorServiceProvider.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Services/ValidatorServiceProvider.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/AutomatedTest.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/AutomatedTest.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/GenericTestConfig.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/GenericTestConfig.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ITestConfig.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ITestConfig.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ITestScript.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ITestScript.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/AutomatedTestScriptableObject.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/AutomatedTestScriptableObject.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/Editor.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/Editor/ValidationTestScriptableObjectInspector.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/Editor/ValidationTestScriptableObjectInspector.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/ValidationTestScriptableObject.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/Scriptable Objects/ValidationTestScriptableObject.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ValidationTest.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Definitions/ValidationTest.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckAnimationClips.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckAnimationClips.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckAudioClipping.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckAudioClipping.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckColliders.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckColliders.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckCompressedFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckCompressedFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckEmptyPrefabs.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckEmptyPrefabs.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckFileMenuNames.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckFileMenuNames.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckLODs.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckLODs.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckLineEndings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckLineEndings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMeshPrefabs.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMeshPrefabs.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMissingComponentsinAssets.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMissingComponentsinAssets.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMissingComponentsinScenes.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckMissingComponentsinScenes.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelImportLogs.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelImportLogs.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelOrientation.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelOrientation.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelTypes.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckModelTypes.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckNormalMapTextures.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckNormalMapTextures.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPackageNaming.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPackageNaming.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckParticleSystems.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckParticleSystems.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPathLengths.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPathLengths.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPrefabTransforms.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckPrefabTransforms.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckScriptCompilation.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckScriptCompilation.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckShaderCompilation.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckShaderCompilation.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckTextureDimensions.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckTextureDimensions.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckTypeNamespaces.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/CheckTypeNamespaces.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveExecutableFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveExecutableFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveJPGFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveJPGFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveJavaScriptFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveJavaScriptFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveLossyAudioFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveLossyAudioFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveMixamoFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveMixamoFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveSpeedTreeFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveSpeedTreeFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveVideoFiles.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/Generic/RemoveVideoFiles.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckDemoScenes.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckDemoScenes.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckDocumentation.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckDocumentation.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckPackageSize.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckPackageSize.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckProjectTemplateAssets.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Test Methods/UnityPackage/CheckProjectTemplateAssets.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorResults.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorResults.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorTest.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorTest.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorTestGroup.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Abstractions/IValidatorTestGroup.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateData.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateData.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateDataContractResolver.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateDataContractResolver.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateResults.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateResults.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/Serialization/ValidatorStateSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorResults.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorResults.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorSettings.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorSettings.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorTest.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorTest.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorTestGroup.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Data/ValidatorTestGroup.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorButtonElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorButtonElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorDescriptionElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorDescriptionElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorPathsElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorPathsElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorResultsElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorResultsElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorSettingsElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorSettingsElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorTestElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorTestElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorTestGroupElement.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Elements/ValidatorTestGroupElement.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/ValidatorWindow.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/ValidatorWindow.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Views.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Views/ValidatorTestsView.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/UI/Views/ValidatorTestsView.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Utility.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Utility/ValidatorUtility.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/Utility/ValidatorUtility.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/ValidatorBase.cs create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Scripts/ValidatorBase.cs.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/Style.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/Style.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/ThemeDark.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/ThemeDark.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/ThemeLight.uss create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Styles/ThemeLight.uss.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Animation Clips.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Audio Clipping.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Colliders.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Compressed Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Empty Prefabs.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check File Menu Names.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check LODs.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Line Endings.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Mesh Prefabs.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Missing Components in Assets.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Missing Components in Scenes.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Model Import Logs.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Model Orientation.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Model Types.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Normal Map Textures.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Package Naming.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Particle Systems.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Path Lengths.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Prefab Transforms.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Script Compilation.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Shader Compilation.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Texture Dimensions.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Check Type Namespaces.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove Executable Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove JPG Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove JavaScript Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove Lossy Audio Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove Mixamo Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove SpeedTree Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/Generic/Remove Video Files.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/UnityPackage.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/UnityPackage/Check Demo Scenes.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/UnityPackage/Check Documentation.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/UnityPackage/Check Package Size.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/Editor/Validator/Tests/UnityPackage/Check Project Template Assets.asset.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/LICENSE.md create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/LICENSE.md.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/package.json create mode 100644 TestProjects/AssetStoreUploads/Packages/com.unity.asset-store-tools/package.json.meta create mode 100644 TestProjects/AssetStoreUploads/Packages/manifest.json create mode 100644 TestProjects/AssetStoreUploads/Packages/packages-lock.json create mode 100644 TestProjects/AssetStoreUploads/ProjectSettings/BurstAotSettings_StandaloneWindows.json create mode 100644 TestProjects/AssetStoreUploads/ProjectSettings/CommonBurstAotSettings.json create mode 100644 TestProjects/AssetStoreUploads/ProjectSettings/ProjectVersion.txt create mode 100644 TestProjects/AssetStoreUploads/ProjectSettings/SceneTemplateSettings.json create mode 100644 TestProjects/UnityMCPTests/.gitignore create mode 100644 TestProjects/UnityMCPTests/Assets/Packages.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes/SampleScene.unity create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes/SampleScene.unity.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes/Test.unity.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes/Test.unity/Test.unity create mode 100644 TestProjects/UnityMCPTests/Assets/Scenes/Test.unity/Test.unity.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/Bouncer.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/Bouncer.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/UnityEventTestComponent.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/UnityEventTestComponent.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Temp.meta create mode 100644 TestProjects/UnityMCPTests/Assets/TestMat.mat create mode 100644 TestProjects/UnityMCPTests/Assets/TestMat.mat.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobManagerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenPrefsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenProvidersTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/BlenderDetectionTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/BlenderDetectionTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/EnvKeyOverrideTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/EnvKeyOverrideTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/FalAdapterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateImageTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateModelHandlerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/GenerateModelHandlerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ImportModelFileHandlerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ImportModelFileHandlerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ImportModelHandlerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ImportModelHandlerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MeshyAdapterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ModelImportPipelineTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/ModelImportPipelineTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/OpenRouterAdapterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SafeZipExtractorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SafeZipExtractorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecretRedactorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecretRedactorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecureKeyStoreTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecureKeyStoreTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SketchfabAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SketchfabAdapterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/TripoAdapterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/CheckStatusUrlPropertyTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/CheckStatusUrlPropertyTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/IsInstalledTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/IsInstalledTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/SupportedTransportsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Clients/SupportedTransportsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/AssetPathUtilityOfflineTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ClientConfigFormatTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ClientConfigFormatTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/Matrix4x4ConverterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/Matrix4x4ConverterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/PaginationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/PaginationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ToolParamsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/ToolParamsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/PathResolverClaudeCliTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/PathResolverClaudeCliTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources/GetMenuItemsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources/GetMenuItemsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Characterization.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Characterization/ServerManagementServiceCharacterizationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Characterization/ServerManagementServiceCharacterizationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Characterization/Services_Characterization.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Characterization/Services_Characterization.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ConfigureDetectedClientsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ConfigureDetectedClientsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/EditorConfigurationCacheTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/EditorConfigurationCacheTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/HttpAutoStartHandlerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/HttpAutoStartHandlerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/HttpBridgeReloadHandlerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/HttpBridgeReloadHandlerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PackageUpdateServiceTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PackageUpdateServiceTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PortManagerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PortManagerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessDetectorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessDetectorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerCommandBuilderTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerCommandBuilderTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/TerminalLauncherTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/TerminalLauncherTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StartupConfigRewriteTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StartupConfigRewriteTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBridgeReconnectTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/StdioBridgeReconnectTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TestJobManagerInitTimeoutTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ToolDiscoveryServiceTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportManagerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/TransportManagerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/WebSocketTransportClientTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/WebSocketTransportClientTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/TestUtilities.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/TestUtilities.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteKeyPreservationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BatchExecuteKeyPreservationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BuildTargetMappingTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/BuildTargetMappingTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Characterization.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Characterization/EditorTools_Characterization.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Characterization/EditorTools_Characterization.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentOpsUnityEventTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentOpsUnityEventTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/DomainReloadResilienceTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/DomainReloadResilienceTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteCodeTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteMenuItemTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ExecuteMenuItemTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/ArrayStressSO.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/ArrayStressSO.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/ComplexStressSO.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/ComplexStressSO.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/DeepStressSO.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/StressTestSOs/DeepStressSO.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/FusionUnsafeTypeSerializationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectComponentHelpersErrorTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectComponentHelpersErrorTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAnimationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageAnimationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorUndoTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageEditorUndoTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGraphicsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGraphicsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialPropertiesTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialReproTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialStressTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageMaterialTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePhysicsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePhysicsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsStageTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsStageTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageProBuilderTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneMultiSceneTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneMultiSceneTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneTemplateTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneTemplateTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneValidationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneValidationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageUITests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageUITests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialDirectPropertiesTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialMeshInstantiationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/PropertyConversionErrorHandlingTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/PropertyConversionErrorHandlingTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/PropertyConversion_ArrayForFloat_Test.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/PropertyConversion_ArrayForFloat_Test.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ReadConsoleTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/UIDocumentSerializationTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/UIDocumentSerializationTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/UnityReflectTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/UnityReflectTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Transport.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Transport/StdioTransportClientReadinessTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Transport/StdioTransportClientReadinessTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/UvInstallerTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/UvInstallerTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/Characterization/Windows_Characterization.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/McpConnectionSectionHealthDebounceTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/McpConnectionSectionHealthDebounceTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/McpConnectionSectionOrphanDetectionTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/McpConnectionSectionOrphanDetectionTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/Editor.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/MCPForUnityTests.PlayMode.asmdef.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/PlayMode/PlayModeBasicTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/UI Toolkit.meta create mode 100644 TestProjects/UnityMCPTests/Assets/UI Toolkit/UnityThemes.meta create mode 100644 TestProjects/UnityMCPTests/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss create mode 100644 TestProjects/UnityMCPTests/Assets/UI Toolkit/UnityThemes/UnityDefaultRuntimeTheme.tss.meta create mode 100644 TestProjects/UnityMCPTests/Packages/manifest.json create mode 100644 TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json create mode 100644 TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt create mode 100644 TestProjects/UnityMCPTests/ProjectSettings/SceneTemplateSettings.json create mode 100644 docker-compose.yml create mode 100644 docs/asset-gen-manual-verification.md create mode 100644 docs/development/README-DEV-zh.md create mode 100644 docs/i18n/README-zh.md create mode 100644 docs/images/advanced-setting.png create mode 100644 docs/images/building_scene.gif create mode 100644 docs/images/connector.svg create mode 100644 docs/images/coplay-logo.png create mode 100644 docs/images/logo-header-dark.png create mode 100644 docs/images/logo-header-light.png create mode 100644 docs/images/networking-architecture.png create mode 100644 docs/images/readme_ui.png create mode 100644 docs/images/unity-mcp-ui-v8.6.png create mode 100644 docs/images/v5_01_uninstall.png create mode 100644 docs/images/v5_02_install.png create mode 100644 docs/images/v5_03_open_mcp_window.png create mode 100644 docs/images/v5_04_rebuild_mcp_server.png create mode 100644 docs/images/v5_05_rebuild_success.png create mode 100644 docs/images/v6_2_create_python_tools_asset.png create mode 100644 docs/images/v6_2_python_tools_asset.png create mode 100644 docs/images/v6_new_ui_asset_store_version.png create mode 100644 docs/images/v6_new_ui_dark.png create mode 100644 docs/images/v6_new_ui_light.png create mode 100644 docs/wiki/V10.md create mode 100644 manifest.json create mode 100755 mcp_source.py create mode 100755 scripts/validate-nlt-coverage.sh create mode 100644 tools/UPDATE_DOCS_PROMPT.md create mode 100644 tools/check-unity-versions.ps1 create mode 100755 tools/check-unity-versions.sh create mode 100755 tools/docker_publish.sh create mode 100644 tools/generate_docs_reference.py create mode 100755 tools/generate_mcpb.py create mode 100755 tools/hooks/pre-commit create mode 100755 tools/hooks/pre-push create mode 100755 tools/install-hooks.sh create mode 100644 tools/local_harness.py create mode 100644 tools/prepare_unity_asset_store_release.py create mode 100755 tools/pypi_publish.sh create mode 100644 tools/stress_editor_state.py create mode 100644 tools/stress_mcp.py create mode 100644 tools/sync_release_notes.py create mode 100644 tools/tests/__init__.py create mode 100644 tools/tests/test_build_release_characterization.py create mode 100644 tools/tests/test_local_harness.py create mode 100644 tools/unity-versions.json create mode 100755 tools/update_fork.bat create mode 100755 tools/update_fork.sh create mode 100755 tools/update_versions.py create mode 100644 unity-mcp-skill/SKILL.md create mode 100644 unity-mcp-skill/references/probuilder-guide.md create mode 100644 unity-mcp-skill/references/resources-reference.md create mode 100644 unity-mcp-skill/references/tools-reference.md create mode 100644 unity-mcp-skill/references/workflows.md create mode 100644 website/.gitignore create mode 100644 website/README.md create mode 100644 website/docs/architecture/external-analytics.md create mode 100644 website/docs/architecture/manage-physics.md create mode 100644 website/docs/architecture/project-roadmap.md create mode 100644 website/docs/architecture/python-layers.md create mode 100644 website/docs/architecture/remote-auth.md create mode 100644 website/docs/architecture/roadmap.md create mode 100644 website/docs/architecture/telemetry.md create mode 100644 website/docs/architecture/transports.md create mode 100644 website/docs/architecture/unity-compat.md create mode 100644 website/docs/contributing/dev-setup.md create mode 100644 website/docs/contributing/docs.md create mode 100644 website/docs/contributing/releases.md create mode 100644 website/docs/contributing/testing.md create mode 100644 website/docs/getting-started/clients.md create mode 100644 website/docs/getting-started/first-prompt.md create mode 100644 website/docs/getting-started/index.md create mode 100644 website/docs/getting-started/install.md create mode 100644 website/docs/guides/claude-code-cli.md create mode 100644 website/docs/guides/cli-examples.md create mode 100644 website/docs/guides/cli.md create mode 100644 website/docs/guides/client-configurators.md create mode 100644 website/docs/guides/custom-tools.md create mode 100644 website/docs/guides/multi-instance.md create mode 100644 website/docs/guides/remote-server-auth.md create mode 100644 website/docs/guides/roslyn.md create mode 100644 website/docs/guides/tool-groups.md create mode 100644 website/docs/guides/troubleshooting.md create mode 100644 website/docs/guides/uv-setup.md create mode 100644 website/docs/migrations/v10.md create mode 100644 website/docs/migrations/v5.md create mode 100644 website/docs/migrations/v6.md create mode 100644 website/docs/migrations/v8.md create mode 100644 website/docs/reference/cli.md create mode 100644 website/docs/reference/manifest.md create mode 100644 website/docs/reference/resources/index.md create mode 100644 website/docs/reference/tools/animation/_category_.json create mode 100644 website/docs/reference/tools/animation/index.md create mode 100644 website/docs/reference/tools/animation/manage_animation.md create mode 100644 website/docs/reference/tools/asset_gen/_category_.json create mode 100644 website/docs/reference/tools/asset_gen/generate_image.md create mode 100644 website/docs/reference/tools/asset_gen/generate_model.md create mode 100644 website/docs/reference/tools/asset_gen/import_model.md create mode 100644 website/docs/reference/tools/asset_gen/import_model_file.md create mode 100644 website/docs/reference/tools/asset_gen/index.md create mode 100644 website/docs/reference/tools/core/_category_.json create mode 100644 website/docs/reference/tools/core/apply_text_edits.md create mode 100644 website/docs/reference/tools/core/batch_execute.md create mode 100644 website/docs/reference/tools/core/create_script.md create mode 100644 website/docs/reference/tools/core/debug_request_context.md create mode 100644 website/docs/reference/tools/core/delete_script.md create mode 100644 website/docs/reference/tools/core/execute_custom_tool.md create mode 100644 website/docs/reference/tools/core/execute_menu_item.md create mode 100644 website/docs/reference/tools/core/find_gameobjects.md create mode 100644 website/docs/reference/tools/core/find_in_file.md create mode 100644 website/docs/reference/tools/core/get_sha.md create mode 100644 website/docs/reference/tools/core/index.md create mode 100644 website/docs/reference/tools/core/manage_asset.md create mode 100644 website/docs/reference/tools/core/manage_build.md create mode 100644 website/docs/reference/tools/core/manage_camera.md create mode 100644 website/docs/reference/tools/core/manage_components.md create mode 100644 website/docs/reference/tools/core/manage_editor.md create mode 100644 website/docs/reference/tools/core/manage_gameobject.md create mode 100644 website/docs/reference/tools/core/manage_graphics.md create mode 100644 website/docs/reference/tools/core/manage_material.md create mode 100644 website/docs/reference/tools/core/manage_packages.md create mode 100644 website/docs/reference/tools/core/manage_physics.md create mode 100644 website/docs/reference/tools/core/manage_prefabs.md create mode 100644 website/docs/reference/tools/core/manage_scene.md create mode 100644 website/docs/reference/tools/core/manage_script.md create mode 100644 website/docs/reference/tools/core/manage_script_capabilities.md create mode 100644 website/docs/reference/tools/core/manage_tools.md create mode 100644 website/docs/reference/tools/core/read_console.md create mode 100644 website/docs/reference/tools/core/refresh_unity.md create mode 100644 website/docs/reference/tools/core/script_apply_edits.md create mode 100644 website/docs/reference/tools/core/set_active_instance.md create mode 100644 website/docs/reference/tools/core/validate_script.md create mode 100644 website/docs/reference/tools/docs/_category_.json create mode 100644 website/docs/reference/tools/docs/index.md create mode 100644 website/docs/reference/tools/docs/unity_docs.md create mode 100644 website/docs/reference/tools/docs/unity_reflect.md create mode 100644 website/docs/reference/tools/index.md create mode 100644 website/docs/reference/tools/probuilder/_category_.json create mode 100644 website/docs/reference/tools/probuilder/index.md create mode 100644 website/docs/reference/tools/probuilder/manage_probuilder.md create mode 100644 website/docs/reference/tools/profiling/_category_.json create mode 100644 website/docs/reference/tools/profiling/index.md create mode 100644 website/docs/reference/tools/profiling/manage_profiler.md create mode 100644 website/docs/reference/tools/scripting_ext/_category_.json create mode 100644 website/docs/reference/tools/scripting_ext/execute_code.md create mode 100644 website/docs/reference/tools/scripting_ext/index.md create mode 100644 website/docs/reference/tools/scripting_ext/manage_scriptable_object.md create mode 100644 website/docs/reference/tools/testing/_category_.json create mode 100644 website/docs/reference/tools/testing/get_test_job.md create mode 100644 website/docs/reference/tools/testing/index.md create mode 100644 website/docs/reference/tools/testing/run_tests.md create mode 100644 website/docs/reference/tools/ui/_category_.json create mode 100644 website/docs/reference/tools/ui/index.md create mode 100644 website/docs/reference/tools/ui/manage_ui.md create mode 100644 website/docs/reference/tools/vfx/_category_.json create mode 100644 website/docs/reference/tools/vfx/index.md create mode 100644 website/docs/reference/tools/vfx/manage_shader.md create mode 100644 website/docs/reference/tools/vfx/manage_texture.md create mode 100644 website/docs/reference/tools/vfx/manage_vfx.md create mode 100644 website/docs/releases.md create mode 100644 website/docusaurus.config.js create mode 100644 website/package-lock.json create mode 100644 website/package.json create mode 100644 website/scripts/fetch-stats.mjs create mode 100644 website/scripts/fetch-stats.test.mjs create mode 100644 website/scripts/gen-brand-assets.mjs create mode 100644 website/sidebars.js create mode 100644 website/src/components/CopyButton/index.js create mode 100644 website/src/components/CopyButton/styles.module.css create mode 100644 website/src/components/HomeArchitecture/index.js create mode 100644 website/src/components/HomeArchitecture/styles.module.css create mode 100644 website/src/components/HomeCloser/index.js create mode 100644 website/src/components/HomeCloser/styles.module.css create mode 100644 website/src/components/HomeFeatures/index.js create mode 100644 website/src/components/HomeFeatures/styles.module.css create mode 100644 website/src/components/HomeHero/index.js create mode 100644 website/src/components/HomeHero/styles.module.css create mode 100644 website/src/components/HomeStats/index.js create mode 100644 website/src/components/HomeStats/styles.module.css create mode 100644 website/src/css/custom.css create mode 100644 website/src/pages/index.js create mode 100644 website/static/.nojekyll create mode 100644 website/static/img/android-chrome-192.png create mode 100644 website/static/img/android-chrome-512.png create mode 100644 website/static/img/apple-touch-icon.png create mode 100644 website/static/img/building_scene.gif create mode 100644 website/static/img/building_scene.mp4 create mode 100644 website/static/img/building_scene.webm create mode 100644 website/static/img/favicon-32.png create mode 100644 website/static/img/favicon.png create mode 100644 website/static/img/github-social-preview.png create mode 100644 website/static/img/logo-mark.svg create mode 100644 website/static/img/logo.png create mode 100644 website/static/img/networking-architecture.png create mode 100644 website/static/img/package-icon.png create mode 100644 website/static/img/social-card.png create mode 100644 website/static/img/v5_01_uninstall.png create mode 100644 website/static/img/v5_02_install.png create mode 100644 website/static/img/v5_03_open_mcp_window.png create mode 100644 website/static/img/v5_04_rebuild_mcp_server.png create mode 100644 website/static/img/v5_05_rebuild_success.png create mode 100644 website/static/img/v6_new_ui_asset_store_version.png create mode 100644 website/static/img/v6_new_ui_dark.png create mode 100644 website/static/img/v6_new_ui_light.png create mode 100644 website/static/robots.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5bc1e90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Server/build +.git +.venv +__pycache__ +*.pyc +.DS_Store diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..33cbf6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,122 @@ +name: Bug report +description: Something isn't working as expected +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug. The more specific you can be, the faster we can help. + + **Before filing:** + - Search [existing issues](https://github.com/CoplayDev/unity-mcp/issues?q=is%3Aissue) to avoid duplicates + - Check the [troubleshooting docs](https://coplaydev.github.io/unity-mcp/guides/troubleshooting) and [setup wiki](https://github.com/CoplayDev/unity-mcp/wiki) + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Clear, concrete description. What did you expect? What did you get? + placeholder: When I run X, I expect Y, but I see Z. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction steps + description: Numbered steps. Include the exact prompt or command if relevant. + placeholder: | + 1. Open Window > MCP for Unity + 2. Click Start Server + 3. In Claude Desktop, prompt: "create a red cube" + 4. Observe error in console: ... + validations: + required: true + + - type: input + id: unity-version + attributes: + label: Unity version + placeholder: "6000.0.34f1, 2022.3.42f1, etc." + validations: + required: true + + - type: input + id: package-version + attributes: + label: MCP for Unity package version + description: Found under Window > Package Manager → MCP for Unity. + placeholder: "9.6.3" + validations: + required: true + + - type: input + id: server-version + attributes: + label: Python server version + description: Run `uv pip show mcpforunityserver` or check the bundled wheel. + placeholder: "9.6.3" + validations: + required: false + + - type: dropdown + id: client + attributes: + label: MCP client + options: + - Claude Desktop + - Claude Code + - Cursor + - VS Code (Copilot) + - Windsurf + - Cline + - GitHub Copilot CLI + - Codex + - Qwen Code + - Gemini CLI + - OpenClaw + - Other (specify in description) + validations: + required: true + + - type: dropdown + id: transport + attributes: + label: Transport + options: + - HTTP (default) + - stdio + - Don't know + validations: + required: true + + - type: dropdown + id: os + attributes: + label: OS + options: + - macOS + - Windows + - Linux + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs / console output + description: Editor console, MCP for Unity log, client log. Strip secrets. + render: text + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Checks + options: + - label: I searched existing issues and did not find a duplicate + required: true + - label: I included logs / steps to reproduce + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..95bbcb2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Discord — chat with maintainers + url: https://discord.gg/y4p8KfzrN4 + about: Best for quick questions, troubleshooting, and design discussion. + - name: Documentation + url: https://coplaydev.github.io/unity-mcp/ + about: Getting Started, guides, tool reference, and architecture notes. + - name: Security vulnerability + url: https://github.com/CoplayDev/unity-mcp/security/advisories/new + about: Report privately via GitHub Security Advisories or email security@coplay.dev. Do NOT open a public issue. + - name: Discussion / design idea + url: https://github.com/CoplayDev/unity-mcp/discussions + about: Broad questions, design conversations, show-and-tell. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..630fada --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,63 @@ +name: Feature request +description: Suggest a new tool, capability, or quality-of-life improvement +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Got an idea? Great. The more concrete you can be about *who* this helps and *what* they're trying to do, the easier it is to scope. + + For tool requests: please name the Unity API/system you'd be wrapping and what your MCP client would actually say. + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Frame the user need first, not the implementation. + placeholder: | + When I'm prototyping with the LLM, I can't get it to build a NavMesh because there's no MCP tool for the AI navigation system. I have to flip back to the Editor manually. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like to see? + description: Be specific. New tool? New action on an existing tool? New resource? Workflow change? + placeholder: | + A new `manage_navigation` tool with actions: bake, clear, set_agent_radius, query_nearest_edge, ... + validations: + required: true + + - type: textarea + id: prior-art + attributes: + label: Prior art / how do you work around this today? + placeholder: I currently bake manually via Window > AI > Navigation, then ask the LLM to reason about it. + validations: + required: false + + - type: dropdown + id: scope + attributes: + label: Scope hint + options: + - New tool (substantial) + - New action on an existing tool + - New resource + - UX / editor window + - CLI improvement + - Documentation + - Other + validations: + required: true + + - type: checkboxes + id: contribute + attributes: + label: Would you be willing to help build this? + options: + - label: I can open a PR if a maintainer sketches the approach + - label: I can help test + - label: I can write the docs diff --git a/.github/actions/publish-docker/action.yml b/.github/actions/publish-docker/action.yml new file mode 100644 index 0000000..ea4f3c9 --- /dev/null +++ b/.github/actions/publish-docker/action.yml @@ -0,0 +1,111 @@ +name: Publish Docker image +description: Build and push the Docker image to Docker Hub +inputs: + docker_username: + required: true + description: Docker Hub username + docker_password: + required: true + description: Docker Hub password + image: + required: true + description: Docker image name (e.g. user/repo) + version: + required: false + default: "" + description: Optional version string (e.g. 1.2.3). If provided, tags are computed from this version instead of the GitHub ref. + include_branch_tags: + required: false + default: "true" + description: Whether to also publish a branch tag when running on a branch ref (e.g. manual runs). + context: + required: false + default: . + description: Docker build context + dockerfile: + required: false + default: Server/Dockerfile + description: Path to Dockerfile + platforms: + required: false + default: linux/amd64 + description: Target platforms +runs: + using: composite + steps: + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ inputs.docker_username }} + password: ${{ inputs.docker_password }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + if: ${{ inputs.version == '' && inputs.include_branch_tags == 'true' }} + with: + images: ${{ inputs.image }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + + - name: Extract metadata (tags, labels) for Docker + id: meta_nobranch + uses: docker/metadata-action@v5 + if: ${{ inputs.version == '' && inputs.include_branch_tags != 'true' }} + with: + images: ${{ inputs.image }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Compute Docker tags from version + id: version_tags + if: ${{ inputs.version != '' }} + shell: bash + run: | + set -euo pipefail + IFS='.' read -r MA MI PA <<< "${{ inputs.version }}" + echo "major=$MA" >> "$GITHUB_OUTPUT" + echo "minor=$MI" >> "$GITHUB_OUTPUT" + + - name: Extract metadata (tags, labels) for Docker + id: meta_version + uses: docker/metadata-action@v5 + if: ${{ inputs.version != '' && inputs.include_branch_tags == 'true' }} + with: + images: ${{ inputs.image }} + tags: | + type=raw,value=v${{ inputs.version }} + type=raw,value=v${{ steps.version_tags.outputs.major }}.${{ steps.version_tags.outputs.minor }} + type=raw,value=v${{ steps.version_tags.outputs.major }} + type=ref,event=branch + + - name: Extract metadata (tags, labels) for Docker + id: meta_version_nobranch + uses: docker/metadata-action@v5 + if: ${{ inputs.version != '' && inputs.include_branch_tags != 'true' }} + with: + images: ${{ inputs.image }} + tags: | + type=raw,value=v${{ inputs.version }} + type=raw,value=v${{ steps.version_tags.outputs.major }}.${{ steps.version_tags.outputs.minor }} + type=raw,value=v${{ steps.version_tags.outputs.major }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.meta.outputs.tags || steps.meta_nobranch.outputs.tags || steps.meta_version.outputs.tags || steps.meta_version_nobranch.outputs.tags }} + labels: ${{ steps.meta.outputs.labels || steps.meta_nobranch.outputs.labels || steps.meta_version.outputs.labels || steps.meta_version_nobranch.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/actions/publish-pypi/action.yml b/.github/actions/publish-pypi/action.yml new file mode 100644 index 0000000..f147be2 --- /dev/null +++ b/.github/actions/publish-pypi/action.yml @@ -0,0 +1,22 @@ +name: Publish Python distribution to PyPI +description: Build and publish the Python package from Server/ to PyPI +runs: + using: composite + steps: + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + enable-cache: true + cache-dependency-glob: "Server/uv.lock" + + - name: Build a binary wheel and a source tarball + shell: bash + run: uv build + working-directory: ./Server + + - name: Publish distribution to PyPI + # Pin to v1.12.4 to avoid Docker container name issue with uppercase repo names in v1.13.0+ + uses: pypa/gh-action-pypi-publish@v1.12.4 + with: + packages-dir: Server/dist/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6dcd9a7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,43 @@ +## Description + + +## Type of Change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update +- [ ] Refactoring (no functional changes) +- [ ] Test update + +## Changes Made + + +## Compatibility / Package Source + +- Unity version(s) tested: +- Package source used (`#beta`, `#main`, tag, branch, or `file:`): +- Resolved commit hash from `Packages/packages-lock.json` (if using a Git package URL): + +## Testing/Screenshots/Recordings + +- [ ] Python tests (`cd Server && uv run pytest tests/ -v`) +- [ ] Unity EditMode tests +- [ ] Unity PlayMode tests +- [ ] Package import/compile check +- [ ] Not applicable (explain why in Additional Notes) + + +## Documentation Updates + +- [ ] I have added/removed/modified tools or resources +- [ ] If yes, I have updated all documentation files using: + - [ ] The LLM prompt at `tools/UPDATE_DOCS_PROMPT.md` (recommended) + - [ ] Manual review of the generated changes + + +## Related Issues + + +## Additional Notes + diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py new file mode 100755 index 0000000..4811850 --- /dev/null +++ b/.github/scripts/mark_skipped.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Post-processes a JUnit XML so that "expected"/environmental failures +(e.g., permission prompts, empty MCP resources, or schema hiccups) +are converted to . Leaves real failures intact. + +Usage: + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml +""" + +from __future__ import annotations +import sys +import os +import re +import xml.etree.ElementTree as ET + +PATTERNS = [ + r"\bpermission\b", + r"\bpermissions\b", + r"\bautoApprove\b", + r"\bapproval\b", + r"\bdenied\b", + r"requested\s+permissions", + r"^MCP resources list is empty$", + r"No MCP resources detected", + r"aggregator.*returned\s*\[\s*\]", + r"Unknown resource:\s*mcpforunity://", + r"Input should be a valid dictionary.*ctx", + r"validation error .* ctx", +] + + +def should_skip(msg: str) -> bool: + if not msg: + return False + msg_l = msg.strip() + for pat in PATTERNS: + if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE): + return True + return False + + +def summarize_counts(ts: ET.Element): + tests = 0 + failures = 0 + errors = 0 + skipped = 0 + for case in ts.findall("testcase"): + tests += 1 + if case.find("failure") is not None: + failures += 1 + if case.find("error") is not None: + errors += 1 + if case.find("skipped") is not None: + skipped += 1 + return tests, failures, errors, skipped + + +def main(path: str) -> int: + if not os.path.exists(path): + print(f"[mark_skipped] No JUnit at {path}; nothing to do.") + return 0 + + try: + tree = ET.parse(path) + except ET.ParseError as e: + print(f"[mark_skipped] Could not parse {path}: {e}") + return 0 + + root = tree.getroot() + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + + changed = False + for ts in suites: + for case in list(ts.findall("testcase")): + nodes = [n for n in list(case) if n.tag in ("failure", "error")] + if not nodes: + continue + # If any node matches skip patterns, convert the whole case to skipped. + first_match_text = None + to_skip = False + for n in nodes: + msg = (n.get("message") or "") + "\n" + (n.text or "") + if should_skip(msg): + first_match_text = ( + n.text or "").strip() or first_match_text + to_skip = True + if to_skip: + for n in nodes: + case.remove(n) + reason = "Marked skipped: environment/permission precondition not met" + skip = ET.SubElement(case, "skipped") + skip.set("message", reason) + skip.text = first_match_text or reason + changed = True + # Recompute tallies per testsuite + tests, failures, errors, skipped = summarize_counts(ts) + ts.set("tests", str(tests)) + ts.set("failures", str(failures)) + ts.set("errors", str(errors)) + ts.set("skipped", str(skipped)) + + if changed: + tree.write(path, encoding="utf-8", xml_declaration=True) + print( + f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") + else: + print(f"[mark_skipped] No environmental failures detected in {path}.") + + return 0 + + +if __name__ == "__main__": + target = ( + sys.argv[1] + if len(sys.argv) > 1 + else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml") + ) + raise SystemExit(main(target)) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..d5df5a3 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,241 @@ +name: Beta Release (PyPI Pre-release) + +concurrency: + group: beta-release + cancel-in-progress: true + +on: + push: + branches: + - beta + paths: + - "Server/**" + - "MCPForUnity/**" + +jobs: + unity_tests: + name: Unity tests gate + if: github.actor != 'github-actions[bot]' + uses: ./.github/workflows/unity-tests.yml + secrets: inherit + + python_tests: + name: Python tests gate + if: github.actor != 'github-actions[bot]' + uses: ./.github/workflows/python-tests.yml + + update_unity_beta_version: + name: Update Unity package to beta version + runs-on: ubuntu-latest + needs: [unity_tests, python_tests] + # Avoid running when the workflow's own automation merges the PR + # created by this workflow (prevents a version-bump loop). + if: github.actor != 'github-actions[bot]' + permissions: + contents: write + pull-requests: write + outputs: + unity_beta_version: ${{ steps.version.outputs.unity_beta_version }} + version_updated: ${{ steps.commit.outputs.updated }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: beta + + - name: Generate beta version for Unity package + id: version + shell: bash + run: | + set -euo pipefail + # Read current Unity package version + CURRENT_VERSION=$(jq -r '.version' MCPForUnity/package.json) + echo "Current Unity package version: $CURRENT_VERSION" + + # Check if already a beta version - increment beta number + if [[ "$CURRENT_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\.([0-9]+)$ ]]; then + BASE_VERSION="${BASH_REMATCH[1]}" + BETA_NUM="${BASH_REMATCH[2]}" + NEXT_BETA=$((BETA_NUM + 1)) + BETA_VERSION="${BASE_VERSION}-beta.${NEXT_BETA}" + echo "Incrementing beta number: $CURRENT_VERSION -> $BETA_VERSION" + elif [[ "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + # Stable version - bump patch and add -beta.1 suffix + # This ensures beta is "newer" than stable (9.3.2-beta.1 > 9.3.1) + # The release workflow decides final bump type (patch/minor/major) + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + NEXT_PATCH=$((PATCH + 1)) + BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.1" + echo "Converting stable to beta: $CURRENT_VERSION -> $BETA_VERSION" + else + echo "Error: Could not parse version '$CURRENT_VERSION'" >&2 + exit 1 + fi + + # Always output the computed version + echo "unity_beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT" + + # Only skip update if computed version matches current (no change needed) + if [[ "$BETA_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version unchanged, skipping update" + echo "needs_update=false" >> "$GITHUB_OUTPUT" + else + echo "Version will be updated: $CURRENT_VERSION -> $BETA_VERSION" + echo "needs_update=true" >> "$GITHUB_OUTPUT" + fi + + - name: Update Unity package.json with beta version + if: steps.version.outputs.needs_update == 'true' + env: + BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }} + shell: bash + run: | + set -euo pipefail + # Update package.json version + jq --arg v "$BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json + mv tmp.json MCPForUnity/package.json + echo "Updated MCPForUnity/package.json:" + jq '.version' MCPForUnity/package.json + + - name: Commit to temporary branch and create PR + id: commit + if: steps.version.outputs.needs_update == 'true' + env: + GH_TOKEN: ${{ github.token }} + BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }} + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + if git diff --quiet MCPForUnity/package.json; then + echo "No changes to commit" + echo "updated=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Create a temporary branch for the version update + BRANCH="beta-version-${BETA_VERSION}-${GITHUB_RUN_ID}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + git checkout -b "$BRANCH" + git add MCPForUnity/package.json + git commit -m "chore: update Unity package to beta version ${BETA_VERSION}" + git push origin "$BRANCH" + + # Check if PR already exists + if gh pr view "$BRANCH" >/dev/null 2>&1; then + echo "PR already exists for $BRANCH" + PR_NUMBER=$(gh pr view "$BRANCH" --json number -q '.number') + else + PR_URL=$(gh pr create \ + --base beta \ + --head "$BRANCH" \ + --title "chore: update Unity package to beta version ${BETA_VERSION}" \ + --body "Automated beta version bump for the Unity package.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + fi + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "updated=true" >> "$GITHUB_OUTPUT" + + - name: Auto-merge version bump PR + if: steps.commit.outputs.updated == 'true' && steps.commit.outputs.pr_number != '' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.commit.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + gh pr merge "$PR_NUMBER" --merge --delete-branch + + publish_pypi_prerelease: + name: Publish beta to PyPI (pre-release) + runs-on: ubuntu-latest + needs: [unity_tests, python_tests] + # Avoid double-publish when the bot merges the version bump PR + if: github.actor != 'github-actions[bot]' + environment: + name: pypi + url: https://pypi.org/p/mcpforunityserver + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: beta + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + enable-cache: true + cache-dependency-glob: "Server/uv.lock" + + - name: Generate beta version + id: version + shell: bash + run: | + set -euo pipefail + RAW_VERSION=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml) + echo "Raw version: $RAW_VERSION" + + # Check if already a beta/prerelease version + if [[ "$RAW_VERSION" =~ (a|b|rc|\.dev|\.post)[0-9]+$ ]]; then + IS_PRERELEASE=true + # Strip the prerelease suffix to get base version + BASE_VERSION=$(echo "$RAW_VERSION" | sed -E 's/(a|b|rc|\.dev|\.post)[0-9]+$//') + else + IS_PRERELEASE=false + BASE_VERSION="$RAW_VERSION" + fi + + # Validate we have a proper X.Y.Z format + if ! [[ "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Could not parse version '$RAW_VERSION' -> '$BASE_VERSION'" >&2 + exit 1 + fi + + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION" + + # Only bump patch if coming from stable; keep same base if already prerelease + if [[ "$IS_PRERELEASE" == "true" ]]; then + # Already on a beta series - keep the same base version + NEXT_PATCH="$PATCH" + echo "Already prerelease, keeping base: $BASE_VERSION" + else + # Stable version - bump patch to ensure beta is "newer" + NEXT_PATCH=$((PATCH + 1)) + echo "Stable version, bumping patch: $PATCH -> $NEXT_PATCH" + fi + + BETA_NUMBER="$(date +%Y%m%d%H%M%S)" + BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}b${BETA_NUMBER}" + echo "Base version: $BASE_VERSION" + echo "Beta version: $BETA_VERSION" + echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT" + + - name: Update version for beta release + env: + BETA_VERSION: ${{ steps.version.outputs.beta_version }} + shell: bash + run: | + set -euo pipefail + sed -i "s/^version = .*/version = \"${BETA_VERSION}\"/" Server/pyproject.toml + echo "Updated pyproject.toml:" + grep "^version" Server/pyproject.toml + + - name: Build a binary wheel and a source tarball + shell: bash + run: uv build + working-directory: ./Server + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: Server/dist/ diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml new file mode 100644 index 0000000..09fa826 --- /dev/null +++ b/.github/workflows/claude-nl-suite.yml @@ -0,0 +1,1674 @@ +name: Claude NL/T Full Suite (Unity live) + +on: [workflow_dispatch] + +permissions: + contents: read + checks: write + id-token: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 + +jobs: + nl-suite: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md + + steps: + # ---------- Secrets check ---------- + - name: Detect secrets (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -e + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ---------- Python env for MCP server (uv) ---------- + - uses: astral-sh/setup-uv@v4 + with: + python-version: "3.11" + + - name: Install MCP server + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f Server/pyproject.toml ]; then + uv pip install -e Server + elif [ -f Server/requirements.txt ]; then + uv pip install -r Server/requirements.txt + else + echo "No MCP Python deps found (skipping)" + fi + + # --- Licensing: allow both ULF and EBL when available --- + - name: Decide license sources + id: lic + shell: bash + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + use_ulf=false; use_ebl=false + [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true + [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true + echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" + echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" + echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Stage Unity .ulf license (from secret) + if: steps.lic.outputs.use_ulf == 'true' + id: ulf + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + shell: bash + run: | + set -eu + mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" + f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" + if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then + printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" + else + printf "%s" "$UNITY_LICENSE" > "$f" + fi + chmod 600 "$f" || true + # Detect ULF first; it is XML and includes a element. + if grep -qi '' "$f"; then + # provide it in the standard local-share path too + cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" + echo "License source: ULF (Signature found)" + echo "ok=true" >> "$GITHUB_OUTPUT" + # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: + elif grep -qi 'Entitlement|entitlement' "$f"; then + mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" + mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" + echo "License source: Entitlement XML (re-homed)" + echo "ok=false" >> "$GITHUB_OUTPUT" + else + echo "License source: Unknown format (no ULF Signature or Entitlement markers)" + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + # --- Activate via EBL inside the same Unity image (writes host-side entitlement) --- + - name: Activate Unity (EBL via container - host-mount) + if: steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -euo pipefail + # host dirs to receive the full Unity config and local-share + mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" + + # Try Pro first if serial is present, otherwise named-user EBL. + docker run --rm --network host \ + -e HOME=/root \ + -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" bash -lc ' + set -euxo pipefail + if [[ -n "${UNITY_SERIAL:-}" ]]; then + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true + else + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true + fi + ls -la /root/.config/unity3d/Unity/licenses || true + ' + + # Verify entitlement written to host mount; allow ULF-only runs to proceed + if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then + if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then + echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." + else + echo "No entitlement produced and no valid ULF; cannot continue." >&2 + exit 1 + fi + fi + + # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step + + # ---------- Warm up project (import Library once) ---------- + - name: Warm up project (import Library once) + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + docker run --rm --network host \ + -e HOME=/root \ + -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ + "${manual_args[@]}" \ + -quit + + # ---------- Clean old MCP status ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" + rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true + + # ---------- Start headless Unity (persistent bridge) ---------- + - name: Start Unity (persistent bridge) + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" + docker rm -f unity-mcp >/dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 \ + -e UNITY_MCP_STATUS_DIR="${{ github.workspace }}/.unity-mcp" \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \ + -stackTraceLogType Full \ + -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ + "${manual_args[@]}" \ + -executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi + + # ---------- Wait for Unity bridge ---------- + - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') + shell: bash + run: | + set -euo pipefail + deadline=$((SECONDS+600)) # 10 min max + fatal_after=$((SECONDS+120)) # give licensing 2 min to settle + + # Fail fast only if container actually died + st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)" + case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac + + # Patterns + ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' + # Only truly fatal signals; allow transient "Licensing::..." chatter + license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' + + while [ $SECONDS -lt $deadline ]; do + logs="$(docker logs unity-mcp 2>&1 || true)" + + # 1) Primary: status JSON exposes TCP port + port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then + echo "Bridge ready on port $port" + # Ensure status file is readable by all (Claude container might run as different user) + docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true + exit 0 + fi + + # 2) Secondary: log markers + if echo "$logs" | grep -qiE "$ok_pat"; then + echo "Bridge ready (log markers)" + docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true + exit 0 + fi + + # Only treat license failures as fatal *after* warm-up + if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then + echo "::error::Fatal licensing signal detected after warm-up" + echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' + exit 1 + fi + + # If the container dies mid-wait, bail + st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" + if [[ "$st" != "running" ]]; then + echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' + exit 1 + fi + + sleep 2 + done + + echo "::error::Bridge not ready before deadline" + docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' + exit 1 + + # ---------- Debug Unity bridge status ---------- + - name: Debug Unity bridge status + if: always() && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true') + shell: bash + run: | + set -euxo pipefail + echo "--- Unity container state ---" + docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp || true + echo "--- Unity container logs (tail 200) ---" + docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true + echo "--- Container status dir ---" + docker exec unity-mcp ls -la "${{ github.workspace }}/.unity-mcp" || true + echo "--- Host status dir ---" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + echo "--- Host status file (first 120 lines) ---" + jq -r . "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,120p' || true + echo "--- Port probe from host ---" + port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + echo "unity_port=${port:-}" + if [[ -n "${port:-}" ]]; then + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" || echo "TCP probe failed" + else + echo "No unity_port in status file" + fi + echo "--- Config dir listing ---" + docker exec unity-mcp ls -la /root/.config/unity3d || true + echo "--- Editor log tail ---" + docker exec unity-mcp tail -n 200 /root/.config/unity3d/Editor.log || true + # Fail fast if no status file was written + shopt -s nullglob + status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json) + if ((${#status_files[@]} == 0)); then + echo "::error::No Unity MCP status file found; failing fast." + exit 1 + fi + + # (moved) — return license after Unity is stopped + + - name: Pin Claude tool permissions (.claude/settings.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/settings.json <<'JSON' + { + "permissions": { + "allow": [ + "mcp__unity", + "Edit(reports/**)", + "MultiEdit(reports/**)" + ], + "deny": [ + "Bash", + "WebFetch", + "WebSearch", + "Task", + "TodoWrite", + "NotebookEdit", + "NotebookRead" + ] + } + } + JSON + + # ---------- Reports & helper ---------- + - name: Prepare reports and dirs + run: | + set -eux + rm -f reports/*.xml reports/*.md || true + mkdir -p reports reports/_snapshots reports/_staging + + - name: Create report skeletons + run: | + set -eu + cat > "$JUNIT_OUT" <<'XML' + + + + Bootstrap placeholder; suite will append real tests. + + + XML + printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" + + - name: Verify Unity bridge status/port + run: | + set -euxo pipefail + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + jq -r . "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,80p' || true + + shopt -s nullglob + status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json) + if ((${#status_files[@]})); then + port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \ + | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)" + else + port="" + fi + + echo "unity_port=$port" + if [[ -n "$port" ]]; then + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" + fi + + if ((${#status_files[@]})); then + first_status="${status_files[0]}" + fname="$(basename "$first_status")" + hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}" + proj="$(jq -r '.project_name // empty' "$first_status" || true)" + if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then + echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV" + echo "Default instance set to ${proj}@${hash_part}" + fi + fi + + # ---------- MCP client config ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + python3 - <<'PY' + import json + import os + import textwrap + from pathlib import Path + + workspace = os.environ["GITHUB_WORKSPACE"] + default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip() + + cfg = { + "mcpServers": { + "unity": { + "args": [ + "run", + "--active", + "--directory", + "Server", + "mcp-for-unity", + "--transport", + "stdio", + ], + "transport": {"type": "stdio"}, + "env": { + "PYTHONUNBUFFERED": "1", + "MCP_LOG_LEVEL": "debug", + "UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests", + "UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp", + "UNITY_MCP_HOST": "127.0.0.1", + }, + } + } + } + + unity = cfg["mcpServers"]["unity"] + if default_inst: + unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst + if "--default-instance" not in unity["args"]: + unity["args"] += ["--default-instance", default_inst] + + runner_script = Path(".claude/run-unity-mcp.sh") + workspace_path = Path(workspace) + uv_candidate = workspace_path / ".venv" / "bin" / "uv" + uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv" + script = textwrap.dedent(f"""\ + #!/usr/bin/env bash + set -euo pipefail + LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log" + mkdir -p "$(dirname "$LOG")" + echo "" >> "$LOG" + echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG" + # Redirect stderr to log, keep stdout for MCP communication + exec {uv_cmd} "$@" 2>> "$LOG" + """) + runner_script.write_text(script) + runner_script.chmod(0o755) + + unity["command"] = runner_script.resolve().as_posix() + + path = Path(".claude/mcp.json") + path.write_text(json.dumps(cfg, indent=2) + "\n") + print(f"Wrote {path} and {runner_script} (UNITY_MCP_DEFAULT_INSTANCE={default_inst or 'unset'})") + PY + + - name: Debug MCP config + run: | + set -eux + echo "=== .claude/mcp.json ===" + cat .claude/mcp.json + echo "" + echo "=== Status dir contents ===" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + echo "" + echo "=== Status file content ===" + cat "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null || echo "(no status files)" + + - name: Preflight MCP server (with retries) + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + run: | + set -euxo pipefail + export PYTHONUNBUFFERED=1 + export MCP_LOG_LEVEL=debug + export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" + export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" + export UNITY_MCP_HOST=127.0.0.1 + if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then + export UNITY_MCP_DEFAULT_INSTANCE + fi + + # Debug: probe Unity's actual ping/pong response + echo "--- Unity ping/pong probe ---" + python3 <<'PY' + import socket, struct, sys + port = 6400 + try: + s = socket.create_connection(("127.0.0.1", port), timeout=2) + s.settimeout(2) + hs = s.recv(512) + print(f"handshake: {hs!r}") + hs_ok = b"FRAMING=1" in hs + print(f"FRAMING=1 present: {hs_ok}") + if hs_ok: + s.sendall(struct.pack(">Q", 4) + b"ping") + hdr = s.recv(8) + print(f"response header len: {len(hdr)}") + if len(hdr) == 8: + length = struct.unpack(">Q", hdr)[0] + resp = s.recv(length) + print(f"response payload: {resp!r}") + pong_check = b'"message":"pong"' + print(f"contains pong_check: {pong_check in resp}") + s.close() + except Exception as e: + print(f"probe error: {e}") + PY + + attempt=0 + while true; do + attempt=$((attempt+1)) + if uv run --active --directory Server python <<'PY' > /tmp/mcp-preflight.log 2>&1 + import json + import os + import sys + sys.path.insert(0, "src") + from transport.legacy.unity_connection import send_command_with_retry + + unity_instance = (os.environ.get("UNITY_MCP_DEFAULT_INSTANCE") or "").strip() or None + resp = send_command_with_retry( + "read_console", + {"action": "get", "count": "1", "include_stacktrace": False}, + instance_id=unity_instance, + max_retries=1, + retry_ms=200, + retry_on_reload=False, + ) + print(json.dumps(resp, default=str)) + ok = isinstance(resp, dict) and (resp.get("success") is True or resp.get("status") == "success") + raise SystemExit(0 if ok else 1) + PY + then + echo "MCP command-plane preflight passed on attempt ${attempt}" + cat /tmp/mcp-preflight.log + break + fi + echo "MCP command-plane preflight failed on attempt ${attempt}" + cat /tmp/mcp-preflight.log || true + if [ "$attempt" -ge 8 ]; then + echo "::error::Unity command plane not ready after $attempt attempts" + cat /tmp/mcp-preflight.log || true + exit 1 + fi + sleep 2 + done + + # ---------- Readiness diagnostics (only if preflight fails) ---------- + - name: Readiness diagnostics (on preflight failure) + if: failure() + continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + run: | + set -euxo pipefail + export PYTHONUNBUFFERED=1 MCP_LOG_LEVEL=debug + export UNITY_PROJECT_ROOT="$GITHUB_WORKSPACE/TestProjects/UnityMCPTests" + export UNITY_MCP_STATUS_DIR="$GITHUB_WORKSPACE/.unity-mcp" + export UNITY_MCP_HOST=127.0.0.1 + if [[ -n "${UNITY_MCP_DEFAULT_INSTANCE:-}" ]]; then + export UNITY_MCP_DEFAULT_INSTANCE + fi + + echo "=== Unity container status ===" + docker inspect -f '{{.State.Status}} {{.State.Running}} {{.State.ExitCode}}' unity-mcp || true + echo "=== Unity container logs (tail 200) ===" + docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + + echo "=== Status directory ===" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + jq -r . "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json | sed -n '1,120p' || true + + echo "=== Raw TCP probe to 6400 ===" + for host in 127.0.0.1 localhost; do + if timeout 2 bash -c "exec 3<>/dev/tcp/$host/6400" 2>/dev/null; then + echo "$host:6400 - SUCCESS" + else + echo "$host:6400 - FAILED" + fi + done + + echo "--- PortDiscovery debug ---" + python3 - <<'PY' + import sys + sys.path.insert(0, "Server/src") + from transport.legacy.port_discovery import PortDiscovery + + print(f"status_dir: {PortDiscovery.get_registry_dir()}") + instances = PortDiscovery.discover_all_unity_instances() + print(f"discover_all_unity_instances: {[{'id': i.id, 'port': i.port} for i in instances]}") + print(f"discover_unity_port: {PortDiscovery.discover_unity_port()}") + PY + + echo "--- Stdio registry debug ---" + uv run --active --directory Server python - <<'PY' + from transport.legacy.stdio_port_registry import stdio_port_registry + import json + instances = stdio_port_registry.get_instances(force_refresh=True) + print(json.dumps([{"id": i.id, "port": i.port} for i in instances])) + PY + + echo "=== MCP startup debug log ===" + cat "$GITHUB_WORKSPACE/.unity-mcp/mcp-server-startup-debug.log" 2>/dev/null || echo "(no startup debug log)" + + # ---------- Run suite in two passes ---------- + - name: Load NL prompt + id: nl_prompt + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + cp .claude/prompts/nl-unity-suite-nl.md .claude/prompts/nl-unity-suite-nl-run.md + cat >> .claude/prompts/nl-unity-suite-nl-run.md <<'EOF' + + You are running the NL pass only. + - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. + - Write each to reports/${ID}_results.xml. + - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests. + - Stop after NL-4_results.xml is written. + EOF + cp .claude/prompts/nl-unity-suite-nl.md .claude/prompts/nl-unity-suite-nl-retry.md + cat >> .claude/prompts/nl-unity-suite-nl-retry.md <<'EOF' + + You are retrying the NL pass only. + - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. + - Overwrite reports/${ID}_results.xml for each ID. + - Do not emit any T-* or GO-* tests. + - Stop after NL-4_results.xml is written. + EOF + + { + echo "run_prompt<<__NL_RUN_PROMPT__" + cat .claude/prompts/nl-unity-suite-nl-run.md + echo "__NL_RUN_PROMPT__" + echo "retry_prompt<<__NL_RETRY_PROMPT__" + cat .claude/prompts/nl-unity-suite-nl-retry.md + echo "__NL_RETRY_PROMPT__" + } >> "$GITHUB_OUTPUT" + + - name: Run Claude NL pass + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.nl_prompt.outputs.run_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-haiku-4-5-20251001 + --fallback-model claude-sonnet-4-5-20250929 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Debug MCP server startup (after NL pass) + if: always() + run: | + set -eux + echo "=== MCP Server Startup Debug Log ===" + cat "$GITHUB_WORKSPACE/.unity-mcp/mcp-server-startup-debug.log" 2>/dev/null || echo "(no debug log found - MCP server may not have started)" + echo "" + echo "=== Status dir after Claude ===" + ls -la "$GITHUB_WORKSPACE/.unity-mcp" || true + + - name: Check NL coverage incomplete/failed (pre-retry) + id: nl_cov + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + failed=() + for id in NL-0 NL-1 NL-2 NL-3 NL-4; do + f="reports/${id}_results.xml" + if [[ ! -s "$f" && ! -s "reports/_staging/${id}_results.xml" ]]; then + missing+=("$id") + continue + fi + if [[ ! -s "$f" && -s "reports/_staging/${id}_results.xml" ]]; then + f="reports/_staging/${id}_results.xml" + fi + if grep -Eq '<(failure|error)\b' "$f"; then + failed+=("$id") + fi + done + echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" + echo "failed=${#failed[@]}" >> "$GITHUB_OUTPUT" + if (( ${#missing[@]} )); then + echo "missing_list=${missing[*]}" >> "$GITHUB_OUTPUT" + fi + if (( ${#failed[@]} )); then + echo "failed_list=${failed[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Retry NL pass (Sonnet) if incomplete or failed + if: steps.nl_cov.outputs.missing != '0' || steps.nl_cov.outputs.failed != '0' + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.nl_prompt.outputs.retry_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-sonnet-4-5-20250929 + --fallback-model claude-haiku-4-5-20251001 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Load T prompt + id: t_prompt + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + cp .claude/prompts/nl-unity-suite-t.md .claude/prompts/nl-unity-suite-t-run.md + cat >> .claude/prompts/nl-unity-suite-t-run.md <<'EOF' + + You are running the T pass (A–J) only. + Output requirements: + - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. + - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. + - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. + - Do not emit any NL-* fragments. + Stop condition: + - After T-J_results.xml is written, stop. + EOF + + cp .claude/prompts/nl-unity-suite-t.md .claude/prompts/nl-unity-suite-t-retry.md + cat >> .claude/prompts/nl-unity-suite-t-retry.md <<'EOF' + + You are running the T pass only. + Output requirements: + - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. + - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. + - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. + - Do not emit any NL-* fragments. + Stop condition: + - After T-J_results.xml is written, stop. + EOF + + { + echo "run_prompt<<__T_RUN_PROMPT__" + cat .claude/prompts/nl-unity-suite-t-run.md + echo "__T_RUN_PROMPT__" + echo "retry_prompt<<__T_RETRY_PROMPT__" + cat .claude/prompts/nl-unity-suite-t-retry.md + echo "__T_RETRY_PROMPT__" + } >> "$GITHUB_OUTPUT" + + - name: Run Claude T pass A-J + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.t_prompt.outputs.run_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-haiku-4-5-20251001 + --fallback-model claude-sonnet-4-5-20250929 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # (moved) Assert T coverage after staged fragments are promoted + + - name: Check T coverage incomplete (pre-retry) + id: t_cov + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then + missing+=("$id") + fi + done + echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" + if (( ${#missing[@]} )); then + echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Retry T pass (Sonnet) if incomplete + if: steps.t_cov.outputs.missing != '0' + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + env: + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.t_prompt.outputs.retry_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-sonnet-4-5-20250929 + --fallback-model claude-haiku-4-5-20251001 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Re-assert T coverage (post-retry) + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + [[ -s "reports/${id}_results.xml" ]] || missing+=("$id") + done + if (( ${#missing[@]} )); then + echo "::error::Still missing T fragments: ${missing[*]}" + exit 1 + fi + + # ---------- Run GO pass (GameObject API tests) ---------- + - name: Load GO prompt + id: go_prompt + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + cp .claude/prompts/nl-gameobject-suite.md .claude/prompts/nl-gameobject-suite-run.md + cat >> .claude/prompts/nl-gameobject-suite-run.md <<'EOF' + + You are running the GO pass (GameObject API tests) only. + Output requirements: + - Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10. + - Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch. + - Do not emit any NL-* or T-* fragments. + Stop condition: + - After GO-10_results.xml is written, stop. + EOF + + cp .claude/prompts/nl-gameobject-suite.md .claude/prompts/nl-gameobject-suite-retry.md + cat >> .claude/prompts/nl-gameobject-suite-retry.md <<'EOF' + + You are running the GO pass only. + Output requirements: + - Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10. + - Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch. + - Do not emit any NL-* or T-* fragments. + Stop condition: + - After GO-10_results.xml is written, stop. + EOF + + { + echo "run_prompt<<__GO_RUN_PROMPT__" + cat .claude/prompts/nl-gameobject-suite-run.md + echo "__GO_RUN_PROMPT__" + echo "retry_prompt<<__GO_RETRY_PROMPT__" + cat .claude/prompts/nl-gameobject-suite-retry.md + echo "__GO_RETRY_PROMPT__" + } >> "$GITHUB_OUTPUT" + + - name: Run Claude GO pass + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + env: + UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }} + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.go_prompt.outputs.run_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-haiku-4-5-20251001 + --fallback-model claude-sonnet-4-5-20250929 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Check GO coverage incomplete (pre-retry) + id: go_cov + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in GO-0 GO-1 GO-2 GO-3 GO-4 GO-5 GO-6 GO-7 GO-8 GO-9 GO-10; do + if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then + missing+=("$id") + fi + done + echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" + if (( ${#missing[@]} )); then + echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Retry GO pass (Sonnet) if incomplete + if: steps.go_cov.outputs.missing != '0' + uses: anthropics/claude-code-action@cc5ef44546fda0649ddde3c5ab0cd3db7b7c5035 + env: + GITHUB_STEP_SUMMARY: /dev/null + with: + prompt: ${{ steps.go_prompt.outputs.retry_prompt }} + settings: .claude/settings.json + claude_args: | + --mcp-config .claude/mcp.json + --allowedTools mcp__unity,Edit(reports/**),MultiEdit(reports/**) + --disallowedTools Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead + --model claude-sonnet-4-5-20250929 + --fallback-model claude-haiku-4-5-20251001 + track_progress: false + show_full_output: true + display_report: false + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # (kept) Finalize staged report fragments (promote to reports/) + + # (removed duplicate) Finalize staged report fragments + + - name: Assert T coverage (after promotion) + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + if [[ ! -s "reports/${id}_results.xml" ]]; then + # Accept staged fragment as present + [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id") + fi + done + if (( ${#missing[@]} )); then + echo "::error::Missing T fragments: ${missing[*]}" + exit 1 + fi + + - name: Canonicalize testcase names (NL/T prefixes) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET, re, os + + RULES = [ + ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"), + ("NL-1", r"\b(NL-1|Core\s*Method)\b"), + ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"), + ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"), + ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"), + ("T-A", r"\b(T-?A|Temporary\s*Helper)\b"), + ("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"), + ("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"), + ("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"), + ("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"), + ("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"), + ("T-G", r"\b(T-?G|Path\s*Normalization)\b"), + ("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"), + ("T-I", r"\b(T-?I|Failure\s*Surface)\b"), + ("T-J", r"\b(T-?J|Idempotenc(y|e))\b"), + ("GO-0", r"\b(GO-?0|Hierarchy.*ComponentTypes)\b"), + ("GO-1", r"\b(GO-?1|Find\s*GameObjects\s*Tool)\b"), + ("GO-2", r"\b(GO-?2|GameObject\s*Resource)\b"), + ("GO-3", r"\b(GO-?3|Components\s*Resource)\b"), + ("GO-4", r"\b(GO-?4|Manage\s*Components)\b"), + ("GO-5", r"\b(GO-?5|Find.*by.*Name)\b"), + ("GO-6", r"\b(GO-?6|Find.*by.*Tag)\b"), + ("GO-7", r"\b(GO-?7|Single\s*Component)\b"), + ("GO-8", r"\b(GO-?8|Remove\s*Component)\b"), + ("GO-9", r"\b(GO-?9|Pagination)\b"), + ("GO-10", r"\b(GO-?10|Deprecation)\b"), + ] + + def canon_name(name: str) -> str: + n = name or "" + for tid, pat in RULES: + if re.search(pat, n, flags=re.I): + # If it already starts with the correct format, leave it alone + if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I): + return n.strip() + # If it has a different separator, extract title and reformat + title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I) + if title_match: + title = title_match.group(1).strip() + return f"{tid} — {title}" + # Otherwise, just return the canonical ID + return tid + return n + + def id_from_filename(p: Path): + n = p.name + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) + if m: + return f"NL-{int(m.group(1))}" + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) + if m: + return f"T-{m.group(1).upper()}" + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) + if m: + return f"GO-{int(m.group(1))}" + return None + + frags = list(sorted(Path("reports").glob("*_results.xml"))) + for frag in frags: + try: + tree = ET.parse(frag); root = tree.getroot() + except Exception: + continue + if root.tag != "testcase": + continue + file_id = id_from_filename(frag) + old = root.get("name") or "" + # Prefer filename-derived ID; if name doesn't start with it, override + if file_id: + # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns) + title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', old).strip() + new = f"{file_id} — {title}" if title else file_id + else: + new = canon_name(old) + if new != old and new: + root.set("name", new) + tree.write(frag, encoding="utf-8", xml_declaration=False) + print(f'canon: {frag.name}: "{old}" -> "{new}"') + + # Note: Do not auto-relable fragments. We rely on per-test strict emission + # and the backfill step to surface missing tests explicitly. + PY + + - name: Backfill missing NL/T tests (fail placeholders) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import re + import shutil + + DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J","GO-0","GO-1","GO-2","GO-3","GO-4","GO-5","GO-6","GO-7","GO-8","GO-9","GO-10"] + seen = set() + bad = set() + def id_from_filename(p: Path): + n = p.name + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) + if m: + return f"NL-{int(m.group(1))}" + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) + if m: + return f"T-{m.group(1).upper()}" + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) + if m: + return f"GO-{int(m.group(1))}" + return None + + for p in Path("reports").glob("*_results.xml"): + fid = id_from_filename(p) + try: + r = ET.parse(p).getroot() + except Exception: + # If the file exists but isn't parseable, preserve it for debugging and + # treat it as a failing (malformed) fragment rather than "not produced". + if fid in DESIRED and p.exists() and p.stat().st_size > 0: + staging = Path("reports/_staging") + staging.mkdir(parents=True, exist_ok=True) + preserved = staging / f"{fid}_malformed.xml" + try: + shutil.copyfile(p, preserved) + except Exception: + pass + bad.add(fid) + continue + # Count by filename id primarily; fall back to testcase name if needed + if fid in DESIRED: + seen.add(fid) + continue + if r.tag == "testcase": + name = (r.get("name") or "").strip() + for d in DESIRED: + if name.startswith(d): + seen.add(d) + break + + Path("reports").mkdir(parents=True, exist_ok=True) + for d in DESIRED: + if d in seen: + continue + frag = Path(f"reports/{d}_results.xml") + tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d}) + if d in bad: + fail = ET.SubElement(tc, "failure", {"message":"malformed xml"}) + fail.text = "The agent wrote a fragment file, but it was not valid XML (parse failed). See reports/_staging/*_malformed.xml for the preserved original." + else: + fail = ET.SubElement(tc, "failure", {"message":"not produced"}) + fail.text = "The agent did not emit a fragment for this test." + ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) + print(f"backfill: {d}") + PY + + - name: "Debug: list testcase names" + if: always() + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + for p in sorted(Path('reports').glob('*_results.xml')): + try: + r = ET.parse(p).getroot() + if r.tag == 'testcase': + print(f"{p.name}: {(r.get('name') or '').strip()}") + except Exception: + pass + PY + + # ---------- Merge testcase fragments into JUnit ---------- + - name: Normalize/assemble JUnit in-place (single file) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import re, os + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + if not src.exists(): + raise SystemExit(0) + + tree = ET.parse(src) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + if suite is None: + raise SystemExit(0) + + def id_from_filename(p: Path): + n = p.name + m = re.match(r'NL-?(\d+)_results\.xml$', n, re.I) + if m: + return f"NL-{int(m.group(1))}" + m = re.match(r'T-?([A-J])_results\.xml$', n, re.I) + if m: + return f"T-{m.group(1).upper()}" + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) + if m: + return f"GO-{int(m.group(1))}" + return None + + def id_from_system_out(tc): + so = tc.find('system-out') + if so is not None and so.text: + m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text) + if m: + return m.group(1) + return None + + fragments = sorted(Path('reports').glob('*_results.xml')) + report_names = {p.name for p in fragments} + fragments += sorted(p for p in Path('reports/_staging').glob('*_results.xml') if p.name not in report_names) + if fragments: + print("merge fragments:", ", ".join(p.as_posix() for p in fragments)) + added = 0 + renamed = 0 + + for frag in fragments: + tcs = [] + try: + froot = ET.parse(frag).getroot() + if localname(froot.tag) == 'testcase': + tcs = [froot] + else: + tcs = list(froot.findall('.//testcase')) + except Exception: + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + # Extract all testcase nodes from raw text + nodes = re.findall(r'', txt, flags=re.DOTALL) + for m in nodes: + try: + tcs.append(ET.fromstring(m)) + except Exception: + pass + + # Guard: keep only the first testcase from each fragment + if len(tcs) > 1: + tcs = tcs[:1] + + test_id = id_from_filename(frag) + + for tc in tcs: + current_name = tc.get('name') or '' + tid = test_id or id_from_system_out(tc) + # Enforce filename-derived ID as prefix; repair names if needed + if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', current_name): + title = current_name.strip() + new_name = f'{tid} — {title}' if title else tid + tc.set('name', new_name) + elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name): + # Replace any wrong leading ID with the correct one + title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', current_name).strip() + new_name = f'{tid} — {title}' if title else tid + tc.set('name', new_name) + renamed += 1 + suite.append(tc) + added += 1 + print(f"merge add: {frag.name} -> {tc.get('name')}") + + if added: + # Drop bootstrap placeholder and recompute counts + for tc in list(suite.findall('.//testcase')): + if (tc.get('name') or '') == 'NL-Suite.Bootstrap': + suite.remove(tc) + testcases = suite.findall('.//testcase') + failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) + suite.set('tests', str(len(testcases))) + suite.set('failures', str(failures_cnt)) + suite.set('errors', '0') + suite.set('skipped', '0') + tree.write(src, encoding='utf-8', xml_declaration=True) + print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") + PY + + # Guard is GO-specific; only parse GO fragments here. + - name: "Guard: ensure GO fragments merged into JUnit" + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import os, re + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + junit_path = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + if not junit_path.exists(): + raise SystemExit(0) + + tree = ET.parse(junit_path) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + if suite is None: + raise SystemExit(0) + + def id_from_filename(p: Path): + n = p.name + m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I) + if m: + return f"GO-{int(m.group(1))}" + return None + + expected = set() + for p in list(Path("reports").glob("GO-*_results.xml")) + list(Path("reports/_staging").glob("GO-*_results.xml")): + fid = id_from_filename(p) + if fid: + expected.add(fid) + + seen = set() + for tc in suite.findall('.//testcase'): + name = (tc.get('name') or '').strip() + m = re.match(r'(GO-\d+)\b', name) + if m: + seen.add(m.group(1)) + + missing = sorted(expected - seen) + if missing: + print(f"::error::GO fragments present but not merged into JUnit: {' '.join(missing)}") + raise SystemExit(1) + PY + + # ---------- Markdown summary from JUnit ---------- + - name: Build markdown summary from JUnit + if: always() + shell: bash + run: | + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + import os, html, re + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + md_out.parent.mkdir(parents=True, exist_ok=True) + + if not src.exists(): + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') + raise SystemExit(0) + + tree = ET.parse(src) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + cases = [] if suite is None else list(suite.findall('.//testcase')) + + def id_from_case(tc): + n = (tc.get('name') or '') + m = re.match(r'\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', n) + if m: + return m.group(1) + so = tc.find('system-out') + if so is not None and so.text: + m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text) + if m: + return m.group(1) + return None + + id_status = {} + name_map = {} + for tc in cases: + tid = id_from_case(tc) + ok = (tc.find('failure') is None and tc.find('error') is None) + if tid and tid not in id_status: + id_status[tid] = ok + name_map[tid] = (tc.get('name') or tid) + + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10'] + default_titles = { + 'NL-0': 'Baseline State Capture', + 'NL-1': 'Core Method Operations', + 'NL-2': 'Anchor Comment Insertion', + 'NL-3': 'End-of-Class Content', + 'NL-4': 'Console State Verification', + 'T-A': 'Temporary Helper', + 'T-B': 'Method Body Interior', + 'T-C': 'Different Method Interior', + 'T-D': 'End-of-Class Helper', + 'T-E': 'Method Evolution', + 'T-F': 'Atomic Multi-Edit', + 'T-G': 'Path Normalization', + 'T-H': 'Validation on Modified', + 'T-I': 'Failure Surface', + 'T-J': 'Idempotency', + 'GO-0': 'Hierarchy with ComponentTypes', + 'GO-1': 'Find GameObjects Tool', + 'GO-2': 'GameObject Resource Read', + 'GO-3': 'Components Resource Read', + 'GO-4': 'Manage Components Tool', + 'GO-5': 'Find GameObjects by Name', + 'GO-6': 'Find GameObjects by Tag', + 'GO-7': 'Single Component Resource Read', + 'GO-8': 'Remove Component', + 'GO-9': 'Find with Pagination', + 'GO-10': 'Deprecation Warnings', + } + + def display_name(test_id: str) -> str: + # Prefer the emitted testcase "name" attribute (it may already include ID + title). + n = (name_map.get(test_id) or '').strip() + if n: + return n + t = (default_titles.get(test_id) or '').strip() + return f"{test_id} — {t}" if t else test_id + + total = len(cases) + failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) + passed = total - failures + + lines = [] + lines += [ + '# Unity NL/T Editing Suite Test Results', + '', + f'Totals: {passed} passed, {failures} failed, {total} total', + '', + '## Test Checklist' + ] + for p in desired: + st = id_status.get(p, None) + label = display_name(p) + lines.append(f"- [x] {label}" if st is True else (f"- [ ] {label} (fail)" if st is False else f"- [ ] {label} (not run)")) + lines.append('') + + lines.append('## Test Details (trimmed)') + + def order_key(n: str): + if n.startswith('NL-'): + try: + return (0, int(n.split('-')[1])) + except: + return (0, 999) + if n.startswith('T-') and len(n) > 2: + return (1, ord(n[2])) + if n.startswith('GO-'): + try: + return (2, int(n.split('-')[1])) + except: + return (2, 999) + return (3, n) + + MAX_CHARS = 800 + MAX_LINES = 8 + seen = set() + for tid in sorted(id_status.keys(), key=order_key): + seen.add(tid) + tc = next((c for c in cases if (id_from_case(c) == tid)), None) + if not tc: + continue + title = name_map.get(tid, tid) + status_badge = "PASS" if id_status[tid] else "FAIL" + lines.append(f"### {title} — {status_badge}") + so = tc.find('system-out') + text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n')) + if text.strip(): + t = text.strip() + truncated = False + lines_out = t.splitlines() + if len(lines_out) > MAX_LINES: + t = "\n".join(lines_out[:MAX_LINES]).rstrip() + truncated = True + if len(t) > MAX_CHARS: + t = t[:MAX_CHARS].rstrip() + truncated = True + if truncated: + t += "\n…(truncated)" + fence = '```' if '```' not in t else '````' + lines += [fence, t, fence] + else: + lines.append('(no system-out)') + node = tc.find('failure') or tc.find('error') + if node is not None: + msg = (node.get('message') or '').strip() + body = (node.text or '').strip() + if msg: + lines.append(f"- Message: {msg}") + if body: + lines.append(f"- Detail: {body.splitlines()[0][:500]}") + lines.append('') + + for tc in cases: + if id_from_case(tc) in seen: + continue + title = tc.get('name') or '(unnamed)' + status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" + lines.append(f"### {title} — {status_badge}") + lines.append('(unmapped test id)') + lines.append('') + + md_out.write_text('\n'.join(lines), encoding='utf-8') + PY + + # ---------- CI gate: fail job if any NL/T test missing or failed ---------- + - name: Fail CI if NL/T incomplete or failed + if: always() + shell: bash + run: | + python3 - <<'PY' + import os, re, sys + from pathlib import Path + import xml.etree.ElementTree as ET + + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10'] + + junit_path = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + if not junit_path.exists(): + print("::error::No JUnit output found; failing CI gate.") + sys.exit(1) + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + tree = ET.parse(junit_path) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + cases = [] if suite is None else list(suite.findall('.//testcase')) + + def id_from_case(tc): + name = (tc.get('name') or '').strip() + m = re.match(r'(NL-\d+|T-[A-Z]|GO-\d+)\b', name) + if m: + return m.group(1) + so = tc.find('system-out') + if so is not None and so.text: + m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text) + if m: + return m.group(1) + return None + + # Determine status per desired ID (first occurrence wins, matching the summary builder) + id_status = {} + for tc in cases: + tid = id_from_case(tc) + if not tid or tid not in desired or tid in id_status: + continue + ok = (tc.find('failure') is None and tc.find('error') is None) + id_status[tid] = ok + + missing = [d for d in desired if d not in id_status] + failed = [d for d, ok in id_status.items() if ok is False] + + if missing: + print(f"::error::Missing NL/T tests in JUnit: {' '.join(missing)}") + if failed: + print(f"::error::Failing NL/T tests in JUnit: {' '.join(sorted(failed))}") + + # Gate: all desired must be present and passing + if missing or failed: + sys.exit(1) + + print("NL/T CI gate passed: all required tests present and passing.") + PY + + # ---------- Collect execution transcript (if present) ---------- + - name: Collect action execution transcript + if: always() + shell: bash + run: | + set -eux + if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then + cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json + elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then + cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json + fi + + - name: Sanitize markdown (normalize newlines) + if: always() + run: | + set -eu + python3 - <<'PY' + from pathlib import Path + rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) + for p in rp.glob('*.md'): + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY + + - name: NL/T details -> Job Summary + if: always() + run: | + python3 - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + + print("## Unity NL/T Editing Suite — Summary") + print("") + + p = Path('reports/junit-nl-suite.md') + if not p.exists(): + print("_No markdown report found._") + raise SystemExit(0) + + text = p.read_bytes().decode('utf-8', 'replace').replace('\r\n', '\n') + lines = text.splitlines() + + details_start = None + for i, line in enumerate(lines): + if line.startswith("## Test Details"): + details_start = i + break + + # Keep the summary compact: show heading/totals/checklist only. + prefix_lines = lines if details_start is None else lines[:details_start] + prefix = "\n".join(prefix_lines).strip() + if prefix: + print(prefix) + else: + print("_No summary section found in markdown report._") + + failed_blocks = [] + if details_start is not None: + i = details_start + 1 + while i < len(lines): + line = lines[i] + if line.startswith("### "): + block = [line] + i += 1 + while i < len(lines) and not lines[i].startswith("### "): + block.append(lines[i]) + i += 1 + if " — FAIL" in line or " - FAIL" in line: + failed_blocks.append(block) + continue + i += 1 + + if failed_blocks: + print("") + print("## Failing Test Details") + print("") + max_block_lines = 40 + for block in failed_blocks: + if len(block) > max_block_lines: + block = block[:max_block_lines] + ["…(truncated)"] + print("\n".join(block).rstrip()) + print("") + else: + print("") + print("_All tests passed. Full per-test details are in artifact file `reports/junit-nl-suite.md`._") + PY + + - name: Fallback JUnit if missing + if: always() + run: | + set -eu + mkdir -p reports + if [ ! -f "$JUNIT_OUT" ]; then + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "$JUNIT_OUT" + fi + + - name: Publish JUnit report + if: always() + uses: mikepenz/action-junit-report@v5 + with: + report_paths: "${{ env.JUNIT_OUT }}" + include_passed: false + detailed_summary: false + annotate_notice: false + check_annotations: false + job_summary: false + verbose_summary: false + skip_success_summary: true + require_tests: false + fail_on_parse_error: true + + - name: Upload artifacts (reports + fragments + transcript + debug) + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-suite-artifacts + path: | + ${{ env.JUNIT_OUT }} + ${{ env.MD_OUT }} + reports/*_results.xml + reports/claude-execution-output.json + ${{ github.workspace }}/.unity-mcp/mcp-server-startup-debug.log + retention-days: 7 + + # ---------- Always stop Unity ---------- + - name: Stop Unity + if: always() + run: | + docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + docker rm -f unity-mcp || true + + - name: Return Pro license (if used) + if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' + uses: game-ci/unity-return-license@v2 + continue-on-error: true + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..367f8b0 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,97 @@ +name: Docs — Build & Deploy + +# Builds the Docusaurus site under /website and, on push to beta, deploys +# to GitHub Pages at https://coplaydev.github.io/unity-mcp/. +# +# PR runs only build (no deploy) as a preview-validation step. +# Re-deploys also fire when the Python tool/resource registry changes, +# so M3's auto-generated reference pages stay fresh. + +on: + push: + branches: [beta] + paths: + - website/** + - docs/** + - Server/src/services/tools/** + - Server/src/services/resources/** + - Server/src/services/registry/** + - .github/workflows/docs-deploy.yml + pull_request: + branches: [beta, main] + paths: + - website/** + - docs/** + - Server/src/services/tools/** + - Server/src/services/resources/** + - Server/src/services/registry/** + - .github/workflows/docs-deploy.yml + workflow_dispatch: {} + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history so Docusaurus's showLastUpdateTime / showLastUpdateAuthor + # can resolve real per-file commit metadata. Shallow clones make every + # page report the latest commit instead. + fetch-depth: 0 + # No push back from this workflow — the deploy uses the Pages + # OIDC token issued by actions/deploy-pages, not the repo token. + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Setup Pages + if: github.event_name == 'push' && github.ref == 'refs/heads/beta' + uses: actions/configure-pages@v5 + + - name: Build + working-directory: website + env: + # Inject the cookieless GoatCounter beacon when provisioned. The site + # gates on process.env.GOATCOUNTER_CODE, which Actions does NOT + # auto-expose — the Actions variable must be mapped in explicitly. + GOATCOUNTER_CODE: ${{ vars.GOATCOUNTER_CODE }} + run: npm run build + + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/beta' + uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + name: Deploy to GitHub Pages + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/beta' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs-generate.yml b/.github/workflows/docs-generate.yml new file mode 100644 index 0000000..5db34ef --- /dev/null +++ b/.github/workflows/docs-generate.yml @@ -0,0 +1,72 @@ +name: Docs — Reference Drift Check + +# Fails a PR if the committed /website/docs/reference/ output disagrees +# with what tools/generate_docs_reference.py would produce from the live +# Python tool/resource registry. Contributors should regenerate locally: +# +# cd Server && uv run python ../tools/generate_docs_reference.py +# +# or install the pre-commit hook to make this automatic: +# +# tools/install-hooks.sh + +on: + pull_request: + branches: [beta, main] + paths: + - Server/src/services/tools/** + - Server/src/services/resources/** + - Server/src/services/registry/** + - website/docs/reference/** + - tools/generate_docs_reference.py + - .github/workflows/docs-generate.yml + push: + branches: [beta] + paths: + - Server/src/services/tools/** + - Server/src/services/resources/** + - Server/src/services/registry/** + - website/docs/reference/** + - tools/generate_docs_reference.py + - .github/workflows/docs-generate.yml + workflow_dispatch: {} + +jobs: + check: + name: Check docs reference is fresh + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: latest + + - name: Set up Python + run: uv python install 3.10 + + - name: Sync Server deps + working-directory: Server + run: uv sync + + - name: Drift check + working-directory: Server + run: uv run python ../tools/generate_docs_reference.py --check + + - name: Tool-count sanity + run: | + set -euo pipefail + # Match the decorator at start-of-line (skips imports and docstring mentions). + decorator_count=$(grep -rhE "^@mcp_for_unity_tool\(" Server/src/services/tools/ | wc -l | tr -d ' ') + # md_count excludes group landing pages (index.md) and the catalog root. + md_count=$(find website/docs/reference/tools -name '*.md' -not -name 'index.md' | wc -l | tr -d ' ') + echo "decorator_count=$decorator_count, md_count=$md_count" + if [[ "$decorator_count" != "$md_count" ]]; then + echo "Mismatch: $decorator_count @mcp_for_unity_tool decorators vs $md_count reference pages." >&2 + echo "Run: cd Server && uv run python ../tools/generate_docs_reference.py" >&2 + exit 1 + fi diff --git a/.github/workflows/e2e-bridge.yml b/.github/workflows/e2e-bridge.yml new file mode 100644 index 0000000..e6efaf9 --- /dev/null +++ b/.github/workflows/e2e-bridge.yml @@ -0,0 +1,194 @@ +name: E2E Bridge Smoke (deterministic, no LLM) + +# Boots a headless Unity Editor, starts the Python MCP server's wire path, and +# drives a fixed sequence of real tool calls with exact assertions +# (Server/tests/e2e/bridge_smoke.py). Unlike claude-nl-suite.yml this needs +# NO Anthropic API key -- it is deterministic and cheap, so it can gate PRs and +# releases. It still needs Unity license secrets to boot the Editor. + +on: + workflow_dispatch: + pull_request: + paths: + - "MCPForUnity/Editor/**" + - "MCPForUnity/Runtime/**" + - "Server/src/**" + - "Server/tests/e2e/**" + - "tools/local_harness.py" + - ".github/workflows/e2e-bridge.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3 + +jobs: + e2e-bridge: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Detect Unity license secrets + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -e + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + echo "::warning::Unity license secrets absent; E2E bridge smoke will be skipped (not failed)." + fi + + - uses: actions/checkout@v4 + if: steps.detect.outputs.unity_ok == 'true' + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v4 + if: steps.detect.outputs.unity_ok == 'true' + with: + python-version: "3.11" + + - name: Install MCP server + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + uv pip install -e Server + + # --- License staging (mirrors claude-nl-suite.yml) --- + - name: Decide license sources + if: steps.detect.outputs.unity_ok == 'true' + id: lic + shell: bash + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + use_ulf=false; use_ebl=false + [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true + [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" && -n "${UNITY_SERIAL:-}" ]] && use_ebl=true + echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" + echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" + + - name: Stage Unity .ulf license (from secret) + if: steps.detect.outputs.unity_ok == 'true' && steps.lic.outputs.use_ulf == 'true' + id: ulf + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + shell: bash + run: | + set -eu + mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" + f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" + if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then + printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" + else + printf "%s" "$UNITY_LICENSE" > "$f" + fi + chmod 600 "$f" || true + if grep -qi '' "$f"; then + cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Activate Unity (EBL via container) + if: steps.detect.outputs.unity_ok == 'true' && steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" + docker run --rm --network host \ + -e HOME=/root -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" bash -lc ' + set -euxo pipefail + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true + ' + + - name: Warm up project (import Library once) + if: steps.detect.outputs.unity_ok == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + docker run --rm --network host \ + -e HOME=/root \ + -v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + -v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \ + "${manual_args[@]}" -quit + + - name: Clean old MCP status + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + mkdir -p "$GITHUB_WORKSPACE/.unity-mcp" + rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true + + - name: Run headless bridge harness (boot + wait + smoke/editmode/playmode) + if: steps.detect.outputs.unity_ok == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + # In --ci mode the harness drives the DockerLauncher: it runs the same + # docker container (repo .unity-mcp status dir, docker liveness/teardown, + # log redaction), waits on the status file, derives the instance, then + # runs the smoke + EditMode + PlayMode legs over the bridge. + license_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + license_args=(--editor-arg -manualLicenseFile \ + --editor-arg "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + python3 tools/local_harness.py --ci \ + --legs smoke,editmode,playmode \ + --project-path TestProjects/UnityMCPTests \ + --reports reports \ + "${license_args[@]}" + + - name: Unity logs on failure + if: failure() && steps.detect.outputs.unity_ok == 'true' + run: docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' || true + + - name: Upload E2E report + if: always() && steps.detect.outputs.unity_ok == 'true' + uses: actions/upload-artifact@v4 + with: + name: e2e-bridge-report + path: reports/junit-*.xml + if-no-files-found: ignore diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml new file mode 100644 index 0000000..344b4df --- /dev/null +++ b/.github/workflows/github-repo-stats.yml @@ -0,0 +1,20 @@ +name: github-repo-stats + +on: + # schedule: + # Run this once per day, towards the end of the day for keeping the most + # recent data point most meaningful (hours are interpreted in UTC). + #- cron: "0 23 * * *" + workflow_dispatch: # Allow for running this manually. + +jobs: + j1: + if: github.repository == 'CoplayDev/unity-mcp' + name: github-repo-stats + runs-on: ubuntu-latest + steps: + - name: run-ghrs + # Use latest release. + uses: jgehrcke/github-repo-stats@RELEASE + with: + ghtoken: ${{ secrets.ghrs_github_api_token }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..d21112b --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,81 @@ +name: Python Tests + +on: + push: + # Exclude beta and main: those branches re-trigger this workflow via + # workflow_call from beta-release.yml / release.yml. Without the exclusion, + # a push to beta that touches Server/** fires this workflow twice for the + # same SHA, and GitHub auto-cancels the duplicate. + branches-ignore: [beta, main] + paths: + - Server/** + - tools/** + - .github/workflows/python-tests.yml + pull_request: + branches: [main, beta] + paths: + - Server/** + - tools/** + - .github/workflows/python-tests.yml + workflow_dispatch: {} + workflow_call: + inputs: + ref: + description: "Git ref to test (defaults to the triggering ref)." + type: string + required: false + default: "" + +jobs: + test: + name: Run Python Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.10 + + - name: Install dependencies + run: | + cd Server + uv sync + uv pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + cd Server + uv run pytest tests/ -v --tb=short --cov --cov-report=xml --cov-report=html --cov-report=term + + - name: Run local harness unit tests (hermetic, no Unity) + run: | + cd Server + uv run python -m pytest "$GITHUB_WORKSPACE/tools/tests/" -v --tb=short + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: always() + with: + files: ./Server/coverage.xml + flags: python + name: python-coverage + fail_ci_if_error: false + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-results + path: | + Server/.pytest_cache/ + Server/tests/ + Server/coverage.xml + Server/htmlcov/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4815e7c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,557 @@ +name: Release + +concurrency: + group: release-main + cancel-in-progress: false + +on: + workflow_dispatch: + inputs: + version_bump: + description: "Version bump type (none = release beta version as-is)" + type: choice + options: + - patch + - minor + - major + - none + default: patch + required: true + +jobs: + unity_tests: + name: Unity tests gate + uses: ./.github/workflows/unity-tests.yml + with: + ref: beta + secrets: inherit + + python_tests: + name: Python tests gate + uses: ./.github/workflows/python-tests.yml + with: + ref: beta + + bump: + name: Bump version, tag, and create release + runs-on: ubuntu-latest + needs: [unity_tests, python_tests] + permissions: + contents: write + pull-requests: write + outputs: + new_version: ${{ steps.compute.outputs.new_version }} + tag: ${{ steps.tag.outputs.tag }} + bump_branch: ${{ steps.bump_branch.outputs.name }} + steps: + - name: Ensure workflow is running on main + shell: bash + run: | + set -euo pipefail + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "This workflow must be run on the main branch. Current ref: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - name: Show current versions + id: preview + shell: bash + run: | + set -euo pipefail + echo "============================================" + echo "CURRENT VERSION STATUS" + echo "============================================" + + # Get main version + MAIN_VERSION=$(jq -r '.version' "MCPForUnity/package.json") + MAIN_PYPI=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml) + echo "Main branch:" + echo " Unity package: $MAIN_VERSION" + echo " PyPI server: $MAIN_PYPI" + echo "" + + # Get beta version + git fetch origin beta + BETA_VERSION=$(git show origin/beta:MCPForUnity/package.json | jq -r '.version') + BETA_PYPI=$(git show origin/beta:Server/pyproject.toml | grep -oP '(?<=version = ")[^"]+') + echo "Beta branch:" + echo " Unity package: $BETA_VERSION" + echo " PyPI server: $BETA_PYPI" + echo "" + + # Compute stripped version (used for "none" bump option) + STRIPPED=$(echo "$BETA_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + echo "stripped_version=$STRIPPED" >> "$GITHUB_OUTPUT" + + # Show what will happen + BUMP="${{ inputs.version_bump }}" + echo "Selected bump type: $BUMP" + echo "After stripping beta suffix: $STRIPPED" + + if [[ "$BUMP" == "none" ]]; then + echo "Release version will be: $STRIPPED" + else + IFS='.' read -r MA MI PA <<< "$STRIPPED" + case "$BUMP" in + major) ((MA+=1)); MI=0; PA=0 ;; + minor) ((MI+=1)); PA=0 ;; + patch) ((PA+=1)) ;; + esac + echo "Release version will be: $MA.$MI.$PA" + fi + echo "============================================" + + - name: Merge beta into main + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Fetch beta branch + git fetch origin beta + + # Check if beta has changes not in main + if git merge-base --is-ancestor origin/beta HEAD; then + echo "beta is already merged into main. Nothing to merge." + else + echo "Merging beta into main..." + git merge origin/beta --no-edit -m "chore: merge beta into main for release" + echo "Beta merged successfully." + fi + + - name: Strip beta suffix from version if present + shell: bash + run: | + set -euo pipefail + CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") + echo "Current version: $CURRENT_VERSION" + + # Strip beta/alpha/rc suffix if present (e.g., "9.4.0-beta.1" -> "9.4.0") + if [[ "$CURRENT_VERSION" == *"-"* ]]; then + STABLE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//') + # Validate we have a proper X.Y.Z format after stripping + if ! [[ "$STABLE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Could not parse version '$CURRENT_VERSION' -> '$STABLE_VERSION'" >&2 + exit 1 + fi + echo "Stripping prerelease suffix: $CURRENT_VERSION -> $STABLE_VERSION" + jq --arg v "$STABLE_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json + mv tmp.json MCPForUnity/package.json + + # Also update pyproject.toml + sed -i "s/^version = .*/version = \"${STABLE_VERSION}\"/" Server/pyproject.toml + else + echo "Version is already stable: $CURRENT_VERSION" + fi + + - name: Compute new version + id: compute + env: + PREVIEWED_STRIPPED: ${{ steps.preview.outputs.stripped_version }} + shell: bash + run: | + set -euo pipefail + BUMP="${{ inputs.version_bump }}" + CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") + echo "Current version: $CURRENT_VERSION" + + # Sanity check: ensure current version matches what was previewed + if [[ "$CURRENT_VERSION" != "$PREVIEWED_STRIPPED" ]]; then + echo "Warning: Current version ($CURRENT_VERSION) differs from previewed ($PREVIEWED_STRIPPED)" + echo "This may indicate an unexpected merge result. Proceeding with current version." + fi + + if [[ "$BUMP" == "none" ]]; then + # Use the previewed stripped version to ensure consistency with what user saw + NEW_VERSION="$PREVIEWED_STRIPPED" + else + IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION" + case "$BUMP" in + major) + ((MA+=1)); MI=0; PA=0 + ;; + minor) + ((MI+=1)); PA=0 + ;; + patch) + ((PA+=1)) + ;; + *) + echo "Unknown version_bump: $BUMP" >&2 + exit 1 + ;; + esac + NEW_VERSION="$MA.$MI.$PA" + fi + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + + - name: Compute tag + id: tag + env: + NEW_VERSION: ${{ steps.compute.outputs.new_version }} + shell: bash + run: | + set -euo pipefail + echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT" + + - name: Update files to new version + env: + NEW_VERSION: ${{ steps.compute.outputs.new_version }} + shell: bash + run: | + set -euo pipefail + + echo "Updating all version references to $NEW_VERSION" + python3 tools/update_versions.py --version "$NEW_VERSION" + + - name: Commit version bump to a temporary branch + id: bump_branch + env: + NEW_VERSION: ${{ steps.compute.outputs.new_version }} + shell: bash + run: | + set -euo pipefail + BRANCH="release/v${NEW_VERSION}" + echo "name=$BRANCH" >> "$GITHUB_OUTPUT" + + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git checkout -b "$BRANCH" + git add MCPForUnity/package.json manifest.json "Server/pyproject.toml" Server/README.md + if git diff --cached --quiet; then + echo "No version changes to commit." + else + git commit -m "chore: bump version to ${NEW_VERSION}" + fi + + echo "Pushing bump branch $BRANCH" + git push origin "$BRANCH" + + - name: Create PR for version bump into main + id: bump_pr + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.compute.outputs.new_version }} + BRANCH: ${{ steps.bump_branch.outputs.name }} + shell: bash + run: | + set -euo pipefail + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: bump version to ${NEW_VERSION}" \ + --body "Automated version bump to ${NEW_VERSION}.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Enable auto-merge and merge PR + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.bump_pr.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + # Enable auto-merge (requires repo setting "Allow auto-merge") + gh pr merge "$PR_NUMBER" --merge --auto || true + # Wait for PR to be merged (poll up to 2 minutes) + for i in {1..24}; do + STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state') + if [[ "$STATE" == "MERGED" ]]; then + echo "PR merged successfully." + exit 0 + fi + echo "Waiting for PR to merge... (state: $STATE)" + sleep 5 + done + echo "PR did not merge in time. Attempting direct merge..." + gh pr merge "$PR_NUMBER" --merge + + - name: Fetch merged main and create tag + env: + TAG: ${{ steps.tag.outputs.tag }} + shell: bash + run: | + set -euo pipefail + git fetch origin main + git checkout main + git pull origin main + + echo "Preparing to create tag $TAG" + + if git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then + echo "Tag $TAG already exists on remote. Refusing to release." >&2 + exit 1 + fi + + git tag -a "$TAG" -m "Version ${TAG#v}" + git push origin "$TAG" + + - name: Clean up release branch + if: always() + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.bump_branch.outputs.name }} + shell: bash + run: | + set -euo pipefail + git push origin --delete "$BRANCH" || true + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + generate_release_notes: true + + sync_beta: + name: Merge main back into beta via PR + needs: + - bump + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout beta + uses: actions/checkout@v6 + with: + ref: beta + fetch-depth: 0 + + - name: Prepare sync branch from beta with merged main + id: sync_branch + env: + NEW_VERSION: ${{ needs.bump.outputs.new_version }} + shell: bash + run: | + set -euo pipefail + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Fetch both branches so we can build a merge commit in CI. + git fetch origin main beta + if git merge-base --is-ancestor origin/main origin/beta; then + echo "beta is already up to date with main. Skipping sync." + echo "skipped=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + SYNC_BRANCH="sync/main-v${NEW_VERSION}-into-beta-${GITHUB_RUN_ID}" + echo "name=$SYNC_BRANCH" >> "$GITHUB_OUTPUT" + echo "skipped=false" >> "$GITHUB_OUTPUT" + + git checkout -b "$SYNC_BRANCH" origin/beta + + if git merge origin/main --no-ff --no-commit; then + echo "main merged cleanly into sync branch." + else + echo "Merge conflicts detected. Attempting expected conflict resolution for beta version files." + CONFLICTS=$(git diff --name-only --diff-filter=U || true) + if [[ -n "$CONFLICTS" ]]; then + echo "$CONFLICTS" + fi + + # Keep beta-side prerelease versions if these files conflict. + for file in MCPForUnity/package.json Server/pyproject.toml; do + if git ls-files -u -- "$file" | grep -q .; then + echo "Keeping beta version for $file" + git checkout --ours -- "$file" + git add "$file" + fi + done + + REMAINING=$(git diff --name-only --diff-filter=U || true) + if [[ -n "$REMAINING" ]]; then + echo "Unexpected unresolved conflicts remain:" + echo "$REMAINING" + exit 1 + fi + fi + + git commit -m "chore: sync main (v${NEW_VERSION}) into beta" + + # After releasing X.Y.Z on main, beta should move to X.Y.(Z+1)-beta.1. + IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW_VERSION" + NEXT_PATCH=$((PATCH + 1)) + NEXT_BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.1" + echo "beta_version=$NEXT_BETA_VERSION" >> "$GITHUB_OUTPUT" + echo "Setting beta version to $NEXT_BETA_VERSION" + + CURRENT_BETA_VERSION=$(jq -r '.version' MCPForUnity/package.json) + if [[ "$CURRENT_BETA_VERSION" != "$NEXT_BETA_VERSION" ]]; then + jq --arg v "$NEXT_BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json + mv tmp.json MCPForUnity/package.json + git add MCPForUnity/package.json + git commit -m "chore: set beta version to ${NEXT_BETA_VERSION} after release v${NEW_VERSION}" + else + echo "Beta version already at target: $NEXT_BETA_VERSION" + fi + + echo "Pushing sync branch $SYNC_BRANCH" + git push origin "$SYNC_BRANCH" + + - name: Create PR to merge sync branch into beta + if: steps.sync_branch.outputs.skipped != 'true' + id: sync_pr + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ needs.bump.outputs.new_version }} + NEXT_BETA_VERSION: ${{ steps.sync_branch.outputs.beta_version }} + SYNC_BRANCH: ${{ steps.sync_branch.outputs.name }} + shell: bash + run: | + set -euo pipefail + PR_URL=$(gh pr create \ + --base beta \ + --head "$SYNC_BRANCH" \ + --title "chore: sync main (v${NEW_VERSION}) into beta" \ + --body "Automated sync of main back into beta after release v${NEW_VERSION}, including beta version set to ${NEXT_BETA_VERSION}.") + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Merge sync PR + if: steps.sync_branch.outputs.skipped != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.sync_pr.outputs.pr_number }} + shell: bash + run: | + set -euo pipefail + + # Best effort: auto-merge if repository settings allow it. + gh pr merge "$PR_NUMBER" --merge --auto --delete-branch || true + + # Retry direct merge for up to 2 minutes while checks settle. + for i in {1..24}; do + STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state') + if [[ "$STATE" == "MERGED" ]]; then + echo "Sync PR merged successfully." + exit 0 + fi + + if gh pr merge "$PR_NUMBER" --merge --delete-branch >/dev/null 2>&1; then + echo "Sync PR merged successfully." + exit 0 + fi + + echo "Waiting for sync PR to become mergeable... (state: $STATE)" + sleep 5 + done + + echo "Sync PR did not merge in time." + gh pr view "$PR_NUMBER" --json state,mergeStateStatus,isDraft -q '{state: .state, mergeStateStatus: .mergeStateStatus, isDraft: .isDraft}' + exit 1 + + publish_docker: + name: Publish Docker image + needs: + - bump + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v6 + with: + ref: ${{ needs.bump.outputs.tag }} + fetch-depth: 0 + + - name: Build and push Docker image + uses: ./.github/actions/publish-docker + with: + docker_username: ${{ secrets.DOCKER_USERNAME }} + docker_password: ${{ secrets.DOCKER_PASSWORD }} + image: ${{ secrets.DOCKER_USERNAME }}/mcp-for-unity-server + version: ${{ needs.bump.outputs.new_version }} + include_branch_tags: "false" + context: . + dockerfile: Server/Dockerfile + platforms: linux/amd64 + + publish_pypi: + name: Publish Python distribution to PyPI + needs: + - bump + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/mcpforunityserver + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.bump.outputs.tag }} + fetch-depth: 0 + + # Inlined from .github/actions/publish-pypi to avoid nested composite action issue + # with pypa/gh-action-pypi-publish (see https://github.com/pypa/gh-action-pypi-publish/issues/338) + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + enable-cache: true + cache-dependency-glob: "Server/uv.lock" + + - name: Build a binary wheel and a source tarball + shell: bash + run: uv build + working-directory: ./Server + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: Server/dist/ + + publish_mcpb: + name: Generate and publish MCPB bundle + needs: + - bump + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out the repo + uses: actions/checkout@v6 + with: + ref: ${{ needs.bump.outputs.tag }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Generate MCPB bundle + env: + NEW_VERSION: ${{ needs.bump.outputs.new_version }} + shell: bash + run: | + set -euo pipefail + python3 tools/generate_mcpb.py "$NEW_VERSION" \ + --output "unity-mcp-${NEW_VERSION}.mcpb" \ + --icon docs/images/coplay-logo.png + + - name: Upload MCPB to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.bump.outputs.tag }} + files: unity-mcp-${{ needs.bump.outputs.new_version }}.mcpb diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml new file mode 100644 index 0000000..4120a30 --- /dev/null +++ b/.github/workflows/stats.yml @@ -0,0 +1,25 @@ +name: Adoption stats (maintainer-only) +# Posts unified PyPI install + docs-traffic numbers to the workflow run summary, +# which is visible ONLY to repo collaborators. Nothing is published publicly. +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: {} +permissions: + contents: read +jobs: + stats: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 20 } + - name: Fetch + summarize stats (private to collaborators) + env: + # public stars/forks work with the default token; unique cloners/viewers + # need a maintainer PAT with Administration: read (STATS_GITHUB_TOKEN). + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STATS_GITHUB_TOKEN: ${{ secrets.STATS_GITHUB_TOKEN }} + GOATCOUNTER_TOKEN: ${{ secrets.GOATCOUNTER_TOKEN }} + GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }} + run: node website/scripts/fetch-stats.mjs >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sync-releases.yml b/.github/workflows/sync-releases.yml new file mode 100644 index 0000000..8892af0 --- /dev/null +++ b/.github/workflows/sync-releases.yml @@ -0,0 +1,78 @@ +name: Docs — Sync Release Notes + +# Keeps website/docs/releases.md and the README's "Recent Updates" block +# in sync with the GitHub Releases API. Source of truth is GitHub Releases. +# +# Triggers (intentionally narrow): +# - release.published / edited / unpublished / deleted: the only time +# the synced files can legitimately go stale. Fires on every release +# event so the docs reflect the new version within ~30 seconds. +# - workflow_dispatch: manual escape hatch (re-run after a one-off +# GitHub UI edit to a release body, or to backfill after a downtime). +# +# Why no pull_request / schedule triggers: +# - PR-time drift-check would fail outsider PRs that touched README for +# unrelated reasons (e.g. typo fixes) and the contributor has no push +# access to fix it. The synced files are maintained by the release +# pipeline, not by PR authors — drift can't logically be introduced +# by a PR that the workflow couldn't already handle on release. +# - A daily cron would mask the source-of-truth (release events) and +# produce mystery commits unattached to a release. Better to let +# workflow_dispatch handle the rare out-of-band UI edit. +# +# Behavior: +# - Commits directly to `beta` with [skip ci] in the message — the +# change is mechanical (re-render from API output), pre-validated, +# and confined to two well-known files. + +on: + release: + types: [published, edited, unpublished, deleted] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: docs-sync-releases + cancel-in-progress: false + +jobs: + sync: + name: Sync release notes + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout beta + uses: actions/checkout@v4 + with: + ref: beta + # Full history so the commit lands on a fresh ref tip. + fetch-depth: 0 + # `sync` needs to push back, so the token must persist here. + persist-credentials: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Sync release notes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python tools/sync_release_notes.py + + - name: Commit & push (if anything changed) + run: | + set -euo pipefail + if git diff --quiet -- website/docs/releases.md README.md; then + echo "No drift — release notes already up to date." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add website/docs/releases.md README.md + git commit -m "docs: sync release notes from GitHub Releases [skip ci]" + git push origin beta diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml new file mode 100644 index 0000000..05b3372 --- /dev/null +++ b/.github/workflows/unity-tests.yml @@ -0,0 +1,265 @@ +name: Unity Tests + +on: + workflow_dispatch: {} + workflow_call: + inputs: + ref: + description: "Git ref to test (defaults to the triggering ref)." + type: string + required: false + default: "" + push: + # Exclude beta and main: those branches re-trigger this workflow via + # workflow_call from beta-release.yml / release.yml. Without the exclusion, + # a push to beta that touches MCPForUnity/** fires this workflow twice + # for the same SHA, and GitHub's auto-generated concurrency group + # (`unity-tests-refs/heads/beta`) cancels the older run with the noisy + # "higher priority waiting request" annotation. + branches-ignore: [beta, main] + paths: + - TestProjects/UnityMCPTests/** + - MCPForUnity/Editor/** + - MCPForUnity/Runtime/** + - .github/workflows/unity-tests.yml + # Same-repo PRs get a unity-tests status check on every open / push via this trigger + # (mirrors python-tests.yml). Fork PRs ALSO fire this trigger but run in the fork's + # context without secrets — the detect step downstream writes unity_ok=false and the + # job exits clean with a "missing license secrets" notice so the status check still + # appears. Maintainers apply 'safe-to-test' to invoke pull_request_target below for + # a real fork-PR test run. + pull_request: + branches: [main, beta] + paths: + - TestProjects/UnityMCPTests/** + - MCPForUnity/Editor/** + - MCPForUnity/Runtime/** + - .github/workflows/unity-tests.yml + # Fork PRs: maintainer applies the 'safe-to-test' label after reviewing + # the diff. The workflow runs with UNITY_LICENSE in scope against the + # PR's head SHA. Re-pushed commits do NOT auto-trigger — maintainer must + # remove and re-apply the label to re-run after additional review. + pull_request_target: + types: [labeled] + branches: [main, beta] + paths: + - TestProjects/UnityMCPTests/** + - MCPForUnity/Editor/** + - MCPForUnity/Runtime/** + - .github/workflows/unity-tests.yml + +# Dedup runs for the same branch across push / pull_request / pull_request_target / workflow_call. +# Same-repo PRs would otherwise fire both push (on the branch SHA) AND pull_request (on the PR); +# concurrency keeps only the newer in-flight run per branch. +concurrency: + group: unity-tests-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + matrix: + name: Compute Unity version matrix + runs-on: ubuntu-latest + permissions: + contents: read + # Gate (mirrored by testAllModes below): + # - Always run for non-PR triggers (push / workflow_call / workflow_dispatch). + # - Fork PRs: require 'safe-to-test' to be applied (existing secret-safety gate); + # 'full-matrix' may be added on top to opt into the full 4-version matrix. + # - In-repo PRs: only re-run via pull_request_target when 'full-matrix' is the + # label that just fired (the push-event run already covered the default leg). + if: > + github.event_name != 'pull_request_target' || + ( + github.event.pull_request.head.repo.full_name != github.repository && + contains(github.event.pull_request.labels.*.name, 'safe-to-test') && + (github.event.label.name == 'safe-to-test' || github.event.label.name == 'full-matrix') + ) || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.event.label.name == 'full-matrix' + ) + outputs: + versions: ${{ steps.set.outputs.versions }} + steps: + - name: Checkout version manifest + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }} + sparse-checkout: tools/unity-versions.json + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Select versions for this trigger + id: set + env: + EVENT_NAME: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + FULL_MATRIX_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'full-matrix') }} + run: | + set -euo pipefail + # Full matrix on: beta push, workflow_call (release pipelines), workflow_dispatch, + # or any PR (pull_request OR pull_request_target) labeled with 'full-matrix'. + # Default (single defaultVersion from tools/unity-versions.json) otherwise — fast PR feedback. + if [[ "$EVENT_NAME" == "workflow_dispatch" ]] || \ + [[ "$EVENT_NAME" == "workflow_call" ]] || \ + { [[ "$EVENT_NAME" == "push" ]] && [[ "$GH_REF" == "refs/heads/beta" ]]; } || \ + { { [[ "$EVENT_NAME" == "pull_request" ]] || [[ "$EVENT_NAME" == "pull_request_target" ]]; } && [[ "$FULL_MATRIX_LABEL" == "true" ]]; }; then + versions=$(jq -c '[.versions[].id]' tools/unity-versions.json) + echo "Trigger '$EVENT_NAME' on ref '$GH_REF' (full_matrix_label=$FULL_MATRIX_LABEL) → full matrix: $versions" + else + versions=$(jq -c '[.defaultVersion]' tools/unity-versions.json) + echo "Trigger '$EVENT_NAME' on ref '$GH_REF' → default only: $versions" + fi + echo "versions=$versions" >> "$GITHUB_OUTPUT" + + testAllModes: + name: Test in ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }} + needs: matrix + runs-on: ubuntu-latest + permissions: + contents: read + if: > + github.event_name != 'pull_request_target' || + ( + github.event.pull_request.head.repo.full_name != github.repository && + contains(github.event.pull_request.labels.*.name, 'safe-to-test') && + (github.event.label.name == 'safe-to-test' || github.event.label.name == 'full-matrix') + ) || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.event.label.name == 'full-matrix' + ) + strategy: + fail-fast: false + matrix: + projectPath: + - TestProjects/UnityMCPTests + testMode: + - editmode + unityVersion: ${{ fromJson(needs.matrix.outputs.versions) }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }} + persist-credentials: false + + - name: Detect Unity license secrets + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -e + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ] && [ -n "$UNITY_SERIAL" ]; }; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Skip Unity tests (missing license secrets) + if: steps.detect.outputs.unity_ok != 'true' + run: | + echo "Unity license secrets missing; skipping Unity tests." + + - uses: actions/cache@v4 + with: + path: ${{ matrix.projectPath }}/Library + key: Library-${{ matrix.projectPath }}-${{ matrix.unityVersion }} + restore-keys: | + Library-${{ matrix.projectPath }}- + Library- + + # Run domain reload tests first (they're [Explicit] so need explicit category) + - name: Run domain reload tests + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + id: domain-tests + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: ${{ matrix.projectPath }} + unityVersion: ${{ matrix.unityVersion }} + testMode: ${{ matrix.testMode }} + customParameters: -testCategory domain_reload + + - name: Run tests + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + id: tests + continue-on-error: true + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: ${{ matrix.projectPath }} + unityVersion: ${{ matrix.unityVersion }} + testMode: ${{ matrix.testMode }} + + - name: Check test results + if: steps.detect.outputs.unity_ok == 'true' + env: + ARTIFACTS_PATH: ${{ steps.tests.outputs.artifactsPath }} + run: | + set -euo pipefail + # `|| true` so a missing $ARTIFACTS_PATH (Unity crashed before producing any) doesn't trip + # `pipefail` and skip the explicit empty-check diagnostic below. + RESULTS_XML=$(find "$ARTIFACTS_PATH" -name '*.xml' 2>/dev/null | head -1 || true) + if [ -z "$RESULTS_XML" ]; then + echo "::error::No test results XML found — Unity may have crashed" + exit 1 + fi + python3 - "$RESULTS_XML" <<'PY' + import sys, xml.etree.ElementTree as ET + # Escape workflow-command payloads so test-controlled XML (under pull_request_target this + # is fork-supplied) can't break annotation rendering or inject extra workflow commands. + # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions + def esc_data(s): + return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + def esc_prop(s): + return esc_data(s).replace(":", "%3A").replace(",", "%2C") + root = ET.parse(sys.argv[1]).getroot() + totals = root.attrib + passed = totals.get("passed", "?") + failed = totals.get("failed", "?") + total = totals.get("total", "?") + incon = totals.get("inconclusive", "?") + skipped = totals.get("skipped", "?") + print(f"Results: {passed} passed, {failed} failed, {incon} inconclusive, {skipped} skipped (total: {total})") + fails = [tc for tc in root.iter("test-case") if tc.attrib.get("result") == "Failed"] + if not fails: + sys.exit(0) + # Surface every failure inline so a CI watcher doesn't need to download the NUnit XML artifact. + for tc in fails: + name = tc.attrib.get("fullname") or tc.attrib.get("name") or "" + f = tc.find("failure") + msg = (f.findtext("message") or "").strip() if f is not None else "" + stack = (f.findtext("stack-trace") or "").strip() if f is not None else "" + # First line of the message becomes the GitHub annotation title. + first_line = msg.splitlines()[0] if msg else "(no message)" + # GitHub annotations don't render multi-line bodies, so emit the full failure inside a collapsible group. + print(f"::error title=Failed: {esc_prop(name)}::{esc_data(first_line)}") + print(f"::group::Failure details — {esc_data(name)}") + if msg: + print("Message:") + print(msg) + if stack: + print("Stack trace:") + print(stack) + print("::endgroup::") + print(f"::error::{len(fails)} test(s) failed") + sys.exit(1) + PY + + - uses: actions/upload-artifact@v4 + if: always() && steps.detect.outputs.unity_ok == 'true' && steps.tests.outcome != 'skipped' + with: + name: Test results for ${{ matrix.testMode }} on Unity ${{ matrix.unityVersion }} + path: ${{ steps.tests.outputs.artifactsPath }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d75b5fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# AI-related files (user-specific) +.cursorrules +.cursorignore +.windsurf +.codeiumignore +.kiro + +# Local worktrees for parallel feature work +.worktrees/ + +# Code-copy related files +.clipignore + +# Python-generated files +__pycache__/ +__pycache__.meta +build/ +!MCPForUnity/**/Build/ +dist/ +wheels/ +*.egg-info + +# Test coverage +.coverage +.coverage.* +htmlcov/ +coverage.xml +*.cover + +# Virtual environments +.venv + +# Environment files (API keys) +.env + +# Unity Editor +*.unitypackage +*.asset +LICENSE.meta +CONTRIBUTING.md.meta + +# IDE +.idea/ +.vscode/ +.aider* +.DS_Store* +# Unity test project lock files +TestProjects/UnityMCPTests/Packages/packages-lock.json + +# UnityMCPTests stress-run artifacts (these are created by tests/tools and should never be committed) +TestProjects/UnityMCPTests/Assets/Temp/ + +# Backup artifacts +*.backup +*.backup.meta + +.wt-origin-main/ + +# CI test reports (generated during test runs) +reports/ + +# Local testing harness +scripts/local-test/ +.claude/settings.local.json + +# Per-version logs from tools/check-unity-versions.{sh,ps1} +tools/.unity-check-logs/ + +# Ignore the .claude directory, since it might contain local/project-level setting such as deny and allowlist. +/.claude +.mcp.json + +# Superpowers skill working artifacts (specs, plans, SDD ledger) +docs/superpowers/ +.superpowers/ diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000..cd911c3 --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,82 @@ +# MCPB Ignore File +# This bundle uses uvx pattern - package downloaded from PyPI at runtime +# Only manifest.json, icon.png, README.md, and LICENSE are needed + +# Server source code (downloaded via uvx from PyPI) +Server/ + +# Unity Client plugin (separate installation) +MCPForUnity/ + +# Test projects +TestProjects/ + +# Documentation folder +docs/ + +# Custom Tools (shipped separately) +CustomTools/ + +# Development scripts at root +scripts/ +tools/ + +# Claude skill zip (separate distribution) +claude_skill_unity.zip + +# Development batch files +deploy-dev.bat +restore-dev.bat + +# Test files at root +test_unity_socket_framing.py +mcp_source.py +prune_tool_results.py + +# Docker +docker-compose.yml +.dockerignore +Dockerfile + +# Chinese README (keep English only) +README-zh.md + +# GitHub and CI +.github/ +.claude/ + +# IDE +.vscode/ +.idea/ + +# Python artifacts +*.pyc +__pycache__/ +.pytest_cache/ +.mypy_cache/ +*.egg-info/ +dist/ +build/ + +# Environment +.env* +*.local +.venv/ +venv/ + +# Git +.git/ +.gitignore +.gitattributes + +# Package management +uv.lock +poetry.lock +requirements*.txt +pyproject.toml + +# Logs and temp +*.log +*.tmp +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5ebb8a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,200 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +**MCP for Unity** is a bridge that lets AI assistants (Claude, Cursor, Windsurf, etc.) control the Unity Editor through the Model Context Protocol (MCP). It enables AI-driven game development workflows - creating GameObjects, editing scripts, managing assets, running tests, and more. + +## Architecture + +```text +AI Assistant (Claude/Cursor) + ↓ MCP Protocol (stdio/HTTP) +Python Server (Server/src/) + ↓ WebSocket + HTTP +Unity Editor Plugin (MCPForUnity/) + ↓ Unity Editor API +Scene, Assets, Scripts +``` + +**Two codebases, one system:** +- `Server/` - Python MCP server using FastMCP +- `MCPForUnity/` - Unity C# Editor package + +### Three Layers on the Python Side + +The Python server has three distinct layers. These are **not** auto-generated from each other: + +| Layer | Location | Framework | Purpose | +|-------|----------|-----------|---------| +| **MCP Tools** | `Server/src/services/tools/` | FastMCP (`@mcp_for_unity_tool`) | Exposed to AI assistants via MCP protocol | +| **CLI Commands** | `Server/src/cli/commands/` | Click (`@click.command`) | Terminal interface for developers | +| **Resources** | `Server/src/services/resources/` | FastMCP (`@mcp_for_unity_resource`) | Read-only state exposed to AI assistants | + +MCP tools call Unity via WebSocket (`send_with_unity_instance`). CLI commands call Unity via HTTP (`run_command`). Both route to the same C# `HandleCommand` methods. + +### Transport Modes + +- **Stdio**: Single-agent only. Separate Python process per client. Legacy TCP bridge to Unity. New connections stomp old ones. +- **HTTP**: Multi-agent ready. Single shared Python server. WebSocket hub at `/hub/plugin`. Session isolation via `client_id`. + +## Code Philosophy + +### 1. Domain Symmetry +Python MCP tools mirror C# Editor tools. Each domain exists in both: +- `Server/src/services/tools/manage_material.py` ↔ `MCPForUnity/Editor/Tools/ManageMaterial.cs` +- CLI commands (`Server/src/cli/commands/`) also mirror these but are a separate implementation. + +### 2. Minimal Abstraction +Avoid premature abstraction. Three similar lines of code is better than a helper that's used once. Only abstract when you have 3+ genuine use cases. + +### 3. Delete Rather Than Deprecate +When removing functionality, delete it completely. No `_unused` renames, no `// removed` comments, no backwards-compatibility shims for internal code. + +### 4. Test Coverage Required +Every new feature needs tests. Run them before PRs. + +### 5. Keep Tools Focused +Each MCP tool does one thing well. Resist the urge to add "convenient" parameters that bloat the API surface. + +### 6. Use Resources for Reading +Keep them smart and focused rather than "read everything" type resources. Resources should be quick and LLM-friendly. + +## Key Patterns + +### Python MCP Tool Registration +Tools in `Server/src/services/tools/` are auto-discovered. Use the `@mcp_for_unity_tool` decorator: +```python +from services.registry import mcp_for_unity_tool + +@mcp_for_unity_tool( + description="Does something in Unity.", + group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs +) +async def manage_something( + ctx: Context, + action: Annotated[Literal["create", "delete"], "Action to perform"], +) -> dict[str, Any]: + unity_instance = await get_unity_instance_from_context(ctx) + params = {"action": action} + response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_something", params) + return response +``` + +The `group` parameter controls tool visibility. Only `"core"` is enabled by default. Non-core groups (vfx, animation, etc.) start disabled and are toggled via `manage_tools`. + +### Python CLI Error Handling +CLI commands (not MCP tools) use the `@handle_unity_errors` decorator: +```python +@handle_unity_errors +async def my_command(ctx, ...): + result = await call_unity_tool(...) +``` + +### C# Tool Registration +Tools are auto-discovered by `CommandRegistry` via reflection. Use the `[McpForUnityTool]` attribute: +```csharp +[McpForUnityTool("manage_something", AutoRegister = false, Group = "core")] +public static class ManageSomething +{ + // Sync handler (most tools): + public static object HandleCommand(JObject @params) + { + var p = new ToolParams(@params); + // ... + return new SuccessResponse("Done.", new { data = result }); + } + + // OR async handler (for long-running operations like play-test, refresh, batch): + public static async Task HandleCommand(JObject @params) + { + // CommandRegistry detects Task return type automatically + await SomeAsyncOperation(); + return new SuccessResponse("Done."); + } +} +``` + +Async handlers use `EditorApplication.update` polling with `TaskCompletionSource` — see `RefreshUnity.cs` for the canonical pattern. + +### C# Parameter Handling +Use `ToolParams` for consistent parameter validation: +```csharp +var p = new ToolParams(parameters); +var pageSize = p.GetInt("page_size", "pageSize") ?? 50; +var name = p.RequireString("name"); +``` + +### C# Resources +Resources use `[McpForUnityResource]` and follow the same `HandleCommand` pattern as tools. They provide read-only state to AI assistants. + +### Paging Large Results +Always page results that could be large (hierarchies, components, search results): +- Use `page_size` and `cursor` parameters +- Return `next_cursor` when more results exist + +### Composing Tools Internally (C#) +Use `CommandRegistry.InvokeCommandAsync` to call other tools from within a handler: +```csharp +var result = await CommandRegistry.InvokeCommandAsync("read_console", consoleParams); +``` + +### Unity API Compatibility Shims +We support a wide Unity version range (2021+ → 6.x → CoreCLR 6.8). When an API is renamed, deprecated, or removed across versions, **don't sprinkle `#if UNITY_x_y_OR_NEWER` at every call site** — add a shim in `MCPForUnity/Runtime/Helpers/Unity*Compat.cs` and route every caller through it. + +The catalog of active shims, the policy for when to add one, what does NOT belong in a shim, and the reflection-cache pattern all live in **`MCPForUnity/Runtime/Helpers/UnityCompatShims.cs`** — the XML doc on that empty marker class is the source of truth and ships inside the UPM package, so end-users can `F12`/Go-to-definition into it. Sources for current deprecations: Unity 6.x upgrade guides and the [CoreCLR 2026 thread](https://discussions.unity.com/t/path-to-coreclr-2026-upgrade-guide/1714279). + +When you touch a shim or anything else gated by `#if UNITY_*_OR_NEWER`, run `tools/check-unity-versions.sh` to compile-check across the CI matrix locally before committing — the matrix lives in `tools/unity-versions.json`. + +## Commands + +### Running Tests +```bash +# Python (all tests) +cd Server && uv run pytest tests/ -v + +# Python (single test file) +cd Server && uv run pytest tests/test_manage_material.py -v + +# Python (single test by name) +cd Server && uv run pytest tests/ -k "test_create_material" -v + +# Unity - open TestProjects/UnityMCPTests in Unity, use Test Runner window + +# Local multi-version compile check (parity with CI matrix, see tools/unity-versions.json) +tools/check-unity-versions.sh # compile-only across installed Unity Hub editors +tools/check-unity-versions.sh --full # full EditMode test run +``` + +#### Local headless test harness +One command boots a headless Hub-licensed Editor against `TestProjects/UnityMCPTests` and runs the smoke + EditMode + PlayMode legs over the bridge — the same entrypoint CI uses (`.github/workflows/e2e-bridge.yml`): + +```bash +python tools/local_harness.py +``` + +Key flags: `--legs smoke,editmode,playmode` (subset to run), `--project-path` (target project, default `TestProjects/UnityMCPTests`), `--reuse` (attach to an already-resident bridge instead of booting one), `--keep-alive` (leave the Editor running after the legs), `--no-warmup` (skip the warm-up import phase). + +Exit codes: `0` pass, `1` blocking-leg regression, `2` bridge unreachable / setup failure, `3` project does not compile, `4` no Unity license / Hub seat, `5` Editor binary/version not found. Requires a Hub-activated Editor locally (no ULF/serial). + +### Local Development +1. Set **Server Source Override** in MCP for Unity Advanced Settings to your local `Server/` path +2. Enable **Dev Mode** checkbox to force fresh installs +3. Use `mcp_source.py` to switch Unity package sources +4. Test on Windows and Mac if possible, and multiple clients (Claude Desktop and Claude Code are tricky for configuration as of this writing) + +### Adding a New Tool +1. Add Python MCP tool in `Server/src/services/tools/manage_.py` using `@mcp_for_unity_tool` +2. Add Python CLI commands in `Server/src/cli/commands/.py` using Click +3. Add C# implementation in `MCPForUnity/Editor/Tools/Manage.cs` with `[McpForUnityTool]` +4. Add Python tests in `Server/tests/test_manage_.py` +5. Add Unity tests in `TestProjects/UnityMCPTests/Assets/Tests/` + +## What Not To Do + +- Don't add features without tests +- Don't create helper functions for one-time operations +- Don't add error handling for scenarios that can't happen +- Don't commit to `main` directly - branch off `beta` for PRs +- Don't add docstrings/comments to code you didn't change diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9973d78 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,75 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in MCP for Unity a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the community as a whole + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information (physical or email address) without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing these standards and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful. + +Maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces — GitHub repository, Discord, official communications channels — and also when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers at **conduct@coplay.dev**. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Project maintainers will follow these guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome. + +**Consequence:** A private, written warning, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact:** A violation through a single incident or series of actions. + +**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. + +### 3. Temporary Ban + +**Community Impact:** A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. + +### 4. Permanent Ban + +**Community Impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6896c5c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to MCP for Unity + +Thanks for wanting to help! MCP for Unity is community-maintained and PRs of any size are welcome — bug fixes, new tools, docs improvements, tests. + +## Quick Start + +1. **Fork** this repo and **clone** your fork. +2. Branch off `beta` (not `main`): + ```bash + git checkout -b feat/your-idea upstream/beta + ``` +3. Install the dev environment (see [Dev Setup](https://coplaydev.github.io/unity-mcp/contributing/dev-setup)). +4. Make your change with tests. +5. Open a PR against `beta`. PRs against `main` will be redirected. + +## What We Look For + +- **Tests for new behavior.** Python tests live in `Server/tests/`; Unity EditMode tests live in `TestProjects/UnityMCPTests/Assets/Tests/`. +- **Domain symmetry.** New tools live in *both* `Server/src/services/tools/manage_.py` (Python MCP tool) and `MCPForUnity/Editor/Tools/Manage.cs` (C# implementation). See [Adding a New Tool](https://coplaydev.github.io/unity-mcp/contributing/dev-setup). +- **Minimal abstraction.** Three similar lines of code is better than a helper that's only used once. +- **Documentation as code.** Tool reference pages under `website/docs/reference/` are auto-generated — never hand-edit them outside the `` blocks. + +## Before You Push + +```bash +# Python tests +cd Server && uv run pytest tests/ -v + +# Unity multi-version compile check (matches CI) +tools/check-unity-versions.sh + +# Pre-commit hook for docs reference (one-time setup) +tools/install-hooks.sh +``` + +The pre-commit hook regenerates `website/docs/reference/` whenever you touch a tool/resource module — saves you a CI round trip. + +## Pull Request Checklist + +- [ ] Branched off `beta` +- [ ] New or updated tests +- [ ] Docs updated (the auto-gen handles the tool reference; narrative docs under `website/docs/` are hand-written) +- [ ] No commented-out code, no `// removed for X` markers, no `_unused` renames +- [ ] PR description explains the **why**, not just the **what** + +## Code Style + +- **Python:** type hints required; follow the patterns in existing `manage_*.py` files. +- **C#:** match existing namespace conventions under `MCPForUnity.Editor.*`; route Unity API differences through `MCPForUnity/Runtime/Helpers/Unity*Compat.cs` shims rather than `#if UNITY_*_OR_NEWER` blocks. +- **Markdown:** wrap at sensible widths; use sentence case in headings. + +## Areas That Need Help + +- Examples in tool reference pages (`website/docs/reference/tools/**/*.md` — add inside the `` blocks). +- Net-new guide content (multi-instance routing, tool groups, transport modes). +- Translations beyond Chinese. +- Cross-platform shell testing for the CLI. + +## Reporting Bugs / Requesting Features + +Use the issue templates under [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/). For security concerns, see [SECURITY.md](SECURITY.md) — do **not** open a public issue. + +## Code of Conduct + +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Be excellent to each other. + +## Questions? + +- [GitHub Issues](https://github.com/CoplayDev/unity-mcp/issues) — bugs, features +- [Discord](https://discord.gg/y4p8KfzrN4) — chat with maintainers and other contributors +- [Discussions](https://github.com/CoplayDev/unity-mcp/discussions) — design ideas, broad questions diff --git a/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs b/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs new file mode 100644 index 0000000..799341b --- /dev/null +++ b/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs @@ -0,0 +1,528 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.Helpers; + +#if USE_ROSLYN +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +#endif + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Runtime compilation tool for MCP Unity. + /// Compiles and loads C# code at runtime without triggering domain reload via Roslyn Runtime Compilation, where in traditional Unity workflow it would take seconds to reload assets and reset script states for each script change. + /// + [McpForUnityTool( + name:"runtime_compilation", + Description = "Enable runtime compilation of C# code within Unity without domain reload via Roslyn.")] + public static class ManageRuntimeCompilation + { + private static readonly Dictionary LoadedAssemblies = new Dictionary(); + private static string DynamicAssembliesPath => Path.Combine(Application.temporaryCachePath, "DynamicAssemblies"); + + private class LoadedAssemblyInfo + { + public string Name; + public Assembly Assembly; + public string DllPath; + public DateTime LoadedAt; + public List TypeNames; + } + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLower(); + + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required. Valid actions: compile_and_load, list_loaded, get_types, execute_with_roslyn, get_history, save_history, clear_history"); + } + + switch (action) + { + case "compile_and_load": + return CompileAndLoad(@params); + + case "list_loaded": + return ListLoadedAssemblies(); + + case "get_types": + return GetAssemblyTypes(@params); + + case "execute_with_roslyn": + return ExecuteWithRoslyn(@params); + + case "get_history": + return GetCompilationHistory(); + + case "save_history": + return SaveCompilationHistory(); + + case "clear_history": + return ClearCompilationHistory(); + + default: + return new ErrorResponse($"Unknown action '{action}'. Valid actions: compile_and_load, list_loaded, get_types, execute_with_roslyn, get_history, save_history, clear_history"); + } + } + + private static object CompileAndLoad(JObject @params) + { +#if !USE_ROSLYN + return new ErrorResponse( + "Runtime compilation requires Roslyn. Please install Microsoft.CodeAnalysis.CSharp NuGet package and add USE_ROSLYN to Scripting Define Symbols. " + + "See ManageScript.cs header for installation instructions." + ); +#else + try + { + string code = @params["code"]?.ToString(); + string assemblyName = @params["assembly_name"]?.ToString() ?? $"DynamicAssembly_{DateTime.Now.Ticks}"; + string attachTo = @params["attach_to"]?.ToString(); + bool loadImmediately = @params["load_immediately"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(code)) + { + return new ErrorResponse("'code' parameter is required"); + } + + // Ensure unique assembly name + if (LoadedAssemblies.ContainsKey(assemblyName)) + { + assemblyName = $"{assemblyName}_{DateTime.Now.Ticks}"; + } + + // Create output directory + Directory.CreateDirectory(DynamicAssembliesPath); + string dllPath = Path.Combine(DynamicAssembliesPath, $"{assemblyName}.dll"); + + // Parse code + var syntaxTree = CSharpSyntaxTree.ParseText(code); + + // Get references + var references = GetDefaultReferences(); + + // Create compilation + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithOptimizationLevel(OptimizationLevel.Debug) + .WithPlatform(Platform.AnyCpu) + ); + + // Emit to file + EmitResult emitResult; + using (var stream = new FileStream(dllPath, FileMode.Create)) + { + emitResult = compilation.Emit(stream); + } + + // Check for compilation errors + if (!emitResult.Success) + { + var errors = emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => new + { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + column = d.Location.GetLineSpan().StartLinePosition.Character + 1, + message = d.GetMessage(), + id = d.Id + }) + .ToList(); + + return new ErrorResponse("Compilation failed", new + { + errors = errors, + error_count = errors.Count + }); + } + + // Load assembly if requested + Assembly loadedAssembly = null; + List typeNames = new List(); + + if (loadImmediately) + { + loadedAssembly = Assembly.LoadFrom(dllPath); + typeNames = loadedAssembly.GetTypes().Select(t => t.FullName).ToList(); + + // Store info + LoadedAssemblies[assemblyName] = new LoadedAssemblyInfo + { + Name = assemblyName, + Assembly = loadedAssembly, + DllPath = dllPath, + LoadedAt = DateTime.Now, + TypeNames = typeNames + }; + + Debug.Log($"[MCP] Runtime compilation successful: {assemblyName} ({typeNames.Count} types)"); + } + + // Optionally attach to GameObject + GameObject attachedTo = null; + Type attachedType = null; + + if (!string.IsNullOrEmpty(attachTo) && loadedAssembly != null) + { + var go = GameObject.Find(attachTo); + if (go == null) + { + // Try hierarchical path search + go = FindGameObjectByPath(attachTo); + } + + if (go != null) + { + // Find first MonoBehaviour type + var behaviourType = loadedAssembly.GetTypes() + .FirstOrDefault(t => t.IsSubclassOf(typeof(MonoBehaviour)) && !t.IsAbstract); + + if (behaviourType != null) + { + go.AddComponent(behaviourType); + attachedTo = go; + attachedType = behaviourType; + Debug.Log($"[MCP] Attached {behaviourType.Name} to {go.name}"); + } + else + { + Debug.LogWarning($"[MCP] No MonoBehaviour types found in {assemblyName} to attach"); + } + } + else + { + Debug.LogWarning($"[MCP] GameObject '{attachTo}' not found"); + } + } + + return new SuccessResponse("Runtime compilation completed successfully", new + { + assembly_name = assemblyName, + dll_path = dllPath, + loaded = loadImmediately, + type_count = typeNames.Count, + types = typeNames, + attached_to = attachedTo != null ? attachedTo.name : null, + attached_type = attachedType != null ? attachedType.FullName : null + }); + } + catch (Exception ex) + { + return new ErrorResponse($"Runtime compilation failed: {ex.Message}", new + { + exception = ex.GetType().Name, + stack_trace = ex.StackTrace + }); + } +#endif + } + + private static object ListLoadedAssemblies() + { + var assemblies = LoadedAssemblies.Values.Select(info => new + { + name = info.Name, + dll_path = info.DllPath, + loaded_at = info.LoadedAt.ToString("o"), + type_count = info.TypeNames.Count, + types = info.TypeNames + }).ToList(); + + return new SuccessResponse($"Found {assemblies.Count} loaded dynamic assemblies", new + { + count = assemblies.Count, + assemblies = assemblies + }); + } + + private static object GetAssemblyTypes(JObject @params) + { + string assemblyName = @params["assembly_name"]?.ToString(); + + if (string.IsNullOrEmpty(assemblyName)) + { + return new ErrorResponse("'assembly_name' parameter is required"); + } + + if (!LoadedAssemblies.TryGetValue(assemblyName, out var info)) + { + return new ErrorResponse($"Assembly '{assemblyName}' not found in loaded assemblies"); + } + + var types = info.Assembly.GetTypes().Select(t => new + { + full_name = t.FullName, + name = t.Name, + @namespace = t.Namespace, + is_class = t.IsClass, + is_abstract = t.IsAbstract, + is_monobehaviour = t.IsSubclassOf(typeof(MonoBehaviour)), + base_type = t.BaseType?.FullName + }).ToList(); + + return new SuccessResponse($"Retrieved {types.Count} types from {assemblyName}", new + { + assembly_name = assemblyName, + type_count = types.Count, + types = types + }); + } + + /// + /// Execute code using RoslynRuntimeCompiler with full GUI tool integration + /// Supports MonoBehaviours, static methods, and coroutines + /// + private static object ExecuteWithRoslyn(JObject @params) + { + try + { + string code = @params["code"]?.ToString(); + string className = @params["class_name"]?.ToString() ?? "AIGenerated"; + string methodName = @params["method_name"]?.ToString() ?? "Run"; + string targetObjectName = @params["target_object"]?.ToString(); + bool attachAsComponent = @params["attach_as_component"]?.ToObject() ?? false; + + if (string.IsNullOrEmpty(code)) + { + return new ErrorResponse("'code' parameter is required"); + } + + // Get or create the RoslynRuntimeCompiler instance + var compiler = GetOrCreateRoslynCompiler(); + + // Find target GameObject if specified + GameObject targetObject = null; + if (!string.IsNullOrEmpty(targetObjectName)) + { + targetObject = GameObject.Find(targetObjectName); + if (targetObject == null) + { + targetObject = FindGameObjectByPath(targetObjectName); + } + + if (targetObject == null) + { + return new ErrorResponse($"Target GameObject '{targetObjectName}' not found"); + } + } + + // Use the RoslynRuntimeCompiler's CompileAndExecute method + bool success = compiler.CompileAndExecute( + code, + className, + methodName, + targetObject, + attachAsComponent, + out string errorMessage + ); + + if (success) + { + return new SuccessResponse($"Code compiled and executed successfully", new + { + class_name = className, + method_name = methodName, + target_object = targetObject != null ? targetObject.name : "compiler_host", + attached_as_component = attachAsComponent, + diagnostics = compiler.lastCompileDiagnostics + }); + } + else + { + return new ErrorResponse($"Execution failed: {errorMessage}", new + { + diagnostics = compiler.lastCompileDiagnostics + }); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to execute with Roslyn: {ex.Message}", new + { + exception = ex.GetType().Name, + stack_trace = ex.StackTrace + }); + } + } + + /// + /// Get compilation history from RoslynRuntimeCompiler + /// + private static object GetCompilationHistory() + { + try + { + var compiler = GetOrCreateRoslynCompiler(); + var history = compiler.CompilationHistory; + + var historyData = history.Select(entry => new + { + timestamp = entry.timestamp, + type_name = entry.typeName, + method_name = entry.methodName, + success = entry.success, + diagnostics = entry.diagnostics, + execution_target = entry.executionTarget, + source_code_preview = entry.sourceCode.Length > 200 + ? entry.sourceCode.Substring(0, 200) + "..." + : entry.sourceCode + }).ToList(); + + return new SuccessResponse($"Retrieved {historyData.Count} history entries", new + { + count = historyData.Count, + history = historyData + }); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to get history: {ex.Message}"); + } + } + + /// + /// Save compilation history to JSON file + /// + private static object SaveCompilationHistory() + { + try + { + var compiler = GetOrCreateRoslynCompiler(); + + if (compiler.SaveHistoryToFile(out string savedPath, out string error)) + { + return new SuccessResponse($"History saved successfully", new + { + path = savedPath, + entry_count = compiler.CompilationHistory.Count + }); + } + else + { + return new ErrorResponse($"Failed to save history: {error}"); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to save history: {ex.Message}"); + } + } + + /// + /// Clear compilation history + /// + private static object ClearCompilationHistory() + { + try + { + var compiler = GetOrCreateRoslynCompiler(); + int count = compiler.CompilationHistory.Count; + compiler.ClearHistory(); + + return new SuccessResponse($"Cleared {count} history entries"); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to clear history: {ex.Message}"); + } + } + +#if USE_ROSLYN + private static List GetDefaultReferences() + { + var references = new List(); + + // Add core .NET references + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + references.Add(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location)); + + // Add Unity references + var unityEngine = typeof(UnityEngine.Object).Assembly.Location; + references.Add(MetadataReference.CreateFromFile(unityEngine)); + + // Add UnityEditor if available + try + { + var unityEditor = typeof(UnityEditor.Editor).Assembly.Location; + references.Add(MetadataReference.CreateFromFile(unityEditor)); + } + catch { /* Editor assembly not always needed */ } + + // Add Assembly-CSharp (user scripts) + try + { + var assemblyCSharp = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp"); + if (assemblyCSharp != null) + { + references.Add(MetadataReference.CreateFromFile(assemblyCSharp.Location)); + } + } + catch { /* User assembly not always needed */ } + + return references; + } +#endif + + private static GameObject FindGameObjectByPath(string path) + { + // Handle hierarchical paths like "Canvas/Panel/Button" + var parts = path.Split('/'); + GameObject current = null; + + foreach (var part in parts) + { + if (current == null) + { + // Find root object + current = GameObject.Find(part); + } + else + { + // Find child + var transform = current.transform.Find(part); + if (transform == null) + return null; + current = transform.gameObject; + } + } + + return current; + } + + /// + /// Get or create a RoslynRuntimeCompiler instance for GUI integration + /// This allows MCP commands to leverage the existing GUI tool + /// + private static RoslynRuntimeCompiler GetOrCreateRoslynCompiler() + { + var existing = UnityEngine.Object.FindFirstObjectByType(); + if (existing != null) + { + return existing; + } + + var go = new GameObject("MCPRoslynCompiler"); + var compiler = go.AddComponent(); + compiler.enableHistory = true; // Enable history tracking for MCP operations + if (!Application.isPlaying) + { + go.hideFlags = HideFlags.HideAndDontSave; + } + return compiler; + } + } +} diff --git a/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs.meta b/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs.meta new file mode 100644 index 0000000..b33b4f6 --- /dev/null +++ b/CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c3b2419382faa04481f4a631c510ee6 \ No newline at end of file diff --git a/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs b/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs new file mode 100644 index 0000000..734e948 --- /dev/null +++ b/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs @@ -0,0 +1,1210 @@ +// RoslynRuntimeCompiler.cs +// Single-file Unity tool for Editor+PlayMode dynamic C# compilation using Roslyn. +// Features: +// - EditorWindow GUI with a large text area for LLM-generated code +// - Compile button (compiles in-memory using Roslyn) +// - Run button (invokes a well-known entry point in the compiled assembly) +// - Shows compile errors and runtime exceptions +// - Safe: Does NOT write .cs files to Assets (no Domain Reload) +// +// Requirements: +// 1) Add Microsoft.CodeAnalysis.CSharp.dll and Microsoft.CodeAnalysis.dll to your Unity project +// (place under Assets/Plugins or Packages and target the Editor). These come from the Roslyn nuget package. +// 2) This tool is designed to run in the Unity Editor (Play Mode or Edit Mode). It uses Assembly.Load(byte[]). +// 3) Generated code should expose a public type and a public static entry method matching one of the supported signatures: +// - public static void Run(UnityEngine.GameObject host) +// - public static void Run(UnityEngine.MonoBehaviour host) +// - public static System.Collections.IEnumerator RunCoroutine(UnityEngine.MonoBehaviour host) // if you want a coroutine +// By convention this demo looks for a type name you specify in the window (default: "AIGenerated"). +// +// Usage: +// - Window -> Roslyn Runtime Compiler +// - Paste code into the big text area (or use LLM output pasted there) +// - Optionally set Entry Type (default AIGenerated) and Entry Method (default Run) +// - Press "Compile". Compiler diagnostics appear below. +// - In Play Mode, press "Run" to invoke the entry method. In Edit Mode it will attempt to run if valid. +// +// Security note: Any dynamically compiled code runs with the same permissions as the editor. Be careful when running untrusted code. + +#if UNITY_EDITOR +using UnityEditor; +#endif +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Collections.Generic; +using UnityEngine; + +#if UNITY_EDITOR +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +#endif + +public class RoslynRuntimeCompiler : MonoBehaviour +{ + [TextArea(8, 20)] + [Tooltip("Code to compile at runtime. Example class name: AIGenerated with public static void Run(GameObject host)")] + public string code = "using UnityEngine;\npublic class AIGenerated {\n public static void Run(GameObject host) {\n Debug.Log($\"Hello from AI - {host.name}\");\n host.transform.Rotate(Vector3.up * 45f * Time.deltaTime);\n }\n}"; + + [Tooltip("Fully qualified type name to invoke (default: AIGenerated)")] + public string entryTypeName = "AIGenerated"; + [Tooltip("Method name to call on entry type (default: Run)")] + public string entryMethodName = "Run"; + + [Header("MonoBehaviour Support")] + [Tooltip("If true, attempts to attach generated MonoBehaviour to target GameObject")] + public bool attachAsComponent = false; + [Tooltip("Target GameObject to attach component to (if null, uses this.gameObject)")] + public GameObject targetGameObject; + + [Header("History & Tracing")] + [Tooltip("Enable automatic history tracking of compiled scripts")] + public bool enableHistory = true; + [Tooltip("Maximum number of history entries to keep")] + public int maxHistoryEntries = 20; + + // compiled assembly & method cache + private Assembly compiledAssembly; + private MethodInfo entryMethod; + private Type entryType; + private Component attachedComponent; // Track dynamically attached component + + public bool HasCompiledAssembly => compiledAssembly != null; + public bool HasEntryMethod => entryMethod != null; + public bool HasEntryType => entryType != null; + public Type EntryType => entryType; // Public accessor for editor + + // compile result diagnostics (string-friendly) + public string lastCompileDiagnostics = ""; + + // History tracking - SHARED across all instances + [System.Serializable] + public class CompilationHistoryEntry + { + public string timestamp; + public string sourceCode; + public string typeName; + public string methodName; + public bool success; + public string diagnostics; + public string executionTarget; + } + + // Static shared history + private static System.Collections.Generic.List _sharedHistory = new System.Collections.Generic.List(); + + public System.Collections.Generic.List CompilationHistory => _sharedHistory; + + // public wrapper so EditorWindow or other runtime UI can call compile/run + public bool CompileInMemory(out string diagnostics) + { +#if UNITY_EDITOR + diagnostics = string.Empty; + lastCompileDiagnostics = string.Empty; + + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(code ?? string.Empty); + + // collect references from loaded assemblies (Editor-safe) + var refs = new List(); + + // Always include mscorlib / system.runtime + refs.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + + // Add all currently loaded assemblies' locations that are not dynamic and have a location + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Distinct(); + + foreach (var a in assemblies) + { + try + { + refs.Add(MetadataReference.CreateFromFile(a.Location)); + } + catch { } + } + + var compilation = CSharpCompilation.Create( + assemblyName: "RoslynRuntimeAssembly_" + Guid.NewGuid().ToString("N"), + syntaxTrees: new[] { syntaxTree }, + references: refs, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + using (var ms = new MemoryStream()) + { + var result = compilation.Emit(ms); + if (!result.Success) + { + var diagText = string.Join("\n", result.Diagnostics.Select(d => d.ToString())); + lastCompileDiagnostics = diagText; + diagnostics = diagText; + Debug.LogError("Roslyn compile failed:\n" + diagText); + return false; + } + + ms.Seek(0, SeekOrigin.Begin); + var assemblyData = ms.ToArray(); + compiledAssembly = Assembly.Load(assemblyData); + + // find entry type + var type = compiledAssembly.GetType(entryTypeName); + if (type == null) + { + lastCompileDiagnostics = $"Type '{entryTypeName}' not found in compiled assembly."; + diagnostics = lastCompileDiagnostics; + return false; + } + + entryType = type; + + // Check if it's a MonoBehaviour + if (typeof(MonoBehaviour).IsAssignableFrom(type)) + { + lastCompileDiagnostics = $"Compilation OK. Type '{entryTypeName}' is a MonoBehaviour and can be attached as a component."; + diagnostics = lastCompileDiagnostics; + Debug.Log(diagnostics); + return true; + } + + // try various method signatures for non-MonoBehaviour types + entryMethod = type.GetMethod(entryMethodName, BindingFlags.Public | BindingFlags.Static); + if (entryMethod == null) + { + lastCompileDiagnostics = $"Static method '{entryMethodName}' not found on type '{entryTypeName}'.\n" + + $"For MonoBehaviour types, set 'attachAsComponent' to true instead."; + diagnostics = lastCompileDiagnostics; + return false; + } + + lastCompileDiagnostics = "Compilation OK."; + diagnostics = lastCompileDiagnostics; + Debug.Log("Roslyn compilation successful."); + return true; + } + } + catch (Exception ex) + { + diagnostics = ex.ToString(); + lastCompileDiagnostics = diagnostics; + Debug.LogError("Roslyn compile exception: " + diagnostics); + return false; + } +#else + diagnostics = "Roslyn compilation is only supported in the Unity Editor when referencing Roslyn assemblies."; + lastCompileDiagnostics = diagnostics; + Debug.LogError(diagnostics); + return false; +#endif + } + + public bool InvokeEntry(GameObject host, out string runtimeError) + { + runtimeError = null; + if (compiledAssembly == null || entryType == null) + { + runtimeError = "No compiled assembly / entry type. Call CompileInMemory first."; + return false; + } + + // Handle MonoBehaviour types + if (typeof(MonoBehaviour).IsAssignableFrom(entryType)) + { + return AttachMonoBehaviour(host, out runtimeError); + } + + // Handle static method invocation + if (entryMethod == null) + { + runtimeError = "No entry method found. For MonoBehaviour types, use attachAsComponent=true."; + return false; + } + + try + { + var parameters = entryMethod.GetParameters(); + if (parameters.Length == 0) + { + entryMethod.Invoke(null, null); + return true; + } + else if (parameters.Length == 1) + { + var pType = parameters[0].ParameterType; + if (pType == typeof(GameObject)) + entryMethod.Invoke(null, new object[] { host }); + else if (typeof(MonoBehaviour).IsAssignableFrom(pType)) + { + var component = host.GetComponent(pType); + entryMethod.Invoke(null, new object[] { component != null ? component : (object)host }); + } + else if (pType == typeof(Transform)) + entryMethod.Invoke(null, new object[] { host.transform }); + else if (pType == typeof(object)) + entryMethod.Invoke(null, new object[] { host }); + else + entryMethod.Invoke(null, new object[] { host }); // best effort + + return true; + } + else + { + runtimeError = "Entry method has unsupported parameter signature."; + return false; + } + } + catch (TargetInvocationException tie) + { + runtimeError = tie.InnerException?.ToString() ?? tie.ToString(); + Debug.LogError("Runtime invocation error: " + runtimeError); + return false; + } + catch (Exception ex) + { + runtimeError = ex.ToString(); + Debug.LogError("Runtime invocation error: " + runtimeError); + return false; + } + } + + /// + /// Attaches a dynamically compiled MonoBehaviour to a GameObject + /// + public bool AttachMonoBehaviour(GameObject host, out string runtimeError) + { + runtimeError = null; + + if (host == null) + { + runtimeError = "Target GameObject is null."; + return false; + } + + if (entryType == null || !typeof(MonoBehaviour).IsAssignableFrom(entryType)) + { + runtimeError = $"Type '{entryTypeName}' is not a MonoBehaviour."; + return false; + } + + try + { + // Check if component already exists + var existing = host.GetComponent(entryType); + if (existing != null) + { + Debug.LogWarning($"Component '{entryType.Name}' already exists on '{host.name}'. Removing old instance."); + if (Application.isPlaying) + Destroy(existing); + else + DestroyImmediate(existing); + } + + // Add the component + attachedComponent = host.AddComponent(entryType); + + if (attachedComponent == null) + { + runtimeError = "Failed to add component to GameObject."; + return false; + } + + Debug.Log($"Successfully attached '{entryType.Name}' to '{host.name}'"); + return true; + } + catch (Exception ex) + { + runtimeError = ex.ToString(); + Debug.LogError("Failed to attach MonoBehaviour: " + runtimeError); + return false; + } + } + + /// + /// Invokes a coroutine on the compiled type if it returns IEnumerator + /// + public bool InvokeCoroutine(MonoBehaviour host, out string runtimeError) + { + runtimeError = null; + + if (entryMethod == null) + { + runtimeError = "No entry method found."; + return false; + } + + if (!typeof(System.Collections.IEnumerator).IsAssignableFrom(entryMethod.ReturnType)) + { + runtimeError = $"Method '{entryMethodName}' does not return IEnumerator."; + return false; + } + + try + { + var parameters = entryMethod.GetParameters(); + object result = null; + + if (parameters.Length == 0) + { + result = entryMethod.Invoke(null, null); + } + else if (parameters.Length == 1) + { + var pType = parameters[0].ParameterType; + if (pType == typeof(GameObject)) + result = entryMethod.Invoke(null, new object[] { host.gameObject }); + else if (typeof(MonoBehaviour).IsAssignableFrom(pType)) + result = entryMethod.Invoke(null, new object[] { host }); + else + result = entryMethod.Invoke(null, new object[] { host }); + } + + if (result is System.Collections.IEnumerator coroutine) + { + host.StartCoroutine(coroutine); + Debug.Log($"Started coroutine '{entryMethodName}' on '{host.name}'"); + return true; + } + else + { + runtimeError = "Method did not return a valid IEnumerator."; + return false; + } + } + catch (Exception ex) + { + runtimeError = ex.ToString(); + Debug.LogError("Failed to start coroutine: " + runtimeError); + return false; + } + } + + /// + /// MCP-callable function: Compiles code and optionally attaches to a GameObject + /// + /// C# source code to compile + /// Type name to instantiate/invoke + /// Method name to invoke (for static methods) + /// Target GameObject (null = this.gameObject) + /// If true and type is MonoBehaviour, attach as component + /// Output error message if operation fails + /// True if successful, false otherwise + public bool CompileAndExecute( + string sourceCode, + string typeName, + string methodName, + GameObject targetObject, + bool shouldAttachComponent, + out string errorMessage) + { + errorMessage = null; + + // Validate inputs + if (string.IsNullOrWhiteSpace(sourceCode)) + { + errorMessage = "Source code cannot be empty."; + return false; + } + + if (string.IsNullOrWhiteSpace(typeName)) + { + errorMessage = "Type name cannot be empty."; + return false; + } + + // Set properties + code = sourceCode; + entryTypeName = typeName; + entryMethodName = string.IsNullOrWhiteSpace(methodName) ? "Run" : methodName; + attachAsComponent = shouldAttachComponent; + targetGameObject = targetObject; + + // Determine target GameObject first + GameObject target = targetGameObject != null ? targetGameObject : this.gameObject; + string targetName = target != null ? target.name : "null"; + + // Compile + if (!CompileInMemory(out string compileError)) + { + errorMessage = $"Compilation failed:\n{compileError}"; + AddHistoryEntry(sourceCode, typeName, entryMethodName, false, compileError, targetName); + return false; + } + + if (target == null) + { + errorMessage = "No target GameObject available."; + AddHistoryEntry(sourceCode, typeName, entryMethodName, false, "No target GameObject", "null"); + return false; + } + + // Execute based on type + try + { + // MonoBehaviour attachment + if (shouldAttachComponent && entryType != null && typeof(MonoBehaviour).IsAssignableFrom(entryType)) + { + if (!AttachMonoBehaviour(target, out string attachError)) + { + errorMessage = $"Failed to attach MonoBehaviour:\n{attachError}"; + AddHistoryEntry(sourceCode, typeName, entryMethodName, false, attachError, target.name); + return false; + } + + Debug.Log($"[MCP] MonoBehaviour '{typeName}' successfully attached to '{target.name}'"); + AddHistoryEntry(sourceCode, typeName, entryMethodName, true, "Component attached successfully", target.name); + return true; + } + + // Coroutine invocation + if (entryMethod != null && typeof(System.Collections.IEnumerator).IsAssignableFrom(entryMethod.ReturnType)) + { + var host = target.GetComponent() ?? this; + if (!InvokeCoroutine(host, out string coroutineError)) + { + errorMessage = $"Failed to start coroutine:\n{coroutineError}"; + AddHistoryEntry(sourceCode, typeName, entryMethodName, false, coroutineError, target.name); + return false; + } + + Debug.Log($"[MCP] Coroutine '{methodName}' started on '{target.name}'"); + AddHistoryEntry(sourceCode, typeName, entryMethodName, true, "Coroutine started successfully", target.name); + return true; + } + + // Static method invocation + if (!InvokeEntry(target, out string invokeError)) + { + errorMessage = $"Failed to invoke method:\n{invokeError}"; + AddHistoryEntry(sourceCode, typeName, entryMethodName, false, invokeError, target.name); + return false; + } + + Debug.Log($"[MCP] Method '{methodName}' executed successfully on '{target.name}'"); + AddHistoryEntry(sourceCode, typeName, entryMethodName, true, "Method executed successfully", target.name); + return true; + } + catch (Exception ex) + { + errorMessage = $"Execution error:\n{ex.Message}\n{ex.StackTrace}"; + return false; + } + } + + /// + /// Simplified MCP-callable function with default parameters + /// + public bool CompileAndExecute(string sourceCode, string typeName, GameObject targetObject, out string errorMessage) + { + // Auto-detect if it's a MonoBehaviour by checking the source + bool shouldAttach = sourceCode.Contains(": MonoBehaviour") || sourceCode.Contains(":MonoBehaviour"); + return CompileAndExecute(sourceCode, typeName, "Run", targetObject, shouldAttach, out errorMessage); + } + + /// + /// MCP-callable: Compile and attach to current GameObject + /// + public bool CompileAndAttachToSelf(string sourceCode, string typeName, out string errorMessage) + { + return CompileAndExecute(sourceCode, typeName, "Run", this.gameObject, true, out errorMessage); + } + + // helper: convenience method to compile + run on this.gameObject + public void CompileAndRunOnSelf() + { + if (CompileInMemory(out var diag)) + { + if (!Application.isPlaying) + Debug.LogWarning("Running compiled code in Edit Mode. Some UnityEngine APIs may not behave as expected."); + + GameObject target = targetGameObject != null ? targetGameObject : this.gameObject; + + // Check if we should attach as component + if (attachAsComponent && entryType != null && typeof(MonoBehaviour).IsAssignableFrom(entryType)) + { + if (AttachMonoBehaviour(target, out var attachErr)) + { + Debug.Log($"MonoBehaviour '{entryTypeName}' attached successfully to '{target.name}'."); + } + else + { + Debug.LogError("Failed to attach MonoBehaviour: " + attachErr); + } + } + // Check if it's a coroutine + else if (entryMethod != null && typeof(System.Collections.IEnumerator).IsAssignableFrom(entryMethod.ReturnType)) + { + var host = target.GetComponent() ?? this; + if (InvokeCoroutine(host, out var coroutineErr)) + { + Debug.Log("Coroutine started successfully."); + } + else + { + Debug.LogError("Failed to start coroutine: " + coroutineErr); + } + } + // Regular static method invocation + else if (InvokeEntry(target, out var runtimeErr)) + { + Debug.Log("Entry invoked successfully."); + } + else + { + Debug.LogError("Failed to invoke entry: " + runtimeErr); + } + } + else + { + Debug.LogError("Compile failed: " + lastCompileDiagnostics); + } + } + + /// + /// Adds an entry to the compilation history + /// + private void AddHistoryEntry(string sourceCode, string typeName, string methodName, bool success, string diagnostics, string target) + { + if (!enableHistory) return; + + var entry = new CompilationHistoryEntry + { + timestamp = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), + sourceCode = sourceCode, + typeName = typeName, + methodName = methodName, + success = success, + diagnostics = diagnostics, + executionTarget = target + }; + + _sharedHistory.Add(entry); + + // Trim if exceeded max + while (_sharedHistory.Count > maxHistoryEntries) + { + _sharedHistory.RemoveAt(0); + } + } + + /// + /// Saves the compilation history to a JSON file outside Assets + /// + public bool SaveHistoryToFile(out string savedPath, out string error) + { + error = ""; + savedPath = ""; + + try + { + string projectRoot = Application.dataPath.Replace("/Assets", "").Replace("\\Assets", ""); + string historyDir = System.IO.Path.Combine(projectRoot, "RoslynHistory"); + + if (!System.IO.Directory.Exists(historyDir)) + { + System.IO.Directory.CreateDirectory(historyDir); + } + + string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string filename = $"RoslynHistory_{timestamp}.json"; + savedPath = System.IO.Path.Combine(historyDir, filename); + + string json = JsonUtility.ToJson(new HistoryWrapper { entries = _sharedHistory }, true); + System.IO.File.WriteAllText(savedPath, json); + + Debug.Log($"[RuntimeRoslynDemo] Saved {_sharedHistory.Count} history entries to: {savedPath}"); + return true; + } + catch (System.Exception ex) + { + error = ex.Message; + Debug.LogError($"[RuntimeRoslynDemo] Failed to save history: {error}"); + return false; + } + } + + /// + /// Saves a specific history entry as a standalone .cs file outside Assets + /// + public bool SaveHistoryEntryAsScript(int index, out string savedPath, out string error) + { + error = ""; + savedPath = ""; + + if (index < 0 || index >= _sharedHistory.Count) + { + error = "Invalid history index"; + return false; + } + + try + { + var entry = _sharedHistory[index]; + string projectRoot = Application.dataPath.Replace("/Assets", "").Replace("\\Assets", ""); + string scriptsDir = System.IO.Path.Combine(projectRoot, "RoslynHistory", "Scripts"); + + if (!System.IO.Directory.Exists(scriptsDir)) + { + System.IO.Directory.CreateDirectory(scriptsDir); + } + + string timestamp = System.DateTime.Parse(entry.timestamp).ToString("yyyyMMdd_HHmmss"); + string filename = $"{entry.typeName}_{timestamp}.cs"; + savedPath = System.IO.Path.Combine(scriptsDir, filename); + + // Add header comment + string header = $"// Roslyn Runtime Compiled Script\n// Original Timestamp: {entry.timestamp}\n// Type: {entry.typeName}\n// Method: {entry.methodName}\n// Success: {entry.success}\n// Target: {entry.executionTarget}\n\n"; + + System.IO.File.WriteAllText(savedPath, header + entry.sourceCode); + + Debug.Log($"[RuntimeRoslynDemo] Saved script to: {savedPath}"); + return true; + } + catch (System.Exception ex) + { + error = ex.Message; + Debug.LogError($"[RuntimeRoslynDemo] Failed to save script: {error}"); + return false; + } + } + + /// + /// Clears the compilation history + /// + public void ClearHistory() + { + _sharedHistory.Clear(); + Debug.Log("[RuntimeRoslynDemo] Compilation history cleared"); + } + + [System.Serializable] + private class HistoryWrapper + { + public System.Collections.Generic.List entries; + } +} + +/// +/// Static helper class for MCP tools to compile and execute C# code at runtime +/// +public static class RoslynMCPHelper +{ + private static RoslynRuntimeCompiler _compiler; + + /// + /// Get or create the runtime compiler instance + /// + private static RoslynRuntimeCompiler GetOrCreateCompiler() + { + if (_compiler == null || _compiler.gameObject == null) + { + var existing = UnityEngine.Object.FindFirstObjectByType(); + if (existing != null) + { + _compiler = existing; + } + else + { + var go = new GameObject("MCPRoslynCompiler"); + _compiler = go.AddComponent(); + if (!Application.isPlaying) + { + go.hideFlags = HideFlags.HideAndDontSave; + } + } + } + return _compiler; + } + + /// + /// MCP Entry Point: Compile C# code and attach to a GameObject + /// + /// Complete C# source code + /// Name of the class to instantiate + /// Name of GameObject to attach to (null = create new) + /// Output result message + /// True if successful + public static bool CompileAndAttach(string sourceCode, string className, string targetGameObjectName, out string result) + { + try + { + var compiler = GetOrCreateCompiler(); + + // Find or create target GameObject + GameObject target = null; + if (!string.IsNullOrEmpty(targetGameObjectName)) + { + target = GameObject.Find(targetGameObjectName); + if (target == null) + { + result = $"GameObject '{targetGameObjectName}' not found."; + return false; + } + } + else + { + // Create a new GameObject for the script + target = new GameObject($"Generated_{className}"); + UnityEngine.Debug.Log($"[MCP] Created new GameObject: {target.name}"); + } + + // Compile and execute + bool success = compiler.CompileAndExecute(sourceCode, className, target, out string error); + + if (success) + { + result = $"Successfully compiled and attached '{className}' to '{target.name}'"; + UnityEngine.Debug.Log($"[MCP] {result}"); + return true; + } + else + { + result = $"Failed: {error}"; + UnityEngine.Debug.LogError($"[MCP] {result}"); + return false; + } + } + catch (Exception ex) + { + result = $"Exception: {ex.Message}"; + UnityEngine.Debug.LogError($"[MCP] {result}\n{ex.StackTrace}"); + return false; + } + } + + /// + /// MCP Entry Point: Compile and execute static method + /// + /// Complete C# source code + /// Name of the class containing the method + /// Name of the static method to invoke + /// GameObject to pass as parameter (optional) + /// Output result message + /// True if successful + public static bool CompileAndExecuteStatic(string sourceCode, string className, string methodName, string targetGameObjectName, out string result) + { + try + { + var compiler = GetOrCreateCompiler(); + + GameObject target = compiler.gameObject; + if (!string.IsNullOrEmpty(targetGameObjectName)) + { + var found = GameObject.Find(targetGameObjectName); + if (found != null) + { + target = found; + } + } + + bool success = compiler.CompileAndExecute(sourceCode, className, methodName, target, false, out string error); + + if (success) + { + result = $"Successfully compiled and executed '{className}.{methodName}'"; + UnityEngine.Debug.Log($"[MCP] {result}"); + return true; + } + else + { + result = $"Failed: {error}"; + UnityEngine.Debug.LogError($"[MCP] {result}"); + return false; + } + } + catch (Exception ex) + { + result = $"Exception: {ex.Message}"; + UnityEngine.Debug.LogError($"[MCP] {result}\n{ex.StackTrace}"); + return false; + } + } + + /// + /// MCP Entry Point: Quick compile and attach MonoBehaviour + /// + /// MonoBehaviour source code + /// MonoBehaviour class name + /// Target GameObject name (creates if null) + /// Success status message + public static string QuickAttachScript(string sourceCode, string className, string gameObjectName = null) + { + bool success = CompileAndAttach(sourceCode, className, gameObjectName, out string result); + return result; + } + + /// + /// MCP Entry Point: Execute code snippet with minimal parameters + /// + public static string ExecuteCode(string sourceCode, string className = "AIGenerated") + { + bool success = CompileAndExecuteStatic(sourceCode, className, "Run", null, out string result); + return result; + } +} + +#if UNITY_EDITOR +// Editor window +public class RoslynRuntimeCompilerWindow : EditorWindow +{ + private RoslynRuntimeCompiler helperInScene; + private Vector2 scrollPos; + private Vector2 diagScroll; + private Vector2 historyScroll; + private int selectedTab = 0; + private string[] tabNames = { "Compiler", "History" }; + private int selectedHistoryIndex = -1; + private Vector2 historyCodeScroll; + + // Editor UI state + private string codeText = string.Empty; + private string typeName = "AIGenerated"; + private string methodName = "Run"; + private bool attachAsComponent = false; + private GameObject targetGameObject = null; + + [MenuItem("Window/Roslyn Runtime Compiler")] + public static void ShowWindow() + { + var w = GetWindow("Roslyn Runtime Compiler"); + w.minSize = new Vector2(600, 400); + } + + void OnEnable() + { + // try to find an existing helper in scene + helperInScene = FindFirstObjectByType(FindObjectsInactive.Include); + if (helperInScene == null) + { + var go = new GameObject("RoslynRuntimeHelper"); + helperInScene = go.AddComponent(); + // Don't save this helper into scene assets + go.hideFlags = HideFlags.HideAndDontSave; + } + + if (helperInScene != null) + { + codeText = helperInScene.code; + typeName = helperInScene.entryTypeName; + methodName = helperInScene.entryMethodName; + attachAsComponent = helperInScene.attachAsComponent; + targetGameObject = helperInScene.targetGameObject; + } + } + + void OnDisable() + { + // keep editor text back to helper if it still exists + if (helperInScene != null && helperInScene.gameObject != null) + { + helperInScene.code = codeText; + helperInScene.entryTypeName = typeName; + helperInScene.entryMethodName = methodName; + helperInScene.attachAsComponent = attachAsComponent; + helperInScene.targetGameObject = targetGameObject; + } + } + + void OnDestroy() + { + // Clean up helper object when window is destroyed + if (helperInScene != null && helperInScene.gameObject != null) + { + DestroyImmediate(helperInScene.gameObject); + helperInScene = null; + } + } + + void OnGUI() + { + // Ensure helper exists before drawing GUI - recreate if needed + if (helperInScene == null || helperInScene.gameObject == null) + { + // Try to find existing helper first + helperInScene = FindFirstObjectByType(FindObjectsInactive.Include); + + // If still not found, create a new one + if (helperInScene == null) + { + var go = new GameObject("RoslynRuntimeHelper"); + helperInScene = go.AddComponent(); + go.hideFlags = HideFlags.HideAndDontSave; + + // Initialize with default values + helperInScene.code = codeText; + helperInScene.entryTypeName = typeName; + helperInScene.entryMethodName = methodName; + helperInScene.attachAsComponent = attachAsComponent; + helperInScene.targetGameObject = targetGameObject; + } + else + { + // Load state from found helper + codeText = helperInScene.code; + typeName = helperInScene.entryTypeName; + methodName = helperInScene.entryMethodName; + attachAsComponent = helperInScene.attachAsComponent; + targetGameObject = helperInScene.targetGameObject; + } + } + + EditorGUILayout.LabelField("Roslyn Runtime Compiler (Editor)", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // Tab selector + selectedTab = GUILayout.Toolbar(selectedTab, tabNames); + EditorGUILayout.Space(); + + if (selectedTab == 0) + { + DrawCompilerTab(); + } + else if (selectedTab == 1) + { + DrawHistoryTab(); + } + } + + void DrawCompilerTab() + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Entry Type:", GUILayout.Width(70)); + typeName = EditorGUILayout.TextField(typeName); + EditorGUILayout.LabelField("Method:", GUILayout.Width(50)); + methodName = EditorGUILayout.TextField(methodName, GUILayout.Width(120)); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + attachAsComponent = EditorGUILayout.Toggle("Attach as Component", attachAsComponent, GUILayout.Width(200)); + if (attachAsComponent) + { + EditorGUILayout.LabelField("Target:", GUILayout.Width(45)); + targetGameObject = (GameObject)EditorGUILayout.ObjectField(targetGameObject, typeof(GameObject), true); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Code (paste LLM output here):"); + scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.Height(position.height * 0.55f)); + codeText = EditorGUILayout.TextArea(codeText, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + + EditorGUILayout.Space(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Compile")) + { + ApplyToHelper(); + if (helperInScene != null) + { + var ok = helperInScene.CompileInMemory(out var diag); + Debug.Log(ok ? "Compile OK" : "Compile Failed\n" + diag); + } + } + + bool canRun = helperInScene != null && helperInScene.HasCompiledAssembly && + (helperInScene.HasEntryMethod || (helperInScene.HasEntryType && typeof(MonoBehaviour).IsAssignableFrom(helperInScene.EntryType))); + GUI.enabled = canRun; + if (GUILayout.Button("Run (invoke on selected)")) + { + ApplyToHelper(); + var sel = Selection.activeGameObject; + if (sel == null && helperInScene != null && helperInScene.gameObject != null) + sel = helperInScene.gameObject; + + if (sel != null && helperInScene != null) + { + if (helperInScene.InvokeEntry(sel, out var runtimeErr)) + Debug.Log("Invocation OK on: " + sel.name); + else + Debug.LogError("Invocation failed: " + runtimeErr); + } + } + + GUI.enabled = true; + if (GUILayout.Button("Compile & Run on helper")) + { + ApplyToHelper(); + if (helperInScene != null) + { + helperInScene.CompileAndRunOnSelf(); + } + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Diagnostics:"); + diagScroll = EditorGUILayout.BeginScrollView(diagScroll, GUILayout.Height(120)); + string diagnosticsText = (helperInScene != null && helperInScene.lastCompileDiagnostics != null) + ? helperInScene.lastCompileDiagnostics + : "No diagnostics available."; + EditorGUILayout.HelpBox(diagnosticsText, MessageType.Info); + EditorGUILayout.EndScrollView(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Notes:"); + EditorGUILayout.HelpBox("This compiles code in-memory using Roslyn. Do not write .cs files into Assets while running. Generated code runs with editor permissions.\n\n" + + "Supported patterns:\n" + + "1. Static method: public static void Run(GameObject host)\n" + + "2. MonoBehaviour: Enable 'Attach as Component' for classes inheriting MonoBehaviour\n" + + "3. Coroutine: public static IEnumerator RunCoroutine(MonoBehaviour host)\n" + + "4. Parameterless: public static void Run()", MessageType.None); + } + + void DrawHistoryTab() + { + if (helperInScene == null) return; + + var history = helperInScene.CompilationHistory; + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"Compilation History ({history.Count} entries)", EditorStyles.boldLabel); + + if (GUILayout.Button("Save History JSON", GUILayout.Width(140))) + { + if (helperInScene.SaveHistoryToFile(out string path, out string error)) + { + EditorUtility.DisplayDialog("Success", $"History saved to:\n{path}", "OK"); + } + else + { + EditorUtility.DisplayDialog("Error", $"Failed to save history:\n{error}", "OK"); + } + } + + if (GUILayout.Button("Clear History", GUILayout.Width(100))) + { + if (EditorUtility.DisplayDialog("Clear History", "Are you sure you want to clear all compilation history?", "Yes", "No")) + { + helperInScene.ClearHistory(); + selectedHistoryIndex = -1; + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + if (history.Count == 0) + { + EditorGUILayout.HelpBox("No compilation history yet. Compile and run scripts to see them here.", MessageType.Info); + return; + } + + EditorGUILayout.BeginHorizontal(); + + // Left panel - history list + EditorGUILayout.BeginVertical(GUILayout.Width(position.width * 0.4f)); + EditorGUILayout.LabelField("History Entries:", EditorStyles.boldLabel); + historyScroll = EditorGUILayout.BeginScrollView(historyScroll); + + for (int i = history.Count - 1; i >= 0; i--) // Reverse order (newest first) + { + var entry = history[i]; + GUIStyle entryStyle = new GUIStyle(GUI.skin.button); + entryStyle.alignment = TextAnchor.MiddleLeft; + entryStyle.normal.textColor = entry.success ? Color.green : Color.red; + + if (selectedHistoryIndex == i) + { + entryStyle.normal.background = Texture2D.grayTexture; + } + + string label = $"[{i}] {entry.timestamp} - {entry.typeName}.{entry.methodName}"; + if (entry.success) + label += " ✓"; + else + label += " ✗"; + + if (GUILayout.Button(label, entryStyle, GUILayout.Height(30))) + { + selectedHistoryIndex = i; + } + } + + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + + // Right panel - selected entry details + EditorGUILayout.BeginVertical(); + + if (selectedHistoryIndex >= 0 && selectedHistoryIndex < history.Count) + { + var entry = history[selectedHistoryIndex]; + + EditorGUILayout.LabelField("Entry Details:", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Timestamp:", entry.timestamp); + EditorGUILayout.LabelField("Type:", entry.typeName); + EditorGUILayout.LabelField("Method:", entry.methodName); + EditorGUILayout.LabelField("Target:", entry.executionTarget); + EditorGUILayout.LabelField("Success:", entry.success ? "Yes" : "No"); + + EditorGUILayout.Space(); + + if (!string.IsNullOrEmpty(entry.diagnostics)) + { + EditorGUILayout.LabelField("Diagnostics:"); + EditorGUILayout.HelpBox(entry.diagnostics, entry.success ? MessageType.Info : MessageType.Error); + } + + EditorGUILayout.Space(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Load to Compiler", GUILayout.Height(25))) + { + codeText = entry.sourceCode; + typeName = entry.typeName; + methodName = entry.methodName; + selectedTab = 0; // Switch to compiler tab + } + + if (GUILayout.Button("Save as .cs File", GUILayout.Height(25))) + { + if (helperInScene.SaveHistoryEntryAsScript(selectedHistoryIndex, out string path, out string error)) + { + EditorUtility.DisplayDialog("Success", $"Script saved to:\n{path}", "OK"); + EditorUtility.RevealInFinder(path); + } + else + { + EditorUtility.DisplayDialog("Error", $"Failed to save script:\n{error}", "OK"); + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("Source Code:"); + historyCodeScroll = EditorGUILayout.BeginScrollView(historyCodeScroll, GUILayout.ExpandHeight(true)); + EditorGUILayout.TextArea(entry.sourceCode, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + } + else + { + EditorGUILayout.HelpBox("Select a history entry to view details.", MessageType.Info); + } + + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + } + + void ApplyToHelper() + { + if (helperInScene == null || helperInScene.gameObject == null) + { + Debug.LogError("Helper object is missing or destroyed. Cannot apply settings."); + return; + } + + helperInScene.code = codeText; + helperInScene.entryTypeName = typeName; + helperInScene.entryMethodName = methodName; + helperInScene.attachAsComponent = attachAsComponent; + helperInScene.targetGameObject = targetGameObject; + } +} +#endif diff --git a/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs.meta b/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs.meta new file mode 100644 index 0000000..066adb5 --- /dev/null +++ b/CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97f1198c66ce56043a3c8a5e05ba0150 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7f878d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CoplayDev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MCPForUnity/Editor.meta b/MCPForUnity/Editor.meta new file mode 100644 index 0000000..26495d4 --- /dev/null +++ b/MCPForUnity/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31e7fac5858840340a75cc6df0ad3d9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/AssemblyInfo.cs b/MCPForUnity/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..bae75b6 --- /dev/null +++ b/MCPForUnity/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] diff --git a/MCPForUnity/Editor/AssemblyInfo.cs.meta b/MCPForUnity/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..72bf5f7 --- /dev/null +++ b/MCPForUnity/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be61633e00d934610ac1ff8192ffbe3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients.meta b/MCPForUnity/Editor/Clients.meta new file mode 100644 index 0000000..b4105b3 --- /dev/null +++ b/MCPForUnity/Editor/Clients.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9d47f01d06964ee7843765d1bd71205 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators.meta b/MCPForUnity/Editor/Clients/Configurators.meta new file mode 100644 index 0000000..a259c21 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 59ff83375c2c74c8385c4a22549778dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs new file mode 100644 index 0000000..83556bd --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class AntigravityConfigurator : JsonFileMcpConfigurator + { + // Antigravity 2.x migrated its MCP config from ~/.gemini/antigravity/mcp_config.json + // (where Antigravity also stores its own runtime state — conversations, scratch, etc.) + // to a dedicated ~/.gemini/config/mcp_config.json. The migration drops a `.migrated` + // marker in the new location and renames the previous folder to `antigravity-backup`. + // The old path is no longer read by Antigravity at all, so writing there silently + // fails to register UnityMCP on every modern install. + public AntigravityConfigurator() : base(new McpClient + { + name = "Antigravity 2.0", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "config", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "config", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "config", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + // Detect Antigravity itself, not just its config dir. ~/.gemini/config/ is created on + // first launch of Antigravity 2.x, so the inherited ParentDirectoryExists check + // false-negatives on a fresh install where the user hasn't opened Antigravity yet. + // ~/.antigravity/ is created by the installer (VS-Code-style support dir) and the + // macOS app bundle is dropped by the installer; either is conclusive evidence that + // Antigravity is installed, regardless of whether it has been launched. + public override bool IsInstalled + { + get + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (Directory.Exists(Path.Combine(home, ".antigravity"))) return true; + if (Directory.Exists(Path.Combine(home, ".gemini", "config"))) return true; + if (Directory.Exists(Path.Combine(home, ".gemini", "antigravity"))) return true; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return Directory.Exists("/Applications/Antigravity.app"); + return false; + } + } + + public override IList GetInstallationSteps() => new List + { + "Open Antigravity 2.0", + "Click the more_horiz menu in the Agent pane > MCP Servers", + "Select 'Install' for Unity MCP or use the Configure button above", + "Restart Antigravity if necessary" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta new file mode 100644 index 0000000..76e91a0 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 331b33961513042e3945d0a1d06615b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs new file mode 100644 index 0000000..da7e790 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Antigravity IDE — the separate IDE build that ships its own ~/.gemini/antigravity-ide/ + /// runtime dir and reads its MCP config from that same folder. It did NOT migrate to + /// ~/.gemini/config/ the way Antigravity 2.0 did, so it still uses the legacy in-folder + /// mcp_config.json layout. The two apps coexist on the same machine, so we expose them + /// as separate clients rather than trying to autodetect which one to write to. + /// + public class AntigravityIdeConfigurator : JsonFileMcpConfigurator + { + public AntigravityIdeConfigurator() : base(new McpClient + { + name = "Antigravity IDE", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity-ide", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity-ide", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity-ide", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + // ~/.gemini/antigravity-ide/ is created by the IDE on first launch and holds both + // its runtime state (annotations/, brain/, conversations/, ...) and its mcp_config.json + // — presence of the dir is the canonical "Antigravity IDE has been installed and + // launched at least once" signal. + public override bool IsInstalled + { + get + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Directory.Exists(Path.Combine(home, ".gemini", "antigravity-ide")); + } + } + + public override IList GetInstallationSteps() => new List + { + "Open Antigravity IDE", + "Click the more_horiz menu in the Agent pane > MCP Servers", + "Select 'Install' for Unity MCP or use the Configure button above", + "Restart Antigravity IDE if necessary" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs.meta new file mode 100644 index 0000000..c7da79b --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/AntigravityIdeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c74a24c69bb0481682d5e35731cba6b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs new file mode 100644 index 0000000..bf14559 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CherryStudioConfigurator : JsonFileMcpConfigurator + { + public const string ClientName = "Cherry Studio"; + + public CherryStudioConfigurator() : base(new McpClient + { + name = ClientName, + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Cherry Studio", "config"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Cherry Studio", "config"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Cherry Studio", "config"), + SupportsHttpTransport = false + }) + { } + + public override bool SupportsAutoConfigure => false; + + public override IList GetInstallationSteps() => new List + { + "Open Cherry Studio", + "Go to Settings (âš™ï¸) → MCP Server", + "Click 'Add Server' button", + "For STDIO mode (recommended):", + " - Name: unity-mcp", + " - Type: STDIO", + " - Command: uvx", + " - Arguments: Copy from the Manual Configuration JSON below", + "Click Save and restart Cherry Studio", + "", + "Note: Cherry Studio uses UI-based configuration.", + "Use the manual snippet below as reference for the values to enter." + }; + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + client.SetStatus(McpStatus.NotConfigured, "Cherry Studio requires manual UI configuration"); + return client.status; + } + + public override void Configure() + { + throw new InvalidOperationException( + "Cherry Studio uses UI-based configuration. " + + "Please use the Manual Configuration snippet and Installation Steps to configure manually." + ); + } + + public override string GetManualSnippet() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + + if (useHttp) + { + return "# Cherry Studio does not support WebSocket transport.\n" + + "# Cherry Studio supports STDIO and SSE transports.\n" + + "# \n" + + "# To use Cherry Studio:\n" + + "# 1. Switch transport to 'Stdio' in Advanced Settings below\n" + + "# 2. Return to this configuration screen\n" + + "# 3. Copy the STDIO configuration snippet that will appear\n" + + "# \n" + + "# OPTION 2: SSE mode (future support)\n" + + "# Note: Unity MCP does not currently have an SSE endpoint.\n" + + "# This may be added in a future update."; + } + + return base.GetManualSnippet() + "\n\n" + + "# Cherry Studio Configuration Instructions:\n" + + "# Cherry Studio uses UI-based configuration, not a JSON file.\n" + + "# \n" + + "# To configure:\n" + + "# 1. Open Cherry Studio\n" + + "# 2. Go to Settings (âš™ï¸) → MCP Server\n" + + "# 3. Click 'Add Server'\n" + + "# 4. Enter the following values from the JSON above:\n" + + "# - Name: unity-mcp\n" + + "# - Type: STDIO\n" + + "# - Command: (copy 'command' value from JSON)\n" + + "# - Arguments: (copy 'args' array values, space-separated or as individual entries)\n" + + "# - Active: true\n" + + "# 5. Click Save\n" + + "# 6. Restart Cherry Studio"; + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta new file mode 100644 index 0000000..8145051 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6de06c6bb0399154d840a1e4c84be869 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs new file mode 100644 index 0000000..2250420 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Claude Code configurator using the CLI-based registration (claude mcp add/remove). + /// This integrates with Claude Code's native MCP management. + /// + public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator + { + public ClaudeCodeConfigurator() : base(new McpClient + { + name = "Claude Code", + SupportsHttpTransport = true, + }) + { } + + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".claude", "skills", "unity-mcp-skill"); + } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed (comes with Claude Code)", + "Click Configure to add UnityMCP via 'claude mcp add'", + "The server will be automatically available in Claude Code", + "Use Unregister to remove via 'claude mcp remove'" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta new file mode 100644 index 0000000..b5ceb3f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d0d22681fc594475db1c189f2d9abdf7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs new file mode 100644 index 0000000..9ecd057 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class ClaudeDesktopConfigurator : JsonFileMcpConfigurator + { + public const string ClientName = "Claude Desktop"; + + public ClaudeDesktopConfigurator() : base(new McpClient + { + name = ClientName, + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json"), + SupportsHttpTransport = false, + StripEnvWhenNotRequired = true + }) + { } + + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".claude", "skills", "unity-mcp-skill"); + } + + public override IList GetInstallationSteps() => new List + { + "Open Claude Desktop", + "Go to Settings > Developer > Edit Config\nOR open the config path", + "Paste the configuration JSON", + "Save and restart Claude Desktop" + }; + + private static readonly ConfiguredTransport[] StdioOnly = { ConfiguredTransport.Stdio }; + public override IReadOnlyList SupportedTransports => StdioOnly; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta new file mode 100644 index 0000000..905c262 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d5e5d87c9db57495f842dc366f1ebd65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs new file mode 100644 index 0000000..90a995b --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class ClineConfigurator : JsonFileMcpConfigurator + { + public ClineConfigurator() : base(new McpClient + { + name = "Cline", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"), + HttpTypeValue = "streamableHttp", + DefaultUnityFields = { { "disabled", false }, { "autoApprove", new object[] { } } } + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Cline in VS Code", + "Click the MCP Servers icon in the Cline pane", + "Go to Configure tab and click 'Configure MCP Servers'\nOR open the config file at the path above", + "Paste the configuration JSON into the mcpServers object", + "Save and restart VS Code" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs.meta new file mode 100644 index 0000000..77045ec --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b8abf0951c7413d9ff97a053b0adf2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs new file mode 100644 index 0000000..4ef3624 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configures the CodeBuddy CLI (~/.codebuddy.json) MCP settings. + /// + public class CodeBuddyCliConfigurator : JsonFileMcpConfigurator + { + public CodeBuddyCliConfigurator() : base(new McpClient + { + name = "CodeBuddy CLI", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install CodeBuddy CLI and ensure '~/.codebuddy.json' exists", + "Click Configure to add the UnityMCP entry (or manually edit the file above)", + "Restart your CLI session if needed" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta new file mode 100644 index 0000000..255f740 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 923728a98c8c74cfaa6e9203c408f34e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs new file mode 100644 index 0000000..00cc0fe --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CodexConfigurator : CodexMcpConfigurator + { + public CodexConfigurator() : base(new McpClient + { + name = "Codex", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml") + }) + { } + + public override bool SupportsSkills => true; + + public override string GetSkillInstallPath() + { + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(userHome, ".codex", "skills", "unity-mcp-skill"); + } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' in a terminal\nOR open the config file at the path above", + "Paste the configuration TOML", + "Save and restart Codex" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta new file mode 100644 index 0000000..14bc60e --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7037ef8b168e49f79247cb31c3be75a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs new file mode 100644 index 0000000..84120a4 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CopilotCliConfigurator : JsonFileMcpConfigurator + { + public CopilotCliConfigurator() : base(new McpClient + { + name = "GitHub Copilot CLI", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot CLI (https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)", + "Open or create mcp-config.json at the path above", + "Paste the configuration JSON (or use /mcp add in the CLI)", + "Restart your Copilot CLI session" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta new file mode 100644 index 0000000..d6c740f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 14a4b9a7f749248d496466c2a3a53e56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs new file mode 100644 index 0000000..d63b226 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CursorConfigurator : JsonFileMcpConfigurator + { + public CursorConfigurator() : base(new McpClient + { + name = "Cursor", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Cursor", + "Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Cursor" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta new file mode 100644 index 0000000..578eb0f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b708eda314746481fb8f4a1fb0652b03 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs new file mode 100644 index 0000000..eef111a --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class GeminiCliConfigurator : JsonFileMcpConfigurator + { + public GeminiCliConfigurator() : base(new McpClient + { + name = "Gemini CLI", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "settings.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "settings.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "settings.json"), + HttpUrlProperty = "httpUrl", + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Ensure Gemini CLI is installed (see https://geminicli.com/docs/get-started/installation/)", + "Click Register to add UnityMCP via 'gemini mcp add'", + "The server will be automatically available in Gemini CLI", + "Use Unregister to remove via 'gemini mcp remove'" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs.meta new file mode 100644 index 0000000..c537de2 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c5e9bbb45e552453ab5cb557a22d43e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs new file mode 100644 index 0000000..47ac8af --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class KiloCodeConfigurator : JsonFileMcpConfigurator + { + public KiloCodeConfigurator() : base(new McpClient + { + name = "Kilo Code", + // Kilo Code v7.0.33+ moved MCP config out of the VS Code extension's + // globalStorage/mcp_settings.json to a CLI-style kilo.jsonc under ~/.config/kilo. + // The new schema (https://app.kilo.ai/config.json) uses an "mcp" container, + // type:"remote" for HTTP servers, type:"local" for stdio, and an "enabled" flag. + // ~/.config/kilo/kilo.jsonc on every OS (UserProfile resolves to C:\Users\ on Windows). + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kilo", "kilo.jsonc"), + IsVsCodeLayout = false, + ServerContainerKey = "mcp", + HttpTypeValue = "remote", + StdioTypeValue = "local", + SchemaUrl = "https://app.kilo.ai/config.json", + DefaultUnityFields = { { "enabled", true } } + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install or update Kilo Code (v7.0.33 or newer)", + "Open the Kilo Code MCP Servers view\nOR edit the config file at the path above (~/.config/kilo/kilo.jsonc)", + "Paste the configuration JSON into the \"mcp\" object", + "Save and restart Kilo Code" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta new file mode 100644 index 0000000..9e2bbfb --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3286d62ffe5644f5ea60488fd7e6513d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs new file mode 100644 index 0000000..940a8ca --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Kimi Code CLI MCP client configurator. + /// Kimi Code uses a JSON-based configuration file with mcpServers section. + /// Config path: ~/.kimi/mcp.json + /// + /// Kimi Code supports both stdio (uvx) and HTTP transport modes. + /// Default: stdio mode (works without Unity Editor for basic operations) + /// HTTP mode: requires Unity Editor running with MCP HTTP server started + /// + public class KimiCodeConfigurator : JsonFileMcpConfigurator + { + public KimiCodeConfigurator() : base(new McpClient + { + name = "Kimi Code", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kimi", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kimi", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kimi", "mcp.json"), + SupportsHttpTransport = true, + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Ensure Kimi Code CLI is installed (pip install kimi-cli or see https://github.com/MoonshotAI/kimi-cli)", + "Click 'Auto Configure' to automatically add UnityMCP to ~/.kimi/mcp.json", + "OR click 'Manual Setup' to copy the configuration JSON", + "Open ~/.kimi/mcp.json and paste the configuration", + "Save and restart Kimi Code CLI", + "Use 'kimi mcp list' to verify Unity MCP is connected", + "Note: For full functionality, open Unity Editor and start HTTP server" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs.meta new file mode 100644 index 0000000..d5f19d4 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KimiCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 562758226f6844438b5b1ae03a2cc7b7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs new file mode 100644 index 0000000..445b6e5 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class KiroConfigurator : JsonFileMcpConfigurator + { + public KiroConfigurator() : base(new McpClient + { + name = "Kiro", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + EnsureEnvObject = true, + DefaultUnityFields = { { "disabled", false } } + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Kiro", + "Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Kiro" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta new file mode 100644 index 0000000..dacb043 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9b73ff071a6043dda1f2ec7d682ef71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs new file mode 100644 index 0000000..1d80033 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configurator for OpenClaw via the openclaw-mcp-bridge plugin. + /// OpenClaw stores config at ~/.openclaw/openclaw.json. + /// + public class OpenClawConfigurator : McpClientConfiguratorBase + { + private const string PluginName = "openclaw-mcp-bridge"; + private const string ServerName = "unityMCP"; + private const string HttpTransportName = "http"; + private const string StdioTransportName = "stdio"; + private const string StdioUrl = "stdio://local"; + + public OpenClawConfigurator() : base(new McpClient + { + name = "OpenClaw", + windowsConfigPath = BuildConfigPath(), + macConfigPath = BuildConfigPath(), + linuxConfigPath = BuildConfigPath() + }) + { } + + private static string BuildConfigPath() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".openclaw", + "openclaw.json"); + } + + public override string GetConfigPath() => CurrentOsPath(); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = ConfiguredTransport.Unknown; + return client.status; + } + + JObject root = LoadConfig(path); + JObject pluginEntry = root["plugins"]?["entries"]?[PluginName] as JObject; + JObject unityServer = FindUnityServer(pluginEntry?["config"]?["servers"]); + + if (pluginEntry == null || unityServer == null) + { + client.SetStatus(McpStatus.MissingConfig); + client.configuredTransport = ConfiguredTransport.Unknown; + return client.status; + } + + if (!IsEnabled(pluginEntry) || !IsEnabled(unityServer)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = ConfiguredTransport.Unknown; + return client.status; + } + + bool matches = ServerMatchesCurrentEndpoint(unityServer); + if (matches) + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = ResolveTransport(unityServer); + return client.status; + } + + if (attemptAutoRewrite) + { + Configure(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + client.configuredTransport = ConfiguredTransport.Unknown; + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + if (EditorPrefs.GetBool(EditorPrefKeys.LockCursorConfig, false)) + return; + + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + + JObject root = File.Exists(path) ? LoadConfig(path) : new JObject(); + + JObject plugins = root["plugins"] as JObject ?? new JObject(); + root["plugins"] = plugins; + + JObject entries = plugins["entries"] as JObject ?? new JObject(); + plugins["entries"] = entries; + + JObject pluginEntry = entries[PluginName] as JObject ?? new JObject(); + entries[PluginName] = pluginEntry; + pluginEntry["enabled"] = true; + + JObject pluginConfig = pluginEntry["config"] as JObject ?? new JObject(); + pluginEntry["config"] = pluginConfig; + pluginConfig.Remove("timeout"); // removed in openclaw-mcp-bridge v2+ + pluginConfig.Remove("retries"); // removed in openclaw-mcp-bridge v2+ + pluginConfig["servers"] = UpsertUnityServer(pluginConfig["servers"]); + + McpConfigurationHelper.WriteAtomicFile(path, root.ToString(Formatting.Indented)); + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + + public override string GetManualSnippet() + { + JObject snippet = new JObject + { + ["plugins"] = new JObject + { + ["entries"] = new JObject + { + [PluginName] = new JObject + { + ["enabled"] = true, + ["config"] = new JObject + { + ["servers"] = new JObject + { + [ServerName] = BuildUnityServerEntry() + } + } + } + } + } + }; + + return snippet.ToString(Formatting.Indented); + } + + public override IList GetInstallationSteps() => new List + { + "Install OpenClaw", + "Install the bridge plugin: npm install -g openclaw-mcp-bridge (or pnpm add -g openclaw-mcp-bridge)", + "In MCP for Unity, choose OpenClaw and click Configure", + "OpenClaw uses the currently selected MCP for Unity transport (HTTP or stdio)", + "OpenClaw exposes a proxy tool such as unityMCP__call for Unity MCP access", + "Restart OpenClaw if the plugin does not hot-reload the new config" + }; + + private JObject LoadConfig(string path) + { + string text = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(text)) + { + return new JObject(); + } + + try + { + return JsonConvert.DeserializeObject(text) ?? new JObject(); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"OpenClaw config contains non-JSON content and cannot be safely auto-edited: {ex.Message}"); + } + } + + private JObject FindUnityServer(JToken serversToken) + { + if (serversToken is JObject serverMap) + { + return serverMap[ServerName] as JObject; + } + + if (serversToken is JArray legacyServers) + { + foreach (JToken token in legacyServers) + { + JObject server = token as JObject; + if (server == null) + { + continue; + } + + string name = server["name"]?.ToString(); + if (string.Equals(name, ServerName, StringComparison.OrdinalIgnoreCase)) + { + return server; + } + } + } + + return null; + } + + private JObject UpsertUnityServer(JToken serversToken) + { + JObject servers = NormalizeServers(serversToken); + JObject entry = servers[ServerName] as JObject ?? new JObject(); + JObject desiredEntry = BuildUnityServerEntry(); + + entry.Remove("name"); + entry.Remove("prefix"); + entry.Remove("healthCheck"); + entry.Remove("command"); + entry.Remove("args"); + entry.Remove("env"); + entry.Remove("connectTimeoutMs"); + + foreach (var property in desiredEntry.Properties()) + { + entry[property.Name] = property.Value.DeepClone(); + } + + servers[ServerName] = entry; + + return servers; + } + + private static JObject NormalizeServers(JToken serversToken) + { + if (serversToken is JObject serverMap) + { + return serverMap; + } + + var normalized = new JObject(); + if (!(serversToken is JArray legacyServers)) + { + return normalized; + } + + foreach (JToken token in legacyServers) + { + if (!(token is JObject legacyServer)) + { + continue; + } + + string name = legacyServer["name"]?.ToString(); + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + normalized[name] = legacyServer; + } + + return normalized; + } + + private static JObject BuildUnityServerEntry() + { + ConfiguredTransport transport = HttpEndpointUtility.GetCurrentServerTransport(); + if (transport == ConfiguredTransport.Stdio) + { + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrWhiteSpace(uvxPath)) + { + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); + } + + var args = new JArray(); + foreach (string value in AssetPathUtility.GetUvxDevFlagsList()) + { + args.Add(value); + } + foreach (string value in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(value); + } + args.Add(packageName); + args.Add("--transport"); + args.Add("stdio"); + + return new JObject + { + ["enabled"] = true, + ["url"] = StdioUrl, + ["transport"] = StdioTransportName, + ["command"] = uvxPath, + ["args"] = args, + ["toolPrefix"] = ServerName, + ["requestTimeoutMs"] = 60000, + ["connectTimeoutMs"] = 15000 + }; + } + + return new JObject + { + ["enabled"] = true, + ["url"] = HttpEndpointUtility.GetMcpRpcUrl(), + ["transport"] = HttpTransportName, + ["toolPrefix"] = ServerName, + ["requestTimeoutMs"] = 30000 + }; + } + + private bool ServerMatchesCurrentEndpoint(JObject server) + { + if (server == null) + { + return false; + } + + ConfiguredTransport expectedTransport = HttpEndpointUtility.GetCurrentServerTransport(); + ConfiguredTransport configuredTransport = ResolveTransport(server); + if (configuredTransport != expectedTransport) + { + return false; + } + + if (configuredTransport == ConfiguredTransport.Stdio) + { + string configuredUrl = server["url"]?.ToString(); + string command = server["command"]?.ToString(); + if (!UrlsEqual(configuredUrl, StdioUrl) || string.IsNullOrWhiteSpace(command)) + { + return false; + } + + // Validate the --from package source hasn't drifted (e.g. stable vs prerelease switch) + string[] args = (server["args"] as JArray)?.ToObject(); + string configuredSource = McpConfigurationHelper.ExtractUvxUrl(args); + string expectedSource = GetExpectedPackageSourceForValidation(); + if (!string.IsNullOrEmpty(configuredSource) && !string.IsNullOrEmpty(expectedSource) && + !McpConfigurationHelper.PathsEqual(configuredSource, expectedSource)) + { + return false; + } + } + else + { + string configuredUrl = server["url"]?.ToString(); + if (string.IsNullOrWhiteSpace(configuredUrl) || + (!UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl()) && + !UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl()))) + { + return false; + } + } + + string toolPrefix = server["toolPrefix"]?.ToString(); + return string.IsNullOrWhiteSpace(toolPrefix) || + string.Equals(toolPrefix, ServerName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsEnabled(JObject entry) + { + JToken enabledToken = entry["enabled"]; + return enabledToken == null || enabledToken.Type != JTokenType.Boolean || enabledToken.Value(); + } + + private ConfiguredTransport ResolveTransport(JObject server) + { + string configuredTransport = server?["transport"]?.ToString(); + string configuredUrl = server?["url"]?.ToString(); + + if (string.Equals(configuredTransport, StdioTransportName, StringComparison.OrdinalIgnoreCase) || + UrlsEqual(configuredUrl, StdioUrl)) + { + return ConfiguredTransport.Stdio; + } + + if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetRemoteMcpRpcUrl())) + { + return ConfiguredTransport.HttpRemote; + } + + if (UrlsEqual(configuredUrl, HttpEndpointUtility.GetLocalMcpRpcUrl())) + { + return ConfiguredTransport.Http; + } + + return ConfiguredTransport.Unknown; + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs.meta new file mode 100644 index 0000000..16f5304 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/OpenClawConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19f29c88761345158fc766d24e7c18f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs new file mode 100644 index 0000000..f496bd7 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configurator for OpenCode (opencode.ai) - a Go-based terminal AI coding assistant. + /// OpenCode uses ~/.config/opencode/opencode.json with a custom "mcp" format. + /// + public class OpenCodeConfigurator : McpClientConfiguratorBase + { + private const string ServerName = "unityMCP"; + private const string SchemaUrl = "https://opencode.ai/config.json"; + private const string RemoteType = "remote"; + private const string LocalType = "local"; + + public OpenCodeConfigurator() : base(new McpClient + { + name = "OpenCode", + windowsConfigPath = BuildConfigPath(), + macConfigPath = BuildConfigPath(), + linuxConfigPath = BuildConfigPath() + }) + { } + + private static string BuildConfigPath() + { + string xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string configBase = !string.IsNullOrEmpty(xdgConfigHome) + ? xdgConfigHome + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + return Path.Combine(configBase, "opencode", "opencode.json"); + } + + public override string GetConfigPath() => CurrentOsPath(); + + /// + /// Attempts to load and parse the config file. + /// Returns null if file doesn't exist or cannot be read. + /// Returns parsed JObject if valid JSON found. + /// Logs warning if file exists but contains malformed JSON. + /// + private JObject TryLoadConfig(string path) + { + if (!File.Exists(path)) + return null; + + string content; + try + { + content = File.ReadAllText(path); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Failed to read config file {path}: {ex.Message}"); + return null; + } + + try + { + return JsonConvert.DeserializeObject(content) ?? new JObject(); + } + catch (JsonException ex) + { + // Malformed JSON - log warning and return null. + // When Configure() receives null, it will do: TryLoadConfig(path) ?? new JObject() + // This creates a fresh empty JObject, which replaces the entire file with only the unityMCP section. + // Existing config sections are lost. To preserve sections, a different recovery strategy + // (e.g., line-by-line parsing, JSON repair, or manual user intervention) would be needed. + UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Malformed JSON in {path}: {ex.Message}"); + return null; + } + } + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + var config = TryLoadConfig(path); + + if (config == null) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + var unityMcp = config["mcp"]?[ServerName] as JObject; + + if (unityMcp == null) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + if (EntryMatchesCurrentTransport(unityMcp)) + { + client.SetStatus(McpStatus.Configured); + } + else if (attemptAutoRewrite) + { + Configure(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + + return client.status; + } + + public override void Configure() + { + try + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + + // Load existing config or start fresh, preserving all other properties and MCP servers + var config = TryLoadConfig(path) ?? new JObject(); + + // Only add $schema if creating a new file + if (!File.Exists(path)) + { + config["$schema"] = SchemaUrl; + } + + // Preserve existing mcp section and only update our server entry + var mcpSection = config["mcp"] as JObject ?? new JObject(); + config["mcp"] = mcpSection; + + mcpSection[ServerName] = BuildServerEntry(); + + McpConfigurationHelper.WriteAtomicFile(path, JsonConvert.SerializeObject(config, Formatting.Indented)); + client.SetStatus(McpStatus.Configured); + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + } + + public override string GetManualSnippet() + { + var snippet = new JObject + { + ["mcp"] = new JObject { [ServerName] = BuildServerEntry() } + }; + return JsonConvert.SerializeObject(snippet, Formatting.Indented); + } + + public override IList GetInstallationSteps() => new List + { + "Install OpenCode (https://opencode.ai)", + "Click Configure to add Unity MCP to ~/.config/opencode/opencode.json", + "Restart OpenCode", + "The Unity MCP server should be detected automatically" + }; + + private static JObject BuildServerEntry() + { + if (HttpEndpointUtility.GetCurrentServerTransport() == ConfiguredTransport.Stdio) + { + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrWhiteSpace(uvxPath)) + { + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); + } + + var command = new JArray { uvxPath }; + foreach (string value in AssetPathUtility.GetUvxDevFlagsList()) + { + command.Add(value); + } + foreach (string value in AssetPathUtility.GetBetaServerFromArgsList()) + { + command.Add(value); + } + command.Add(packageName); + command.Add("--transport"); + command.Add("stdio"); + + return new JObject + { + ["type"] = LocalType, + ["command"] = command, + ["enabled"] = true + }; + } + + return new JObject + { + ["type"] = RemoteType, + ["url"] = HttpEndpointUtility.GetMcpRpcUrl(), + ["enabled"] = true + }; + } + + private bool EntryMatchesCurrentTransport(JObject entry) + { + string entryType = entry["type"]?.ToString(); + ConfiguredTransport expected = HttpEndpointUtility.GetCurrentServerTransport(); + + if (expected == ConfiguredTransport.Stdio) + { + return string.Equals(entryType, LocalType, StringComparison.OrdinalIgnoreCase) + && entry["command"] is JArray; + } + + return string.Equals(entryType, RemoteType, StringComparison.OrdinalIgnoreCase) + && UrlsEqual(entry["url"]?.ToString(), HttpEndpointUtility.GetMcpRpcUrl()); + } + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta new file mode 100644 index 0000000..c3b334d --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 489f99ffb7e6743e88e3203552c8b37b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs new file mode 100644 index 0000000..aad0f63 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Qwen Code MCP client configurator. + /// Qwen Code uses a JSON-based configuration file with mcpServers section. + /// Config path: ~/.qwen/settings.json + /// + /// Qwen Code supports both stdio (uvx) and HTTP transport modes. + /// Default: stdio mode (works without Unity Editor for basic operations) + /// HTTP mode: requires Unity Editor running with MCP HTTP server started + /// + public class QwenCodeConfigurator : JsonFileMcpConfigurator + { + public QwenCodeConfigurator() : base(new McpClient + { + name = "Qwen Code", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".qwen", "settings.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".qwen", "settings.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".qwen", "settings.json"), + SupportsHttpTransport = true, + // Default to stdio transport for Qwen Code (like Cursor) + // User can switch to HTTP in Unity: Window > MCP for Unity > Settings + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Ensure Qwen Code is installed (npm install -g @qwen-code/qwen-code or download from https://github.com/QwenLM/qwen-code)", + "Open Qwen Code", + "Click 'Auto Configure' to automatically add UnityMCP to settings.json", + "OR click 'Manual Setup' to copy the configuration JSON", + "Open ~/.qwen/settings.json and paste the configuration", + "Save and restart Qwen Code", + "Use /mcp command in Qwen Code to verify Unity MCP is connected", + "Note: For full functionality, open Unity Editor and start HTTP server" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs.meta new file mode 100644 index 0000000..a08b500 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46891bcdb00e468cbd04afbfb8f3095e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs new file mode 100644 index 0000000..2558a42 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class RiderConfigurator : JsonFileMcpConfigurator + { + public RiderConfigurator() : base(new McpClient + { + name = "Rider GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "github-copilot", "intellij", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "github-copilot", "intellij", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "github-copilot", "intellij", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot plugin in Rider", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart Rider" + }; + } +} + diff --git a/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta new file mode 100644 index 0000000..8f76ac0 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2511b0d05271d486bb61f8cc9fd11363 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs new file mode 100644 index 0000000..f32b688 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class TraeConfigurator : JsonFileMcpConfigurator + { + public TraeConfigurator() : base(new McpClient + { + name = "Trae", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Trae", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Trae", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Trae", "mcp.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Trae and go to Settings > MCP", + "Select Add Server > Add Manually", + "Paste the JSON or point to the mcp.json file\n"+ + "Windows: %AppData%\\Trae\\mcp.json\n" + + "macOS: ~/Library/Application Support/Trae/mcp.json\n" + + "Linux: ~/.config/Trae/mcp.json\n", + "Save and restart Trae" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta new file mode 100644 index 0000000..09e953c --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3ab39e22ae0948ab94beae307f9902e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs new file mode 100644 index 0000000..9057930 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class VSCodeConfigurator : JsonFileMcpConfigurator + { + public VSCodeConfigurator() : base(new McpClient + { + name = "VSCode GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot extension", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart VSCode" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta new file mode 100644 index 0000000..056d0d4 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcc7ead475a4d4ea2978151c217757b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs new file mode 100644 index 0000000..55adb28 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class VSCodeInsidersConfigurator : JsonFileMcpConfigurator + { + public VSCodeInsidersConfigurator() : base(new McpClient + { + name = "VSCode Insiders GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code - Insiders", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code - Insiders", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code - Insiders", "User", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot extension in VS Code Insiders", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart VS Code Insiders" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta new file mode 100644 index 0000000..e751d6f --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs new file mode 100644 index 0000000..4437170 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class WindsurfConfigurator : JsonFileMcpConfigurator + { + public WindsurfConfigurator() : base(new McpClient + { + name = "Windsurf", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Windsurf", + "Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Windsurf" + }; + } +} diff --git a/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta new file mode 100644 index 0000000..1b95156 --- /dev/null +++ b/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b528971e189f141d38db577f155bd222 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs new file mode 100644 index 0000000..4c1be1c --- /dev/null +++ b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs @@ -0,0 +1,77 @@ +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Contract for MCP client configurators. Each client is responsible for + /// status detection, auto-configure, and manual snippet/steps. + /// + public interface IMcpClientConfigurator + { + /// Stable identifier (e.g., "cursor"). + string Id { get; } + + /// Display name shown in the UI. + string DisplayName { get; } + + /// Current status cached by the configurator. + McpStatus Status { get; } + + /// + /// The transport type the client is currently configured for. + /// Returns Unknown if the client is not configured or the transport cannot be determined. + /// + ConfiguredTransport ConfiguredTransport { get; } + + /// True if this client supports auto-configure. + bool SupportsAutoConfigure { get; } + + /// + /// True if this client appears installed on the user's machine. Used to filter + /// "configure all detected" so we don't write configs for apps the user doesn't have. + /// Implementations should be cheap (filesystem stat or cached path lookup). + /// + bool IsInstalled { get; } + + /// + /// Transports this client can be configured with. Order is "preference if user has no opinion"; + /// the configure path picks the user's global preference if present in this list, else falls back to the first entry. + /// + System.Collections.Generic.IReadOnlyList SupportedTransports { get; } + + /// Label to show on the configure button for the current state. + string GetConfigureActionLabel(); + + /// Returns the platform-specific config path (or message for CLI-managed clients). + string GetConfigPath(); + + /// Checks and updates status; returns current status. + McpStatus CheckStatus(bool attemptAutoRewrite = true); + + /// Runs auto-configuration (register/write file/CLI etc.). Always idempotent + /// — calling twice with the same settings is safe and is what the bulk "Configure All" + /// path relies on to refresh transport / server-version drift across every detected + /// client. + void Configure(); + + /// + /// Removes UnityMCP from this client's config (JSON entry, CLI registration, etc.). + /// Default is a no-op for client types that don't yet implement removal (Codex TOML); + /// callers should treat this as best-effort. The UI's per-client button routes here + /// when is . + /// + void Unregister(); + + /// Returns the manual configuration snippet (JSON/TOML/commands). + string GetManualSnippet(); + + /// Returns ordered human-readable installation steps. + System.Collections.Generic.IList GetInstallationSteps(); + + /// True if this client supports skill installation/sync. + bool SupportsSkills { get; } + + /// Returns the absolute path where skills should be installed, or null if unsupported. + string GetSkillInstallPath(); + } +} diff --git a/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta new file mode 100644 index 0000000..fc57396 --- /dev/null +++ b/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5a5078d9e6e14027a1abfebf4018634 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs new file mode 100644 index 0000000..74fd836 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -0,0 +1,1473 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// Shared base class for MCP configurators. + public abstract class McpClientConfiguratorBase : IMcpClientConfigurator + { + protected readonly McpClient client; + + protected McpClientConfiguratorBase(McpClient client) + { + this.client = client; + } + + internal McpClient Client => client; + + public string Id => client.name.Replace(" ", "").ToLowerInvariant(); + public virtual string DisplayName => client.name; + public McpStatus Status => client.status; + public ConfiguredTransport ConfiguredTransport => client.configuredTransport; + public virtual bool SupportsAutoConfigure => true; + // Default to a filesystem check on the configured path. Concrete configurators + // whose presence isn't path-based (CLI binaries, etc.) override this. This makes + // any future configurator that forgets to override fail-closed rather than be + // treated as "detected" by ConfigureAllDetectedClients. + public virtual bool IsInstalled => ParentDirectoryExists(GetConfigPath()); + private static readonly ConfiguredTransport[] DefaultTransports = + { ConfiguredTransport.Stdio, ConfiguredTransport.Http }; + public virtual IReadOnlyList SupportedTransports => DefaultTransports; + public virtual bool SupportsSkills => false; + public virtual string GetConfigureActionLabel() => "Configure"; + public virtual string GetSkillInstallPath() => null; + + public abstract string GetConfigPath(); + public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true); + public abstract void Configure(); + + /// Default Unregister is a no-op. Override in JsonFileMcpConfigurator / + /// ClaudeCliMcpConfigurator etc. where removal has a concrete implementation. + public virtual void Unregister() { } + + public abstract string GetManualSnippet(); + public abstract IList GetInstallationSteps(); + + protected string GetUvxPathOrError() + { + string uvx = MCPServiceLocator.Paths.GetUvxPath(); + if (string.IsNullOrEmpty(uvx)) + { + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); + } + return uvx; + } + + protected string CurrentOsPath() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return client.windowsConfigPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return client.macConfigPath; + return client.linuxConfigPath; + } + + protected static bool ParentDirectoryExists(string configPath) + { + try + { + if (string.IsNullOrEmpty(configPath)) return false; + string parent = Path.GetDirectoryName(configPath); + return !string.IsNullOrEmpty(parent) && Directory.Exists(parent); + } + catch { return false; } + } + + protected bool UrlsEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return false; + } + + if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && + Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) + { + return Uri.Compare( + uriA, + uriB, + UriComponents.HttpRequestUrl, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + string Normalize(string value) => value.Trim().TrimEnd('/'); + return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the expected package source for validation based on the installed package version. + /// This should match what Configure() would actually use for the --from argument. + /// MUST be called from the main thread due to EditorPrefs access. + /// + protected static string GetExpectedPackageSourceForValidation() + { + // Includes explicit override, stable pin, or prerelease range depending on package version. + return AssetPathUtility.GetMcpServerPackageSource(); + } + + /// + /// Checks if a package source string represents a beta/prerelease version. + /// Beta versions include: + /// - PyPI beta: "mcpforunityserver==9.4.0b20250203..." (contains 'b' before timestamp) + /// - PyPI prerelease range: "mcpforunityserver>=0.0.0a0" (used for prerelease package builds) + /// - Git beta branch: contains "@beta" or "-beta" + /// + protected static bool IsBetaPackageSource(string packageSource) + { + if (string.IsNullOrEmpty(packageSource)) + return false; + + // PyPI beta format: mcpforunityserver==X.Y.Zb + // The 'b' suffix before numbers indicates a PEP 440 beta version + if (System.Text.RegularExpressions.Regex.IsMatch(packageSource, @"==\d+\.\d+\.\d+b\d+")) + return true; + + // PyPI prerelease range: >=0.0.0a0 (used for prerelease package builds) + if (packageSource.Contains(">=0.0.0a0", StringComparison.OrdinalIgnoreCase)) + return true; + + // Git-based beta references + if (packageSource.Contains("@beta", StringComparison.OrdinalIgnoreCase)) + return true; + + if (packageSource.Contains("-beta", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + } + + /// JSON-file based configurator (Cursor, Windsurf, VS Code, etc.). + public abstract class JsonFileMcpConfigurator : McpClientConfiguratorBase + { + public JsonFileMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override bool IsInstalled => ParentDirectoryExists(GetConfigPath()); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + string configJson = File.ReadAllText(path); + string[] args = null; + string configuredUrl = null; + bool configExists = false; + + { + var rootConfig = JsonConvert.DeserializeObject(configJson) as JObject; + JToken unityToken = null; + if (rootConfig != null) + { + string containerKey = string.IsNullOrEmpty(client.ServerContainerKey) + ? "mcpServers" : client.ServerContainerKey; + unityToken = client.IsVsCodeLayout + ? rootConfig["servers"]?["unityMCP"] + ?? rootConfig["mcp"]?["servers"]?["unityMCP"] + : rootConfig[containerKey]?["unityMCP"]; + } + + if (unityToken is JObject unityObj) + { + configExists = true; + + var argsToken = unityObj["args"]; + if (argsToken is JArray) + { + args = argsToken.ToObject(); + } + + // Clients diverge on the HTTP URL property name: "url" (Cursor/VSCode/Claude), + // "serverUrl" (Antigravity/Windsurf), "httpUrl" (Gemini CLI). Accept all three + // so CheckStatus matches what Configure() actually wrote. + var urlToken = unityObj["url"] ?? unityObj["serverUrl"] ?? unityObj["httpUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } + } + } + + if (!configExists) + { + client.SetStatus(McpStatus.MissingConfig); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // Determine and set the configured transport type + if (args != null && args.Length > 0) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + // Distinguish HTTP Local from HTTP Remote by matching against both URLs + string localRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl(); + string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); + if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(configuredUrl, remoteRpcUrl)) + { + client.configuredTransport = Models.ConfiguredTransport.HttpRemote; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Http; + } + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + bool matches = false; + bool hasVersionMismatch = false; + string mismatchReason = null; + + if (args != null && args.Length > 0) + { + // Use beta-aware expected package source for comparison + string expectedUvxUrl = GetExpectedPackageSourceForValidation(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + + if (!string.IsNullOrEmpty(configuredUvxUrl) && !string.IsNullOrEmpty(expectedUvxUrl)) + { + if (McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl)) + { + matches = true; + } + else + { + // Check for beta/stable mismatch + bool configuredIsBeta = IsBetaPackageSource(configuredUvxUrl); + bool expectedIsBeta = IsBetaPackageSource(expectedUvxUrl); + + if (configuredIsBeta && !expectedIsBeta) + { + hasVersionMismatch = true; + mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; + } + else if (!configuredIsBeta && expectedIsBeta) + { + hasVersionMismatch = true; + mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; + } + else + { + hasVersionMismatch = true; + mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; + } + } + } + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + // Match against the active scope's URL + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + + if (hasVersionMismatch) + { + if (attemptAutoRewrite) + { + var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + client.SetStatus(McpStatus.VersionMismatch, mismatchReason); + } + } + else + { + client.SetStatus(McpStatus.VersionMismatch, mismatchReason); + } + } + else if (attemptAutoRewrite) + { + var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + // Always idempotent-write. The per-client UI button routes through Unregister + // when the user clicks while the client is already Configured; the bulk + // "Configure All" path calls this directly and expects an unconditional write. + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetConfigureActionLabel() + => client.status == McpStatus.Configured ? "Unregister" : "Configure"; + + /// + /// Removes the unityMCP entry from the client's JSON config (VS Code-style + /// `servers` / `mcp.servers` layouts, the standard `mcpServers` layout, or a + /// client-specific container such as Kilo's `mcp`). Leaves the file in place so we + /// don't clobber other servers the user has configured. + /// + public override void Unregister() + { + string path = GetConfigPath(); + try + { + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return; + } + + var root = JsonConvert.DeserializeObject(File.ReadAllText(path)) as JObject; + if (root == null) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return; + } + + bool removed = false; + if (client.IsVsCodeLayout) + { + if ((root["servers"] as JObject)?.Remove("unityMCP") == true) removed = true; + if ((root["mcp"]?["servers"] as JObject)?.Remove("unityMCP") == true) removed = true; + } + else + { + string containerKey = string.IsNullOrEmpty(client.ServerContainerKey) + ? "mcpServers" : client.ServerContainerKey; + if ((root[containerKey] as JObject)?.Remove("unityMCP") == true) removed = true; + } + + if (removed) + { + File.WriteAllText(path, root.ToString(Formatting.Indented)); + } + + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to unregister: {ex.Message}", ex); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return ConfigJsonBuilder.BuildManualConfigJson(uvx, client); + } + catch (Exception ex) + { + var errorObj = new { error = ex.Message }; + return JsonConvert.SerializeObject(errorObj); + } + } + + public override IList GetInstallationSteps() => new List { "Configuration steps not available for this client." }; + } + + /// Codex (TOML) configurator. + public abstract class CodexMcpConfigurator : McpClientConfiguratorBase + { + public CodexMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override bool IsInstalled => ParentDirectoryExists(GetConfigPath()); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + string toml = File.ReadAllText(path); + if (CodexConfigHelper.TryParseCodexServer(toml, out _, out var args, out var url)) + { + // Determine and set the configured transport type + if (!string.IsNullOrEmpty(url)) + { + // Distinguish HTTP Local from HTTP Remote + string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); + if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(url, remoteRpcUrl)) + { + client.configuredTransport = Models.ConfiguredTransport.HttpRemote; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Http; + } + } + else if (args != null && args.Length > 0) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + bool matches = false; + bool hasVersionMismatch = false; + string mismatchReason = null; + + if (!string.IsNullOrEmpty(url)) + { + // Match against the active scope's URL + matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl()); + } + else if (args != null && args.Length > 0) + { + // Use beta-aware expected package source for comparison + string expected = GetExpectedPackageSourceForValidation(); + string configured = McpConfigurationHelper.ExtractUvxUrl(args); + + if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected)) + { + if (McpConfigurationHelper.PathsEqual(configured, expected)) + { + matches = true; + } + else + { + // Check for beta/stable mismatch + bool configuredIsBeta = IsBetaPackageSource(configured); + bool expectedIsBeta = IsBetaPackageSource(expected); + + if (configuredIsBeta && !expectedIsBeta) + { + hasVersionMismatch = true; + mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; + } + else if (!configuredIsBeta && expectedIsBeta) + { + hasVersionMismatch = true; + mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; + } + else + { + hasVersionMismatch = true; + mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; + } + } + } + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + + if (hasVersionMismatch) + { + if (attemptAutoRewrite) + { + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + return client.status; + } + } + client.SetStatus(McpStatus.VersionMismatch, mismatchReason); + return client.status; + } + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + if (attemptAutoRewrite) + { + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return CodexConfigHelper.BuildCodexServerBlock(uvx); + } + catch (Exception ex) + { + return $"# error: {ex.Message}"; + } + } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' or open the config path", + "Paste the TOML", + "Save and restart Codex" + }; + } + + /// CLI-based configurator (Claude Code). + public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase + { + public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } + + public override bool SupportsAutoConfigure => true; + public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure"; + + public override string GetConfigPath() => "Managed via Claude CLI"; + + public override bool IsInstalled => MCPServiceLocator.Paths.IsClaudeCliDetected(); + + /// + /// Returns the project directory that CLI-based configurators will use as the working directory + /// for `claude mcp add/remove --scope local`. Checks for an explicit override in EditorPrefs + /// first, then falls back to the current Unity project directory. + /// The override is useful when the Claude Code workspace is at a different path than the Unity project + /// (e.g., plugin developers running CC from the repo root while Unity is open with a test project). + /// MUST be called from the main Unity thread (accesses Application.dataPath and EditorPrefs). + /// + internal static string GetClientProjectDir() + { + string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty); + if (!string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir)) + return overrideDir; + return Path.GetDirectoryName(Application.dataPath); + } + + /// + /// Returns true if a valid client project directory override is set. + /// + internal static bool HasClientProjectDirOverride + { + get + { + string overrideDir = EditorPrefs.GetString(EditorPrefKeys.ClientProjectDirOverride, string.Empty); + return !string.IsNullOrEmpty(overrideDir) && Directory.Exists(overrideDir); + } + } + /// Checks the Claude CLI registration status. + /// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access. + /// + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + // Capture main-thread-only values before delegating to thread-safe method + string projectDir = GetClientProjectDir(); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + // Resolve claudePath on the main thread (EditorPrefs access) + string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); + RuntimePlatform platform = Application.platform; + bool isRemoteScope = HttpEndpointUtility.IsRemoteScope(); + // Get expected package source for the installed package version (matches what Register() would use) + string expectedPackageSource = GetExpectedPackageSourceForValidation(); + return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite, HasClientProjectDirOverride); + } + + /// + /// Internal thread-safe version of CheckStatus. + /// Can be called from background threads because all main-thread-only values are passed as parameters. + /// projectDir, useHttpTransport, claudePath, platform, isRemoteScope, and expectedPackageSource are REQUIRED + /// (non-nullable where applicable) to enforce thread safety at compile time. + /// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread. + /// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration + /// on the main thread based on the returned status. + /// + internal McpStatus CheckStatusWithProjectDir( + string projectDir, bool useHttpTransport, string claudePath, RuntimePlatform platform, + bool isRemoteScope, string expectedPackageSource, + bool attemptAutoRewrite = false, bool hasProjectDirOverride = false) + { + try + { + if (string.IsNullOrEmpty(claudePath)) + { + client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // projectDir is required - no fallback to Application.dataPath + if (string.IsNullOrEmpty(projectDir)) + { + throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution"); + } + + // Read Claude Code config directly from ~/.claude.json instead of using slow CLI + // This is instant vs 15+ seconds for `claude mcp list` which does health checks + var configResult = ReadClaudeCodeConfig(projectDir); + if (configResult.error != null) + { + client.SetStatus(McpStatus.NotConfigured, configResult.error); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + if (configResult.serverConfig == null) + { + // UnityMCP not found in config + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // UnityMCP is registered - check transport and version + bool currentUseHttp = useHttpTransport; + var serverConfig = configResult.serverConfig; + + // Determine registered transport type + string registeredType = serverConfig["type"]?.ToString()?.ToLowerInvariant() ?? ""; + bool registeredWithHttp = registeredType == "http"; + bool registeredWithStdio = registeredType == "stdio"; + + // Set the configured transport based on what we detected + if (registeredWithHttp) + { + client.configuredTransport = isRemoteScope + ? Models.ConfiguredTransport.HttpRemote + : Models.ConfiguredTransport.Http; + } + else if (registeredWithStdio) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + // Check for transport mismatch. + // When a project dir override is active, the local UseHttpTransport + // GUI setting may legitimately differ from the registered transport + // in the overridden project, so skip this check. + bool hasTransportMismatch = !hasProjectDirOverride + && ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp)); + + // For stdio transport, also check package version + bool hasVersionMismatch = false; + string configuredPackageSource = null; + string mismatchReason = null; + if (registeredWithStdio) + { + configuredPackageSource = ExtractPackageSourceFromConfig(serverConfig); + if (!string.IsNullOrEmpty(configuredPackageSource) && !string.IsNullOrEmpty(expectedPackageSource)) + { + // Check for exact match first + if (!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase)) + { + hasVersionMismatch = true; + + // Provide more specific mismatch reason for beta/stable differences + bool configuredIsBeta = IsBetaPackageSource(configuredPackageSource); + bool expectedIsBeta = IsBetaPackageSource(expectedPackageSource); + + if (configuredIsBeta && !expectedIsBeta) + { + mismatchReason = "Configured for prerelease server, but this package is stable. Re-configure to switch to stable."; + } + else if (!configuredIsBeta && expectedIsBeta) + { + mismatchReason = "Configured for stable server, but this package is prerelease. Re-configure to switch to prerelease."; + } + else + { + mismatchReason = "Server version doesn't match the plugin. Re-configure to update."; + } + } + } + } + + // If there's any mismatch and auto-rewrite is enabled, re-register + if (hasTransportMismatch || hasVersionMismatch) + { + // Configure() requires main thread (accesses EditorPrefs, Application.dataPath) + // Only attempt auto-rewrite if we're on the main thread + bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1; + if (attemptAutoRewrite && isMainThread) + { + string reason = hasTransportMismatch + ? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})" + : mismatchReason ?? $"Package version mismatch"; + McpLog.Info($"{reason}. Re-registering..."); + try + { + // Force re-register by ensuring status is not Configured (which would toggle to Unregister) + client.SetStatus(McpStatus.IncorrectPath); + Configure(); + return client.status; + } + catch (Exception ex) + { + McpLog.Warn($"Auto-reregister failed: {ex.Message}"); + client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register."); + return client.status; + } + } + else + { + if (hasTransportMismatch) + { + string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register."; + client.SetStatus(McpStatus.Error, errorMsg); + McpLog.Warn(errorMsg); + } + else + { + client.SetStatus(McpStatus.VersionMismatch, mismatchReason); + } + return client.status; + } + } + + client.SetStatus(McpStatus.Configured); + return client.status; + } + catch (Exception ex) + { + McpLog.Warn($"[Claude Code] CheckStatus exception: {ex.GetType().Name}: {ex.Message}"); + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + if (client.status == McpStatus.Configured) + { + Unregister(); + } + else + { + Register(); + } + } + + /// + /// Thread-safe version of Configure that uses pre-captured main-thread values. + /// All parameters must be captured on the main thread before calling this method. + /// + public void ConfigureWithCapturedValues( + string projectDir, string claudePath, string pathPrepend, + bool useHttpTransport, string httpUrl, + string uvxPath, string fromArgs, string packageName, string uvxDevFlags, + string apiKey, + Models.ConfiguredTransport serverTransport) + { + if (client.status == McpStatus.Configured) + { + UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend); + } + else + { + RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, + useHttpTransport, httpUrl, uvxPath, fromArgs, packageName, uvxDevFlags, + apiKey, serverTransport); + } + } + + /// + /// Thread-safe registration using pre-captured values. + /// + private void RegisterWithCapturedValues( + string projectDir, string claudePath, string pathPrepend, + bool useHttpTransport, string httpUrl, + string uvxPath, string fromArgs, string packageName, string uvxDevFlags, + string apiKey, + Models.ConfiguredTransport serverTransport) + { + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string args; + if (useHttpTransport) + { + // Only include API key header for remote-hosted mode + // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) + if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; + } + } + else + { + // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) + args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {uvxDevFlags}{fromArgs} {packageName}"; + } + + // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) + McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); + + // Now add the registration + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + + McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); + client.SetStatus(McpStatus.Configured); + client.configuredTransport = serverTransport; + } + + /// + /// Thread-safe unregistration using pre-captured values. + /// + private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend) + { + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + // Remove from ALL scopes to ensure complete cleanup (#664) + McpLog.Info("Removing all UnityMCP registrations from all scopes..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); + + McpLog.Info("MCP server successfully unregistered from Claude Code."); + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + private void Register() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + + string args; + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + // Only include API key header for remote-hosted mode + // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; + } + } + else + { + args = $"mcp add --scope local --transport http UnityMCP {httpUrl}"; + } + } + else + { + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + string devFlags = AssetPathUtility.GetUvxDevFlags(); + string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); + // Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664) + args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}"; + } + + string projectDir = GetClientProjectDir(); + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + // Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664) + McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); + + // Now add the registration with the current transport mode + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + + McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); + + // Set status to Configured immediately after successful registration + // The UI will trigger an async verification check separately to avoid blocking + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + + public override void Unregister() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string projectDir = GetClientProjectDir(); + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + // Remove from ALL scopes to ensure complete cleanup (#664) + McpLog.Info("Removing all UnityMCP registrations from all scopes..."); + RemoveFromAllScopes(claudePath, projectDir, pathPrepend); + + McpLog.Info("MCP server successfully unregistered from Claude Code."); + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + public override string GetManualSnippet() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + // Only include API key header for remote-hosted mode + string headerArg = ""; + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : ""; + } + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --scope local --transport http UnityMCP {httpUrl}{headerArg}\n\n" + + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + + "claude mcp remove --scope local UnityMCP\n" + + "claude mcp remove --scope user UnityMCP\n" + + "claude mcp remove --scope project UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list"; + } + + if (string.IsNullOrEmpty(uvxPath)) + { + return "# Error: Configuration not available - check paths in Advanced Settings"; + } + + string devFlags = AssetPathUtility.GetUvxDevFlags(); + string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); + + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} mcp-for-unity\n\n" + + "# Unregister the MCP server (from all scopes to clean up any stale configs):\n" + + "claude mcp remove --scope local UnityMCP\n" + + "claude mcp remove --scope user UnityMCP\n" + + "claude mcp remove --scope project UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list"; + } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed", + "Use Configure to add UnityMCP (or run claude mcp add UnityMCP)", + "Restart Claude Code" + }; + + /// + /// Removes UnityMCP registration from all Claude Code configuration scopes (local, user, project). + /// Also removes legacy entries from ~/.claude.json that the CLI scoped removal can't touch. + /// This ensures no stale or conflicting configurations remain across different scopes. + /// Also handles legacy "unityMCP" naming convention. + /// + private static void RemoveFromAllScopes(string claudePath, string projectDir, string pathPrepend) + { + // Remove from all three scopes to prevent stale configs causing connection issues. + // See GitHub issue #664 - conflicting configs at different scopes can cause + // Claude Code to connect with outdated/incorrect configuration. + string[] scopes = { "local", "user", "project" }; + string[] names = { "UnityMCP", "unityMCP" }; // Include legacy naming + + foreach (var scope in scopes) + { + foreach (var name in names) + { + ExecPath.TryRun(claudePath, $"mcp remove --scope {scope} {name}", projectDir, out _, out _, 5000, pathPrepend); + } + } + + // Also remove legacy entries directly from ~/.claude.json. + // Older versions and manual CLI commands without --scope wrote mcpServers entries + // into the projects section of ~/.claude.json. The scoped `claude mcp remove` commands + // above won't touch these, leaving stale/conflicting configs behind. + RemoveLegacyUserConfigEntries(projectDir); + } + + /// + /// Removes UnityMCP entries from the projects section of ~/.claude.json. + /// These are legacy entries that were created by older versions or manual commands + /// that didn't use --scope. The scoped `claude mcp remove` commands don't clean these up. + /// + private static void RemoveLegacyUserConfigEntries(string projectDir) + { + try + { + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string configPath = Path.Combine(homeDir, ".claude.json"); + if (!File.Exists(configPath)) + return; + + string json = File.ReadAllText(configPath); + var config = JObject.Parse(json); + var projects = config["projects"] as JObject; + if (projects == null) + return; + + string normalizedProjectDir = NormalizePath(projectDir); + bool modified = false; + + // Walk all project entries looking for ones that match our project path + foreach (var project in projects.Properties()) + { + string normalizedKey = NormalizePath(project.Name); + + // Match exact path or parent paths (same logic as ReadUserScopeConfig) + if (!string.Equals(normalizedKey, normalizedProjectDir, StringComparison.OrdinalIgnoreCase)) + { + // Also check if projectDir is a child of this config entry + if (!normalizedProjectDir.StartsWith(normalizedKey + "/", StringComparison.OrdinalIgnoreCase)) + continue; + } + + var mcpServers = project.Value?["mcpServers"] as JObject; + if (mcpServers == null) + continue; + + // Remove UnityMCP/unityMCP entries (case-insensitive) + var toRemove = new List(); + foreach (var server in mcpServers.Properties()) + { + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) + { + toRemove.Add(server.Name); + } + } + + foreach (var name in toRemove) + { + mcpServers.Remove(name); + modified = true; + McpLog.Info($"Removed legacy '{name}' entry from ~/.claude.json for project '{project.Name}'"); + } + } + + if (modified) + { + File.WriteAllText(configPath, config.ToString(Formatting.Indented)); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to clean up legacy ~/.claude.json entries: {ex.Message}"); + } + } + + /// + /// Sanitizes a value for safe inclusion inside a double-quoted shell argument. + /// Escapes characters that are special within double quotes (", \, `, $, !) + /// to prevent shell injection or argument splitting. + /// + private static string SanitizeShellHeaderValue(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + var sb = new System.Text.StringBuilder(value.Length); + foreach (char c in value) + { + switch (c) + { + case '"': + case '\\': + case '`': + case '$': + case '!': + sb.Append('\\'); + sb.Append(c); + break; + default: + sb.Append(c); + break; + } + } + return sb.ToString(); + } + + /// + /// Extracts the package source (--from argument value) from claude mcp get output. + /// The output format includes args like: --from "mcpforunityserver==9.0.1" + /// + private static string ExtractPackageSourceFromCliOutput(string cliOutput) + { + if (string.IsNullOrEmpty(cliOutput)) + return null; + + // Look for --from followed by the package source + // The CLI output may have it quoted or unquoted + int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase); + if (fromIndex < 0) + return null; + + // Move past "--from" and any whitespace + int startIndex = fromIndex + 6; + while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex])) + startIndex++; + + if (startIndex >= cliOutput.Length) + return null; + + // Check if value is quoted + char quoteChar = cliOutput[startIndex]; + if (quoteChar == '"' || quoteChar == '\'') + { + startIndex++; + int endIndex = cliOutput.IndexOf(quoteChar, startIndex); + if (endIndex > startIndex) + return cliOutput.Substring(startIndex, endIndex - startIndex); + } + else + { + // Unquoted - read until whitespace or end of line + int endIndex = startIndex; + while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex])) + endIndex++; + + if (endIndex > startIndex) + return cliOutput.Substring(startIndex, endIndex - startIndex); + } + + return null; + } + + /// + /// Reads Claude Code configuration from both local-scope (.claude/mcp.json in the project) + /// and user-scope (~/.claude.json). Local scope takes precedence, matching Claude Code's + /// own config resolution order. + /// This is much faster than running `claude mcp list` which does health checks on all servers. + /// + private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string projectDir) + { + try + { + // 1. Check local-scope config first: {projectDir}/.claude/mcp.json + // This is where `claude mcp add --scope local` writes. + var localResult = ReadLocalScopeConfig(projectDir); + if (localResult.serverConfig != null) + return localResult; + if (localResult.error != null) + return localResult; + + // 2. Fall back to user-scope config: ~/.claude.json + return ReadUserScopeConfig(projectDir); + } + catch (Exception ex) + { + return (null, $"Error reading Claude config: {ex.Message}"); + } + } + + /// + /// Reads UnityMCP config from the local-scope file: {projectDir}/.claude/mcp.json. + /// This is where `claude mcp add --scope local` stores registrations. + /// + private static (JObject serverConfig, string error) ReadLocalScopeConfig(string projectDir) + { + try + { + if (string.IsNullOrEmpty(projectDir)) + return (null, null); + + string localConfigPath = Path.Combine(projectDir, ".claude", "mcp.json"); + if (!File.Exists(localConfigPath)) + return (null, null); + + string json = File.ReadAllText(localConfigPath); + var config = JObject.Parse(json); + var mcpServers = config["mcpServers"] as JObject; + if (mcpServers == null) + return (null, null); + + foreach (var server in mcpServers.Properties()) + { + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) + { + return (server.Value as JObject, null); + } + } + + return (null, null); + } + catch (Exception ex) + { + return (null, $"Error reading local Claude config: {ex.Message}"); + } + } + + /// + /// Reads UnityMCP config from the user-scope file: ~/.claude.json (projects section). + /// This handles legacy configurations and direct user-level entries. + /// + private static (JObject serverConfig, string error) ReadUserScopeConfig(string projectDir) + { + try + { + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string configPath = Path.Combine(homeDir, ".claude.json"); + + if (!File.Exists(configPath)) + return (null, null); + + string configJson = File.ReadAllText(configPath); + var config = JObject.Parse(configJson); + + var projects = config["projects"] as JObject; + if (projects == null) + return (null, null); + + // Build a dictionary of normalized paths for quick lookup + // Use last entry for duplicates (forward/backslash variants) as it's typically more recent + var normalizedProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var project in projects.Properties()) + { + string normalizedPath = NormalizePath(project.Name); + normalizedProjects[normalizedPath] = project.Value as JObject; + } + + // Walk up the directory tree to find a matching project config + // Claude Code may be configured at a parent directory (e.g., repo root) + // while Unity project is in a subdirectory (e.g., TestProjects/UnityMCPTests) + string currentDir = NormalizePath(projectDir); + while (!string.IsNullOrEmpty(currentDir)) + { + if (normalizedProjects.TryGetValue(currentDir, out var projectConfig)) + { + var mcpServers = projectConfig?["mcpServers"] as JObject; + if (mcpServers != null) + { + foreach (var server in mcpServers.Properties()) + { + if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase)) + { + return (server.Value as JObject, null); + } + } + } + // Found the project but no UnityMCP - don't continue walking up + return (null, null); + } + + // Move up one directory + int lastSlash = currentDir.LastIndexOf('/'); + if (lastSlash <= 0) + break; + currentDir = currentDir.Substring(0, lastSlash); + } + + return (null, null); + } + catch (Exception ex) + { + return (null, $"Error reading user Claude config: {ex.Message}"); + } + } + + /// + /// Normalizes a file path for comparison (handles forward/back slashes, trailing slashes). + /// + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + // Replace backslashes with forward slashes and remove trailing slashes + return path.Replace('\\', '/').TrimEnd('/'); + } + + /// + /// Extracts the package source from Claude Code JSON config. + /// For stdio servers, this is in the args array after "--from". + /// + private static string ExtractPackageSourceFromConfig(JObject serverConfig) + { + if (serverConfig == null) + return null; + + var args = serverConfig["args"] as JArray; + if (args == null) + return null; + + // Look for --from argument (either "--from VALUE" or "--from=VALUE" format) + bool foundFrom = false; + foreach (var arg in args) + { + string argStr = arg?.ToString(); + if (argStr == null) + continue; + + if (foundFrom) + { + // This is the package source following --from + return argStr; + } + + if (argStr == "--from") + { + foundFrom = true; + } + else if (argStr.StartsWith("--from=", StringComparison.OrdinalIgnoreCase)) + { + // Handle --from=VALUE format + return argStr.Substring(7).Trim('"', '\''); + } + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta new file mode 100644 index 0000000..17709d1 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d408fd7733cb4a1eb80f785307db2ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Clients/McpClientRegistry.cs b/MCPForUnity/Editor/Clients/McpClientRegistry.cs new file mode 100644 index 0000000..57e4dc1 --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientRegistry.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Central registry that auto-discovers configurators via TypeCache. + /// + public static class McpClientRegistry + { + private static List cached; + + public static IReadOnlyList All + { + get + { + if (cached == null) + { + cached = BuildRegistry(); + } + return cached; + } + } + + private static List BuildRegistry() + { + var configurators = new List(); + + foreach (var type in TypeCache.GetTypesDerivedFrom()) + { + if (type.IsAbstract || !type.IsClass || !type.IsPublic) + continue; + + // Require a public parameterless constructor + if (type.GetConstructor(Type.EmptyTypes) == null) + continue; + + try + { + if (Activator.CreateInstance(type) is IMcpClientConfigurator instance) + { + configurators.Add(instance); + } + } + catch (Exception ex) + { + McpLog.Warn($"UnityMCP: Failed to instantiate configurator {type.Name}: {ex.Message}"); + } + } + + // Alphabetical order by display name + configurators = configurators.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + return configurators; + } + } +} diff --git a/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta b/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta new file mode 100644 index 0000000..2e0400b --- /dev/null +++ b/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4ce08555f995e4e848a826c63f18cb35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants.meta b/MCPForUnity/Editor/Constants.meta new file mode 100644 index 0000000..7c23235 --- /dev/null +++ b/MCPForUnity/Editor/Constants.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f7e009cbf3e74f6c987331c2b438ec59 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants/AuthConstants.cs b/MCPForUnity/Editor/Constants/AuthConstants.cs new file mode 100644 index 0000000..76579e6 --- /dev/null +++ b/MCPForUnity/Editor/Constants/AuthConstants.cs @@ -0,0 +1,10 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Protocol-level constants for API key authentication. + /// + internal static class AuthConstants + { + internal const string ApiKeyHeader = "X-API-Key"; + } +} diff --git a/MCPForUnity/Editor/Constants/AuthConstants.cs.meta b/MCPForUnity/Editor/Constants/AuthConstants.cs.meta new file mode 100644 index 0000000..55af6d2 --- /dev/null +++ b/MCPForUnity/Editor/Constants/AuthConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 96844bc39e9a94cf18b18f8127f3854f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs new file mode 100644 index 0000000..5fdfeb0 --- /dev/null +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -0,0 +1,85 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Centralized list of EditorPrefs keys used by the MCP for Unity package. + /// Keeping them in one place avoids typos and simplifies migrations. + /// + internal static class EditorPrefKeys + { + internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport"; + internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote" + internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid"; + internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort"; + internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc"; + internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash"; + internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath"; + internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken"; + internal const string DebugLogs = "MCPForUnity.DebugLogs"; + internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; + internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; + internal const string ResumeStdioAfterReload = "MCPForUnity.ResumeStdioAfterReload"; + + internal const string UvxPathOverride = "MCPForUnity.UvxPath"; + internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath"; + internal const string ClientProjectDirOverride = "MCPForUnity.ClientProjectDir"; + + internal const string HttpBaseUrl = "MCPForUnity.HttpUrl"; + internal const string HttpRemoteBaseUrl = "MCPForUnity.HttpRemoteUrl"; + internal const string SessionId = "MCPForUnity.SessionId"; + internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; + internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh"; + internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp"; + internal const string AllowLanHttpBind = "MCPForUnity.Security.AllowLanHttpBind"; + internal const string AllowInsecureRemoteHttp = "MCPForUnity.Security.AllowInsecureRemoteHttp"; + + internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath"; + internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath"; + internal const string PackageDeployLastTargetPath = "MCPForUnity.PackageDeploy.LastTargetPath"; + internal const string PackageDeployLastSourcePath = "MCPForUnity.PackageDeploy.LastSourcePath"; + + internal const string ServerSrc = "MCPForUnity.ServerSrc"; + internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; + internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; + internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled"; + internal const string ToolEnabledPrefix = "MCPForUnity.ToolEnabled."; + internal const string ToolFoldoutStatePrefix = "MCPForUnity.ToolFoldout."; + internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled."; + internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout."; + internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel"; + internal const string LastSelectedClientId = "MCPForUnity.LastSelectedClientId"; + internal const string ClientDetailsFoldoutOpen = "MCPForUnity.ClientConfig.DetailsFoldoutOpen"; + + internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; + internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; + + internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled"; + + internal const string LastUpdateCheck = "MCPForUnity.LastUpdateCheck"; + internal const string LatestKnownVersion = "MCPForUnity.LatestKnownVersion"; + internal const string LastAssetStoreUpdateCheck = "MCPForUnity.LastAssetStoreUpdateCheck"; + internal const string LatestKnownAssetStoreVersion = "MCPForUnity.LatestKnownAssetStoreVersion"; + internal const string LastStdIoUpgradeVersion = "MCPForUnity.LastStdIoUpgradeVersion"; + + internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; + internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + + internal const string ApiKey = "MCPForUnity.ApiKey"; + + internal const string AutoStartOnLoad = "MCPForUnity.AutoStartOnLoad"; + internal const string HttpServerLaunchConfirmed = "MCPForUnity.HttpServerLaunchConfirmed"; + internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands"; + internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled"; + + internal const string ExecuteCodeCompiler = "MCPForUnity.ExecuteCode.Compiler"; + + // AI Asset Generation — NON-SECRET config only. Provider API keys live in the OS + // secure store (MCPForUnity.Editor.Security.SecureKeyStore), never in EditorPrefs. + internal const string AssetGenSelectedModelProvider = "MCPForUnity.AssetGen.ModelProvider"; + internal const string AssetGenSelectedImageProvider = "MCPForUnity.AssetGen.ImageProvider"; + internal const string AssetGenDefaultFormat = "MCPForUnity.AssetGen.Format"; + internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; + internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; + internal const string AssetGenProviderEnabledPrefix = "MCPForUnity.AssetGen.Enabled."; + } +} diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta new file mode 100644 index 0000000..9c923da --- /dev/null +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7317786cfb9304b0db20ca73a774b9fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants/HealthStatus.cs b/MCPForUnity/Editor/Constants/HealthStatus.cs new file mode 100644 index 0000000..7506caa --- /dev/null +++ b/MCPForUnity/Editor/Constants/HealthStatus.cs @@ -0,0 +1,14 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Constants for health check status values. + /// Used for coordinating health state between Connection and Advanced sections. + /// + public static class HealthStatus + { + public const string Unknown = "Unknown"; + public const string Healthy = "Healthy"; + public const string PingFailed = "Ping Failed"; + public const string Unhealthy = "Unhealthy"; + } +} diff --git a/MCPForUnity/Editor/Constants/HealthStatus.cs.meta b/MCPForUnity/Editor/Constants/HealthStatus.cs.meta new file mode 100644 index 0000000..a08698c --- /dev/null +++ b/MCPForUnity/Editor/Constants/HealthStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c15ed2426f43860479f1b8a99a343d16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Constants/ProductInfo.cs b/MCPForUnity/Editor/Constants/ProductInfo.cs new file mode 100644 index 0000000..969cba1 --- /dev/null +++ b/MCPForUnity/Editor/Constants/ProductInfo.cs @@ -0,0 +1,9 @@ +namespace MCPForUnity.Editor.Constants +{ + /// Canonical user-facing product identity strings. + public static class ProductInfo + { + public const string ProductName = "MCP for Unity"; + public const string MenuRoot = "Window/MCP for Unity"; + } +} diff --git a/MCPForUnity/Editor/Constants/ProductInfo.cs.meta b/MCPForUnity/Editor/Constants/ProductInfo.cs.meta new file mode 100644 index 0000000..c5a677a --- /dev/null +++ b/MCPForUnity/Editor/Constants/ProductInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1df5e80156ac4f1ebe343e537eaa9da4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies.meta b/MCPForUnity/Editor/Dependencies.meta new file mode 100644 index 0000000..77685d1 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 221a4d6e595be6897a5b17b77aedd4d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs new file mode 100644 index 0000000..c3802c4 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Dependencies.PlatformDetectors; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Main orchestrator for dependency validation and management + /// + public static class DependencyManager + { + private static readonly List _detectors = new List + { + new WindowsPlatformDetector(), + new MacOSPlatformDetector(), + new LinuxPlatformDetector() + }; + + private static IPlatformDetector _currentDetector; + + /// + /// Get the platform detector for the current operating system + /// + public static IPlatformDetector GetCurrentPlatformDetector() + { + if (_currentDetector == null) + { + _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); + if (_currentDetector == null) + { + throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); + } + } + return _currentDetector; + } + + /// + /// Perform a comprehensive dependency check + /// + public static DependencyCheckResult CheckAllDependencies() + { + var result = new DependencyCheckResult(); + + try + { + var detector = GetCurrentPlatformDetector(); + McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); + + // Check Python + var pythonStatus = detector.DetectPython(); + result.Dependencies.Add(pythonStatus); + + // Check uv + var uvStatus = detector.DetectUv(); + result.Dependencies.Add(uvStatus); + + // Generate summary and recommendations + result.GenerateSummary(); + GenerateRecommendations(result, detector); + + McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); + } + catch (Exception ex) + { + McpLog.Error($"Error during dependency check: {ex.Message}"); + result.Summary = $"Dependency check failed: {ex.Message}"; + result.IsSystemReady = false; + } + + return result; + } + + /// + /// Get installation recommendations for the current platform + /// + public static string GetInstallationRecommendations() + { + try + { + var detector = GetCurrentPlatformDetector(); + return detector.GetInstallationRecommendations(); + } + catch (Exception ex) + { + return $"Error getting installation recommendations: {ex.Message}"; + } + } + + /// + /// Get platform-specific installation URLs + /// + public static (string pythonUrl, string uvUrl) GetInstallationUrls() + { + try + { + var detector = GetCurrentPlatformDetector(); + return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl()); + } + catch + { + return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); + } + } + + private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) + { + var missing = result.GetMissingDependencies(); + + if (missing.Count == 0) + { + result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); + return; + } + + foreach (var dep in missing) + { + if (dep.Name == "Python") + { + result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); + } + else if (dep.Name == "uv Package Manager") + { + result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}"); + } + else if (dep.Name == "MCP Server") + { + result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); + } + } + + if (result.GetMissingRequired().Count > 0) + { + result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Local Setup Window) for guided installation."); + } + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta new file mode 100644 index 0000000..fc192c2 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/Models.meta b/MCPForUnity/Editor/Dependencies/Models.meta new file mode 100644 index 0000000..1e05b9d --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4c0f2e87395b4c6c9df8c21b6d0fae13 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs new file mode 100644 index 0000000..5dd2eda --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Result of a comprehensive dependency check + /// + [Serializable] + public class DependencyCheckResult + { + /// + /// List of all dependency statuses checked + /// + public List Dependencies { get; set; } + + /// + /// Overall system readiness for MCP operations + /// + public bool IsSystemReady { get; set; } + + /// + /// Whether all required dependencies are available + /// + public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; + + /// + /// Whether any optional dependencies are missing + /// + public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; + + /// + /// Summary message about the dependency state + /// + public string Summary { get; set; } + + /// + /// Recommended next steps for the user + /// + public List RecommendedActions { get; set; } + + /// + /// Timestamp when this check was performed + /// + public DateTime CheckedAt { get; set; } + + public DependencyCheckResult() + { + Dependencies = new List(); + RecommendedActions = new List(); + CheckedAt = DateTime.UtcNow; + } + + /// + /// Get dependencies by availability status + /// + public List GetMissingDependencies() + { + return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Get missing required dependencies + /// + public List GetMissingRequired() + { + return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Generate a user-friendly summary of the dependency state + /// + public void GenerateSummary() + { + var missing = GetMissingDependencies(); + var missingRequired = GetMissingRequired(); + + if (missing.Count == 0) + { + Summary = "All dependencies are available and ready."; + IsSystemReady = true; + } + else if (missingRequired.Count == 0) + { + Summary = $"System is ready. {missing.Count} optional dependencies are missing."; + IsSystemReady = true; + } + else + { + Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; + IsSystemReady = false; + } + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta new file mode 100644 index 0000000..4d70a11 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6df82faa423f4e9ebb13a3dcee8ba19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs new file mode 100644 index 0000000..e755eca --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs @@ -0,0 +1,65 @@ +using System; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Represents the status of a dependency check + /// + [Serializable] + public class DependencyStatus + { + /// + /// Name of the dependency being checked + /// + public string Name { get; set; } + + /// + /// Whether the dependency is available and functional + /// + public bool IsAvailable { get; set; } + + /// + /// Version information if available + /// + public string Version { get; set; } + + /// + /// Path to the dependency executable/installation + /// + public string Path { get; set; } + + /// + /// Additional details about the dependency status + /// + public string Details { get; set; } + + /// + /// Error message if dependency check failed + /// + public string ErrorMessage { get; set; } + + /// + /// Whether this dependency is required for basic functionality + /// + public bool IsRequired { get; set; } + + /// + /// Suggested installation method or URL + /// + public string InstallationHint { get; set; } + + public DependencyStatus(string name, bool isRequired = true) + { + Name = name; + IsRequired = isRequired; + IsAvailable = false; + } + + public override string ToString() + { + var status = IsAvailable ? "✓" : "✗"; + var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : ""; + return $"{status} {Name}{version}"; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta new file mode 100644 index 0000000..7e93ff4 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ddeeeca2f876f4083a84417404175199 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta new file mode 100644 index 0000000..8c86307 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bdbaced669d14798a4ceeebfbff2b22c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs new file mode 100644 index 0000000..3231105 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -0,0 +1,45 @@ +using MCPForUnity.Editor.Dependencies.Models; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Interface for platform-specific dependency detection + /// + public interface IPlatformDetector + { + /// + /// Platform name this detector handles + /// + string PlatformName { get; } + + /// + /// Whether this detector can run on the current platform + /// + bool CanDetect { get; } + + /// + /// Detect Python installation on this platform + /// + DependencyStatus DetectPython(); + + /// + /// Detect uv package manager on this platform + /// + DependencyStatus DetectUv(); + + /// + /// Get platform-specific installation recommendations + /// + string GetInstallationRecommendations(); + + /// + /// Get platform-specific Python installation URL + /// + string GetPythonInstallUrl(); + + /// + /// Get platform-specific uv installation URL + /// + string GetUvInstallUrl(); + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta new file mode 100644 index 0000000..1ec32f3 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67d73d0e8caef4e60942f4419c6b76bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs new file mode 100644 index 0000000..be9db17 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -0,0 +1,207 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Linux-specific dependency detection + /// + public class LinuxPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Linux"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + + // Fallback: try 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/source/"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#linux"; + } + + public override string GetInstallationRecommendations() + { + return @"Linux Installation Recommendations: + +1. Python: Install via package manager or pyenv + - Ubuntu/Debian: sudo apt install python3 python3-pip + - Fedora/RHEL: sudo dnf install python3 python3-pip + - Arch: sudo pacman -S python python-pip + - Or use pyenv: https://github.com/pyenv/pyenv + +2. uv Package Manager: Install via curl + - Run: curl -LsSf https://astral.sh/uv/install.sh | sh + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by MCP for Unity + +Note: Make sure ~/.local/bin is in your PATH for user-local installations."; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta new file mode 100644 index 0000000..96e1a43 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b682b492eb80d4ed6834b76f72c9f0f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs new file mode 100644 index 0000000..2c06219 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -0,0 +1,206 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// macOS-specific dependency detection + /// + public class MacOSPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "macOS"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // 1. Try 'which' command with augmented PATH (prioritizing Homebrew) + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // 2. Fallback: Try running python directly from PATH + if (TryValidatePython("python3", out string v, out string p) || + TryValidatePython("python", out v, out p)) + { + status.IsAvailable = true; + status.Version = v; + status.Path = p; + status.Details = $"Found Python {v} in PATH"; + return status; + } + + status.ErrorMessage = "Python not found in PATH or standard locations"; + status.Details = "Install Python 3.10+ via Homebrew ('brew install python3') and ensure it's in your PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/macos/"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#macos"; + } + + public override string GetInstallationRecommendations() + { + return @"macOS Installation Recommendations: + +1. Python: Install via Homebrew (recommended) or python.org + - Homebrew: brew install python3 + - Direct download: https://python.org/downloads/macos/ + +2. uv Package Manager: Install via curl or Homebrew + - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh + - Homebrew: brew install uv + +3. MCP Server: Will be installed automatically by MCP for Unity Bridge + +Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + Path.Combine(homeDir, ".pyenv", "shims"), // pyenv: Python/uv when Unity is launched from Dock/Spotlight + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta new file mode 100644 index 0000000..57b516b --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6f602b0a8ca848859197f9a949a7a5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs new file mode 100644 index 0000000..c955381 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -0,0 +1,137 @@ +using System; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Base class for platform-specific dependency detection + /// + public abstract class PlatformDetectorBase : IPlatformDetector + { + public abstract string PlatformName { get; } + public abstract bool CanDetect { get; } + + public abstract DependencyStatus DetectPython(); + public abstract string GetPythonInstallUrl(); + public abstract string GetUvInstallUrl(); + public abstract string GetInstallationRecommendations(); + + public virtual DependencyStatus DetectUv() + { + var status = new DependencyStatus("uv Package Manager", isRequired: true) + { + InstallationHint = GetUvInstallUrl() + }; + + try + { + // Get uv path from PathResolverService (respects override) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + + // Verify uv executable and get version + if (MCPServiceLocator.Paths.TryValidateUvxExecutable(uvxPath, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = uvxPath; + + // Check if we used fallback from override to system path + if (MCPServiceLocator.Paths.HasUvxPathFallback) + { + status.Details = $"Found uv {version} (fallback to system path)"; + status.ErrorMessage = "Override path not found, using system path"; + } + else + { + status.Details = MCPServiceLocator.Paths.HasUvxPathOverride + ? $"Found uv {version} (override path)" + : $"Found uv {version} in system path"; + } + return status; + } + + status.ErrorMessage = "uvx not found"; + status.Details = "Install uv package manager or configure path override in Advanced Settings."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uvx: {ex.Message}"; + } + + return status; + } + + + protected bool TryParseVersion(string version, out int major, out int minor) + { + major = 0; + minor = 0; + + try + { + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + } + } + catch + { + // Ignore parsing errors + } + + return false; + } + // In PlatformDetectorBase.cs + protected bool TryValidateUvWithPath(string command, string augmentedPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string commandToRun = command; + if (TryFindInPath(command, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + string output = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim(); + + if (output.StartsWith("uvx ") || output.StartsWith("uv ")) + { + int spaceIndex = output.IndexOf(' '); + if (spaceIndex >= 0) + { + var remainder = output.Substring(spaceIndex + 1).Trim(); + int nextSpace = remainder.IndexOf(' '); + int parenIndex = remainder.IndexOf('('); + int endIndex = Math.Min( + nextSpace >= 0 ? nextSpace : int.MaxValue, + parenIndex >= 0 ? parenIndex : int.MaxValue + ); + version = endIndex < int.MaxValue ? remainder.Substring(0, endIndex).Trim() : remainder; + fullPath = commandToRun; + return true; + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + + // Add abstract method for subclasses to implement + protected abstract bool TryFindInPath(string executable, out string fullPath); + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta new file mode 100644 index 0000000..4821e75 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44d715aedea2b8b41bf914433bbb2c49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs new file mode 100644 index 0000000..706e503 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Windows-specific dependency detection + /// + public class WindowsPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Windows"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Try running python directly first (works with Windows App Execution Aliases) + if (TryValidatePython("python3.exe", out string version, out string fullPath) || + TryValidatePython("python.exe", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + + // Fallback: try 'where' command + if (TryFindInPath("python3.exe", out string pathResult) || + TryFindInPath("python.exe", out pathResult)) + { + if (TryValidatePython(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + } + + // Fallback: try to find python via uv + if (TryFindPythonViaUv(out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} via uv"; + return status; + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#windows"; + } + + public override string GetInstallationRecommendations() + { + return @"Windows Installation Recommendations: + +1. Python: Install from Microsoft Store or python.org + - Microsoft Store: Search for 'Python 3.10' or higher + - Direct download: https://python.org/downloads/windows/ + +2. uv Package Manager: Install via PowerShell + - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by MCP for Unity Bridge"; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // try to find uv + if (TryValidateUvWithPath("uv.exe", augmentedPath, out string uvVersion, out string uvPath)) + { + status.IsAvailable = true; + status.Version = uvVersion; + status.Path = uvPath; + status.Details = $"Found uv {uvVersion} at {uvPath}"; + return status; + } + + // try to find uvx + if (TryValidateUvWithPath("uvx.exe", augmentedPath, out string uvxVersion, out string uvxPath)) + { + status.IsAvailable = true; + status.Version = uvxVersion; + status.Path = uvxPath; + status.Details = $"Found uvx {uvxVersion} at {uvxPath} (fallback)"; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + + private bool TryFindPythonViaUv(out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + // Try to list installed python versions via uvx + if (!ExecPath.TryRun("uv", "python list", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; + + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains("")) continue; + + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + string potentialPath = parts[parts.Length - 1]; + if (File.Exists(potentialPath) && + (potentialPath.EndsWith("python.exe") || potentialPath.EndsWith("python3.exe"))) + { + if (TryValidatePython(potentialPath, out version, out fullPath)) + { + return true; + } + } + } + } + } + catch + { + // Ignore errors if uv is not installed or fails + } + + return false; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + // Run 'python --version' to get the version + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + var additions = new List(); + + // uv common installation paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "uv")); + if (!string.IsNullOrEmpty(programFiles)) + additions.Add(Path.Combine(programFiles, "uv")); + + // npm global paths + if (!string.IsNullOrEmpty(appData)) + additions.Add(Path.Combine(appData, "npm")); + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "npm")); + + // Python common paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "Python")); + // Instead of hardcoded versions, enumerate existing directories + if (!string.IsNullOrEmpty(programFiles)) + { + try + { + var pythonDirs = Directory.GetDirectories(programFiles, "Python3*") + .OrderByDescending(d => d); // Newest first + foreach (var dir in pythonDirs) + { + additions.Add(dir); + } + } + catch { /* Ignore if directory doesn't exist */ } + } + + // User scripts + if (!string.IsNullOrEmpty(homeDir)) + additions.Add(Path.Combine(homeDir, ".local", "bin")); + + return additions.ToArray(); + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta new file mode 100644 index 0000000..59cf1d9 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1aedc29caa5704c07b487d20a27e9334 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/UvInstaller.cs b/MCPForUnity/Editor/Dependencies/UvInstaller.cs new file mode 100644 index 0000000..002ae69 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/UvInstaller.cs @@ -0,0 +1,93 @@ +using System; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Runs the official uv installer for the current platform. UI-agnostic: build the command, + /// run it (off the main thread), and report the outcome. The caller owns confirmation and + /// re-checking dependencies afterwards. + /// Installer reference: https://docs.astral.sh/uv/getting-started/installation/ + /// + public static class UvInstaller + { + public readonly struct UvInstallResult + { + public readonly bool Success; + public readonly string Output; + + public UvInstallResult(bool success, string output) + { + Success = success; + Output = output ?? string.Empty; + } + } + + /// One-click install is available on Windows, macOS and Linux. + public static bool IsSupported => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || + RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + /// + /// Build the installer invocation for the current platform. Pure and testable — the + /// returned command is exactly what executes and what the confirm + /// dialog shows the user. + /// + public static (string file, string arguments) BuildInstallCommand() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ("powershell", + "-NoProfile -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\""); + } + + // macOS and Linux share the POSIX shell installer. + return ("/bin/sh", "-c \"curl -LsSf https://astral.sh/uv/install.sh | sh\""); + } + + /// Human-readable one-line description of the command that will run. + public static string DescribeCommand() + { + var (file, arguments) = BuildInstallCommand(); + return $"{file} {arguments}"; + } + + /// + /// Run the installer and return the outcome. Blocks until the installer exits or the + /// timeout elapses, so call it off the main thread (e.g. via Task.Run). + /// + public static UvInstallResult Run(int timeoutMs = 180000) + { + try + { + var (file, arguments) = BuildInstallCommand(); + bool ok = ExecPath.TryRun(file, arguments, null, out string stdout, out string stderr, timeoutMs); + return new UvInstallResult(ok, Combine(stdout, stderr)); + } + catch (Exception ex) + { + return new UvInstallResult(false, ex.Message); + } + } + + private static string Combine(string stdout, string stderr) + { + string outText = (stdout ?? string.Empty).Trim(); + string errText = (stderr ?? string.Empty).Trim(); + string combined = + string.IsNullOrEmpty(errText) ? outText : + string.IsNullOrEmpty(outText) ? errText : + outText + "\n" + errText; + + // Keep dialogs readable — echo only the tail of long installer output. + const int max = 1500; + if (combined.Length > max) + { + combined = "…" + combined.Substring(combined.Length - max); + } + return combined; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta b/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta new file mode 100644 index 0000000..d3fe2b3 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55a5a123f9454b75906bdb5b7d008990 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/External.meta b/MCPForUnity/Editor/External.meta new file mode 100644 index 0000000..ce757b1 --- /dev/null +++ b/MCPForUnity/Editor/External.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c11944bcfb9ec4576bab52874b7df584 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/External/Tommy.cs b/MCPForUnity/Editor/External/Tommy.cs new file mode 100644 index 0000000..22e83b8 --- /dev/null +++ b/MCPForUnity/Editor/External/Tommy.cs @@ -0,0 +1,2138 @@ +#region LICENSE + +/* + * MIT License + * + * Copyright (c) 2020 Denis Zhidkikh + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace MCPForUnity.External.Tommy +{ + #region TOML Nodes + + public abstract class TomlNode : IEnumerable + { + public virtual bool HasValue { get; } = false; + public virtual bool IsArray { get; } = false; + public virtual bool IsTable { get; } = false; + public virtual bool IsString { get; } = false; + public virtual bool IsInteger { get; } = false; + public virtual bool IsFloat { get; } = false; + public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; + public virtual bool IsDateTimeLocal { get; } = false; + public virtual bool IsDateTimeOffset { get; } = false; + public virtual bool IsBoolean { get; } = false; + public virtual string Comment { get; set; } + public virtual int CollapseLevel { get; set; } + + public virtual TomlTable AsTable => this as TomlTable; + public virtual TomlString AsString => this as TomlString; + public virtual TomlInteger AsInteger => this as TomlInteger; + public virtual TomlFloat AsFloat => this as TomlFloat; + public virtual TomlBoolean AsBoolean => this as TomlBoolean; + public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; + public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; + public virtual TomlDateTime AsDateTime => this as TomlDateTime; + public virtual TomlArray AsArray => this as TomlArray; + + public virtual int ChildrenCount => 0; + + public virtual TomlNode this[string key] + { + get => null; + set { } + } + + public virtual TomlNode this[int index] + { + get => null; + set { } + } + + public virtual IEnumerable Children + { + get { yield break; } + } + + public virtual IEnumerable Keys + { + get { yield break; } + } + + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + + public virtual bool TryGetNode(string key, out TomlNode node) + { + node = null; + return false; + } + + public virtual bool HasKey(string key) => false; + + public virtual bool HasItemAt(int index) => false; + + public virtual void Add(string key, TomlNode node) { } + + public virtual void Add(TomlNode node) { } + + public virtual void Delete(TomlNode node) { } + + public virtual void Delete(string key) { } + + public virtual void Delete(int index) { } + + public virtual void AddRange(IEnumerable nodes) + { + foreach (var tomlNode in nodes) Add(tomlNode); + } + + public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); + + public virtual string ToInlineToml() => ToString(); + + #region Native type to TOML cast + + public static implicit operator TomlNode(string value) => new TomlString { Value = value }; + + public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; + + public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; + + public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; + + public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; + + public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; + + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; + + public static implicit operator TomlNode(TomlNode[] nodes) + { + var result = new TomlArray(); + result.AddRange(nodes); + return result; + } + + #endregion + + #region TOML to native type cast + + public static implicit operator string(TomlNode value) => value.ToString(); + + public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; + + public static implicit operator long(TomlNode value) => value.AsInteger.Value; + + public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; + + public static implicit operator double(TomlNode value) => value.AsFloat.Value; + + public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; + + public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; + + public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; + + #endregion + } + + public class TomlString : TomlNode + { + public override bool HasValue { get; } = true; + public override bool IsString { get; } = true; + public bool IsMultiline { get; set; } + public bool MultilineTrimFirstLine { get; set; } + public bool PreferLiteral { get; set; } + + public string Value { get; set; } + + public override string ToString() => Value; + + public override string ToInlineToml() + { + // Automatically convert literal to non-literal if there are too many literal string symbols + if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; + var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, + IsMultiline ? 3 : 1); + var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); + if (IsMultiline) + result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); + if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) + result = $"{Environment.NewLine}{result}"; + return $"{quotes}{result}{quotes}"; + } + } + + public class TomlInteger : TomlNode + { + public enum Base + { + Binary = 2, + Octal = 8, + Decimal = 10, + Hexadecimal = 16 + } + + public override bool IsInteger { get; } = true; + public override bool HasValue { get; } = true; + public Base IntegerBase { get; set; } = Base.Decimal; + + public long Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => + IntegerBase != Base.Decimal + ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" + : Value.ToString(CultureInfo.InvariantCulture); + } + + public class TomlFloat : TomlNode, IFormattable + { + public override bool IsFloat { get; } = true; + public override bool HasValue { get; } = true; + + public double Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); + + public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToInlineToml() => + Value switch + { + var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, + var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, + var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, + var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() + }; + } + + public class TomlBoolean : TomlNode + { + public override bool IsBoolean { get; } = true; + public override bool HasValue { get; } = true; + + public bool Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; + } + + public class TomlDateTime : TomlNode, IFormattable + { + public int SecondsPrecision { get; set; } + public override bool HasValue { get; } = true; + public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; + public virtual string ToString(IFormatProvider formatProvider) => string.Empty; + protected virtual string ToInlineTomlInternal() => string.Empty; + + public override string ToInlineToml() => ToInlineTomlInternal() + .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) + .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); + } + + public class TomlDateTimeOffset : TomlDateTime + { + public override bool IsDateTimeOffset { get; } = true; + public DateTimeOffset Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); + } + + public class TomlDateTimeLocal : TomlDateTime + { + public enum DateTimeStyle + { + Date, + Time, + DateTime + } + + public override bool IsDateTimeLocal { get; } = true; + public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; + public DateTime Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + public override string ToInlineToml() => + Style switch + { + DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), + DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + }; + } + + public class TomlArray : TomlNode + { + private List values; + + public override bool HasValue { get; } = true; + public override bool IsArray { get; } = true; + public bool IsMultiline { get; set; } + public bool IsTableArray { get; set; } + public List RawArray => values ??= new List(); + + public override TomlNode this[int index] + { + get + { + if (index < RawArray.Count) return RawArray[index]; + var lazy = new TomlLazy(this); + this[index] = lazy; + return lazy; + } + set + { + if (index == RawArray.Count) + RawArray.Add(value); + else + RawArray[index] = value; + } + } + + public override int ChildrenCount => RawArray.Count; + + public override IEnumerable Children => RawArray.AsEnumerable(); + + public override void Add(TomlNode node) => RawArray.Add(node); + + public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes); + + public override void Delete(TomlNode node) => RawArray.Remove(node); + + public override void Delete(int index) => RawArray.RemoveAt(index); + + public override string ToString() => ToString(false); + + public string ToString(bool multiline) + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.ARRAY_START_SYMBOL); + if (ChildrenCount != 0) + { + var arrayStart = multiline ? $"{Environment.NewLine} " : " "; + var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; + var arrayEnd = multiline ? Environment.NewLine : " "; + sb.Append(arrayStart) + .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) + .Append(arrayEnd); + } + sb.Append(TomlSyntax.ARRAY_END_SYMBOL); + return sb.ToString(); + } + + public override void WriteTo(TextWriter tw, string name = null) + { + // If it's a normal array, write it as usual + if (!IsTableArray) + { + tw.WriteLine(ToString(IsMultiline)); + return; + } + + if (!(Comment is null)) + { + tw.WriteLine(); + Comment.AsComment(tw); + } + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + + var first = true; + + foreach (var tomlNode in RawArray) + { + if (!(tomlNode is TomlTable tbl)) + throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); + + // Ensure it's parsed as a section + tbl.IsInline = false; + + if (!first) + { + tw.WriteLine(); + + Comment?.AsComment(tw); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + + first = false; + + // Don't write section since it's already written here + tbl.WriteTo(tw, name, false); + } + } + } + + public class TomlTable : TomlNode + { + private Dictionary children; + internal bool isImplicit; + + public override bool HasValue { get; } = false; + public override bool IsTable { get; } = true; + public bool IsInline { get; set; } + public Dictionary RawTable => children ??= new Dictionary(); + + public override TomlNode this[string key] + { + get + { + if (RawTable.TryGetValue(key, out var result)) return result; + var lazy = new TomlLazy(this); + RawTable[key] = lazy; + return lazy; + } + set => RawTable[key] = value; + } + + public override int ChildrenCount => RawTable.Count; + public override IEnumerable Children => RawTable.Select(kv => kv.Value); + public override IEnumerable Keys => RawTable.Select(kv => kv.Key); + public override bool HasKey(string key) => RawTable.ContainsKey(key); + public override void Add(string key, TomlNode node) => RawTable.Add(key, node); + public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); + public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); + public override void Delete(string key) => RawTable.Remove(key); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); + + if (ChildrenCount != 0) + { + var collapsed = CollectCollapsedItems(normalizeOrder: false); + + if (collapsed.Count != 0) + sb.Append(' ') + .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => + $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); + sb.Append(' '); + } + + sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); + return sb.ToString(); + } + + private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) + { + var nodes = new LinkedList>(); + var postNodes = normalizeOrder ? new LinkedList>() : nodes; + + foreach (var keyValuePair in RawTable) + { + var node = keyValuePair.Value; + var key = keyValuePair.Key.AsKey(); + + if (node is TomlTable tbl) + { + var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); + // Write main table first before writing collapsed items + if (subnodes.Count == 0 && node.CollapseLevel == level) + { + postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + foreach (var kv in subnodes) + postNodes.AddLast(kv); + } + else if (node.CollapseLevel == level) + nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + + if (normalizeOrder) + foreach (var kv in postNodes) + nodes.AddLast(kv); + + return nodes; + } + + public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); + + internal void WriteTo(TextWriter tw, string name, bool writeSectionName) + { + // The table is inline table + if (IsInline && name != null) + { + tw.WriteLine(ToInlineToml()); + return; + } + + var collapsedItems = CollectCollapsedItems(); + + if (collapsedItems.Count == 0) + return; + + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); + + Comment?.AsComment(tw); + + if (name != null && (hasRealValues || Comment != null) && writeSectionName) + { + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + else if (Comment != null) // Add some spacing between the first node and the comment + { + tw.WriteLine(); + } + + var namePrefix = name == null ? "" : $"{name}."; + var first = true; + + foreach (var collapsedItem in collapsedItems) + { + var key = collapsedItem.Key; + if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) + { + if (!first) tw.WriteLine(); + first = false; + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + continue; + } + first = false; + + collapsedItem.Value.Comment?.AsComment(tw); + tw.Write(key); + tw.Write(' '); + tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); + tw.Write(' '); + + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + } + } + } + + internal class TomlLazy : TomlNode + { + private readonly TomlNode parent; + private TomlNode replacement; + + public TomlLazy(TomlNode parent) => this.parent = parent; + + public override TomlNode this[int index] + { + get => Set()[index]; + set => Set()[index] = value; + } + + public override TomlNode this[string key] + { + get => Set()[key]; + set => Set()[key] = value; + } + + public override void Add(TomlNode node) => Set().Add(node); + + public override void Add(string key, TomlNode node) => Set().Add(key, node); + + public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes); + + private TomlNode Set() where T : TomlNode, new() + { + if (replacement != null) return replacement; + + var newNode = new T + { + Comment = Comment + }; + + if (parent.IsTable) + { + var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); + if (key == null) return default(T); + + parent[key] = newNode; + } + else if (parent.IsArray) + { + var index = parent.Children.TakeWhile(child => child != this).Count(); + if (index == parent.ChildrenCount) return default(T); + parent[index] = newNode; + } + else + { + return default(T); + } + + replacement = newNode; + return newNode; + } + } + + #endregion + + #region Parser + + public class TOMLParser : IDisposable + { + public enum ParseState + { + None, + KeyValuePair, + SkipToNextLine, + Table + } + + private readonly TextReader reader; + private ParseState currentState; + private int line, col; + private List syntaxErrors; + + public TOMLParser(TextReader reader) + { + this.reader = reader; + line = col = 0; + } + + public bool ForceASCII { get; set; } + + public void Dispose() => reader?.Dispose(); + + public TomlTable Parse() + { + syntaxErrors = new List(); + line = col = 1; + var rootNode = new TomlTable(); + var currentNode = rootNode; + currentState = ParseState.None; + var keyParts = new List(); + var arrayTable = false; + StringBuilder latestComment = null; + var firstComment = true; + + int currentChar; + while ((currentChar = reader.Peek()) >= 0) + { + var c = (char)currentChar; + + if (currentState == ParseState.None) + { + // Skip white space + if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; + + if (TomlSyntax.IsNewLine(c)) + { + // Check if there are any comments and so far no items being declared + if (latestComment != null && firstComment) + { + rootNode.Comment = latestComment.ToString().TrimEnd(); + latestComment = null; + firstComment = false; + } + + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + + goto consume_character; + } + + // Start of a comment; ignore until newline + if (c == TomlSyntax.COMMENT_SYMBOL) + { + latestComment ??= new StringBuilder(); + latestComment.AppendLine(ParseComment()); + AdvanceLine(1); + continue; + } + + // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! + firstComment = false; + + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + currentState = ParseState.Table; + goto consume_character; + } + + if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) + { + currentState = ParseState.KeyValuePair; + } + else + { + AddError($"Unexpected character \"{c}\""); + continue; + } + } + + if (currentState == ParseState.KeyValuePair) + { + var keyValuePair = ReadKeyValuePair(keyParts); + + if (keyValuePair == null) + { + latestComment = null; + keyParts.Clear(); + + if (currentState != ParseState.None) + AddError("Failed to parse key-value pair!"); + continue; + } + + keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); + var inserted = InsertNode(keyValuePair, currentNode, keyParts); + latestComment = null; + keyParts.Clear(); + if (inserted) + currentState = ParseState.SkipToNextLine; + continue; + } + + if (currentState == ParseState.Table) + { + if (keyParts.Count == 0) + { + // We have array table + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + // Consume the character + ConsumeChar(); + arrayTable = true; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) + { + keyParts.Clear(); + continue; + } + + if (keyParts.Count == 0) + { + AddError("Table name is emtpy."); + arrayTable = false; + latestComment = null; + keyParts.Clear(); + } + + continue; + } + + if (c == TomlSyntax.TABLE_END_SYMBOL) + { + if (arrayTable) + { + // Consume the ending bracket so we can peek the next character + ConsumeChar(); + var nextChar = reader.Peek(); + if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) + { + AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + continue; + } + } + + currentNode = CreateTable(rootNode, keyParts, arrayTable); + if (currentNode != null) + { + currentNode.IsInline = false; + currentNode.Comment = latestComment?.ToString()?.TrimEnd(); + } + + keyParts.Clear(); + arrayTable = false; + latestComment = null; + + if (currentNode == null) + { + if (currentState != ParseState.None) + AddError("Error creating table array!"); + // Reset a node to root in order to try and continue parsing + currentNode = rootNode; + continue; + } + + currentState = ParseState.SkipToNextLine; + goto consume_character; + } + + if (keyParts.Count != 0) + { + AddError($"Unexpected character \"{c}\""); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + } + } + + if (currentState == ParseState.SkipToNextLine) + { + if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) + goto consume_character; + + if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) + { + currentState = ParseState.None; + AdvanceLine(); + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + col++; + ParseComment(); + continue; + } + + goto consume_character; + } + + AddError($"Unexpected character \"{c}\" at the end of the line."); + } + + consume_character: + reader.Read(); + col++; + } + + if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) + AddError("Unexpected end of file!"); + + if (syntaxErrors.Count > 0) + throw new TomlParseException(rootNode, syntaxErrors); + + return rootNode; + } + + private bool AddError(string message, bool skipLine = true) + { + syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); + // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) + if (skipLine) + { + reader.ReadLine(); + AdvanceLine(1); + } + currentState = ParseState.None; + return false; + } + + private void AdvanceLine(int startCol = 0) + { + line++; + col = startCol; + } + + private int ConsumeChar() + { + col++; + return reader.Read(); + } + + #region Key-Value pair parsing + + /** + * Reads a single key-value pair. + * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). + * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). + * + * Example: + * foo = "bar" ==> foo = "bar" + * ^ ^ + */ + private TomlNode ReadKeyValuePair(List keyParts) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) + { + if (keyParts.Count != 0) + { + AddError("Encountered extra characters in key definition!"); + return null; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) + return null; + + continue; + } + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.KEY_VALUE_SEPARATOR) + { + ConsumeChar(); + return ReadValue(); + } + + AddError($"Unexpected character \"{c}\" in key name."); + return null; + } + + return null; + } + + /** + * Reads a single value. + * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). + * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private TomlNode ReadValue(bool skipNewlines = false) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("No value found!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + if (skipNewlines) + { + reader.Read(); + AdvanceLine(1); + continue; + } + + AddError("Encountered a newline when expecting a value!"); + return null; + } + + if (TomlSyntax.IsQuoted(c)) + { + var isMultiline = IsTripleQuote(c, out var excess); + + // Error occurred in triple quote parsing + if (currentState == ParseState.None) + return null; + + var value = isMultiline + ? ReadQuotedValueMultiLine(c) + : ReadQuotedValueSingleLine(c, excess); + + if (value is null) + return null; + + return new TomlString + { + Value = value, + IsMultiline = isMultiline, + PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL + }; + } + + return c switch + { + TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() + }; + } + + return null; + } + + /** + * Reads a single key name. + * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). + * Consumes all the characters until the `until` character is met (but does not consume the character itself). + * + * Example 1: + * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) + * ^ ^ + * + * Example 2: + * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) + * ^ ^ + */ + private bool ReadKeyName(ref List parts, char until) + { + var buffer = new StringBuilder(); + var quoted = false; + var prevWasSpace = false; + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + // Reached the final character + if (c == until) break; + + if (TomlSyntax.IsWhiteSpace(c)) + { + prevWasSpace = true; + goto consume_character; + } + + if (buffer.Length == 0) prevWasSpace = false; + + if (c == TomlSyntax.SUBKEY_SEPARATOR) + { + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + buffer.Length = 0; + quoted = false; + prevWasSpace = false; + goto consume_character; + } + + if (prevWasSpace) + return AddError("Invalid spacing in key name"); + + if (TomlSyntax.IsQuoted(c)) + { + if (quoted) + + return AddError("Expected a subkey separator but got extra data instead!"); + + if (buffer.Length != 0) + return AddError("Encountered a quote in the middle of subkey name!"); + + // Consume the quote character and read the key name + col++; + buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); + quoted = true; + continue; + } + + if (TomlSyntax.IsBareKey(c)) + { + buffer.Append(c); + goto consume_character; + } + + // If we see an invalid symbol, let the next parser handle it + break; + + consume_character: + reader.Read(); + col++; + } + + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + + return true; + } + + #endregion + + #region Non-string value parsing + + /** + * Reads the whole raw value until the first non-value character is encountered. + * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. + * Example: + * + * 1_0_0_0 ==> 1_0_0_0 + * ^ ^ + */ + private string ReadRawValue() + { + var result = new StringBuilder(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; + result.Append(c); + ConsumeChar(); + } + + // Replace trim with manual space counting? + return result.ToString().Trim(); + } + + /** + * Reads and parses a non-string, non-composite TOML value. + * Assumes the cursor at the first character that is related to the value (with possible spaces). + * Consumes all the characters that are related to the value. + * + * Example + * 1_0_0_0 # This is a comment + * + * ==> 1_0_0_0 # This is a comment + * ^ ^ + */ + private TomlNode ReadTomlValue() + { + var value = ReadRawValue(); + TomlNode node = value switch + { + var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), + var v when TomlSyntax.IsNaN(v) => double.NaN, + var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, + var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, + var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger + { + Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), + IntegerBase = (TomlInteger.Base)numberBase + }, + var _ => null + }; + if (node != null) return node; + + // Normalize by removing space separator + value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalDateTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out var dateTimeResult, + out var precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + SecondsPrecision = precision + }; + + if (DateTime.TryParseExact(value, + TomlSyntax.LocalDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dateTimeResult)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Date + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out dateTimeResult, + out precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Time, + SecondsPrecision = precision + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339Formats, + DateTimeStyles.None, + DateTimeOffset.TryParseExact, + out var dateTimeOffsetResult, + out precision)) + return new TomlDateTimeOffset + { + Value = dateTimeOffsetResult, + SecondsPrecision = precision + }; + + AddError($"Value \"{value}\" is not a valid TOML value!"); + return null; + } + + /** + * Reads an array value. + * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. + * + * Example: + * [1, 2, 3] ==> [1, 2, 3] + * ^ ^ + */ + private TomlArray ReadArray() + { + // Consume the start of array character + ConsumeChar(); + var result = new TomlArray(); + TomlNode currentValue = null; + var expectValue = true; + + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (c == TomlSyntax.ARRAY_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + reader.ReadLine(); + AdvanceLine(1); + continue; + } + + if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + goto consume_character; + } + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators"); + return null; + } + + result.Add(currentValue); + currentValue = null; + expectValue = true; + goto consume_character; + } + + if (!expectValue) + { + AddError("Missing separator between values"); + return null; + } + currentValue = ReadValue(true); + if (currentValue == null) + { + if (currentState != ParseState.None) + AddError("Failed to determine and parse a value!"); + return null; + } + expectValue = false; + + continue; + consume_character: + ConsumeChar(); + } + + if (currentValue != null) result.Add(currentValue); + return result; + } + + /** + * Reads an inline table. + * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. + * + * Example: + * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } + * ^ ^ + */ + private TomlNode ReadInlineTable() + { + ConsumeChar(); + var result = new TomlTable { IsInline = true }; + TomlNode currentValue = null; + var separator = false; + var keyParts = new List(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("Incomplete inline table definition!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + AddError("Inline tables are only allowed to be on single line"); + return null; + } + + if (TomlSyntax.IsWhiteSpace(c)) + goto consume_character; + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators in inline table!"); + return null; + } + + if (!InsertNode(currentValue, result, keyParts)) + return null; + keyParts.Clear(); + currentValue = null; + separator = true; + goto consume_character; + } + + separator = false; + currentValue = ReadKeyValuePair(keyParts); + continue; + + consume_character: + ConsumeChar(); + } + + if (separator) + { + AddError("Trailing commas are not allowed in inline tables."); + return null; + } + + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) + return null; + + return result; + } + + #endregion + + #region String parsing + + /** + * Checks if the string value a multiline string (i.e. a triple quoted string). + * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. + * + * If the result is false, returns the consumed character through the `excess` variable. + * + * Example 1: + * """test""" ==> """test""" + * ^ ^ + * + * Example 2: + * "test" ==> "test" (doesn't return the first quote) + * ^ ^ + * + * Example 3: + * "" ==> "" (returns the extra `"` through the `excess` variable) + * ^ ^ + */ + private bool IsTripleQuote(char quote, out char excess) + { + // Copypasta, but it's faster... + + int cur; + // Consume the first quote + ConsumeChar(); + if ((cur = reader.Peek()) < 0) + { + excess = '\0'; + return AddError("Unexpected end of file!"); + } + + if ((char)cur != quote) + { + excess = '\0'; + return false; + } + + // Consume the second quote + excess = (char)ConsumeChar(); + if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; + + // Consume the final quote + ConsumeChar(); + excess = '\0'; + return true; + } + + /** + * A convenience method to process a single character within a quote. + */ + private bool ProcessQuotedValueCharacter(char quote, + bool isNonLiteral, + char c, + StringBuilder sb, + ref bool escaped) + { + if (TomlSyntax.MustBeEscaped(c)) + return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); + + if (escaped) + { + sb.Append(c); + escaped = false; + return false; + } + + if (c == quote) + { + if (!isNonLiteral && reader.Peek() == quote) + { + reader.Read(); + col++; + sb.Append(quote); + return false; + } + + return true; + } + if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) + escaped = true; + if (c == TomlSyntax.NEWLINE_CHARACTER) + return AddError("Encountered newline in single line string!"); + + sb.Append(c); + return false; + } + + /** + * Reads a single-line string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string (including the closing quote). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') + { + var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + + if (initialData != '\0') + { + var shouldReturn = + ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); + if (currentState == ParseState.None) return null; + if (shouldReturn) + if (isNonLiteral) + { + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + else + return sb.ToString(); + } + + int cur; + var readDone = false; + while ((cur = reader.Read()) >= 0) + { + // Consume the character + col++; + var c = (char)cur; + readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); + if (readDone) + { + if (currentState == ParseState.None) return null; + break; + } + } + + if (!readDone) + { + AddError("Unclosed string."); + return null; + } + + if (!isNonLiteral) return sb.ToString(); + if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; + AddError(unescapedEx.Message); + return null; + } + + /** + * Reads a multiline string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string and the three closing quotes. + * + * Example: + * """test""" ==> """test""" + * ^ ^ + */ + private string ReadQuotedValueMultiLine(char quote) + { + var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + var skipWhitespace = false; + var skipWhitespaceLineSkipped = false; + var quotesEncountered = 0; + var first = true; + int cur; + while ((cur = ConsumeChar()) >= 0) + { + var c = (char)cur; + if (TomlSyntax.MustBeEscaped(c, true)) + { + AddError($"The character U+{(int)c:X8} must be escaped!"); + return null; + } + // Trim the first newline + if (first && TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + first = false; + else + AdvanceLine(); + continue; + } + + first = false; + //TODO: Reuse ProcessQuotedValueCharacter + // Skip the current character if it is going to be escaped later + if (escaped) + { + sb.Append(c); + escaped = false; + continue; + } + + // If we are currently skipping empty spaces, skip + if (skipWhitespace) + { + if (TomlSyntax.IsEmptySpace(c)) + { + if (TomlSyntax.IsLineBreak(c)) + { + skipWhitespaceLineSkipped = true; + AdvanceLine(); + } + continue; + } + + if (!skipWhitespaceLineSkipped) + { + AddError("Non-whitespace character after trim marker."); + return null; + } + + skipWhitespaceLineSkipped = false; + skipWhitespace = false; + } + + // If we encounter an escape sequence... + if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) + { + var next = reader.Peek(); + var nc = (char)next; + if (next >= 0) + { + // ...and the next char is empty space, we must skip all whitespaces + if (TomlSyntax.IsEmptySpace(nc)) + { + skipWhitespace = true; + continue; + } + + // ...and we have \" or \, skip the character + if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; + } + } + + // Count the consecutive quotes + if (c == quote) + quotesEncountered++; + else + quotesEncountered = 0; + + // If the are three quotes, count them as closing quotes + if (quotesEncountered == 3) break; + + sb.Append(c); + } + + // TOML actually allows to have five ending quotes like + // """"" => "" belong to the string + """ is the actual ending + quotesEncountered = 0; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == quote && ++quotesEncountered < 3) + { + sb.Append(c); + ConsumeChar(); + } + else break; + } + + // Remove last two quotes (third one wasn't included by default) + sb.Length -= 2; + if (!isBasic) return sb.ToString(); + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + + #endregion + + #region Node creation + + private bool InsertNode(TomlNode node, TomlNode root, IList path) + { + var latestNode = root; + if (path.Count > 1) + for (var index = 0; index < path.Count - 1; index++) + { + var subkey = path[index]; + if (latestNode.TryGetNode(subkey, out var currentNode)) + { + if (currentNode.HasValue) + return AddError($"The key {".".Join(path)} already has a value assigned to it!"); + } + else + { + currentNode = new TomlTable(); + latestNode[subkey] = currentNode; + } + + latestNode = currentNode; + if (latestNode is TomlTable { IsInline: true }) + return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); + } + + if (latestNode.HasKey(path[path.Count - 1])) + return AddError($"The key {".".Join(path)} is already defined!"); + latestNode[path[path.Count - 1]] = node; + node.CollapseLevel = path.Count - 1; + return true; + } + + private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable) + { + if (path.Count == 0) return null; + var latestNode = root; + for (var index = 0; index < path.Count; index++) + { + var subkey = path[index]; + + if (latestNode.TryGetNode(subkey, out var node)) + { + if (node.IsArray && arrayTable) + { + var arr = (TomlArray)node; + + if (!arr.IsTableArray) + { + AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (index == path.Count - 1) + { + latestNode = new TomlTable(); + arr.Add(latestNode); + break; + } + + latestNode = arr[arr.ChildrenCount - 1]; + continue; + } + + if (node is TomlTable { IsInline: true }) + { + AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); + return null; + } + + if (node.HasValue) + { + if (!(node is TomlArray { IsTableArray: true } array)) + { + AddError($"The key {".".Join(path)} has a value assigned to it!"); + return null; + } + + latestNode = array[array.ChildrenCount - 1]; + continue; + } + + if (index == path.Count - 1) + { + if (arrayTable && !node.IsArray) + { + AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (node is TomlTable { isImplicit: false }) + { + AddError($"The table {".".Join(path)} is defined multiple times!"); + return null; + } + } + } + else + { + if (index == path.Count - 1 && arrayTable) + { + var table = new TomlTable(); + var arr = new TomlArray + { + IsTableArray = true + }; + arr.Add(table); + latestNode[subkey] = arr; + latestNode = table; + break; + } + + node = new TomlTable { isImplicit = true }; + latestNode[subkey] = node; + } + + latestNode = node; + } + + var result = (TomlTable)latestNode; + result.isImplicit = false; + return result; + } + + #endregion + + #region Misc parsing + + private string ParseComment() + { + ConsumeChar(); + var commentLine = reader.ReadLine()?.Trim() ?? ""; + if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) + AddError("Comment must not contain control characters other than tab.", false); + return commentLine; + } + #endregion + } + + #endregion + + public static class TOML + { + public static bool ForceASCII { get; set; } = false; + + public static TomlTable Parse(TextReader reader) + { + using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; + return parser.Parse(); + } + } + + #region Exception Types + + public class TomlFormatException : Exception + { + public TomlFormatException(string message) : base(message) { } + } + + public class TomlParseException : Exception + { + public TomlParseException(TomlTable parsed, IEnumerable exceptions) : + base("TOML file contains format errors") + { + ParsedTable = parsed; + SyntaxErrors = exceptions; + } + + public TomlTable ParsedTable { get; } + + public IEnumerable SyntaxErrors { get; } + } + + public class TomlSyntaxException : Exception + { + public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) + { + ParseState = state; + Line = line; + Column = col; + } + + public TOMLParser.ParseState ParseState { get; } + + public int Line { get; } + + public int Column { get; } + } + + #endregion + + #region Parse utilities + + internal static class TomlSyntax + { + #region Type Patterns + + public const string TRUE_VALUE = "true"; + public const string FALSE_VALUE = "false"; + public const string NAN_VALUE = "nan"; + public const string POS_NAN_VALUE = "+nan"; + public const string NEG_NAN_VALUE = "-nan"; + public const string INF_VALUE = "inf"; + public const string POS_INF_VALUE = "+inf"; + public const string NEG_INF_VALUE = "-inf"; + + public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; + + public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; + + public static bool IsNegInf(string s) => s == NEG_INF_VALUE; + + public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; + + public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); + + public static bool IsFloat(string s) => FloatPattern.IsMatch(s); + + public static bool IsIntegerWithBase(string s, out int numberBase) + { + numberBase = 10; + var match = BasedIntegerPattern.Match(s); + if (!match.Success) return false; + IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); + return true; + } + + /** + * A pattern to verify the integer value according to the TOML specification. + */ + public static readonly Regex IntegerPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); + + /** + * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. + */ + public static readonly Regex BasedIntegerPattern = + new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A pattern to verify the float value according to the TOML specification. + */ + public static readonly Regex FloatPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A helper dictionary to map TOML base codes into the radii. + */ + public static readonly Dictionary IntegerBases = new() + { + ["x"] = 16, + ["o"] = 8, + ["b"] = 2 + }; + + /** + * A helper dictionary to map non-decimal bases to their TOML identifiers + */ + public static readonly Dictionary BaseIdentifiers = new() + { + [2] = "b", + [8] = "o", + [16] = "x" + }; + + public const string RFC3339EmptySeparator = " "; + public const string ISO861Separator = "T"; + public const string ISO861ZeroZone = "+00:00"; + public const string RFC3339ZeroZone = "Z"; + + /** + * Valid date formats with timezone as per RFC3339. + */ + public static readonly string[] RFC3339Formats = + { + "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" + }; + + /** + * Valid date formats without timezone (assumes local) as per RFC3339. + */ + public static readonly string[] RFC3339LocalDateTimeFormats = + { + "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" + }; + + /** + * Valid full date format as per TOML spec. + */ + public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; + + /** + * Valid time formats as per TOML spec. + */ + public static readonly string[] RFC3339LocalTimeFormats = + { + "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", + "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" + }; + + #endregion + + #region Character definitions + + public const char ARRAY_END_SYMBOL = ']'; + public const char ITEM_SEPARATOR = ','; + public const char ARRAY_START_SYMBOL = '['; + public const char BASIC_STRING_SYMBOL = '\"'; + public const char COMMENT_SYMBOL = '#'; + public const char ESCAPE_SYMBOL = '\\'; + public const char KEY_VALUE_SEPARATOR = '='; + public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; + public const char NEWLINE_CHARACTER = '\n'; + public const char SUBKEY_SEPARATOR = '.'; + public const char TABLE_END_SYMBOL = ']'; + public const char TABLE_START_SYMBOL = '['; + public const char INLINE_TABLE_START_SYMBOL = '{'; + public const char INLINE_TABLE_END_SYMBOL = '}'; + public const char LITERAL_STRING_SYMBOL = '\''; + public const char INT_NUMBER_SEPARATOR = '_'; + + public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; + + public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; + + public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; + + public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; + + public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; + + public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); + + public static bool IsBareKey(char c) => + c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; + + public static bool MustBeEscaped(char c, bool allowNewLines = false) + { + var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; + if (!allowNewLines) + result |= c is >= '\u000a' and <= '\u000e'; + return result; + } + + public static bool IsValueSeparator(char c) => + c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; + + #endregion + } + + internal static class StringUtils + { + public static string AsKey(this string key) + { + var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); + return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; + } + + public static string Join(this string self, IEnumerable subItems) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var subItem in subItems) + { + if (!first) sb.Append(self); + first = false; + sb.Append(subItem); + } + + return sb.ToString(); + } + + public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); + + public static bool TryParseDateTime(string s, + string[] formats, + DateTimeStyles styles, + TryDateParseDelegate parser, + out T dateTime, + out int parsedFormat) + { + parsedFormat = 0; + dateTime = default; + for (var i = 0; i < formats.Length; i++) + { + var format = formats[i]; + if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; + parsedFormat = i; + return true; + } + + return false; + } + + public static void AsComment(this string self, TextWriter tw) + { + foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) + tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); + } + + public static string RemoveAll(this string txt, char toRemove) + { + var sb = new StringBuilder(txt.Length); + foreach (var c in txt.Where(c => c != toRemove)) + sb.Append(c); + return sb.ToString(); + } + + public static string Escape(this string txt, bool escapeNewlines = true) + { + var stringBuilder = new StringBuilder(txt.Length + 2); + for (var i = 0; i < txt.Length; i++) + { + var c = txt[i]; + + static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) + ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" + : $"\\u{(ushort)c:X4}"; + + stringBuilder.Append(c switch + { + '\b' => @"\b", + '\t' => @"\t", + '\n' when escapeNewlines => @"\n", + '\f' => @"\f", + '\r' when escapeNewlines => @"\r", + '\\' => @"\\", + '\"' => @"\""", + var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => + CodePoint(txt, ref i, c), + var _ => c + }); + } + + return stringBuilder.ToString(); + } + + public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) + { + try + { + exception = null; + unescaped = txt.Unescape(); + return true; + } + catch (Exception e) + { + exception = e; + unescaped = null; + return false; + } + } + + public static string Unescape(this string txt) + { + if (string.IsNullOrEmpty(txt)) return txt; + var stringBuilder = new StringBuilder(txt.Length); + for (var i = 0; i < txt.Length;) + { + var num = txt.IndexOf('\\', i); + var next = num + 1; + if (num < 0 || num == txt.Length - 1) num = txt.Length; + stringBuilder.Append(txt, i, num - i); + if (num >= txt.Length) break; + var c = txt[next]; + + static string CodePoint(int next, string txt, ref int num, int size) + { + if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); + num += size; + return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); + } + + stringBuilder.Append(c switch + { + 'b' => "\b", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + '\'' => "\'", + '\"' => "\"", + '\\' => "\\", + 'u' => CodePoint(next, txt, ref num, 4), + 'U' => CodePoint(next, txt, ref num, 8), + var _ => throw new Exception("Undefined escape sequence!") + }); + i = num + 2; + } + + return stringBuilder.ToString(); + } + } + + #endregion +} diff --git a/MCPForUnity/Editor/External/Tommy.cs.meta b/MCPForUnity/Editor/External/Tommy.cs.meta new file mode 100644 index 0000000..efcb8ff --- /dev/null +++ b/MCPForUnity/Editor/External/Tommy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea652131dcdaa44ca8cb35cd1191be3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers.meta b/MCPForUnity/Editor/Helpers.meta new file mode 100644 index 0000000..c57a342 --- /dev/null +++ b/MCPForUnity/Editor/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 94cb070dc5e15024da86150b27699ca0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs new file mode 100644 index 0000000..290cb8d --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Project-path conversions shared by the asset-gen import/write code: project-relative + /// ("Assets/...") ↔ absolute on-disk paths, with forward-slash normalization for + /// cross-platform consistency. + /// + public static class AssetGenPaths + { + /// Resolve a project-relative ("Assets/...") path to an absolute, forward-slashed path. + public static string ToAbsolute(string projectRelative) + { + if (string.IsNullOrWhiteSpace(projectRelative)) return projectRelative; + string p = projectRelative.Replace('\\', '/'); + string abs = Path.IsPathRooted(p) ? p : Path.Combine(ProjectRoot(), p); + return Path.GetFullPath(abs).Replace('\\', '/'); + } + + /// Convert an absolute (or already-relative) path to a project-relative ("Assets/...") path. + public static string ToProjectRelative(string path) + { + if (TryGetAssetsRelativePath(path, out string rel)) return rel; + return path?.Replace('\\', '/'); + } + + /// + /// Normalize an absolute or "Assets/..." path and verify it resolves inside the project's + /// Assets directory. Rejects traversal such as "Assets/../ProjectSettings". + /// + public static bool TryGetAssetsRelativePath(string path, out string projectRelative) + { + projectRelative = null; + if (string.IsNullOrWhiteSpace(path)) return false; + + try + { + string p = path.Replace('\\', '/'); + string abs; + if (p == "Assets" || p.StartsWith("Assets/", StringComparison.Ordinal)) + { + abs = Path.Combine(ProjectRoot(), p); + } + else if (Path.IsPathRooted(p)) + { + abs = p; + } + else + { + return false; + } + + string full = Path.GetFullPath(abs).Replace('\\', '/'); + string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/').TrimEnd('/'); + if (string.Equals(full, dataPath, StringComparison.Ordinal)) + { + projectRelative = "Assets"; + return true; + } + + string prefix = dataPath + "/"; + if (!full.StartsWith(prefix, StringComparison.Ordinal)) return false; + + projectRelative = "Assets/" + full.Substring(prefix.Length); + return true; + } + catch + { + return false; + } + } + + /// Normalize an Assets folder path, trimming trailing slashes. + public static bool TryGetAssetsFolder(string path, out string projectRelative) + { + if (!TryGetAssetsRelativePath(path, out projectRelative)) return false; + projectRelative = projectRelative.TrimEnd('/'); + return true; + } + + private static string ProjectRoot() + { + string dataPath = Path.GetFullPath(Application.dataPath).Replace('\\', '/'); + return dataPath.Substring(0, dataPath.Length - "Assets".Length); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta new file mode 100644 index 0000000..a17afd0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPaths.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: afc641959f464b55911f6703a96b5f37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs new file mode 100644 index 0000000..b34ea47 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs @@ -0,0 +1,73 @@ +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user, NON-SECRET configuration for the AI Asset Generation feature. + /// + /// Provider API keys are never stored here — they live in the OS secure store + /// (). Only UI/behavior + /// preferences (selected provider, default format, output folder, normalize toggle, + /// per-provider enable flags) are kept in EditorPrefs. + /// + public static class AssetGenPrefs + { + public const string DefaultOutputRoot = "Assets/Generated"; + public const string DefaultModelProvider = "tripo"; + public const string DefaultImageProvider = "fal"; + public const string DefaultFormatValue = "glb"; + + public static string ModelProvider + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedModelProvider, DefaultModelProvider); + set => SetOrDelete(EditorPrefKeys.AssetGenSelectedModelProvider, value); + } + + public static string ImageProvider + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenSelectedImageProvider, DefaultImageProvider); + set => SetOrDelete(EditorPrefKeys.AssetGenSelectedImageProvider, value); + } + + public static string DefaultFormat + { + get => EditorPrefs.GetString(EditorPrefKeys.AssetGenDefaultFormat, DefaultFormatValue); + set => SetOrDelete(EditorPrefKeys.AssetGenDefaultFormat, value); + } + + /// Project-relative root under which generated assets are written. Defaults to Assets/Generated. + public static string OutputRoot + { + get + { + string v = EditorPrefs.GetString(EditorPrefKeys.AssetGenOutputRoot, string.Empty); + return string.IsNullOrWhiteSpace(v) ? DefaultOutputRoot : v; + } + set => SetOrDelete(EditorPrefKeys.AssetGenOutputRoot, value); + } + + /// When true, imported models are uniformly scaled to a target size on import. + public static bool AutoNormalize + { + get => EditorPrefs.GetBool(EditorPrefKeys.AssetGenAutoNormalize, true); + set => EditorPrefs.SetBool(EditorPrefKeys.AssetGenAutoNormalize, value); + } + + public static bool IsProviderEnabled(string providerId) => + !string.IsNullOrEmpty(providerId) + && EditorPrefs.GetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, false); + + public static void SetProviderEnabled(string providerId, bool enabled) + { + if (string.IsNullOrEmpty(providerId)) return; + EditorPrefs.SetBool(EditorPrefKeys.AssetGenProviderEnabledPrefix + providerId, enabled); + } + + private static void SetOrDelete(string key, string value) + { + if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key); + else EditorPrefs.SetString(key, value.Trim()); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta new file mode 100644 index 0000000..be58822 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetGenPrefs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7704064abf1412e890a066628bae30a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs new file mode 100644 index 0000000..6daeab9 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -0,0 +1,668 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity asset paths. + /// + public static class AssetPathUtility + { + /// + /// Normalizes path separators to forward slashes without modifying the path structure. + /// Use this for non-asset paths (e.g., file system paths, relative directories). + /// + public static string NormalizeSeparators(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + return path.Replace('\\', '/'); + } + + /// + /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// Also protects against path traversal attacks using "../" sequences. + /// + public static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + path = NormalizeSeparators(path); + + // Check for path traversal sequences + if (path.Contains("..")) + { + McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'"); + return null; + } + + // Ensure path starts with Assets/ + if (string.Equals(path, "Assets", StringComparison.OrdinalIgnoreCase)) + { + return "Assets"; + } + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + + return path; + } + + /// + /// Checks if a given asset path is valid and safe (no traversal, within Assets folder). + /// + /// True if the path is valid, false otherwise. + public static bool IsValidAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + // Normalize for comparison + string normalized = NormalizeSeparators(path); + + // Must start with Assets/ + if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Must not contain traversal sequences + if (normalized.Contains("..")) + { + return false; + } + + // Must not contain invalid path characters + char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' }; + foreach (char c in invalidChars) + { + if (normalized.IndexOf(c) >= 0) + { + return false; + } + } + + return true; + } + + /// + /// Gets the MCP for Unity package root path. + /// Works for registry Package Manager, local Package Manager, and Asset Store installations. + /// + /// The package root path (virtual for PM, absolute for Asset Store), or null if not found + public static string GetMcpPackageRootPath() + { + try + { + // Try Package Manager first (registry and local installs) + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) + { + return packageInfo.assetPath; + } + + // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) + string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); + + if (guids.Length == 0) + { + McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); + return null; + } + + string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); + + // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs + // Extract {packageRoot} + int editorIndex = scriptPath.LastIndexOf("/Editor/", StringComparison.Ordinal); + + if (editorIndex >= 0) + { + return scriptPath.Substring(0, editorIndex); + } + + McpLog.Warn($"Could not determine package root from script path: {scriptPath}"); + return null; + } + catch (Exception ex) + { + McpLog.Error($"Failed to get package root path: {ex.Message}"); + return null; + } + } + + /// + /// Reads and parses the package.json file for MCP for Unity. + /// Handles both Package Manager (registry/local) and Asset Store installations. + /// + /// JObject containing package.json data, or null if not found or parse failed + public static JObject GetPackageJson() + { + try + { + string packageRoot = GetMcpPackageRootPath(); + if (string.IsNullOrEmpty(packageRoot)) + { + return null; + } + + string packageJsonPath = Path.Combine(packageRoot, "package.json"); + + // Convert virtual asset path to file system path + if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + // Package Manager install - must use PackageInfo.resolvedPath + // Virtual paths like "Packages/..." don't work with File.Exists() + // Registry packages live in Library/PackageCache/package@version/ + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) + { + packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json"); + } + else + { + McpLog.Warn("Could not resolve Package Manager path for package.json"); + return null; + } + } + else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + // Asset Store install - convert to absolute file system path + // Application.dataPath is the absolute path to the Assets folder + string relativePath = packageRoot.Substring("Assets/".Length); + packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json"); + } + + if (!File.Exists(packageJsonPath)) + { + McpLog.Warn($"package.json not found at: {packageJsonPath}"); + return null; + } + + string json = File.ReadAllText(packageJsonPath); + return JObject.Parse(json); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read or parse package.json: {ex.Message}"); + return null; + } + } + + /// + /// Gets the package source for the MCP server (used with uvx --from). + /// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.), + /// then falls back to PyPI package reference. + /// When the override is a local path, auto-corrects to the "Server" subdirectory + /// if the path doesn't contain pyproject.toml but Server/pyproject.toml exists. + /// + /// Package source string for uvx --from argument + public static string GetMcpServerPackageSource() + { + // Check for override first (supports git URLs, file:// paths, local paths) + string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(sourceOverride)) + { + string resolved = ResolveLocalServerPath(sourceOverride); + // Persist the corrected path so future reads are consistent + if (resolved != sourceOverride) + { + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, resolved); + McpLog.Info($"Auto-corrected server source override from '{sourceOverride}' to '{resolved}'"); + } + return resolved; + } + + // Default to PyPI package (avoids Windows long path issues with git clone) + string version = GetPackageVersion(); + if (version == "unknown") + { + // Fall back to latest PyPI version so configs remain valid in test scenarios + return "mcpforunityserver"; + } + + // Package.json uses semver prerelease tags (e.g., 9.4.5-beta.1) that are not valid + // PEP 440 pins for uvx. Use the beta prerelease range instead of a pinned prerelease. + if (IsSemVerPreRelease(version)) + { + return "mcpforunityserver>=0.0.0a0"; + } + + return $"mcpforunityserver=={version}"; + } + + /// + /// Validates and auto-corrects a local server source path to ensure it points to the + /// directory containing pyproject.toml. If the path points to a parent directory + /// (e.g. the repo root "unity-mcp") instead of the Python package directory ("Server"), + /// this checks for a "Server" subdirectory with pyproject.toml and returns that path. + /// Non-local paths (URLs, PyPI references) are returned unchanged. + /// + internal static string ResolveLocalServerPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + // Skip non-local paths (git URLs, PyPI package names, etc.) + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("git+", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + // If it looks like a PyPI package reference (no path separators), skip + if (!path.Contains('/') && !path.Contains('\\') && !path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + // Strip file:// prefix for filesystem checks, preserve for return value + string checkPath = path; + string prefix = string.Empty; + if (checkPath.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + prefix = checkPath.Substring(0, 7); // preserve original casing + checkPath = checkPath.Substring(7); + } + + // Already correct — pyproject.toml exists at this path + if (System.IO.File.Exists(System.IO.Path.Combine(checkPath, "pyproject.toml"))) + { + return path; + } + + // Check if "Server" subdirectory contains pyproject.toml + string serverSubDir = System.IO.Path.Combine(checkPath, "Server"); + if (System.IO.File.Exists(System.IO.Path.Combine(serverSubDir, "pyproject.toml"))) + { + return prefix + serverSubDir; + } + + // Return as-is; uvx will report the error if the path is truly invalid + return path; + } + + /// + /// Deprecated: Use GetMcpServerPackageSource() instead. + /// Kept for backwards compatibility. + /// + [System.Obsolete("Use GetMcpServerPackageSource() instead")] + public static string GetMcpServerGitUrl() => GetMcpServerPackageSource(); + + /// + /// Gets structured uvx command parts for different client configurations + /// + /// Tuple containing (uvxPath, fromUrl, packageName) + public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string fromUrl = GetMcpServerPackageSource(); + string packageName = "mcp-for-unity"; + + return (uvxPath, fromUrl, packageName); + } + + /// + /// Builds the uvx package source arguments for the MCP server. + /// Handles prerelease package mode (prerelease from PyPI) vs stable mode (pinned version or override). + /// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports. + /// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package. + /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. + /// For background threads, use the overload that accepts pre-captured parameters. + /// + /// Whether to quote the --from path (needed for command-line strings, not for arg lists) + /// The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0") + public static string GetBetaServerFromArgs(bool quoteFromPath = false) + { + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgs(gitUrlOverride, packageSource, quoteFromPath); + } + + /// + /// Thread-safe overload that accepts pre-captured values. + /// Use this when calling from background threads. + /// + /// Pre-captured value from EditorPrefs GitUrlOverride + /// Pre-captured value from GetMcpServerPackageSource() + /// Whether to quote the --from path + public static string GetBetaServerFromArgs(string gitUrlOverride, string packageSource, bool quoteFromPath = false) + { + // Explicit override (local path, git URL, etc.) always wins + if (!string.IsNullOrEmpty(gitUrlOverride)) + { + string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride; + return $"--from {fromValue}"; + } + + bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase); + + // Prerelease package mode: use prerelease from PyPI. + if (usePrereleaseRange) + { + // Use --prerelease explicit with version specifier to only get prereleases of our package, + // not of dependencies (which can be broken on PyPI). + string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0"; + return $"--prerelease explicit --from {fromValue}"; + } + + // Standard mode: use pinned version from package.json + if (!string.IsNullOrEmpty(packageSource)) + { + string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource; + return $"--from {fromValue}"; + } + + return string.Empty; + } + + /// + /// Builds the uvx package source arguments as a list (for JSON config builders). + /// Priority: explicit fromUrl override > package-version-driven prerelease mode > stable pinned package. + /// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread. + /// For background threads, use the overload that accepts pre-captured parameters. + /// + /// List of arguments to add to uvx command + public static System.Collections.Generic.IList GetBetaServerFromArgsList() + { + string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + string packageSource = GetMcpServerPackageSource(); + return GetBetaServerFromArgsList(gitUrlOverride, packageSource); + } + + /// + /// Thread-safe overload that accepts pre-captured values. + /// Use this when calling from background threads. + /// + /// Pre-captured value from EditorPrefs GitUrlOverride + /// Pre-captured value from GetMcpServerPackageSource() + public static System.Collections.Generic.IList GetBetaServerFromArgsList(string gitUrlOverride, string packageSource) + { + var args = new System.Collections.Generic.List(); + + // Explicit override (local path, git URL, etc.) always wins + if (!string.IsNullOrEmpty(gitUrlOverride)) + { + args.Add("--from"); + args.Add(gitUrlOverride); + return args; + } + + bool usePrereleaseRange = string.Equals(packageSource, "mcpforunityserver>=0.0.0a0", StringComparison.OrdinalIgnoreCase); + + // Prerelease package mode: use prerelease from PyPI. + if (usePrereleaseRange) + { + args.Add("--prerelease"); + args.Add("explicit"); + args.Add("--from"); + args.Add("mcpforunityserver>=0.0.0a0"); + return args; + } + + // Standard mode: use pinned version from package.json + if (!string.IsNullOrEmpty(packageSource)) + { + args.Add("--from"); + args.Add(packageSource); + } + + return args; + } + + /// + /// Determines whether uvx should use --no-cache --refresh flags. + /// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path. + /// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache. + /// Note: --reinstall is not supported by uvx and will cause a warning. + /// + public static bool ShouldForceUvxRefresh() + { + bool devForceRefresh = false; + try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } + + if (devForceRefresh) + return true; + + // Auto-enable force refresh when using a local path override. + return IsLocalServerPath(); + } + + private static bool _offlineCacheResult; + private static double _offlineCacheTimestamp = -999; + private const double OfflineCacheTtlSeconds = 30.0; + + /// + /// Determines whether uvx should use --offline mode for faster startup. + /// Runs a lightweight probe (uvx --offline ... mcp-for-unity --help) with a 3-second timeout + /// to check if the package is already cached. If cached, --offline skips the network + /// dependency check that can hang for 30+ seconds on poor connections. + /// Returns false if force refresh is enabled (new download needed). + /// The result is cached for 30 seconds to avoid redundant subprocess spawns. + /// Must be called on the main thread (reads EditorPrefs). + /// + public static bool ShouldUseUvxOffline() + { + if (ShouldForceUvxRefresh()) + return false; + return GetCachedOfflineProbeResult(); + } + + private static bool GetCachedOfflineProbeResult() + { + double now = EditorApplication.timeSinceStartup; + if (now - _offlineCacheTimestamp < OfflineCacheTtlSeconds) + return _offlineCacheResult; + + bool result = RunOfflineProbe(); + _offlineCacheResult = result; + _offlineCacheTimestamp = now; + return result; + } + + private static bool RunOfflineProbe() + { + try + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (string.IsNullOrEmpty(uvxPath)) + return false; + + string fromArgs = GetBetaServerFromArgs(quoteFromPath: false); + string probeArgs = string.IsNullOrEmpty(fromArgs) + ? "--offline mcp-for-unity --help" + : $"--offline {fromArgs} mcp-for-unity --help"; + + return ExecPath.TryRun(uvxPath, probeArgs, null, out _, out _, timeoutMs: 3000); + } + catch + { + return false; + } + } + + /// + /// Returns the uvx dev-mode flags as a single string for command-line builders. + /// Returns "--no-cache --refresh " if force refresh is enabled, + /// "--offline " if the cache is warm, or string.Empty otherwise. + /// Must be called on the main thread (reads EditorPrefs). + /// + public static string GetUvxDevFlags() + { + bool forceRefresh = ShouldForceUvxRefresh(); + return GetUvxDevFlags(forceRefresh, !forceRefresh && GetCachedOfflineProbeResult()); + } + + /// + /// Returns the uvx dev-mode flags from pre-captured bool values. + /// Use this overload when values were captured on the main thread for background use. + /// + public static string GetUvxDevFlags(bool forceRefresh, bool useOffline) + { + if (forceRefresh) return "--no-cache --refresh "; + if (useOffline) return "--offline "; + return string.Empty; + } + + /// + /// Returns the uvx dev-mode flags as a list of individual arguments. + /// Suitable for callers that build argument lists (ConfigJsonBuilder, CodexConfigHelper). + /// Must be called on the main thread (reads EditorPrefs). + /// + public static IReadOnlyList GetUvxDevFlagsList() + { + bool forceRefresh = ShouldForceUvxRefresh(); + if (forceRefresh) return new[] { "--no-cache", "--refresh" }; + if (GetCachedOfflineProbeResult()) return new[] { "--offline" }; + return Array.Empty(); + } + + /// + /// Returns true if the server URL is a local path (file:// or absolute path). + /// + public static bool IsLocalServerPath() + { + string fromUrl = GetMcpServerPackageSource(); + if (string.IsNullOrEmpty(fromUrl)) + return false; + + // Check for file:// protocol or absolute local path + if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + return true; + + try + { + return System.IO.Path.IsPathRooted(fromUrl); + } + catch (System.ArgumentException) + { + // fromUrl contains characters illegal in paths (e.g. a remote URL) + return false; + } + } + + /// + /// Gets the local server path if GitUrlOverride points to a local directory. + /// Returns null if not using a local path. + /// + public static string GetLocalServerPath() + { + if (!IsLocalServerPath()) + return null; + + string fromUrl = GetMcpServerPackageSource(); + if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + // Strip file:// prefix + fromUrl = fromUrl.Substring(7); + } + + return fromUrl; + } + + /// + /// Cleans stale Python build artifacts from the local server path. + /// This is necessary because Python's build system doesn't remove deleted files from build/, + /// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools. + /// + /// True if cleaning was performed, false if not applicable or failed. + public static bool CleanLocalServerBuildArtifacts() + { + string localPath = GetLocalServerPath(); + if (string.IsNullOrEmpty(localPath)) + return false; + + // Clean the build/ directory which can contain stale .py files + string buildPath = System.IO.Path.Combine(localPath, "build"); + if (System.IO.Directory.Exists(buildPath)) + { + try + { + System.IO.Directory.Delete(buildPath, recursive: true); + McpLog.Info($"Cleaned stale build artifacts from: {buildPath}"); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"Failed to clean build artifacts: {ex.Message}"); + return false; + } + } + + return false; + } + + /// + /// Gets the package version from package.json + /// + /// Version string, or "unknown" if not found + public static string GetPackageVersion() + { + try + { + var packageJson = GetPackageJson(); + if (packageJson == null) + { + return "unknown"; + } + + string version = packageJson["version"]?.ToString(); + return string.IsNullOrEmpty(version) ? "unknown" : version; + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get package version: {ex.Message}"); + return "unknown"; + } + } + + /// + /// Returns true if the installed package version is a prerelease (beta, alpha, rc, etc.). + /// Used to auto-enable beta server mode for beta package users. + /// + public static bool IsPreReleaseVersion() + { + try + { + string version = GetPackageVersion(); + if (string.IsNullOrEmpty(version) || version == "unknown") + return false; + + return IsSemVerPreRelease(version); + } + catch + { + return false; + } + } + + private static bool IsSemVerPreRelease(string version) + { + if (string.IsNullOrEmpty(version)) + return false; + + // Common semver prerelease indicators: + // e.g., "9.3.0-beta.1", "9.3.0-alpha", "9.3.0-rc.2", "9.3.0-preview" + return version.Contains("-beta", StringComparison.OrdinalIgnoreCase) || + version.Contains("-alpha", StringComparison.OrdinalIgnoreCase) || + version.Contains("-rc", StringComparison.OrdinalIgnoreCase) || + version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || + version.Contains("-pre", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta new file mode 100644 index 0000000..bd6a0c7 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs b/MCPForUnity/Editor/Helpers/BlenderDetection.cs new file mode 100644 index 0000000..6af74e3 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's + /// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the + /// BlenderMCP server is configured in the user's AI client (that lives outside Unity). + /// + internal static class BlenderDetection + { + /// True if a Blender executable is found in a well-known location or on PATH. + public static bool IsInstalled() + { + try { return DetectIn(CandidatePaths(), File.Exists); } + catch { return false; } + } + + /// Pure core: true if reports any candidate present. Testable. + internal static bool DetectIn(IEnumerable candidates, Func exists) + { + if (candidates == null || exists == null) return false; + foreach (string c in candidates) + if (!string.IsNullOrEmpty(c) && exists(c)) return true; + return false; + } + + /// Well-known Blender executable paths for the current platform, plus PATH entries. + internal static IEnumerable CandidatePaths() + { + var list = new List(); + bool win = Application.platform == RuntimePlatform.WindowsEditor; + string exeName = win ? "blender.exe" : "blender"; + + // PATH entries: /blender(.exe) + string pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (string dir in pathVar.Split(win ? ';' : ':')) + if (!string.IsNullOrWhiteSpace(dir)) list.Add(Path.Combine(dir.Trim(), exeName)); + + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + list.Add("/Applications/Blender.app/Contents/MacOS/Blender"); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + list.Add(Path.Combine(home, "Applications/Blender.app/Contents/MacOS/Blender")); + break; + case RuntimePlatform.WindowsEditor: + foreach (string pf in new[] + { + Environment.GetEnvironmentVariable("ProgramFiles"), + Environment.GetEnvironmentVariable("ProgramFiles(x86)") + }) + { + if (string.IsNullOrEmpty(pf)) continue; + string foundation = Path.Combine(pf, "Blender Foundation"); + // Blender installs under a version subdir (Blender X.Y); enumerate them. + try + { + if (Directory.Exists(foundation)) + foreach (string d in Directory.GetDirectories(foundation)) + list.Add(Path.Combine(d, "blender.exe")); + } + catch { /* unreadable dir; ignore */ } + } + break; + case RuntimePlatform.LinuxEditor: + list.Add("/usr/bin/blender"); + list.Add("/usr/local/bin/blender"); + list.Add("/snap/bin/blender"); + list.Add("/var/lib/flatpak/exports/bin/org.blender.Blender"); + break; + } + return list; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta b/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta new file mode 100644 index 0000000..d5edc29 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf9f02bd846b4e8db34827d949046f70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs new file mode 100644 index 0000000..45ae5a3 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using MCPForUnity.External.Tommy; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Codex CLI specific configuration helpers. Handles TOML snippet + /// generation and lightweight parsing so Codex can join the auto-setup + /// flow alongside JSON-based clients. + /// + public static class CodexConfigHelper + { + private static void AddUvxModeFlags(TomlArray args) + { + if (args == null) return; + foreach (var flag in AssetPathUtility.GetUvxDevFlagsList()) + args.Add(new TomlString { Value = flag }); + } + + public static string BuildCodexServerBlock(string uvPath) + { + var table = new TomlTable(); + var mcpServers = new TomlTable(); + var unityMCP = new TomlTable(); + + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + + // Enable Codex's Rust MCP client for HTTP/SSE transport + EnsureRmcpClientFeature(table); + } + else + { + // Stdio mode: Use command and args + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unityMCP["command"] = uvxPath; + + var args = new TomlArray(); + AddUvxModeFlags(args); + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(new TomlString { Value = arg }); + } + args.Add(new TomlString { Value = packageName }); + args.Add(new TomlString { Value = "--transport" }); + args.Add(new TomlString { Value = "stdio" }); + + unityMCP["args"] = args; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } + + // Allow extra time for uvx to download packages on first run + unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 }; + } + + mcpServers["unityMCP"] = unityMCP; + table["mcp_servers"] = mcpServers; + + using var writer = new StringWriter(); + table.WriteTo(writer); + return writer.ToString(); + } + + public static string UpsertCodexServerBlock(string existingToml, string uvPath) + { + // Parse existing TOML or create new root table + var root = TryParseToml(existingToml) ?? new TomlTable(); + + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + // Ensure mcp_servers table exists + if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable)) + { + root["mcp_servers"] = new TomlTable(); + } + var mcpServers = root["mcp_servers"] as TomlTable; + + // Create or update unityMCP table + mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath); + + if (useHttpTransport) + { + EnsureRmcpClientFeature(root); + } + + // Serialize back to TOML + using var writer = new StringWriter(); + root.WriteTo(writer); + return writer.ToString(); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + return TryParseCodexServer(toml, out command, out args, out _); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url) + { + command = null; + args = null; + url = null; + + var root = TryParseToml(toml); + if (root == null) return false; + + if (!TryGetTable(root, "mcp_servers", out var servers) + && !TryGetTable(root, "mcpServers", out servers)) + { + return false; + } + + if (!TryGetTable(servers, "unityMCP", out var unity)) + { + return false; + } + + // Check for HTTP mode (url field) + url = GetTomlString(unity, "url"); + if (!string.IsNullOrEmpty(url)) + { + // HTTP mode detected - return true with url + return true; + } + + // Check for stdio mode (command + args) + command = GetTomlString(unity, "command"); + args = GetTomlStringArray(unity, "args"); + + return !string.IsNullOrEmpty(command) && args != null; + } + + /// + /// Safely parses TOML string, returning null on failure + /// + private static TomlTable TryParseToml(string toml) + { + if (string.IsNullOrWhiteSpace(toml)) return null; + + try + { + using var reader = new StringReader(toml); + return TOML.Parse(reader); + } + catch (TomlParseException) + { + return null; + } + catch (TomlSyntaxException) + { + return null; + } + catch (FormatException) + { + return null; + } + } + + /// + /// Creates a TomlTable for the unityMCP server configuration + /// + /// Path to uv executable (used as fallback if uvx is not available) + private static TomlTable CreateUnityMcpTable(string uvPath) + { + var unityMCP = new TomlTable(); + + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + } + else + { + // Stdio mode: Use command and args + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unityMCP["command"] = new TomlString { Value = uvxPath }; + + var argsArray = new TomlArray(); + AddUvxModeFlags(argsArray); + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + argsArray.Add(new TomlString { Value = arg }); + } + argsArray.Add(new TomlString { Value = packageName }); + argsArray.Add(new TomlString { Value = "--transport" }); + argsArray.Add(new TomlString { Value = "stdio" }); + unityMCP["args"] = argsArray; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } + + // Allow extra time for uvx to download packages on first run + unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 }; + } + + return unityMCP; + } + + /// + /// Ensures the features table contains the rmcp_client flag for HTTP/SSE transport. + /// + private static void EnsureRmcpClientFeature(TomlTable root) + { + if (root == null) return; + + if (!root.TryGetNode("features", out var featuresNode) || featuresNode is not TomlTable features) + { + features = new TomlTable(); + root["features"] = features; + } + + features["rmcp_client"] = new TomlBoolean { Value = true }; + } + + private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) + { + table = null; + if (parent == null) return false; + + if (parent.TryGetNode(key, out var node)) + { + if (node is TomlTable tbl) + { + table = tbl; + return true; + } + + if (node is TomlArray array) + { + var firstTable = array.Children.OfType().FirstOrDefault(); + if (firstTable != null) + { + table = firstTable; + return true; + } + } + } + + return false; + } + + private static string GetTomlString(TomlTable table, string key) + { + if (table != null && table.TryGetNode(key, out var node)) + { + if (node is TomlString str) return str.Value; + if (node.HasValue) return node.ToString(); + } + return null; + } + + private static string[] GetTomlStringArray(TomlTable table, string key) + { + if (table == null) return null; + if (!table.TryGetNode(key, out var node)) return null; + + if (node is TomlArray array) + { + List values = new List(); + foreach (TomlNode element in array.Children) + { + if (element is TomlString str) + { + values.Add(str.Value); + } + else if (element.HasValue) + { + values.Add(element.ToString()); + } + } + + return values.Count > 0 ? values.ToArray() : Array.Empty(); + } + + if (node is TomlString single) + { + return new[] { single.Value }; + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta new file mode 100644 index 0000000..581a447 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3e68082ffc0b4cd39d3747673a4cc22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ComponentOps.cs b/MCPForUnity/Editor/Helpers/ComponentOps.cs new file mode 100644 index 0000000..6006907 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ComponentOps.cs @@ -0,0 +1,997 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Events; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Low-level component operations extracted from ManageGameObject and ManageComponents. + /// Provides pure C# operations without JSON parsing or response formatting. + /// + public static class ComponentOps + { + /// + /// Adds a component to a GameObject with Undo support. + /// + /// The target GameObject + /// The type of component to add + /// Error message if operation fails + /// The added component, or null if failed + public static Component AddComponent(GameObject target, Type componentType, out string error) + { + error = null; + + if (target == null) + { + error = "Target GameObject is null."; + return null; + } + + if (componentType == null || !typeof(Component).IsAssignableFrom(componentType)) + { + error = $"Type '{componentType?.Name ?? "null"}' is not a valid Component type."; + return null; + } + + // Prevent adding duplicate Transform + if (componentType == typeof(Transform)) + { + error = "Cannot add another Transform component."; + return null; + } + + // Check for 2D/3D physics conflicts + string conflictError = CheckPhysicsConflict(target, componentType); + if (conflictError != null) + { + error = conflictError; + return null; + } + + // Produce a clearer error when this component already exists and cannot be duplicated. + Component existingComponent = target.GetComponent(componentType); + if (existingComponent != null && !AllowsMultiple(target, componentType)) + { + error = $"Component '{componentType.Name}' already exists on '{target.name}' and this type does not allow multiple instances."; + return null; + } + + try + { + Component newComponent = Undo.AddComponent(target, componentType); + if (newComponent == null) + { + if (target.GetComponent(componentType) != null && !AllowsMultiple(target, componentType)) + { + error = $"Component '{componentType.Name}' already exists on '{target.name}' and this type does not allow multiple instances."; + } + else + { + error = $"Failed to add component '{componentType.Name}' to '{target.name}'. Unity may restrict this component on the current target."; + } + return null; + } + + // Apply default values for specific component types + ApplyDefaultValues(newComponent); + + return newComponent; + } + catch (Exception ex) + { + error = $"Error adding component '{componentType.Name}': {ex.Message}"; + return null; + } + } + + /// + /// Removes a component from a GameObject with Undo support. + /// + /// The target GameObject + /// The type of component to remove + /// Error message if operation fails + /// True if component was removed successfully + public static bool RemoveComponent(GameObject target, Type componentType, out string error) + { + error = null; + + if (target == null) + { + error = "Target GameObject is null."; + return false; + } + + if (componentType == null) + { + error = "Component type is null."; + return false; + } + + // Prevent removing Transform + if (componentType == typeof(Transform)) + { + error = "Cannot remove Transform component."; + return false; + } + + Component component = target.GetComponent(componentType); + if (component == null) + { + error = $"Component '{componentType.Name}' not found on '{target.name}'."; + return false; + } + + try + { + Undo.DestroyObjectImmediate(component); + return true; + } + catch (Exception ex) + { + error = $"Error removing component '{componentType.Name}': {ex.Message}"; + return false; + } + } + + /// + /// Sets a property value on a component using reflection. + /// + /// The target component + /// The property or field name + /// The value to set (JToken) + /// Error message if operation fails + /// True if property was set successfully + public static bool SetProperty(Component component, string propertyName, JToken value, out string error) + { + error = null; + + if (component == null) + { + error = "Component is null."; + return false; + } + + if (string.IsNullOrEmpty(propertyName)) + { + error = "Property name is null or empty."; + return false; + } + + Type type = component.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + string normalizedName = ParamCoercion.NormalizePropertyName(propertyName); + + // UnityEventBase-derived types must be set via SerializedProperty, not reflection. + // Reflection creates a disconnected object that Unity's serialization layer doesn't track, + // causing m_PersistentCalls to be empty when the scene is saved. + Type memberType = ResolveMemberType(type, propertyName, normalizedName); + if (memberType != null && typeof(UnityEventBase).IsAssignableFrom(memberType)) + { + return SetViaSerializedProperty(component, propertyName, normalizedName, value, out error); + } + + // Try reflection first (property, field, then non-public serialized field) + if (TrySetViaReflection(component, type, propertyName, normalizedName, flags, value, out error)) + return true; + + // Reflection failed — fall back to SerializedProperty which handles arrays, + // custom serialization (e.g. UdonSharp), and types reflection can't convert. + string reflectionError = error; + if (SetViaSerializedProperty(component, propertyName, normalizedName, value, out error)) + return true; + + // Both paths failed. If reflection found the member but couldn't convert, + // report that (more useful than the SerializedProperty error). + // If reflection didn't find it at all, report the SerializedProperty error. + if (reflectionError != null && !reflectionError.Contains("not found")) + error = reflectionError; + + return false; + } + + private static bool TrySetViaReflection(object component, Type type, string propertyName, string normalizedName, BindingFlags flags, JToken value, out string error) + { + error = null; + + // Skip reflection for UnityEngine.Object types with JObject values + // so SerializedProperty can resolve guid/spriteName/fileID forms. + bool isJObjectValue = value != null && value.Type == JTokenType.Object; + + // Try property first + PropertyInfo propInfo = type.GetProperty(propertyName, flags) + ?? type.GetProperty(normalizedName, flags); + if (propInfo != null && propInfo.CanWrite) + { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(propInfo.PropertyType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + + try + { + object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType); + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for property '{propertyName}' to type '{propInfo.PropertyType.Name}'."; + return false; + } + propInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set property '{propertyName}': {ex.Message}"; + return false; + } + } + + // Try field + FieldInfo fieldInfo = type.GetField(propertyName, flags) + ?? type.GetField(normalizedName, flags); + if (fieldInfo != null && !fieldInfo.IsInitOnly) + { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + + try + { + object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; + return false; + } + fieldInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set field '{propertyName}': {ex.Message}"; + return false; + } + } + + // Try non-public serialized fields — traverse inheritance hierarchy + fieldInfo = FindSerializedFieldInHierarchy(type, propertyName) + ?? FindSerializedFieldInHierarchy(type, normalizedName); + if (fieldInfo != null) + { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + + try + { + object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for serialized field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; + return false; + } + fieldInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set serialized field '{propertyName}': {ex.Message}"; + return false; + } + } + + error = $"Property or field '{propertyName}' not found on component '{type.Name}'."; + return false; + } + + /// + /// Gets all public properties and fields from a component type. + /// + public static List GetAccessibleMembers(Type componentType) + { + var members = new List(); + if (componentType == null) return members; + + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + + foreach (var prop in componentType.GetProperties(flags)) + { + if (prop.CanWrite && prop.GetSetMethod() != null) + { + members.Add(prop.Name); + } + } + + foreach (var field in componentType.GetFields(flags)) + { + if (!field.IsInitOnly) + { + members.Add(field.Name); + } + } + + // Include private [SerializeField] fields - traverse inheritance hierarchy + // Type.GetFields with NonPublic only returns fields declared directly on that type, + // so we need to walk up the chain to find inherited private serialized fields + var seenFieldNames = new HashSet(members); // Avoid duplicates with public fields + Type currentType = componentType; + while (currentType != null && currentType != typeof(object)) + { + foreach (var field in currentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (field.GetCustomAttribute() != null && !seenFieldNames.Contains(field.Name)) + { + members.Add(field.Name); + seenFieldNames.Add(field.Name); + } + } + currentType = currentType.BaseType; + } + + members.Sort(); + return members; + } + + // --- Private Helpers --- + + /// + /// Searches for a non-public [SerializeField] field through the entire inheritance hierarchy. + /// Type.GetField() with NonPublic only returns fields declared directly on that type, + /// so this method walks up the chain to find inherited private serialized fields. + /// + internal static FieldInfo FindSerializedFieldInHierarchy(Type type, string fieldName) + { + if (type == null || string.IsNullOrEmpty(fieldName)) + return null; + + BindingFlags privateFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + Type currentType = type; + + // Walk up the inheritance chain + while (currentType != null && currentType != typeof(object)) + { + // Search for the field on this specific type (case-insensitive) + foreach (var field in currentType.GetFields(privateFlags)) + { + if (string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase) && + field.GetCustomAttribute() != null) + { + return field; + } + } + currentType = currentType.BaseType; + } + + return null; + } + + private static string CheckPhysicsConflict(GameObject target, Type componentType) + { + bool isAdding2DPhysics = + typeof(Rigidbody2D).IsAssignableFrom(componentType) || + typeof(Collider2D).IsAssignableFrom(componentType); + + bool isAdding3DPhysics = + typeof(Rigidbody).IsAssignableFrom(componentType) || + typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + if (target.GetComponent() != null || target.GetComponent() != null) + { + return $"Cannot add 2D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 3D Rigidbody or Collider."; + } + } + else if (isAdding3DPhysics) + { + if (target.GetComponent() != null || target.GetComponent() != null) + { + return $"Cannot add 3D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 2D Rigidbody or Collider."; + } + } + + return null; + } + + private static void ApplyDefaultValues(Component component) + { + // Default newly added Lights to Directional + if (component is Light light) + { + light.type = LightType.Directional; + } + } + + private static bool AllowsMultiple(GameObject target, Type componentType) + { + if (target == null || componentType == null) + { + return false; + } + + if (Attribute.IsDefined(componentType, typeof(DisallowMultipleComponent), inherit: true)) + { + return false; + } + + return true; + } + + // --- UnityEvent SerializedProperty support --- + + private static Type ResolveMemberType(Type componentType, string propertyName, string normalizedName) + { + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + PropertyInfo propInfo = componentType.GetProperty(propertyName, flags) + ?? componentType.GetProperty(normalizedName, flags); + if (propInfo != null) + return propInfo.PropertyType; + + FieldInfo fieldInfo = componentType.GetField(propertyName, flags) + ?? componentType.GetField(normalizedName, flags); + if (fieldInfo != null) + return fieldInfo.FieldType; + + fieldInfo = FindSerializedFieldInHierarchy(componentType, propertyName) + ?? FindSerializedFieldInHierarchy(componentType, normalizedName); + if (fieldInfo != null) + return fieldInfo.FieldType; + + return null; + } + + private static bool SetViaSerializedProperty(Component component, string propertyName, string normalizedName, JToken value, out string error) + { + error = null; + using var so = new SerializedObject(component); + + SerializedProperty prop = so.FindProperty(propertyName) + ?? so.FindProperty(normalizedName); + if (prop == null) + { + error = $"SerializedProperty '{propertyName}' not found on component '{component.GetType().Name}'."; + return false; + } + + if (!SetSerializedPropertyRecursive(prop, value, out error, 0)) + return false; + + so.ApplyModifiedProperties(); + + // Readback verification for ObjectReference — these can silently fail + if (prop.propertyType == SerializedPropertyType.ObjectReference + && value != null + && !(value is JValue jv && jv.Type == JTokenType.Null)) + { + so.Update(); + var verifyProp = so.FindProperty(propertyName) + ?? so.FindProperty(normalizedName); + if (verifyProp != null + && verifyProp.propertyType == SerializedPropertyType.ObjectReference + && verifyProp.objectReferenceValue == null) + { + error = $"Property '{propertyName}' was set but the object reference did not persist. " + + "Check that the referenced object exists and is the correct type."; + return false; + } + } + + return true; + } + + private static bool SetSerializedPropertyRecursive(SerializedProperty prop, JToken value, out string error, int depth) + { + error = null; + const int MaxDepth = 20; + if (depth > MaxDepth) + { + error = $"Maximum recursion depth ({MaxDepth}) exceeded."; + return false; + } + + try + { + // Array + JArray + if (prop.isArray && prop.propertyType != SerializedPropertyType.String && value is JArray jArray) + { + prop.arraySize = jArray.Count; + prop.serializedObject.ApplyModifiedProperties(); + prop.serializedObject.Update(); + + for (int i = 0; i < jArray.Count; i++) + { + var element = prop.GetArrayElementAtIndex(i); + if (!SetSerializedPropertyRecursive(element, jArray[i], out error, depth + 1)) + return false; + } + return true; + } + + // Generic (struct/class) + JObject + if (prop.propertyType == SerializedPropertyType.Generic && !prop.isArray && value is JObject jObj) + { + foreach (var kvp in jObj) + { + var child = FindPropertyRelativeFuzzy(prop, kvp.Key); + if (child == null) + { + error = $"Sub-property '{kvp.Key}' not found under '{prop.propertyPath}'."; + return false; + } + if (!SetSerializedPropertyRecursive(child, kvp.Value, out error, depth + 1)) + return false; + } + return true; + } + + // ObjectReference + if (prop.propertyType == SerializedPropertyType.ObjectReference) + return SetObjectReference(prop, value, out error); + + // Leaf types + switch (prop.propertyType) + { + case SerializedPropertyType.Integer: + if (value == null || value.Type == JTokenType.Null + || (value.Type != JTokenType.Integer && value.Type != JTokenType.Float + && !long.TryParse(value.ToString(), out _))) + { + error = "Expected integer value."; + return false; + } + if (prop.type == "long") + prop.longValue = ParamCoercion.CoerceLong(value, 0); + else + prop.intValue = ParamCoercion.CoerceInt(value, 0); + return true; + + case SerializedPropertyType.Boolean: + if (value == null || value.Type == JTokenType.Null) + { + error = "Expected boolean value."; + return false; + } + prop.boolValue = ParamCoercion.CoerceBool(value, false); + return true; + + case SerializedPropertyType.Float: + float floatVal = ParamCoercion.CoerceFloat(value, float.NaN); + if (float.IsNaN(floatVal)) + { + error = "Expected float value."; + return false; + } + prop.floatValue = floatVal; + return true; + + case SerializedPropertyType.String: + prop.stringValue = value == null || value.Type == JTokenType.Null ? string.Empty : value.ToString(); + return true; + + case SerializedPropertyType.Enum: + return SetEnum(prop, value, out error); + + default: + error = $"Unsupported SerializedPropertyType: {prop.propertyType} at '{prop.propertyPath}'."; + return false; + } + } + catch (Exception ex) + { + error = $"Error setting '{prop.propertyPath}': {ex.Message}"; + return false; + } + } + + internal static bool SetObjectReference(SerializedProperty prop, JToken value, out string error) + { + error = null; + + if (value == null || value.Type == JTokenType.Null) + { + prop.objectReferenceValue = null; + return true; + } + + if (value.Type == JTokenType.Integer) + { + int id = value.Value(); + var resolved = GameObjectLookup.ResolveInstanceID(id); + if (resolved == null) + { + error = $"No object found with instanceID {id}."; + return false; + } + return AssignObjectReference(prop, resolved, null, out error); + } + + if (value is JObject jObj) + { + // Optional component type filter — e.g. {"instanceID": 123, "component": "Button"} + string componentFilter = jObj["component"]?.ToString(); + + var idToken = jObj["instanceID"]; + if (idToken != null) + { + int id = ParamCoercion.CoerceInt(idToken, 0); + var resolved = GameObjectLookup.ResolveInstanceID(id); + if (resolved == null) + { + error = $"No object found with instanceID {id}."; + return false; + } + return AssignObjectReference(prop, resolved, componentFilter, out error); + } + + var guidToken = jObj["guid"]; + if (guidToken != null) + { + string path = AssetDatabase.GUIDToAssetPath(guidToken.ToString()); + if (string.IsNullOrEmpty(path)) + { + error = $"No asset found for GUID '{guidToken}'."; + return false; + } + + var spriteNameToken = jObj["spriteName"]; + if (spriteNameToken != null) + { + string spriteName = spriteNameToken.ToString(); + var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + var originalRef = prop.objectReferenceValue; + foreach (var asset in allAssets) + { + if (asset is Sprite sprite && sprite.name == spriteName) + { + prop.objectReferenceValue = sprite; + if (prop.objectReferenceValue != null) + return true; + // Unity rejected the type — restore and report + prop.objectReferenceValue = originalRef; + error = $"Sprite '{spriteName}' found but is not compatible with the property type."; + return false; + } + } + + error = $"Sprite '{spriteName}' not found in atlas '{path}'."; + return false; + } + + var fileIdToken = jObj["fileID"]; + if (fileIdToken != null) + { + long targetFileId = fileIdToken.Value(); + if (targetFileId != 0) + { + var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + var originalRef = prop.objectReferenceValue; + foreach (var asset in allAssets) + { + if (asset is Sprite sprite) + { + long spriteFileId = GetSpriteFileId(sprite); + if (spriteFileId == targetFileId) + { + prop.objectReferenceValue = sprite; + if (prop.objectReferenceValue != null) + return true; + prop.objectReferenceValue = originalRef; + error = $"Sprite with fileID '{targetFileId}' found but is not compatible with the property type."; + return false; + } + } + } + } + + error = $"Sprite with fileID '{targetFileId}' not found in atlas '{path}'."; + return false; + } + + var loaded = AssetDatabase.LoadAssetAtPath(path); + return AssignObjectReference(prop, loaded, componentFilter, out error); + } + + var pathToken = jObj["path"]; + if (pathToken != null) + { + string sanitized = AssetPathUtility.SanitizeAssetPath(pathToken.ToString()); + var resolved = AssetDatabase.LoadAssetAtPath(sanitized); + if (resolved == null) + { + error = $"No asset found at path '{pathToken}'."; + return false; + } + return AssignObjectReference(prop, resolved, componentFilter, out error); + } + + var nameToken = jObj["name"]; + if (nameToken != null) + { + return ResolveSceneObjectByName(prop, nameToken.ToString(), componentFilter, out error); + } + + error = "Object reference must contain 'instanceID', 'guid', 'path', or 'name'."; + return false; + } + + if (value.Type == JTokenType.String) + { + string strVal = value.ToString(); + + // Try as instanceID if the string is purely numeric + if (int.TryParse(strVal, out int parsedId)) + { + var resolved = GameObjectLookup.ResolveInstanceID(parsedId); + if (resolved != null) + return AssignObjectReference(prop, resolved, null, out error); + // Not a valid instanceID — fall through to path/name resolution + } + + if (strVal.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || strVal.Contains("/")) + { + string sanitized = AssetPathUtility.SanitizeAssetPath(strVal); + var resolved = AssetDatabase.LoadAssetAtPath(sanitized); + if (resolved == null) + { + error = $"No asset found at path '{strVal}'."; + return false; + } + return AssignObjectReference(prop, resolved, null, out error); + } + + // Try as asset GUID (32-char hex string) + if (strVal.Length == 32 && IsHexString(strVal)) + { + string assetPath = AssetDatabase.GUIDToAssetPath(strVal); + if (!string.IsNullOrEmpty(assetPath)) + { + var resolved = AssetDatabase.LoadAssetAtPath(assetPath); + if (resolved != null) + return AssignObjectReference(prop, resolved, null, out error); + } + } + + // Fall back to scene hierarchy lookup by name. + return ResolveSceneObjectByName(prop, strVal, null, out error); + } + + error = $"Unsupported object reference format: {value.Type}."; + return false; + } + + /// + /// Assigns a resolved object to a SerializedProperty, with automatic component fallback. + /// If the resolved object is a GameObject but the property expects a Component type, + /// searches the GameObject's components for a compatible one. + /// Optionally filters by component type name (e.g. "Button", "Rigidbody"). + /// + private static bool AssignObjectReference(SerializedProperty prop, UnityEngine.Object resolved, string componentFilter, out string error) + { + error = null; + if (resolved == null) + { + error = "Resolved object is null."; + return false; + } + + // If a component filter is specified and the resolved object is a GameObject, + // find the specific component by type name. + if (!string.IsNullOrEmpty(componentFilter) && resolved is GameObject filterGo) + { + var components = filterGo.GetComponents(); + foreach (var comp in components) + { + if (comp == null) continue; + if (string.Equals(comp.GetType().Name, componentFilter, StringComparison.OrdinalIgnoreCase) || + string.Equals(comp.GetType().FullName, componentFilter, StringComparison.OrdinalIgnoreCase)) + { + prop.objectReferenceValue = comp; + if (prop.objectReferenceValue != null) + return true; + } + } + error = $"Component '{componentFilter}' not found on GameObject '{filterGo.name}'."; + return false; + } + + // Try direct assignment first + prop.objectReferenceValue = resolved; + if (prop.objectReferenceValue != null) + return true; + + // Sub-asset fallback: e.g., Texture2D → Sprite + string subAssetPath = AssetDatabase.GetAssetPath(resolved); + if (!string.IsNullOrEmpty(subAssetPath)) + { + var subAssets = AssetDatabase.LoadAllAssetsAtPath(subAssetPath); + UnityEngine.Object match = null; + int matchCount = 0; + foreach (var sub in subAssets) + { + if (sub == null || sub == resolved) continue; + prop.objectReferenceValue = sub; + if (prop.objectReferenceValue != null) + { + match = sub; + matchCount++; + if (matchCount > 1) break; + } + } + + if (matchCount == 1) + { + prop.objectReferenceValue = match; + return true; + } + + // Clean up: probing may have left the property dirty + prop.objectReferenceValue = null; + + if (matchCount > 1) + { + error = $"Multiple compatible sub-assets found in '{subAssetPath}'. " + + "Use {\"guid\": \"...\", \"spriteName\": \"\"} or " + + "{\"guid\": \"...\", \"fileID\": } for precise selection."; + return false; + } + } + + // If the resolved object is a GameObject but the property expects a Component, + // try each component on the GameObject until one is accepted. + if (resolved is GameObject go) + { + var components = go.GetComponents(); + foreach (var comp in components) + { + if (comp == null) continue; + prop.objectReferenceValue = comp; + if (prop.objectReferenceValue != null) + return true; + } + error = $"GameObject '{go.name}' found but no compatible component for the property type."; + return false; + } + + error = $"Object '{resolved.name}' (type: {resolved.GetType().Name}) is not compatible with the property type."; + return false; + } + + /// + /// Resolves a scene GameObject by name and assigns it (or a component on it) + /// to a SerializedProperty. Uses GameObjectLookup for robust search + /// including inactive objects and prefab stage support. + /// + private static bool ResolveSceneObjectByName(SerializedProperty prop, string name, string componentFilter, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(name)) + { + error = "Cannot resolve object reference from empty name."; + return false; + } + + var ids = GameObjectLookup.SearchGameObjects( + GameObjectLookup.SearchMethod.ByName, name, includeInactive: true, maxResults: 1); + + if (ids.Count == 0) + { + error = $"No GameObject named '{name}' found in scene."; + return false; + } + + var go = GameObjectLookup.FindById(ids[0]); + if (go == null) + { + error = $"GameObject '{name}' found but could not be resolved."; + return false; + } + + return AssignObjectReference(prop, go, componentFilter, out error); + } + + /// + /// Finds a child SerializedProperty by name, falling back to underscore-insensitive matching. + /// The batch_execute transport can strip underscores from JSON keys + /// (e.g. m_PersistentCalls → mPersistentCalls), so we iterate immediate children + /// and compare with underscores removed. + /// + private static SerializedProperty FindPropertyRelativeFuzzy(SerializedProperty parent, string key) + { + var child = parent.FindPropertyRelative(key); + if (child != null) return child; + + string normalizedKey = key.Replace("_", "").ToLowerInvariant(); + + var end = parent.GetEndProperty(); + var iter = parent.Copy(); + if (!iter.Next(true)) return null; + + while (!SerializedProperty.EqualContents(iter, end)) + { + if (iter.depth == parent.depth + 1) + { + string normalizedName = iter.name.Replace("_", "").ToLowerInvariant(); + if (normalizedName == normalizedKey) + return parent.FindPropertyRelative(iter.name); + } + if (!iter.Next(false)) + break; + } + + return null; + } + + private static bool SetEnum(SerializedProperty prop, JToken value, out string error) + { + error = null; + var names = prop.enumNames; + if (names == null || names.Length == 0) + { + error = "Enum has no names."; + return false; + } + + if (value.Type == JTokenType.Integer) + { + int idx = value.Value(); + if (idx < 0 || idx >= names.Length) + { + error = $"Enum index out of range: {idx}."; + return false; + } + prop.enumValueIndex = idx; + return true; + } + + string s = value.ToString(); + for (int i = 0; i < names.Length; i++) + { + if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase)) + { + prop.enumValueIndex = i; + return true; + } + } + error = $"Unknown enum name '{s}'."; + return false; + } + + private static long GetSpriteFileId(Sprite sprite) + { + if (sprite == null) + return 0; + + try + { + var globalId = GlobalObjectId.GetGlobalObjectIdSlow(sprite); + return unchecked((long)globalId.targetObjectId); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get fileID for sprite '{sprite.name}' (instanceID={sprite.GetInstanceIDCompat()}): {ex.Message}"); + return 0; + } + } + + private static bool IsHexString(string str) + { + foreach (char c in str) + { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return false; + } + return true; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta b/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta new file mode 100644 index 0000000..e887250 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 13dead161bc4540eeb771961df437779 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs new file mode 100644 index 0000000..52f3649 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ConfigJsonBuilder + { + public static string BuildManualConfigJson(string uvPath, McpClient client) + { + var root = new JObject(); + bool isVSCode = client?.IsVsCodeLayout == true; + if (!string.IsNullOrEmpty(client?.SchemaUrl)) root["$schema"] = client.SchemaUrl; + JObject container = EnsureObject(root, GetContainerKey(client, isVSCode)); + + var unity = new JObject(); + PopulateUnityNode(unity, uvPath, client, isVSCode); + + container["unityMCP"] = unity; + + return root.ToString(Formatting.Indented); + } + + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client) + { + if (root == null) root = new JObject(); + bool isVSCode = client?.IsVsCodeLayout == true; + if (!string.IsNullOrEmpty(client?.SchemaUrl) && root["$schema"] == null) root["$schema"] = client.SchemaUrl; + JObject container = EnsureObject(root, GetContainerKey(client, isVSCode)); + JObject unity = container["unityMCP"] as JObject ?? new JObject(); + PopulateUnityNode(unity, uvPath, client, isVSCode); + + container["unityMCP"] = unity; + return root; + } + + /// + /// Centralized builder that applies all caveats consistently. + /// - Sets command/args with uvx and package version + /// - Ensures env exists + /// - Adds transport configuration (HTTP or stdio) + /// - Adds disabled:false for Windsurf/Kiro only when missing + /// + private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) + { + // Get transport preference (default to HTTP) + bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport; + bool clientSupportsHttp = client?.SupportsHttpTransport != false; + bool useHttpTransport = clientSupportsHttp && prefValue; + string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; + var urlPropsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" }; + urlPropsToRemove.Remove(httpProperty); + + if (useHttpTransport) + { + // HTTP mode: Use URL, no command + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unity[httpProperty] = httpUrl; + + foreach (var prop in urlPropsToRemove) + { + if (unity[prop] != null) unity.Remove(prop); + } + + // Remove command/args if they exist from previous config + if (unity["command"] != null) unity.Remove("command"); + if (unity["args"] != null) unity.Remove("args"); + + // Only include API key header for remote-hosted mode + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey }; + unity["headers"] = headers; + } + else + { + if (unity["headers"] != null) unity.Remove("headers"); + } + } + else + { + // Local HTTP doesn't use API keys; remove any stale headers + if (unity["headers"] != null) unity.Remove("headers"); + } + + // Per-client override of the HTTP "type" value: Cline/Roo expect "streamableHttp" + // and Kilo expects "remote"; both fall back to stdio when they see the generic + // "http". Defaults to "http" (standard MCP protocol field) when unset, so clients + // don't default to SSE on seeing a URL without a type. + unity["type"] = string.IsNullOrEmpty(client?.HttpTypeValue) ? "http" : client.HttpTypeValue; + } + else + { + // Stdio mode: Use uvx command + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + var toolArgs = BuildUvxArgs(fromUrl, packageName); + + unity["command"] = uvxPath; + unity["args"] = JArray.FromObject(toolArgs.ToArray()); + + // Remove url/serverUrl if they exist from previous config + if (unity["url"] != null) unity.Remove("url"); + if (unity["serverUrl"] != null) unity.Remove("serverUrl"); + + // Include type for all clients — standard MCP protocol field. A few clients use a + // different token for local transport (e.g. Kilo uses "local"). + unity["type"] = string.IsNullOrEmpty(client?.StdioTypeValue) ? "stdio" : client.StdioTypeValue; + } + + bool requiresEnv = client?.EnsureEnvObject == true; + bool stripEnv = client?.StripEnvWhenNotRequired == true; + + if (requiresEnv) + { + if (unity["env"] == null) + { + unity["env"] = new JObject(); + } + } + else if (stripEnv && unity["env"] != null) + { + unity.Remove("env"); + } + + if (client?.DefaultUnityFields != null) + { + foreach (var kvp in client.DefaultUnityFields) + { + if (unity[kvp.Key] == null) + { + unity[kvp.Key] = kvp.Value != null ? JToken.FromObject(kvp.Value) : JValue.CreateNull(); + } + } + } + } + + private static string GetContainerKey(McpClient client, bool isVSCode) + { + if (isVSCode) return "servers"; + return string.IsNullOrEmpty(client?.ServerContainerKey) ? "mcpServers" : client.ServerContainerKey; + } + + private static JObject EnsureObject(JObject parent, string name) + { + if (parent[name] is JObject o) return o; + var created = new JObject(); + parent[name] = created; + return created; + } + + private static IList BuildUvxArgs(string fromUrl, string packageName) + { + // Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating). + // `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated. + // Note: --reinstall is not supported by uvx and will cause a warning. + // Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name. + var args = new List(); + + foreach (var flag in AssetPathUtility.GetUvxDevFlagsList()) + args.Add(flag); + + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(arg); + } + args.Add(packageName); + + args.Add("--transport"); + args.Add("stdio"); + + return args; + } + + } +} diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta new file mode 100644 index 0000000..f574fde --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c07c3369f73943919d9e086a81d1dcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs new file mode 100644 index 0000000..ee68871 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using MCPForUnity.Runtime.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Captures the pixels currently displayed in an editor window viewport. + /// Uses the editor view's own pixel grab path instead of re-rendering through a Camera. + /// + internal static class EditorWindowScreenshotUtility + { + // Keep capture synchronous so callers can immediately return the screenshot payload. + // The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport. + private const int RepaintSettlingDelayMs = 75; + private static readonly HashSet WindowsReservedNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + /// + /// Captures the active Scene View viewport to a PNG asset. + /// + /// Scene View window to capture. + /// Optional file name, defaulting to a timestamped PNG. + /// + /// Preserved in the result for API consistency, but Scene View capture always uses the current viewport resolution. + /// + /// If true, appends a suffix instead of overwriting an existing file. + /// If true, includes a base64 PNG in the returned result. + /// Maximum edge length for the inline image payload. + /// Captured viewport width in pixels. + /// Captured viewport height in pixels. + public static ScreenshotCaptureResult CaptureSceneViewViewportToProject( + SceneView sceneView, + string fileName, + int superSize, + bool ensureUniqueFileName, + bool includeImage, + int maxResolution, + out int viewportWidth, + out int viewportHeight, + string folderOverride = null) + { + if (sceneView == null) + throw new ArgumentNullException(nameof(sceneView)); + + int effectiveSuperSize = NormalizeSceneViewSuperSize(superSize); + + FocusAndRepaint(sceneView); + + Rect viewportRectPixels = GetSceneViewViewportPixelRect(sceneView); + viewportWidth = Mathf.RoundToInt(viewportRectPixels.width); + viewportHeight = Mathf.RoundToInt(viewportRectPixels.height); + + if (viewportWidth <= 0 || viewportHeight <= 0) + throw new InvalidOperationException("Captured Scene view viewport is empty."); + + Texture2D captured = null; + Texture2D downscaled = null; + try + { + captured = CaptureViewRect(sceneView, viewportRectPixels); + + var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName, folderOverride); + byte[] png = captured.EncodeToPNG(); + File.WriteAllBytes(result.FullPath, png); + + if (includeImage) + { + int targetMax = maxResolution > 0 ? maxResolution : 640; + string imageBase64; + int imageWidth; + int imageHeight; + + if (captured.width > targetMax || captured.height > targetMax) + { + downscaled = ScreenshotUtility.DownscaleTexture(captured, targetMax); + imageBase64 = Convert.ToBase64String(downscaled.EncodeToPNG()); + imageWidth = downscaled.width; + imageHeight = downscaled.height; + } + else + { + imageBase64 = Convert.ToBase64String(png); + imageWidth = captured.width; + imageHeight = captured.height; + } + + return new ScreenshotCaptureResult( + result.FullPath, + result.ProjectRelativePath, + result.SuperSize, + false, + imageBase64, + imageWidth, + imageHeight); + } + + return result; + } + finally + { + DestroyTexture(captured); + DestroyTexture(downscaled); + } + } + + private static void FocusAndRepaint(SceneView sceneView) + { + try + { + sceneView.Focus(); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView focus failed: {ex.Message}"); + } + + try + { + sceneView.Repaint(); + InvokeMethodIfExists(sceneView, "RepaintImmediately"); + SceneView.RepaintAll(); + UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); + EditorApplication.QueuePlayerLoopUpdate(); + Thread.Sleep(RepaintSettlingDelayMs); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView repaint failed: {ex.Message}"); + } + } + + private static Rect GetSceneViewViewportPixelRect(SceneView sceneView) + { + float pixelsPerPoint = EditorGUIUtility.pixelsPerPoint; + Rect viewportLocalPoints = GetViewportLocalRectPoints(sceneView, pixelsPerPoint); + if (viewportLocalPoints.width <= 0f || viewportLocalPoints.height <= 0f) + throw new InvalidOperationException("Failed to resolve Scene view viewport rect."); + + return new Rect( + Mathf.Round(viewportLocalPoints.x * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.y * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.width * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.height * pixelsPerPoint)); + } + + private static Rect GetViewportLocalRectPoints(SceneView sceneView, float pixelsPerPoint) + { + Rect? cameraViewport = GetRectProperty(sceneView, "cameraViewport"); + if (cameraViewport.HasValue && cameraViewport.Value.width > 0f && cameraViewport.Value.height > 0f) + { + return cameraViewport.Value; + } + + Camera camera = sceneView.camera; + if (camera == null) + throw new InvalidOperationException("Active Scene View has no camera to derive viewport size from."); + + float viewportWidth = camera.pixelWidth / Mathf.Max(0.0001f, pixelsPerPoint); + float viewportHeight = camera.pixelHeight / Mathf.Max(0.0001f, pixelsPerPoint); + Rect windowRect = sceneView.position; + + return new Rect( + 0f, + Mathf.Max(0f, windowRect.height - viewportHeight), + Mathf.Min(windowRect.width, viewportWidth), + Mathf.Min(windowRect.height, viewportHeight)); + } + + private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectPixels) + { + object hostView = GetHostView(sceneView); + if (hostView == null) + throw new InvalidOperationException("Failed to resolve Scene view host view."); + + // GrabPixels is an internal extern on GUIView (parent of HostView), present since at least Unity 2021.1. + // See: UnityCsReference/Editor/Mono/GUIView.bindings.cs — `internal extern void GrabPixels(RenderTexture, Rect)` + // If Unity removes this, the MissingMethodException below keeps the failure explicit. + MethodInfo grabPixels = hostView.GetType().GetMethod( + "GrabPixels", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] { typeof(RenderTexture), typeof(Rect) }, + null); + + if (grabPixels == null) + throw new MissingMethodException($"{hostView.GetType().FullName}.GrabPixels(RenderTexture, Rect)"); + + int width = Mathf.RoundToInt(viewportRectPixels.width); + int height = Mathf.RoundToInt(viewportRectPixels.height); + + RenderTexture rt = null; + RenderTexture previousActive = RenderTexture.active; + try + { + rt = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) + { + antiAliasing = 1, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + rt.Create(); + + grabPixels.Invoke(hostView, new object[] { rt, viewportRectPixels }); + + RenderTexture.active = rt; + var texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); + texture.Apply(); + FlipTextureVertically(texture); + return texture; + } + catch (TargetInvocationException ex) + { + ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw(); + throw; + } + finally + { + RenderTexture.active = previousActive; + if (rt != null) + { + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + } + } + } + + private static object GetHostView(EditorWindow window) + { + if (window == null) + return null; + + Type windowType = typeof(EditorWindow); + FieldInfo parentField = windowType.GetField("m_Parent", BindingFlags.Instance | BindingFlags.NonPublic); + if (parentField != null) + { + object parent = parentField.GetValue(window); + if (parent != null) + return parent; + } + + PropertyInfo hostViewProperty = windowType.GetProperty("hostView", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return hostViewProperty?.GetValue(window, null); + } + + private static Rect? GetRectProperty(object instance, string propertyName) + { + if (instance == null) + return null; + + Type type = instance.GetType(); + PropertyInfo property = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property == null || property.PropertyType != typeof(Rect)) + return null; + + try + { + return (Rect)property.GetValue(instance, null); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] Failed to read rect property '{propertyName}': {ex.Message}"); + return null; + } + } + + private static void InvokeMethodIfExists(object instance, string methodName) + { + if (instance == null) + return; + + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (method == null || method.GetParameters().Length != 0) + return; + + try + { + method.Invoke(instance, null); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] Best-effort invoke of '{methodName}' failed: {ex.Message}"); + } + } + + private static void FlipTextureVertically(Texture2D texture) + { + if (texture == null) + return; + + int width = texture.width; + int height = texture.height; + Color32[] pixels = texture.GetPixels32(); + var temp = new Color32[width]; + + for (int y = 0; y < height / 2; y++) + { + int topRow = y * width; + int bottomRow = (height - 1 - y) * width; + Array.Copy(pixels, topRow, temp, 0, width); + Array.Copy(pixels, bottomRow, pixels, topRow, width); + Array.Copy(temp, 0, pixels, bottomRow, width); + } + + texture.SetPixels32(pixels); + texture.Apply(); + } + + private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName, string folderOverride) + { + int size = Mathf.Max(1, superSize); + string resolvedName = BuildFileName(fileName); + string folder = ScreenshotUtility.ResolveFolderAbsolute(folderOverride); + Directory.CreateDirectory(folder); + + string fullPath = Path.Combine(folder, resolvedName); + if (ensureUniqueFileName) + { + fullPath = EnsureUnique(fullPath); + } + + string normalizedFullPath = fullPath.Replace('\\', '/'); + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")).Replace('\\', '/'); + string normalizedRoot = projectRoot.EndsWith("/") ? projectRoot : projectRoot + "/"; + string projectRelativePath = normalizedFullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) + ? normalizedFullPath.Substring(normalizedRoot.Length) + : normalizedFullPath; + return new ScreenshotCaptureResult(normalizedFullPath, projectRelativePath, size, false); + } + + private static string BuildFileName(string fileName) + { + string baseName = string.IsNullOrWhiteSpace(fileName) + ? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png" + : SanitizeFileName(fileName); + + if (!baseName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + baseName += ".png"; + + return baseName; + } + + private static int NormalizeSceneViewSuperSize(int superSize) + { + if (superSize > 1) + { + McpLog.Warn("[EditorWindowScreenshotUtility] Scene View capture ignores superSize and uses the displayed viewport resolution."); + return 1; + } + + return Mathf.Max(1, superSize); + } + + private static string SanitizeFileName(string fileName) + { + string trimmed = (fileName ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(trimmed)) + return $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + + string candidate = trimmed; + string normalizedSeparators = candidate.Replace('\\', '/'); + if (Path.IsPathRooted(candidate) || normalizedSeparators.Contains("/") || normalizedSeparators.Contains("..")) + { + string[] pathParts = normalizedSeparators.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + candidate = pathParts.Length > 0 ? pathParts[pathParts.Length - 1] : string.Empty; + } + + if (string.IsNullOrWhiteSpace(candidate) || candidate == "." || candidate == "..") + candidate = $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + + char[] invalidChars = Path.GetInvalidFileNameChars(); + foreach (char invalidChar in invalidChars) + { + candidate = candidate.Replace(invalidChar, '_'); + } + + string extension = Path.GetExtension(candidate); + string stem = Path.GetFileNameWithoutExtension(candidate); + extension = extension.TrimEnd(' ', '.'); + stem = stem.TrimEnd(' ', '.'); + if (WindowsReservedNames.Contains(stem)) + { + candidate = $"_{stem}{extension}"; + } + + return candidate; + } + + private static string EnsureUnique(string fullPath) + { + if (!File.Exists(fullPath)) + return fullPath; + + string directory = Path.GetDirectoryName(fullPath) ?? string.Empty; + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullPath); + string extension = Path.GetExtension(fullPath); + + for (int i = 1; i < 10000; i++) + { + string candidate = Path.Combine(directory, $"{fileNameWithoutExtension}-{i}{extension}"); + if (!File.Exists(candidate)) + return candidate; + } + + throw new IOException($"Could not generate a unique screenshot filename for '{fullPath}'."); + } + + private static void DestroyTexture(Texture2D texture) + { + if (texture == null) + return; + + UnityEngine.Object.DestroyImmediate(texture); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta new file mode 100644 index 0000000..d120907 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b73350febfd6534436726d19b4d270fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs new file mode 100644 index 0000000..4125421 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -0,0 +1,332 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class ExecPath + { + private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride; + + // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. + internal static string ResolveClaude() + { + try + { + string pref = EditorPrefs.GetString(PrefClaude, string.Empty); + if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; + } + catch { } + + string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + // Common npm global locations + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + // Native installer locations + Path.Combine(localAppData, "Programs", "claude", "claude.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), + Path.Combine(home, ".local", "bin", "claude.exe"), + Path.Combine(home, ".claude", "local", "claude.exe"), // `claude migrate-installer` location + // npm global locations (.cmd preferred for non-interactive processes) + Path.Combine(appData, "npm", "claude.cmd"), + Path.Combine(localAppData, "npm", "claude.cmd"), + // Fall back to PowerShell shim if only .ps1 is present + Path.Combine(appData, "npm", "claude.ps1"), + Path.Combine(localAppData, "npm", "claude.ps1"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + string fromWhere = FindInPathWindows("claude.exe") ?? FindInPathWindows("claude.cmd") ?? FindInPathWindows("claude.ps1") ?? FindInPathWindows("claude"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + + // Linux + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/usr/local/bin/claude", + "/usr/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + } + + // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version + private static string ResolveClaudeFromNvm(string home) + { + try + { + if (string.IsNullOrEmpty(home)) return null; + string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); + if (!Directory.Exists(nvmNodeDir)) return null; + + string bestPath = null; + Version bestVersion = null; + foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) + { + string name = Path.GetFileName(versionDir); + if (string.IsNullOrEmpty(name)) continue; + if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 + string versionStr = name.Substring(1); + int dashIndex = versionStr.IndexOf('-'); + if (dashIndex > 0) + { + versionStr = versionStr.Substring(0, dashIndex); + } + if (Version.TryParse(versionStr, out Version parsed)) + { + string candidate = Path.Combine(versionDir, "bin", "claude"); + if (File.Exists(candidate)) + { + if (bestVersion == null || parsed > bestVersion) + { + bestVersion = parsed; + bestPath = candidate; + } + } + } + } + } + return bestPath; + } + catch { return null; } + } + + // Explicitly set the Claude CLI absolute path override in EditorPrefs + internal static void SetClaudeCliPath(string absolutePath) + { + try + { + if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) + { + EditorPrefs.SetString(PrefClaude, absolutePath); + } + } + catch { } + } + + // Clear any previously set Claude CLI override path + internal static void ClearClaudeCliPath() + { + try + { + if (EditorPrefs.HasKey(PrefClaude)) + { + EditorPrefs.DeleteKey(PrefClaude); + } + } + catch { } + } + + internal static bool TryRun( + string file, + string args, + string workingDir, + out string stdout, + out string stderr, + int timeoutMs = 15000, + string extraPathPrepend = null) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + // Handle PowerShell scripts on Windows by invoking through powershell.exe + bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + + var psi = new ProcessStartInfo + { + FileName = isPs1 ? "powershell.exe" : file, + Arguments = isPs1 + ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() + : args, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + } + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; + + var sb = new StringBuilder(); + var se = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) sb.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { } + return false; + } + + // Ensure async buffers are flushed + process.WaitForExit(); + + stdout = sb.ToString(); + stderr = se.ToString(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + /// + /// Cross-platform path lookup. Uses 'where' on Windows, 'which' on macOS/Linux. + /// Returns the full path if found, null otherwise. + /// + internal static string FindInPath(string executable, string extraPathPrepend = null) + { +#if UNITY_EDITOR_WIN + return FindInPathWindows(executable, extraPathPrepend); +#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which(executable, extraPathPrepend ?? string.Empty); +#else + return null; +#endif + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + private static string Which(string exe, string prependPath) + { + try + { + var psi = new ProcessStartInfo("/usr/bin/which", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + + using var p = Process.Start(psi); + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string output = so.ToString().Trim(); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + catch { return null; } + } +#endif + +#if UNITY_EDITOR_WIN + internal static string FindInPathWindows(string exe, string extraPathPrepend = null) + { + try + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + string effectivePath = string.IsNullOrEmpty(extraPathPrepend) + ? currentPath + : (string.IsNullOrEmpty(currentPath) ? extraPathPrepend : extraPathPrepend + Path.PathSeparator + currentPath); + + var psi = new ProcessStartInfo("where", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(effectivePath)) + { + psi.EnvironmentVariables["PATH"] = effectivePath; + } + + using var p = Process.Start(psi); + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string first = so.ToString() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + catch { return null; } + } +#endif + } +} diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs.meta b/MCPForUnity/Editor/Helpers/ExecPath.cs.meta new file mode 100644 index 0000000..9e64a15 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs new file mode 100644 index 0000000..7ee58ef --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for finding and looking up GameObjects in the scene. + /// Provides search functionality by name, tag, layer, component, path, and instance ID. + /// + public static class GameObjectLookup + { + /// + /// Supported search methods for finding GameObjects. + /// + public enum SearchMethod + { + ByName, + ByTag, + ByLayer, + ByComponent, + ByPath, + ById + } + + /// + /// Parses a search method string into the enum value. + /// + public static SearchMethod ParseSearchMethod(string method) + { + if (string.IsNullOrEmpty(method)) + return SearchMethod.ByName; + + return method.ToLowerInvariant() switch + { + "by_name" => SearchMethod.ByName, + "by_tag" => SearchMethod.ByTag, + "by_layer" => SearchMethod.ByLayer, + "by_component" => SearchMethod.ByComponent, + "by_path" => SearchMethod.ByPath, + "by_id" => SearchMethod.ById, + _ => SearchMethod.ByName + }; + } + + /// + /// Finds a single GameObject based on the target and search method. + /// + /// The target identifier (name, ID, path, etc.) + /// The search method to use + /// Whether to include inactive objects + /// The found GameObject or null + public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false) + { + if (target == null) + return null; + + var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1); + return results.Count > 0 ? FindById(results[0]) : null; + } + + /// + /// Resolves an instance ID to a UnityEngine.Object. + /// + public static UnityEngine.Object ResolveInstanceID(int instanceId) + { + return UnityObjectIdCompat.InstanceIDToObjectCompat(instanceId); + } + + /// + /// Finds a GameObject by its instance ID. + /// + public static GameObject FindById(int instanceId) + { + return ResolveInstanceID(instanceId) as GameObject; + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method string (by_name, by_tag, etc.) + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var method = ParseSearchMethod(searchMethod); + return SearchGameObjects(method, searchTerm, includeInactive, maxResults); + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var results = new List(); + + switch (method) + { + case SearchMethod.ById: + if (int.TryParse(searchTerm, out int instanceId)) + { + var obj = ResolveInstanceID(instanceId) as GameObject; + if (obj != null && (includeInactive || obj.activeInHierarchy)) + { + results.Add(instanceId); + } + } + break; + + case SearchMethod.ByName: + results.AddRange(SearchByName(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByPath: + results.AddRange(SearchByPath(searchTerm, includeInactive)); + break; + + case SearchMethod.ByTag: + results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByLayer: + results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByComponent: + results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults)); + break; + } + + return results; + } + + private static IEnumerable SearchByName(string name, bool includeInactive, int maxResults) + { + var allObjects = GetAllSceneObjects(includeInactive); + var matching = allObjects.Where(go => go.name == name); + + if (maxResults > 0) + matching = matching.Take(maxResults); + + return matching.Select(go => go.GetInstanceIDCompat()); + } + + private static IEnumerable SearchByPath(string path, bool includeInactive) + { + // Check Prefab Stage first - GameObject.Find() doesn't work in Prefab Stage + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + { + // Use GetAllSceneObjects which already handles Prefab Stage + var allObjects = GetAllSceneObjects(includeInactive); + foreach (var go in allObjects) + { + if (MatchesPath(go, path)) + { + yield return go.GetInstanceIDCompat(); + } + } + yield break; + } + + // Normal scene mode + // NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects. + // If includeInactive=true, we need to search manually to find inactive objects. + if (includeInactive) + { + // Search manually to support inactive objects + var allObjects = GetAllSceneObjects(true); + foreach (var go in allObjects) + { + if (MatchesPath(go, path)) + { + yield return go.GetInstanceIDCompat(); + } + } + } + else + { + // Use GameObject.Find for active objects only (Unity API limitation) + var found = GameObject.Find(path); + if (found != null) + { + yield return found.GetInstanceIDCompat(); + } + } + } + + private static IEnumerable SearchByTag(string tag, bool includeInactive, int maxResults) + { + GameObject[] taggedObjects; + try + { + if (includeInactive) + { + // FindGameObjectsWithTag doesn't find inactive, so we need to iterate all + var allObjects = GetAllSceneObjects(true); + taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray(); + } + else + { + taggedObjects = GameObject.FindGameObjectsWithTag(tag); + } + } + catch (UnityException) + { + // Tag doesn't exist + yield break; + } + + var results = taggedObjects.AsEnumerable(); + if (maxResults > 0) + results = results.Take(maxResults); + + foreach (var go in results) + { + yield return go.GetInstanceIDCompat(); + } + } + + private static IEnumerable SearchByLayer(string layerName, bool includeInactive, int maxResults) + { + int layer = LayerMask.NameToLayer(layerName); + if (layer == -1) + { + // Try parsing as layer number + if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31) + { + yield break; + } + } + + var allObjects = GetAllSceneObjects(includeInactive); + var matching = allObjects.Where(go => go.layer == layer); + + if (maxResults > 0) + matching = matching.Take(maxResults); + + foreach (var go in matching) + { + yield return go.GetInstanceIDCompat(); + } + } + + private static IEnumerable SearchByComponent(string componentTypeName, bool includeInactive, int maxResults) + { + Type componentType = FindComponentType(componentTypeName); + if (componentType == null) + { + McpLog.Warn($"[GameObjectLookup] Component type '{componentTypeName}' not found."); + yield break; + } + + var allObjects = GetAllSceneObjects(includeInactive); + var count = 0; + + foreach (var go in allObjects) + { + if (go.GetComponent(componentType) != null) + { + yield return go.GetInstanceIDCompat(); + count++; + + if (maxResults > 0 && count >= maxResults) + yield break; + } + } + } + + /// + /// Gets all GameObjects in the current scene. + /// + public static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // Check Prefab Stage first + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null && prefabStage.prefabContentsRoot != null) + { + // Use Prefab Stage's prefabContentsRoot + foreach (var go in GetObjectAndDescendants(prefabStage.prefabContentsRoot, includeInactive)) + { + yield return go; + } + yield break; + } + + // Normal scene mode + var scene = SceneManager.GetActiveScene(); + if (!scene.IsValid()) + yield break; + + var rootObjects = scene.GetRootGameObjects(); + foreach (var root in rootObjects) + { + foreach (var go in GetObjectAndDescendants(root, includeInactive)) + { + yield return go; + } + } + } + + private static IEnumerable GetObjectAndDescendants(GameObject obj, bool includeInactive) + { + if (!includeInactive && !obj.activeInHierarchy) + yield break; + + yield return obj; + + foreach (Transform child in obj.transform) + { + foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive)) + { + yield return descendant; + } + } + } + + /// + /// Finds a component type by name, searching loaded assemblies. + /// + /// + /// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution. + /// + public static Type FindComponentType(string typeName) + { + return UnityTypeResolver.ResolveComponent(typeName); + } + + /// + /// Checks whether a GameObject matches a path or trailing path segment. + /// + internal static bool MatchesPath(GameObject go, string path) + { + if (go == null || string.IsNullOrEmpty(path)) + return false; + + var goPath = GetGameObjectPath(go); + return goPath == path || goPath.EndsWith("/" + path); + } + + /// + /// Gets the hierarchical path of a GameObject. + /// + public static string GetGameObjectPath(GameObject obj) + { + if (obj == null) + return string.Empty; + + var path = obj.name; + var parent = obj.transform.parent; + + while (parent != null) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + + return path; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta new file mode 100644 index 0000000..82fed7a --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4964205faa8dd4f8a960e58fd8c0d4f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs new file mode 100644 index 0000000..f971136 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -0,0 +1,747 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Runtime.Serialization; // For Converters +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Handles serialization of GameObjects and Components for MCP responses. + /// Includes reflection helpers and caching for performance. + /// + public static class GameObjectSerializer + { + // --- Data Serialization --- + + /// + /// Creates a serializable representation of a GameObject. + /// + public static object GetGameObjectData(GameObject go) + { + if (go == null) + return null; + return new + { + name = go.name, + instanceID = go.GetInstanceIDCompat(), + tag = go.tag, + layer = go.layer, + activeSelf = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + scenePath = go.scene.path, // Identify which scene it belongs to + transform = new // Serialize transform components carefully to avoid JSON issues + { + // Serialize Vector3 components individually to prevent self-referencing loops. + // The default serializer can struggle with properties like Vector3.normalized. + position = new + { + x = go.transform.position.x, + y = go.transform.position.y, + z = go.transform.position.z, + }, + localPosition = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.rotation.eulerAngles.x, + y = go.transform.rotation.eulerAngles.y, + z = go.transform.rotation.eulerAngles.z, + }, + localRotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + forward = new + { + x = go.transform.forward.x, + y = go.transform.forward.y, + z = go.transform.forward.z, + }, + up = new + { + x = go.transform.up.x, + y = go.transform.up.y, + z = go.transform.up.z, + }, + right = new + { + x = go.transform.right.x, + y = go.transform.right.y, + z = go.transform.right.z, + }, + }, + parentInstanceID = go.transform.parent?.gameObject.GetInstanceIDCompat() ?? 0, // 0 if no parent + // Optionally include components, but can be large + // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() + // Or just component names: + componentNames = go.GetComponents() + .Select(c => c.GetType().FullName) + .ToList(), + }; + } + + // --- Metadata Caching for Reflection --- + private class CachedMetadata + { + public readonly List SerializableProperties; + public readonly List SerializableFields; + + public CachedMetadata(List properties, List fields) + { + SerializableProperties = properties; + SerializableFields = fields; + } + } + // Key becomes Tuple + private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); + // --- End Metadata Caching --- + + /// + /// Checks if a type is or derives from a type with the specified full name. + /// Used to detect special-case components including their subclasses. + /// + private static bool IsOrDerivedFrom(Type type, string baseTypeFullName) + { + Type current = type; + while (current != null) + { + if (current.FullName == baseTypeFullName) + return true; + current = current.BaseType; + } + return false; + } + + // Type full names that are known to crash the Editor when accessed via reflection. + // Photon Fusion uses IL weaving to inject fields with these types into NetworkBehaviour + // subclasses. They contain native/unmanaged memory and cannot be safely serialized. + private static readonly HashSet _crashingTypeNames = new HashSet + { + "Fusion.NetworkBehaviourBuffer", + "Fusion.NetworkBehaviourCallbackBuffer", + "Fusion.Networked+Internals", + "Fusion.Changed`1", + }; + private static readonly PropertyInfo _isByRefLikeProperty = typeof(Type).GetProperty("IsByRefLike"); + + /// + /// Checks if a type is unsafe to access via reflection or serialize. + /// Returns true for ref structs (Span, ReadOnlySpan), pointer types, + /// by-ref types, and known IL-weaved types that crash the Editor. + /// + private static bool IsUnsafeType(Type type) + { + return IsUnsafeType(type, new HashSet()); + } + + private static bool IsUnsafeType(Type type, HashSet visitedTypes) + { + if (type == null) return false; + if (!visitedTypes.Add(type)) return false; + + // Pointer and by-ref types cannot be serialized + if (type.IsPointer || type.IsByRef) + return true; + + // Ref structs (Span<>, ReadOnlySpan<>, etc.) cannot be boxed. Use reflection + // so Unity versions without Type.IsByRefLike still compile. + if (type.IsValueType && _isByRefLikeProperty != null && (bool)_isByRefLikeProperty.GetValue(type, null)) + return true; + + // Check the type and its generic definition against the blacklist + string fullName = type.FullName; + if (fullName != null && _crashingTypeNames.Contains(fullName)) + return true; + + if (type.IsGenericType) + { + string genericFullName = type.GetGenericTypeDefinition()?.FullName; + if (genericFullName != null && _crashingTypeNames.Contains(genericFullName)) + return true; + } + + // Catch-all for Fusion buffer types injected by IL weaving + if (fullName != null && fullName.StartsWith("Fusion.") && fullName.Contains("Buffer")) + return true; + + // Arrays and generic containers can wrap unsafe Fusion/ref-like types. + // Newtonsoft.Json would still recurse into those values during serialization. + Type elementType = type.GetElementType(); + if (elementType != null && IsUnsafeType(elementType, visitedTypes)) + return true; + + foreach (Type genericArgument in type.GetGenericArguments()) + { + if (IsUnsafeType(genericArgument, visitedTypes)) + return true; + } + + return false; + } + + /// + /// Serializes a UnityEngine.Object reference to a dictionary with name, instanceID, and assetPath. + /// Used for consistent serialization of asset references in special-case component handlers. + /// + /// The Unity object to serialize + /// Whether to include the asset path (default true) + /// A dictionary with the object's reference info, or null if obj is null + private static Dictionary SerializeAssetReference(UnityEngine.Object obj, bool includeAssetPath = true) + { + if (obj == null) return null; + + var result = new Dictionary + { + { "name", obj.name }, + { "instanceID", obj.GetInstanceIDCompat() } + }; + + if (includeAssetPath) + { + var assetPath = AssetDatabase.GetAssetPath(obj); + result["assetPath"] = string.IsNullOrEmpty(assetPath) ? null : assetPath; + } + + return result; + } + + /// + /// Creates a serializable representation of a Component, attempting to serialize + /// public properties and fields using reflection, with caching and control over non-public fields. + /// + // Add the flag parameter here + public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) + { + // --- Add Early Logging --- + // McpLog.Info($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceIDCompat() ?? 0})"); + // --- End Early Logging --- + + if (c == null) return null; + Type componentType = c.GetType(); + + // --- Special handling for Transform to avoid reflection crashes and problematic properties --- + if (componentType == typeof(Transform)) + { + Transform tr = c as Transform; + // McpLog.Info($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceIDCompat()})"); + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", tr.GetInstanceIDCompat() }, + // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. + { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, // Use Euler angles + { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "parentInstanceID", tr.parent?.gameObject.GetInstanceIDCompat() ?? 0 }, + { "rootInstanceID", tr.root?.gameObject.GetInstanceIDCompat() ?? 0 }, + { "childCount", tr.childCount }, + // Include standard Object/Component properties + { "name", tr.name }, + { "tag", tr.tag }, + { "gameObjectInstanceID", tr.gameObject?.GetInstanceIDCompat() ?? 0 } + }; + } + // --- End Special handling for Transform --- + + // --- Special handling for Camera to avoid matrix-related crashes --- + if (componentType == typeof(Camera)) + { + Camera cam = c as Camera; + var cameraProperties = new Dictionary(); + + // List of safe properties to serialize + var safeProperties = new Dictionary> + { + { "nearClipPlane", () => cam.nearClipPlane }, + { "farClipPlane", () => cam.farClipPlane }, + { "fieldOfView", () => cam.fieldOfView }, + { "renderingPath", () => (int)cam.renderingPath }, + { "actualRenderingPath", () => (int)cam.actualRenderingPath }, + { "allowHDR", () => cam.allowHDR }, + { "allowMSAA", () => cam.allowMSAA }, + { "allowDynamicResolution", () => cam.allowDynamicResolution }, + { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, + { "orthographicSize", () => cam.orthographicSize }, + { "orthographic", () => cam.orthographic }, + { "opaqueSortMode", () => (int)cam.opaqueSortMode }, + { "transparencySortMode", () => (int)cam.transparencySortMode }, + { "depth", () => cam.depth }, + { "aspect", () => cam.aspect }, + { "cullingMask", () => cam.cullingMask }, + { "eventMask", () => cam.eventMask }, + { "backgroundColor", () => cam.backgroundColor }, + { "clearFlags", () => (int)cam.clearFlags }, + { "stereoEnabled", () => cam.stereoEnabled }, + { "stereoSeparation", () => cam.stereoSeparation }, + { "stereoConvergence", () => cam.stereoConvergence }, + { "enabled", () => cam.enabled }, + { "name", () => cam.name }, + { "tag", () => cam.tag }, + { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceIDCompat() } } + }; + + foreach (var prop in safeProperties) + { + try + { + var value = prop.Value(); + if (value != null) + { + AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); + } + } + catch (Exception) + { + // Silently skip any property that fails + continue; + } + } + + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", cam.GetInstanceIDCompat() }, + { "properties", cameraProperties } + }; + } + // --- End Special handling for Camera --- + + // --- Special handling for UIDocument to avoid infinite loops from VisualElement hierarchy (Issue #585) --- + // UIDocument.rootVisualElement contains circular parent/child references that cause infinite serialization loops. + // Use IsOrDerivedFrom to also catch subclasses of UIDocument. + if (IsOrDerivedFrom(componentType, "UnityEngine.UIElements.UIDocument")) + { + var uiDocProperties = new Dictionary(); + + try + { + // Get panelSettings reference safely + var panelSettingsProp = componentType.GetProperty("panelSettings"); + if (panelSettingsProp != null) + { + var panelSettings = panelSettingsProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["panelSettings"] = SerializeAssetReference(panelSettings); + } + + // Get visualTreeAsset reference safely (the UXML file) + var visualTreeAssetProp = componentType.GetProperty("visualTreeAsset"); + if (visualTreeAssetProp != null) + { + var visualTreeAsset = visualTreeAssetProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["visualTreeAsset"] = SerializeAssetReference(visualTreeAsset); + } + + // Get sortingOrder safely + var sortingOrderProp = componentType.GetProperty("sortingOrder"); + if (sortingOrderProp != null) + { + uiDocProperties["sortingOrder"] = sortingOrderProp.GetValue(c); + } + + // Get enabled state (from Behaviour base class) + var enabledProp = componentType.GetProperty("enabled"); + if (enabledProp != null) + { + uiDocProperties["enabled"] = enabledProp.GetValue(c); + } + + // Get parentUI reference safely (no asset path needed - it's a scene reference) + var parentUIProp = componentType.GetProperty("parentUI"); + if (parentUIProp != null) + { + var parentUI = parentUIProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["parentUI"] = SerializeAssetReference(parentUI, includeAssetPath: false); + } + + // NOTE: rootVisualElement is intentionally skipped - it contains circular + // parent/child references that cause infinite serialization loops + uiDocProperties["_note"] = "rootVisualElement skipped to prevent circular reference loops"; + } + catch (Exception e) + { + McpLog.Warn($"[GetComponentData] Error reading UIDocument properties: {e.Message}"); + } + + // Return structure matches Camera special handling (typeName, instanceID, properties) + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceIDCompat() }, + { "properties", uiDocProperties } + }; + } + // --- End Special handling for UIDocument --- + + var data = new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceIDCompat() } + }; + + // --- Get Cached or Generate Metadata (using new cache key) --- + Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); + if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) + { + var propertiesToCache = new List(); + var fieldsToCache = new List(); + + // Traverse the hierarchy from the component type up to MonoBehaviour + Type currentType = componentType; + while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) + { + // Get properties declared only at the current type level + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var propInfo in currentType.GetProperties(propFlags)) + { + // Basic filtering (readable, not indexer, not transform which is handled elsewhere) + if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // Skip properties whose return type would crash when accessed via reflection + // (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers) + if (IsUnsafeType(propInfo.PropertyType)) continue; + // Add if not already added (handles overrides - keep the most derived version) + if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) + { + propertiesToCache.Add(propInfo); + } + } + + // Get fields declared only at the current type level (both public and non-public) + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var declaredFields = currentType.GetFields(fieldFlags); + + // Process the declared Fields for caching + foreach (var fieldInfo in declaredFields) + { + if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields + // Skip fields whose type would crash when accessed via reflection + // (e.g. Fusion IL-weaved types, Span<>, ReadOnlySpan<>, pointers) + if (IsUnsafeType(fieldInfo.FieldType)) continue; + + // Add if not already added (handles hiding - keep the most derived version) + if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; + + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) + var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); + shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } + + if (shouldInclude) + { + fieldsToCache.Add(fieldInfo); + } + } + + // Move to the base type + currentType = currentType.BaseType; + } + // --- End Hierarchy Traversal --- + + cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); + _metadataCache[cacheKey] = cachedData; // Add to cache with combined key + } + // --- End Get Cached or Generate Metadata --- + + // --- Use cached metadata --- + var serializablePropertiesOutput = new Dictionary(); + + // --- Add Logging Before Property Loop --- + // McpLog.Info($"[GetComponentData] Starting property loop for {componentType.Name}..."); + // --- End Logging Before Property Loop --- + + // Use cached properties + foreach (var propInfo in cachedData.SerializableProperties) + { + string propName = propInfo.Name; + + // --- Skip known obsolete/problematic Component shortcut properties --- + bool skipProperty = false; + if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || + propName == "light" || propName == "animation" || propName == "constantForce" || + propName == "renderer" || propName == "audio" || propName == "networkView" || + propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || + propName == "particleSystem" || + // Also skip potentially problematic Matrix properties prone to cycles/errors + propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") + { + // McpLog.Info($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log + skipProperty = true; + } + // --- End Skip Generic Properties --- + + // --- Skip specific potentially problematic Camera properties --- + if (componentType == typeof(Camera) && + (propName == "pixelRect" || + propName == "rect" || + propName == "cullingMatrix" || + propName == "useOcclusionCulling" || + propName == "worldToCameraMatrix" || + propName == "projectionMatrix" || + propName == "nonJitteredProjectionMatrix" || + propName == "previousViewProjectionMatrix" || + propName == "cameraToWorldMatrix")) + { + // McpLog.Info($"[GetComponentData] Explicitly skipping Camera property: {propName}"); + skipProperty = true; + } + // --- End Skip Camera Properties --- + + // --- Skip specific potentially problematic Transform properties --- + if (componentType == typeof(Transform) && + (propName == "lossyScale" || + propName == "rotation" || + propName == "worldToLocalMatrix" || + propName == "localToWorldMatrix")) + { + skipProperty = true; + } + // --- End Skip Transform Properties --- + + // --- Skip Collider properties that cause native crashes via PhysX --- + if (typeof(Collider).IsAssignableFrom(componentType) && + propName == "GeometryHolder") + { + skipProperty = true; + } + // --- End Skip Collider Properties --- + + // Skip if flagged + if (skipProperty) + { + continue; + } + + try + { + // --- Add detailed logging --- + // McpLog.Info($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); + // --- End detailed logging --- + + // --- Special handling for material/mesh properties in edit mode --- + object value; + if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh")) + { + // In edit mode, use sharedMaterial/sharedMesh to avoid instantiation warnings + if ((propName == "material" || propName == "materials") && c is Renderer renderer) + { + if (propName == "material") + value = renderer.sharedMaterial; + else // materials + value = renderer.sharedMaterials; + } + else if (propName == "mesh" && c is MeshFilter meshFilter) + { + value = meshFilter.sharedMesh; + } + else + { + // Fallback to normal property access if type doesn't match + value = propInfo.GetValue(c); + } + } + else + { + value = propInfo.GetValue(c); + } + // --- End special handling --- + + Type propType = propInfo.PropertyType; + AddSerializableValue(serializablePropertiesOutput, propName, propType, value); + } + catch (Exception) + { + // McpLog.Warn($"Could not read property {propName} on {componentType.Name}"); + } + } + + // --- Add Logging Before Field Loop --- + // McpLog.Info($"[GetComponentData] Starting field loop for {componentType.Name}..."); + // --- End Logging Before Field Loop --- + + // Use cached fields + foreach (var fieldInfo in cachedData.SerializableFields) + { + try + { + // --- Add detailed logging for fields --- + // McpLog.Info($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); + // --- End detailed logging for fields --- + object value = fieldInfo.GetValue(c); + string fieldName = fieldInfo.Name; + Type fieldType = fieldInfo.FieldType; + AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); + } + catch (Exception) + { + // McpLog.Warn($"Could not read field {fieldInfo.Name} on {componentType.Name}"); + } + } + // --- End Use cached metadata --- + + if (serializablePropertiesOutput.Count > 0) + { + data["properties"] = serializablePropertiesOutput; + } + + return data; + } + + // Helper function to decide how to serialize different types + private static void AddSerializableValue(Dictionary dict, string name, Type type, object value) + { + // Simplified: Directly use CreateTokenFromValue which uses the serializer + if (value == null) + { + dict[name] = null; + return; + } + + try + { + // Use the helper that employs our custom serializer settings + JToken token = CreateTokenFromValue(value, type); + if (token != null) // Check if serialization succeeded in the helper + { + // Convert JToken back to a basic object structure for the dictionary + dict[name] = ConvertJTokenToPlainObject(token); + } + // If token is null, it means serialization failed and a warning was logged. + } + catch (Exception e) + { + // Catch potential errors during JToken conversion or addition to dictionary + McpLog.Warn($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); + } + } + + // Helper to convert JToken back to basic object structure + private static object ConvertJTokenToPlainObject(JToken token) + { + if (token == null) return null; + + switch (token.Type) + { + case JTokenType.Object: + var objDict = new Dictionary(); + foreach (var prop in ((JObject)token).Properties()) + { + objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); + } + return objDict; + + case JTokenType.Array: + var list = new List(); + foreach (var item in (JArray)token) + { + list.Add(ConvertJTokenToPlainObject(item)); + } + return list; + + case JTokenType.Integer: + return token.ToObject(); // Use long for safety + case JTokenType.Float: + return token.ToObject(); // Use double for safety + case JTokenType.String: + return token.ToObject(); + case JTokenType.Boolean: + return token.ToObject(); + case JTokenType.Date: + return token.ToObject(); + case JTokenType.Guid: + return token.ToObject(); + case JTokenType.Uri: + return token.ToObject(); + case JTokenType.TimeSpan: + return token.ToObject(); + case JTokenType.Bytes: + return token.ToObject(); + case JTokenType.Null: + return null; + case JTokenType.Undefined: + return null; // Treat undefined as null + + default: + // Fallback for simple value types not explicitly listed + if (token is JValue jValue && jValue.Value != null) + { + return jValue.Value; + } + // McpLog.Warn($"Unsupported JTokenType encountered: {token.Type}. Returning null."); + return null; + } + } + + // --- Define custom JsonSerializerSettings for OUTPUT --- + private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine + new UnityEngineObjectConverter() // Handles serialization of references + }, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed + }; + private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); + // --- End Define custom JsonSerializerSettings --- + + // Helper to create JToken using the output serializer + private static JToken CreateTokenFromValue(object value, Type type) + { + if (value == null) return JValue.CreateNull(); + + try + { + // Use the pre-configured OUTPUT serializer instance + return JToken.FromObject(value, _outputSerializer); + } + catch (JsonSerializationException e) + { + McpLog.Warn($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); + return null; // Indicate serialization failure + } + catch (Exception e) // Catch other unexpected errors + { + McpLog.Warn($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); + return null; // Indicate serialization failure + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta new file mode 100644 index 0000000..9eb69d0 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64b8ff807bc9a401c82015cbafccffac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs new file mode 100644 index 0000000..94e2f3b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -0,0 +1,358 @@ +using System; +using System.Net; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper methods for managing HTTP endpoint URLs used by the MCP bridge. + /// Ensures the stored value is always the base URL (without trailing path), + /// and provides convenience accessors for specific endpoints. + /// + /// HTTP Local and HTTP Remote use separate EditorPrefs keys so that switching + /// between scopes does not overwrite the other scope's URL. + /// + public static class HttpEndpointUtility + { + private const string LocalPrefKey = EditorPrefKeys.HttpBaseUrl; + private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl; + private const string DefaultLocalBaseUrl = "http://127.0.0.1:8080"; + private const string DefaultRemoteBaseUrl = ""; + + /// + /// Returns the normalized base URL for the currently active HTTP scope. + /// If the scope is "remote", returns the remote URL; otherwise returns the local URL. + /// + public static string GetBaseUrl() + { + return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl(); + } + + /// + /// Saves a user-provided URL to the currently active HTTP scope's pref. + /// + public static void SaveBaseUrl(string userValue) + { + if (IsRemoteScope()) + { + SaveRemoteBaseUrl(userValue); + } + else + { + SaveLocalBaseUrl(userValue); + } + } + + /// + /// Returns the normalized local HTTP base URL (always reads local pref). + /// + public static string GetLocalBaseUrl() + { + string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl); + return NormalizeBaseUrl(stored, DefaultLocalBaseUrl, remoteScope: false); + } + + /// + /// Saves a user-provided URL to the local HTTP pref. + /// + public static void SaveLocalBaseUrl(string userValue) + { + string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl, remoteScope: false); + EditorPrefs.SetString(LocalPrefKey, normalized); + } + + /// + /// Returns the normalized remote HTTP base URL (always reads remote pref). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteBaseUrl() + { + string stored = EditorPrefs.GetString(RemotePrefKey, DefaultRemoteBaseUrl); + if (string.IsNullOrWhiteSpace(stored)) + { + return DefaultRemoteBaseUrl; + } + return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl, remoteScope: true); + } + + /// + /// Saves a user-provided URL to the remote HTTP pref. + /// + public static void SaveRemoteBaseUrl(string userValue) + { + if (string.IsNullOrWhiteSpace(userValue)) + { + EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl); + return; + } + string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl, remoteScope: true); + EditorPrefs.SetString(RemotePrefKey, normalized); + } + + /// + /// Builds the JSON-RPC endpoint for the currently active scope (base + /mcp). + /// + public static string GetMcpRpcUrl() + { + return AppendPathSegment(GetBaseUrl(), "mcp"); + } + + /// + /// Builds the local JSON-RPC endpoint (local base + /mcp). + /// + public static string GetLocalMcpRpcUrl() + { + return AppendPathSegment(GetLocalBaseUrl(), "mcp"); + } + + /// + /// Builds the remote JSON-RPC endpoint (remote base + /mcp). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteMcpRpcUrl() + { + string remoteBase = GetRemoteBaseUrl(); + return string.IsNullOrEmpty(remoteBase) ? string.Empty : AppendPathSegment(remoteBase, "mcp"); + } + + /// + /// Builds the endpoint used when POSTing custom-tool registration payloads. + /// + public static string GetRegisterToolsUrl() + { + return AppendPathSegment(GetBaseUrl(), "register-tools"); + } + + /// + /// Returns true if the active HTTP transport scope is "remote". + /// + public static bool IsRemoteScope() + { + string scope = EditorConfigurationCache.Instance.HttpTransportScope; + return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the that matches the current server-side + /// transport selection (Stdio, Http, or HttpRemote). + /// Centralises the 3-way determination so callers avoid duplicated logic. + /// + public static ConfiguredTransport GetCurrentServerTransport() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (!useHttp) return ConfiguredTransport.Stdio; + return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http; + } + + /// + /// Returns true when advanced settings allow binding HTTP Local to all interfaces + /// (e.g. 0.0.0.0 / ::). Disabled by default. + /// + public static bool AllowLanHttpBind() + { + return EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false); + } + + /// + /// Returns true when advanced settings allow insecure HTTP/WS for remote endpoints. + /// Disabled by default. + /// + public static bool AllowInsecureRemoteHttp() + { + return EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false); + } + + /// + /// Returns true if the host is loopback-only. + /// + public static bool IsLoopbackHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); + if (normalized == "localhost") + { + return true; + } + + if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) + { + return IPAddress.IsLoopback(parsedIp); + } + + return false; + } + + /// + /// Returns true if the host is a bind-all-interfaces address. + /// + public static bool IsBindAllInterfacesHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + string normalized = host.Trim().Trim('[', ']').ToLowerInvariant(); + if (IPAddress.TryParse(normalized, out IPAddress parsedIp)) + { + return parsedIp.Equals(IPAddress.Any) || parsedIp.Equals(IPAddress.IPv6Any); + } + + return false; + } + + /// + /// Returns true when the URL host is acceptable for HTTP Local launch. + /// Loopback is always allowed. Bind-all interfaces requires explicit opt-in. + /// + public static bool IsHttpLocalUrlAllowedForLaunch(string url, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(url)) + { + error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; + return false; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + error = $"Invalid URL: {url}"; + return false; + } + + string host = uri.Host; + if (IsLoopbackHost(host)) + { + return true; + } + + if (IsBindAllInterfacesHost(host)) + { + if (AllowLanHttpBind()) + { + return true; + } + + error = "Binding to all interfaces (0.0.0.0/::) is disabled by default. " + + "Enable \"Allow LAN bind for HTTP Local\" in Advanced Settings to opt in."; + return false; + } + + error = "HTTP Local requires a loopback URL (localhost/127.0.0.1/::1)."; + return false; + } + + /// + /// Returns true when remote URL is allowed by current security policy. + /// HTTPS is required by default; HTTP needs explicit opt-in. + /// + public static bool IsRemoteUrlAllowed(string remoteBaseUrl, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(remoteBaseUrl)) + { + error = "HTTP Remote requires a configured URL."; + return false; + } + + if (!Uri.TryCreate(remoteBaseUrl, UriKind.Absolute, out var uri)) + { + error = $"Invalid HTTP Remote URL: {remoteBaseUrl}"; + return false; + } + + if (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + if (AllowInsecureRemoteHttp()) + { + return true; + } + + error = "HTTP Remote requires HTTPS by default. Enable \"Allow insecure HTTP for HTTP Remote\" in Advanced Settings to opt in."; + return false; + } + + error = $"Unsupported URL scheme '{uri.Scheme}'. Use https:// (or http:// only with explicit insecure opt-in)."; + return false; + } + + /// + /// Returns true when the currently configured remote URL satisfies security policy. + /// + public static bool IsCurrentRemoteUrlAllowed(out string error) + { + return IsRemoteUrlAllowed(GetRemoteBaseUrl(), out error); + } + + /// + /// Human-readable host requirement for HTTP Local based on current security settings. + /// + public static string GetHttpLocalHostRequirementText() + { + return AllowLanHttpBind() + ? "localhost/127.0.0.1/::1/0.0.0.0/::" + : "localhost/127.0.0.1/::1"; + } + + /// + /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). + /// + private static string NormalizeBaseUrl(string value, string defaultUrl, bool remoteScope) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultUrl; + } + + string trimmed = value.Trim(); + + // Ensure scheme exists. + // For HTTP Remote, default to https:// to avoid accidental plaintext transport. + // For HTTP Local, default to http:// for zero-friction local setup. + if (!trimmed.Contains("://")) + { + string defaultScheme = remoteScope ? "https" : "http"; + trimmed = $"{defaultScheme}://{trimmed}"; + } + + // Remove trailing slash segments. + trimmed = trimmed.TrimEnd('/'); + + // Strip trailing "/mcp" (case-insensitive) if provided. + if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^4]; + } + + // For local scope, force 127.0.0.1 over "localhost". Windows getaddrinfo returns ::1 + // first; clients without Happy Eyeballs (e.g., Codex/reqwest) hit the v6 socket while + // our server binds v4-only, breaking the handshake. The default server bind is + // 127.0.0.1, so emitting the literal v4 keeps every client unambiguous. + if (!remoteScope && Uri.TryCreate(trimmed, UriKind.Absolute, out Uri parsed) + && string.Equals(parsed.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + var builder = new UriBuilder(parsed) { Host = "127.0.0.1" }; + trimmed = builder.Uri.GetLeftPart(UriPartial.Authority); + } + + return trimmed; + } + + private static string AppendPathSegment(string baseUrl, string segment) + { + return $"{baseUrl.TrimEnd('/')}/{segment}"; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta new file mode 100644 index 0000000..55d67cb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2051d90316ea345c09240c80c7138e3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/MaterialOps.cs b/MCPForUnity/Editor/Helpers/MaterialOps.cs new file mode 100644 index 0000000..19f5028 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/MaterialOps.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class MaterialOps + { + /// + /// Applies a set of properties (JObject) to a material, handling aliases and structured formats. + /// + public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Helper for case-insensitive lookup + JToken GetValue(string key) + { + return properties.Properties() + .FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value; + } + + // --- Structured / Legacy Format Handling --- + // Example: Set shader + var shaderToken = GetValue("shader"); + if (shaderToken?.Type == JTokenType.String) + { + string shaderRequest = shaderToken.ToString(); + // Set shader + Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + + // Example: Set color property (structured) + var colorToken = GetValue("color"); + if (colorToken is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = ParseColor(colArr, serializer); + if (mat.HasProperty(propName)) + { + if (mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}"); + } + } + } + else if (colorToken is JArray colorArr) // Structured shorthand + { + string propName = GetMainColorPropertyName(mat); + try + { + Color newColor = ParseColor(colorArr, serializer); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color array: {ex.Message}"); + } + } + + // Example: Set float property (structured) + var floatToken = GetValue("float"); + if (floatToken is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if (!string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer)) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}"); + } + } + } + + // Example: Set texture property (structured) + { + var texToken = GetValue("texture"); + if (texToken is JObject texProps) + { + string rawName = (texProps["name"] ?? texProps["Name"])?.ToString(); + string texPath = (texProps["path"] ?? texProps["Path"])?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath); + var newTex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + // Use ResolvePropertyName to handle aliases even for structured texture names + string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName; + string targetProp = ResolvePropertyName(mat, candidateName); + + if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp)) + { + if (mat.GetTexture(targetProp) != newTex) + { + mat.SetTexture(targetProp, newTex); + modified = true; + } + } + } + } + } + + // --- Direct Property Assignment (Flexible) --- + var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; + + foreach (var prop in properties.Properties()) + { + if (reservedKeys.Contains(prop.Name)) continue; + string shaderProp = ResolvePropertyName(mat, prop.Name); + JToken v = prop.Value; + + if (TrySetShaderProperty(mat, shaderProp, v, serializer)) + { + modified = true; + } + } + + return modified; + } + + /// + /// Resolves common property aliases (e.g. "metallic" -> "_Metallic"). + /// + public static string ResolvePropertyName(Material mat, string name) + { + if (mat == null || string.IsNullOrEmpty(name)) return name; + string[] candidates; + var lower = name.ToLowerInvariant(); + switch (lower) + { + case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; + case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; + case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; + case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; + case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; + case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + // Friendly names → shader property names + case "metallic": candidates = new[] { "_Metallic" }; break; + case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; + default: candidates = new[] { name }; break; // keep original as-is + } + foreach (var candidate in candidates) + { + if (mat.HasProperty(candidate)) return candidate; + } + return name; + } + + /// + /// Auto-detects the main color property name for a material's shader. + /// + public static string GetMainColorPropertyName(Material mat) + { + if (mat == null || mat.shader == null) + return "_Color"; + + string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" }; + foreach (var prop in commonColorProps) + { + if (mat.HasProperty(prop)) + return prop; + } + return "_Color"; + } + + /// + /// Tries to set a shader property on a material based on a JToken value. + /// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures. + /// + public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer) + { + if (material == null || string.IsNullOrEmpty(propertyName) || value == null) + return false; + + // Handle stringified JSON + if (value.Type == JTokenType.String) + { + string s = value.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + JToken parsed = JToken.Parse(s); + return TrySetShaderProperty(material, propertyName, parsed, serializer); + } + catch { } + } + } + + // Use the serializer to convert the JToken value first + if (value is JArray jArray) + { + if (jArray.Count == 4) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + // Log at Debug level since we'll try other conversions + McpLog.Info($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}"); + } + + try { Vector4 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 3) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 2) + { + if (material.HasProperty(propertyName)) + { + try { Vector2 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer)); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.Boolean) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer) ? 1f : 0f); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.String) + { + try + { + // Try loading as asset path first (most common case for strings in this context) + string path = value.ToString(); + if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes + { + // We need to handle texture assignment here. + // Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported), + // we can try to load it. + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path); + Texture tex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (tex != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, tex); + return true; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}"); + } + } + + if (value.Type == JTokenType.Object) + { + try + { + Texture texture = value.ToObject(serializer); + if (texture != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, texture); + return true; + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}"); + } + } + + McpLog.Warn( + $"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}" + ); + return false; + } + + /// + /// Helper to parse color from JToken (array or object). + /// + public static Color ParseColor(JToken token, JsonSerializer serializer) + { + if (token.Type == JTokenType.String) + { + string s = token.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + return ParseColor(JToken.Parse(s), serializer); + } + catch { } + } + } + + if (token is JArray jArray) + { + if (jArray.Count == 4) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + (float)jArray[3] + ); + } + else if (jArray.Count == 3) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + 1f + ); + } + else + { + throw new ArgumentException("Color array must have 3 or 4 elements."); + } + } + + try + { + return token.ToObject(serializer); + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color from token: {ex.Message}"); + throw; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta b/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta new file mode 100644 index 0000000..9296369 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a59e8545e32664dae9a696d449f82c3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs new file mode 100644 index 0000000..61eccb1 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -0,0 +1,283 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for MCP client configuration management with sophisticated + /// logic for preserving existing configs and handling different client types + /// + public static class McpConfigurationHelper + { + private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig; + + /// + /// Writes MCP configuration to the specified path using sophisticated logic + /// that preserves existing configuration and only writes when necessary + /// + public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null) + { + // 0) Respect explicit lock (hidden pref or UI toggle) + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; + + // Read existing config if it exists + string existingJson = "{}"; + if (File.Exists(configPath)) + { + try + { + existingJson = File.ReadAllText(configPath); + } + catch (Exception e) + { + McpLog.Warn($"Error reading existing config: {e.Message}."); + } + } + + // Parse the existing JSON while preserving all properties + dynamic existingConfig; + try + { + if (string.IsNullOrWhiteSpace(existingJson)) + { + existingConfig = new JObject(); + } + else + { + existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); + } + } + catch + { + // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object + if (!string.IsNullOrWhiteSpace(existingJson)) + { + McpLog.Warn("UnityMCP: Configuration file could not be parsed; rewriting server block."); + } + existingConfig = new JObject(); + } + + // Determine existing entry references (command/args) + string existingCommand = null; + string[] existingArgs = null; + bool isVSCode = (mcpClient?.IsVsCodeLayout == true); + try + { + if (isVSCode) + { + existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); + } + else + { + existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); + } + } + catch { } + + // 1) Start from existing, only fill gaps (prefer trusted resolver) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) return "uv package manager not found. Please install uv first."; + + // Ensure containers exist and write back configuration + JObject existingRoot; + if (existingConfig is JObject eo) + existingRoot = eo; + else + existingRoot = JObject.FromObject(existingConfig); + + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient); + + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + EnsureConfigDirectoryExists(configPath); + WriteAtomicFile(configPath, mergedJson); + + return "Configured successfully"; + } + + /// + /// Configures a Codex client with sophisticated TOML handling + /// + public static string ConfigureCodexClient(string configPath, McpClient mcpClient) + { + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + string existingToml = string.Empty; + if (File.Exists(configPath)) + { + try + { + existingToml = File.ReadAllText(configPath); + } + catch (Exception e) + { + McpLog.Warn($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); + existingToml = string.Empty; + } + } + + string existingCommand = null; + string[] existingArgs = null; + if (!string.IsNullOrWhiteSpace(existingToml)) + { + CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); + } + + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) + { + return "uv package manager not found. Please install uv first."; + } + + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath); + + EnsureConfigDirectoryExists(configPath); + WriteAtomicFile(configPath, updatedToml); + + return "Configured successfully"; + } + + /// + /// Gets the appropriate config file path for the given MCP client based on OS + /// + public static string GetClientConfigPath(McpClient mcpClient) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return mcpClient.windowsConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return mcpClient.linuxConfigPath; + } + else + { + return mcpClient.linuxConfigPath; // fallback + } + } + + /// + /// Creates the directory for the config file if it doesn't exist + /// + public static void EnsureConfigDirectoryExists(string configPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + } + + public static string ExtractUvxUrl(string[] args) + { + if (args == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + public static bool PathsEqual(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + try + { + string na = Path.GetFullPath(a.Trim()); + string nb = Path.GetFullPath(b.Trim()); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch + { + return false; + } + } + + public static void WriteAtomicFile(string path, string contents) + { + string tmp = path + ".tmp"; + string backup = path + ".backup"; + bool writeDone = false; + try + { + File.WriteAllText(tmp, contents, new UTF8Encoding(false)); + try + { + File.Replace(tmp, path, backup); + writeDone = true; + } + catch (FileNotFoundException) + { + File.Move(tmp, path); + writeDone = true; + } + catch (PlatformNotSupportedException) + { + if (File.Exists(path)) + { + try + { + if (File.Exists(backup)) File.Delete(backup); + } + catch { } + File.Move(path, backup); + } + File.Move(tmp, path); + writeDone = true; + } + } + catch (Exception ex) + { + try + { + if (!writeDone && File.Exists(backup)) + { + try { File.Copy(backup, path, true); } catch { } + } + } + catch { } + throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta new file mode 100644 index 0000000..17de56c --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e45ac2a13b4c1ba468b8e3aa67b292ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpJobStateStore.cs b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs new file mode 100644 index 0000000..5db093b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility for persisting tool state across domain reloads. State is stored in + /// Library so it stays local to the project and is cleared by Unity as needed. + /// + public static class McpJobStateStore + { + private static string GetStatePath(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + { + throw new ArgumentException("toolName cannot be null or empty", nameof(toolName)); + } + + var libraryPath = Path.Combine(Application.dataPath, "..", "Library"); + var fileName = $"McpState_{toolName}.json"; + return Path.GetFullPath(Path.Combine(libraryPath, fileName)); + } + + public static void SaveState(string toolName, T state) + { + var path = GetStatePath(toolName); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance()); + File.WriteAllText(path, json); + } + + public static T LoadState(string toolName) + { + var path = GetStatePath(toolName); + if (!File.Exists(path)) + { + return default; + } + + try + { + var json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); + } + catch (Exception) + { + return default; + } + } + + public static void ClearState(string toolName) + { + var path = GetStatePath(toolName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta new file mode 100644 index 0000000..df45ef5 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28912085dd68342f8a9fda8a43c83a59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs new file mode 100644 index 0000000..ca9f771 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLog.cs @@ -0,0 +1,53 @@ +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string InfoPrefix = "MCP-FOR-UNITY:"; + private const string DebugPrefix = "MCP-FOR-UNITY:"; + private const string WarnPrefix = "MCP-FOR-UNITY:"; + private const string ErrorPrefix = "MCP-FOR-UNITY:"; + + private static volatile bool _debugEnabled = ReadDebugPreference(); + + private static bool IsDebugEnabled() => _debugEnabled; + + private static bool ReadDebugPreference() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + public static void SetDebugLoggingEnabled(bool enabled) + { + _debugEnabled = enabled; + try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); } + catch { } + } + + public static void Debug(string message) + { + if (!IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{DebugPrefix} {message}"); + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{InfoPrefix} {message}"); + } + + public static void Warn(string message) + { + UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}"); + } + + public static void Error(string message) + { + UnityEngine.Debug.LogError($"{ErrorPrefix} {message}"); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs.meta b/MCPForUnity/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 0000000..bbc0a77 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpLogRecord.cs b/MCPForUnity/Editor/Helpers/McpLogRecord.cs new file mode 100644 index 0000000..f126a47 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLogRecord.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLogRecord + { + private static readonly string LogDir = Path.Combine(Application.dataPath, "UnityMCP", "Log"); + private static readonly string LogPath = Path.Combine(LogDir, "mcp.log"); + private static readonly string ErrorLogPath = Path.Combine(LogDir, "mcpError.log"); + private const long MaxLogSizeBytes = 1024 * 1024; // 1 MB + private static bool _sessionStarted; + private static readonly object _logLock = new(); + private static volatile bool _isEnabledCached; + + [InitializeOnLoadMethod] + private static void RefreshFromPrefs() + { + _isEnabledCached = EditorPrefs.GetBool(EditorPrefKeys.LogRecordEnabled, false); + } + + internal static bool IsEnabled + { + get => _isEnabledCached; + set + { + EditorPrefs.SetBool(EditorPrefKeys.LogRecordEnabled, value); + _isEnabledCached = value; + } + } + + internal static void Log(string commandType, JObject parameters, string type, string status, long durationMs, string error = null) + { + if (!IsEnabled) return; + + try + { + var entry = new JObject + { + ["ts"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"), + ["tool"] = commandType, + ["type"] = type, + ["status"] = status, + ["ms"] = durationMs + }; + + var action = parameters?.Value("action"); + if (!string.IsNullOrEmpty(action)) + entry["action"] = action; + + if (parameters != null) + entry["params"] = parameters; + + if (error != null) + entry["error"] = error; + + var line = entry.ToString(Formatting.None); + + lock (_logLock) + { + if (!_sessionStarted) + { + _sessionStarted = true; + var sessionEntry = new JObject + { + ["ts"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"), + ["event"] = "session_start", + ["unity"] = Application.unityVersion + }; + RotateAndAppend(LogPath, sessionEntry.ToString(Formatting.None)); + } + + RotateAndAppend(LogPath, line); + + if (status == "ERROR") + { + RotateAndAppend(ErrorLogPath, line); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[McpLogRecord] Failed to write log: {ex.Message}"); + } + } + + private static void RotateAndAppend(string path, string line) + { + Directory.CreateDirectory(LogDir); + RotateIfNeeded(path); + File.AppendAllText(path, line + Environment.NewLine); + } + + private static void RotateIfNeeded(string path) + { + try + { + if (!File.Exists(path)) return; + var info = new FileInfo(path); + if (info.Length <= MaxLogSizeBytes) return; + + var lines = File.ReadAllLines(path); + var half = lines.Length / 2; + File.WriteAllLines(path, lines[half..]); + } + catch + { + // Best-effort rotation + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpLogRecord.cs.meta b/MCPForUnity/Editor/Helpers/McpLogRecord.cs.meta new file mode 100644 index 0000000..262ddc8 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLogRecord.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 925ef3d40ecf53649a6af9e94df6114b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ObjectResolver.cs b/MCPForUnity/Editor/Helpers/ObjectResolver.cs new file mode 100644 index 0000000..ee35170 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ObjectResolver.cs @@ -0,0 +1,202 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Resolves Unity Objects by instruction (handles GameObjects, Components, Assets). + /// Extracted from ManageGameObject to eliminate cross-tool dependencies. + /// + public static class ObjectResolver + { + /// + /// Resolves any Unity Object by instruction. + /// + /// The type of Unity Object to resolve + /// JObject with "find" (required), "method" (optional), "component" (optional) + /// The resolved object, or null if not found + public static T Resolve(JObject instruction) where T : UnityEngine.Object + { + return Resolve(instruction, typeof(T)) as T; + } + + /// + /// Resolves any Unity Object by instruction. + /// + /// JObject with "find" (required), "method" (optional), "component" (optional) + /// The type of Unity Object to resolve + /// The resolved object, or null if not found + public static UnityEngine.Object Resolve(JObject instruction, Type targetType) + { + if (instruction == null) + return null; + + string findTerm = instruction["find"]?.ToString(); + string method = instruction["method"]?.ToString()?.ToLower(); + string componentName = instruction["component"]?.ToString(); + + if (string.IsNullOrEmpty(findTerm)) + { + McpLog.Warn("[ObjectResolver] Find instruction missing 'find' term."); + return null; + } + + // Use a flexible default search method if none provided + string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; + + // --- Asset Search --- + // Normalize path separators before checking asset paths + string normalizedPath = AssetPathUtility.NormalizeSeparators(findTerm); + + // If the target is an asset type, try AssetDatabase first + if (IsAssetType(targetType) || + (typeof(GameObject).IsAssignableFrom(targetType) && normalizedPath.StartsWith("Assets/"))) + { + UnityEngine.Object asset = TryLoadAsset(normalizedPath, targetType); + if (asset != null) + return asset; + // If still not found, fall through to scene search + } + + // --- Scene Object Search --- + GameObject foundGo = GameObjectLookup.FindByTarget(new JValue(findTerm), searchMethodToUse, includeInactive: false); + + if (foundGo == null) + { + return null; + } + + // Get the target object/component from the found GameObject + if (targetType == typeof(GameObject)) + { + return foundGo; + } + else if (typeof(Component).IsAssignableFrom(targetType)) + { + Type componentToGetType = targetType; + if (!string.IsNullOrEmpty(componentName)) + { + Type specificCompType = GameObjectLookup.FindComponentType(componentName); + if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) + { + componentToGetType = specificCompType; + } + else + { + McpLog.Warn($"[ObjectResolver] Could not find component type '{componentName}'. Falling back to target type '{targetType.Name}'."); + } + } + + Component foundComp = foundGo.GetComponent(componentToGetType); + if (foundComp == null) + { + McpLog.Warn($"[ObjectResolver] Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); + } + return foundComp; + } + else + { + McpLog.Warn($"[ObjectResolver] Find instruction handling not implemented for target type: {targetType.Name}"); + return null; + } + } + + /// + /// Convenience method to resolve a GameObject. + /// + public static GameObject ResolveGameObject(JToken target, string searchMethod = null) + { + if (target == null) + return null; + + // If target is a simple value, use GameObjectLookup directly + if (target.Type != JTokenType.Object) + { + return GameObjectLookup.FindByTarget(target, searchMethod ?? "by_id_or_name_or_path"); + } + + // If target is an instruction object + var instruction = target as JObject; + if (instruction != null) + { + return Resolve(instruction); + } + + return null; + } + + /// + /// Convenience method to resolve a Material. + /// + public static Material ResolveMaterial(string pathOrName) + { + if (string.IsNullOrEmpty(pathOrName)) + return null; + + var instruction = new JObject { ["find"] = pathOrName }; + return Resolve(instruction); + } + + /// + /// Convenience method to resolve a Texture. + /// + public static Texture ResolveTexture(string pathOrName) + { + if (string.IsNullOrEmpty(pathOrName)) + return null; + + var instruction = new JObject { ["find"] = pathOrName }; + return Resolve(instruction); + } + + // --- Private Helpers --- + + private static bool IsAssetType(Type type) + { + return typeof(Material).IsAssignableFrom(type) || + typeof(Texture).IsAssignableFrom(type) || + typeof(ScriptableObject).IsAssignableFrom(type) || + type.FullName?.StartsWith("UnityEngine.U2D") == true || + typeof(AudioClip).IsAssignableFrom(type) || + typeof(AnimationClip).IsAssignableFrom(type) || + typeof(Font).IsAssignableFrom(type) || + typeof(Shader).IsAssignableFrom(type) || + typeof(ComputeShader).IsAssignableFrom(type); + } + + private static UnityEngine.Object TryLoadAsset(string findTerm, Type targetType) + { + // Try loading directly by path first + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); + if (asset != null) + return asset; + + // Try generic load if type-specific failed + asset = AssetDatabase.LoadAssetAtPath(findTerm); + if (asset != null && targetType.IsAssignableFrom(asset.GetType())) + return asset; + + // Try finding by name/type using FindAssets + string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; + string[] guids = AssetDatabase.FindAssets(searchFilter); + + if (guids.Length == 1) + { + asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); + if (asset != null) + return asset; + } + else if (guids.Length > 1) + { + McpLog.Warn($"[ObjectResolver] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); + return null; + } + + return null; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta b/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta new file mode 100644 index 0000000..610a8f6 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad678f7b0a2e6458bbdb38a15d857acf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/Pagination.cs b/MCPForUnity/Editor/Helpers/Pagination.cs new file mode 100644 index 0000000..e1d1387 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Pagination.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Standard pagination request for all paginated tool operations. + /// Provides consistent handling of page_size/pageSize and cursor/page_number parameters. + /// + public class PaginationRequest + { + /// + /// Number of items per page. Default is 50. + /// + public int PageSize { get; set; } = 50; + + /// + /// 0-based cursor position for the current page. + /// + public int Cursor { get; set; } = 0; + + /// + /// Creates a PaginationRequest from JObject parameters. + /// Accepts both snake_case and camelCase parameter names for flexibility. + /// Converts 1-based page_number to 0-based cursor if needed. + /// + public static PaginationRequest FromParams(JObject @params, int defaultPageSize = 50) + { + if (@params == null) + return new PaginationRequest { PageSize = defaultPageSize }; + + // Accept both page_size and pageSize + int pageSize = ParamCoercion.CoerceInt( + @params["page_size"] ?? @params["pageSize"], + defaultPageSize + ); + + // Accept both cursor (0-based) and page_number (convert 1-based to 0-based) + var cursorToken = @params["cursor"]; + var pageNumberToken = @params["page_number"] ?? @params["pageNumber"]; + + int cursor; + if (cursorToken != null) + { + cursor = ParamCoercion.CoerceInt(cursorToken, 0); + } + else if (pageNumberToken != null) + { + // Convert 1-based page_number to 0-based cursor + int pageNumber = ParamCoercion.CoerceInt(pageNumberToken, 1); + cursor = (pageNumber - 1) * pageSize; + if (cursor < 0) cursor = 0; + } + else + { + cursor = 0; + } + + return new PaginationRequest + { + PageSize = pageSize > 0 ? pageSize : defaultPageSize, + Cursor = cursor + }; + } + } + + /// + /// Standard pagination response for all paginated tool operations. + /// Provides consistent response structure across all tools. + /// + /// The type of items in the paginated list + public class PaginationResponse + { + /// + /// The items on the current page. + /// + [JsonProperty("items")] + public List Items { get; set; } = new List(); + + /// + /// The cursor position for the current page (0-based). + /// + [JsonProperty("cursor")] + public int Cursor { get; set; } + + /// + /// The cursor for the next page, or null if this is the last page. + /// + [JsonProperty("nextCursor")] + public int? NextCursor { get; set; } + + /// + /// Total number of items across all pages. + /// + [JsonProperty("totalCount")] + public int TotalCount { get; set; } + + /// + /// Number of items per page. + /// + [JsonProperty("pageSize")] + public int PageSize { get; set; } + + /// + /// Whether there are more items after this page. + /// + [JsonProperty("hasMore")] + public bool HasMore => NextCursor.HasValue; + + /// + /// Creates a PaginationResponse from a full list of items and pagination parameters. + /// + /// The full list of items to paginate + /// The pagination request parameters + /// A paginated response with the appropriate slice of items + public static PaginationResponse Create(IList allItems, PaginationRequest request) + { + int totalCount = allItems.Count; + int cursor = request.Cursor; + int pageSize = request.PageSize; + + // Clamp cursor to valid range + if (cursor < 0) cursor = 0; + if (cursor > totalCount) cursor = totalCount; + + // Get the page of items + var items = new List(); + int endIndex = System.Math.Min(cursor + pageSize, totalCount); + for (int i = cursor; i < endIndex; i++) + { + items.Add(allItems[i]); + } + + // Calculate next cursor + int? nextCursor = endIndex < totalCount ? endIndex : (int?)null; + + return new PaginationResponse + { + Items = items, + Cursor = cursor, + NextCursor = nextCursor, + TotalCount = totalCount, + PageSize = pageSize + }; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/Pagination.cs.meta b/MCPForUnity/Editor/Helpers/Pagination.cs.meta new file mode 100644 index 0000000..8d0479b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Pagination.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 745564d5894d74c0ca24db39c77bab2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/MCPForUnity/Editor/Helpers/ParamCoercion.cs new file mode 100644 index 0000000..38a8e32 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -0,0 +1,394 @@ +using System; +using System.Globalization; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for coercing JSON parameter values to strongly-typed values. + /// Handles various input formats (strings, numbers, booleans) gracefully. + /// + public static class ParamCoercion + { + /// + /// Coerces a JToken to an integer value, handling strings and floats. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced integer value or default + public static int CoerceInt(JToken token, int defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) + return i; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (int)d; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a long value, handling strings and floats. + /// + public static long CoerceLong(JToken token, long defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + return l; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (long)d; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable integer value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced integer value or null + public static int? CoerceIntNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) + return i; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (int)d; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a boolean value, handling strings like "true", "1", etc. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced boolean value or default + public static bool CoerceBool(JToken token, bool defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Boolean) + return token.Value(); + + var s = token.ToString().Trim().ToLowerInvariant(); + if (s.Length == 0) + return defaultValue; + + if (bool.TryParse(s, out var b)) + return b; + + if (s == "1" || s == "yes" || s == "on") + return true; + + if (s == "0" || s == "no" || s == "off") + return false; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable boolean value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced boolean value or null + public static bool? CoerceBoolNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Boolean) + return token.Value(); + + var s = token.ToString().Trim().ToLowerInvariant(); + if (s.Length == 0) + return null; + + if (bool.TryParse(s, out var b)) + return b; + + if (s == "1" || s == "yes" || s == "on") + return true; + + if (s == "0" || s == "no" || s == "off") + return false; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a float value, handling strings and integers. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced float value or default + public static float CoerceFloat(JToken token, float defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f)) + return f; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable float value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced float value or null + public static float? CoerceFloatNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f)) + return f; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a string value, with null handling. + /// + /// The JSON token to coerce + /// Default value if null or empty + /// The string value or default + public static string CoerceString(JToken token, string defaultValue = null) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + var s = token.ToString(); + return string.IsNullOrEmpty(s) ? defaultValue : s; + } + + /// + /// Coerces a JToken to an enum value, handling strings. + /// + /// The enum type + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced enum value or default + public static T CoerceEnum(JToken token, T defaultValue) where T : struct, Enum + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (Enum.TryParse(s, ignoreCase: true, out var result)) + return result; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Checks if a JToken represents a numeric value (integer or float). + /// Useful for validating JSON values before parsing. + /// + /// The JSON token to check + /// True if the token is an integer or float, false otherwise + public static bool IsNumericToken(JToken token) + { + return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float); + } + + /// + /// Validates that an optional field in a JObject is numeric if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or numeric; false if present but non-numeric + public static bool ValidateNumericField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid (will use default) + } + if (!IsNumericToken(token)) + { + error = $"must be a number, got {token.Type}"; + return false; + } + return true; + } + + /// + /// Validates that an optional field in a JObject is an integer if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or integer; false if present but non-integer + public static bool ValidateIntegerField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid + } + if (token.Type != JTokenType.Integer) + { + error = $"must be an integer, got {token.Type}"; + return false; + } + return true; + } + + /// + /// Normalizes a property name by removing separators and converting to camelCase. + /// Handles common naming variations from LLMs and humans. + /// Examples: + /// "Use Gravity" → "useGravity" + /// "is_kinematic" → "isKinematic" + /// "max-angular-velocity" → "maxAngularVelocity" + /// "Angular Drag" → "angularDrag" + /// + /// The property name to normalize + /// The normalized camelCase property name + public static string NormalizePropertyName(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Split on common separators: space, underscore, dash + var parts = input.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return input; + + // First word is lowercase, subsequent words are Title case (camelCase) + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < parts.Length; i++) + { + string part = parts[i]; + if (i == 0) + { + // First word: all lowercase + sb.Append(part.ToLowerInvariant()); + } + else + { + // Subsequent words: capitalize first letter, lowercase rest + sb.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + sb.Append(part.Substring(1).ToLowerInvariant()); + } + } + return sb.ToString(); + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta b/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta new file mode 100644 index 0000000..36b9ec6 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db54fbbe3ac7f429fbf808f72831374a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs new file mode 100644 index 0000000..f9a2915 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -0,0 +1,347 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using MCPForUnity.Editor.Constants; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Manages dynamic port allocation and persistent storage for MCP for Unity + /// + public static class PortManager + { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + private const int DefaultPort = 6400; + private const int MaxPortAttempts = 100; + private const string RegistryFileName = "unity-mcp-port.json"; + + [Serializable] + public class PortConfig + { + public int unity_port; + public string created_date; + public string project_path; + } + + /// + /// Get the port to use from storage, or return the default if none has been saved yet. + /// + /// Port number to use + public static int GetPortWithFallback() + { + var storedConfig = GetStoredPortConfig(); + if (storedConfig != null && + storedConfig.unity_port > 0 && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return storedConfig.unity_port; + } + + return DefaultPort; + } + + /// + /// Discover and save a new available port (used by Auto-Connect button) + /// + /// New available port + public static int DiscoverNewPort() + { + int newPort = FindAvailablePort(); + SavePort(newPort); + if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}"); + return newPort; + } + + /// + /// How long the configured port may keep returning AddressAlreadyInUse before the + /// bridge gives up on it and discovers a new one. The common cause of a transient + /// conflict is our own previous listener whose OS socket has not been released yet + /// after a domain reload (slower on Windows/macOS); this window covers that race so + /// the bridge keeps the configured port instead of stranding the client on the + /// orphan (#1173). + /// + public const double BusyPortFallbackWindowSeconds = 3.0; + + /// + /// Decide whether a configured port that keeps reporting AddressAlreadyInUse should be + /// abandoned for a freshly discovered port. Returns false (keep retrying the same port) + /// until it has been continuously busy for , + /// which distinguishes a domain-reload socket-release race from a foreign occupant (#1173). + /// + public static bool ShouldAbandonBusyPort(double busyForSeconds) + => busyForSeconds >= BusyPortFallbackWindowSeconds; + + /// + /// Persist a user-selected port and return the value actually stored. + /// If is unavailable, the next available port is chosen instead. + /// + public static int SetPreferredPort(int port) + { + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "Port must be positive."); + } + + if (!IsPortAvailable(port)) + { + throw new InvalidOperationException($"Port {port} is already in use."); + } + + SavePort(port); + return port; + } + + /// + /// Find an available port starting from the default port + /// + /// Available port number + private static int FindAvailablePort() + { + // Always try default port first + if (IsPortAvailable(DefaultPort)) + { + if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}"); + return DefaultPort; + } + + if (IsDebugEnabled()) McpLog.Info($"Default port {DefaultPort} is in use, searching for alternative..."); + + // Search for alternatives + for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) + { + if (IsPortAvailable(port)) + { + if (IsDebugEnabled()) McpLog.Info($"Found available port {port}"); + return port; + } + } + + throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); + } + + /// + /// Check if a specific port is available for binding + /// + /// Port to check + /// True if port is available + public static bool IsPortAvailable(int port) + { + try + { + var testListener = new TcpListener(IPAddress.Loopback, port); +#if UNITY_EDITOR_OSX + // On macOS, SO_REUSEADDR (the default) lets multiple processes bind the same + // port — including AssetImportWorkers. ExclusiveAddressUse prevents this so + // the test bind fails when another process already holds the port. + try { testListener.Server.ExclusiveAddressUse = true; } catch { } +#endif + testListener.Start(); + testListener.Stop(); + } + catch (SocketException) + { + return false; + } + + return true; + } + + /// + /// Check if a port is currently being used by MCP for Unity + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by MCP for Unity + public static bool IsPortUsedByMCPForUnity(int port) + { + try + { + // Try to make a quick connection to see if it's an MCP for Unity server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the MCP for Unity server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by an MCP instance, continue waiting briefly + if (!IsPortUsedByMCPForUnity(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + + /// + /// Save port to persistent storage + /// + /// Port to save + private static void SavePort(int port) + { + try + { + var portConfig = new PortConfig + { + unity_port = port, + created_date = DateTime.UtcNow.ToString("O"), + project_path = Application.dataPath + }; + + string registryDir = GetRegistryDirectory(); + Directory.CreateDirectory(registryDir); + + string registryFile = GetRegistryFilePath(); + string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to hashed, project-scoped file + File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + // Also write to legacy stable filename to avoid hash/case drift across reloads + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); + + if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); + } + catch (Exception ex) + { + McpLog.Warn($"Could not save port to storage: {ex.Message}"); + } + } + + /// + /// Load port from persistent storage + /// + /// Stored port number, or 0 if not found + private static int LoadStoredPort() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file name + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return 0; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + var portConfig = JsonConvert.DeserializeObject(json); + + return portConfig?.unity_port ?? 0; + } + catch (Exception ex) + { + McpLog.Warn($"Could not load port from storage: {ex.Message}"); + return 0; + } + } + + /// + /// Get the current stored port configuration + /// + /// Port configuration if exists, null otherwise + public static PortConfig GetStoredPortConfig() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return null; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + McpLog.Warn($"Could not load port config: {ex.Message}"); + return null; + } + } + + private static string GetRegistryDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + string hash = ComputeProjectHash(Application.dataPath); + string fileName = $"unity-mcp-port-{hash}.json"; + return Path.Combine(dir, fileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs.meta b/MCPForUnity/Editor/Helpers/PortManager.cs.meta new file mode 100644 index 0000000..17fd3ef --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PortManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28c39813a10b4331afc764a04089cbef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs new file mode 100644 index 0000000..88397fb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity Prefab assets. + /// + public static class PrefabUtilityHelper + { + /// + /// Gets the GUID for a prefab asset path. + /// + /// The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab") + /// The GUID string, or null if the path is invalid. + public static string GetPrefabGUID(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + { + return null; + } + + try + { + return AssetDatabase.AssetPathToGUID(assetPath); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get GUID for asset path '{assetPath}': {ex.Message}"); + return null; + } + } + + /// + /// Gets variant information if the prefab is a variant. + /// + /// The prefab GameObject to check. + /// A tuple containing (isVariant, parentPath, parentGuid). + public static (bool isVariant, string parentPath, string parentGuid) GetVariantInfo(GameObject prefabAsset) + { + if (prefabAsset == null) + { + return (false, null, null); + } + + try + { + PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); + if (assetType != PrefabAssetType.Variant) + { + return (false, null, null); + } + + GameObject parentAsset = PrefabUtility.GetCorrespondingObjectFromSource(prefabAsset); + if (parentAsset == null) + { + return (true, null, null); + } + + string parentPath = AssetDatabase.GetAssetPath(parentAsset); + string parentGuid = GetPrefabGUID(parentPath); + + return (true, parentPath, parentGuid); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get variant info for '{prefabAsset.name}': {ex.Message}"); + return (false, null, null); + } + } + + /// + /// Gets the list of component type names on a GameObject. + /// + /// The GameObject to inspect. + /// A list of component type full names. + public static List GetComponentTypeNames(GameObject obj) + { + var typeNames = new List(); + + if (obj == null) + { + return typeNames; + } + + try + { + var components = obj.GetComponents(); + foreach (var component in components) + { + if (component != null) + { + typeNames.Add(component.GetType().FullName); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get component types for '{obj.name}': {ex.Message}"); + } + + return typeNames; + } + + /// + /// Recursively counts all children in the hierarchy. + /// + /// The root transform to count from. + /// Total number of children in the hierarchy. + public static int CountChildrenRecursive(Transform transform) + { + if (transform == null) + { + return 0; + } + + int count = transform.childCount; + for (int i = 0; i < transform.childCount; i++) + { + count += CountChildrenRecursive(transform.GetChild(i)); + } + return count; + } + + /// + /// Gets the source prefab path for a nested prefab instance. + /// + /// The GameObject to check. + /// The asset path of the source prefab, or null if not a nested prefab. + public static string GetNestedPrefabPath(GameObject gameObject) + { + if (gameObject == null || !PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + { + return null; + } + + try + { + var sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(gameObject); + if (sourcePrefab != null) + { + return AssetDatabase.GetAssetPath(sourcePrefab); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get nested prefab path for '{gameObject.name}': {ex.Message}"); + } + + return null; + } + + /// + /// Gets the nesting depth of a prefab instance within the prefab hierarchy. + /// Returns 0 for main prefab root, 1 for first-level nested, 2 for second-level, etc. + /// Returns -1 for non-prefab-root objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root. + public static int GetPrefabNestingDepth(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null) + return -1; + + // Main prefab root + if (gameObject.transform == mainPrefabRoot) + return 0; + + // Not a prefab instance root + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return -1; + + // Calculate depth by walking up the hierarchy + int depth = 0; + Transform current = gameObject.transform; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + depth++; + } + current = current.parent; + } + + return depth; + } + + /// + /// Gets the parent prefab path for a nested prefab instance. + /// Returns null for main prefab root or non-prefab objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// The asset path of the parent prefab, or null if none. + public static string GetParentPrefabPath(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null || gameObject.transform == mainPrefabRoot) + return null; + + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return null; + + // Walk up the hierarchy to find the parent prefab instance + Transform current = gameObject.transform.parent; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + return GetNestedPrefabPath(current.gameObject); + } + current = current.parent; + } + + // Parent is the main prefab root - get its asset path + if (mainPrefabRoot != null) + { + return AssetDatabase.GetAssetPath(mainPrefabRoot.gameObject); + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta new file mode 100644 index 0000000..0f23694 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ebe2be77e64f4d4f811614b198210017 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs new file mode 100644 index 0000000..34a5391 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides shared utilities for deriving deterministic project identity information + /// used by transport clients (hash, name, persistent session id). + /// + [InitializeOnLoad] + internal static class ProjectIdentityUtility + { + private const string SessionPrefKey = EditorPrefKeys.SessionId; + private static bool _legacyKeyCleared; + private static string _cachedProjectName = "Unknown"; + private static string _cachedProjectHash = "default"; + private static string _fallbackSessionId; + private static bool _cacheScheduled; + + static ProjectIdentityUtility() + { + ScheduleCacheRefresh(); + EditorApplication.projectChanged += ScheduleCacheRefresh; + } + + private static void ScheduleCacheRefresh() + { + if (_cacheScheduled) + { + return; + } + + _cacheScheduled = true; + EditorApplication.delayCall += CacheIdentityOnMainThread; + } + + private static void CacheIdentityOnMainThread() + { + EditorApplication.delayCall -= CacheIdentityOnMainThread; + _cacheScheduled = false; + UpdateIdentityCache(); + } + + private static void UpdateIdentityCache() + { + try + { + string dataPath = Application.dataPath; + if (string.IsNullOrEmpty(dataPath)) + { + return; + } + + _cachedProjectHash = ComputeProjectHash(dataPath); + _cachedProjectName = ComputeProjectName(dataPath); + } + catch + { + // Ignore and keep defaults + } + } + + /// + /// Returns the SHA1 hash of the current project path (truncated to 16 characters). + /// Matches the legacy hash used by the stdio bridge and server registry. + /// + public static string GetProjectHash() + { + EnsureIdentityCache(); + return _cachedProjectHash; + } + + /// + /// Returns a human friendly project name derived from the Assets directory path, + /// or "Unknown" if the name cannot be determined. + /// + public static string GetProjectName() + { + EnsureIdentityCache(); + return _cachedProjectName; + } + + private static string ComputeProjectHash(string dataPath) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(dataPath); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(0, Math.Min(16, sb.Length)).ToLowerInvariant(); + } + catch + { + return "default"; + } + } + + private static string ComputeProjectName(string dataPath) + { + try + { + string projectPath = dataPath; + projectPath = projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + projectPath = projectPath[..^6].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + string name = Path.GetFileName(projectPath); + return string.IsNullOrEmpty(name) ? "Unknown" : name; + } + catch + { + return "Unknown"; + } + } + + /// + /// Persists a server-assigned session id. + /// Safe to call from background threads. + /// + public static void SetSessionId(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + EditorApplication.delayCall += () => + { + try + { + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to persist session ID: {ex.Message}"); + } + }; + } + + /// + /// Retrieves a persistent session id for the plugin, creating one if absent. + /// The session id is unique per project (scoped by project hash). + /// + public static string GetOrCreateSessionId() + { + try + { + // Make the session ID project-specific by including the project hash in the key + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + return sessionId; + } + catch + { + // If prefs are unavailable (e.g. during batch tests) fall back to runtime guid. + if (string.IsNullOrEmpty(_fallbackSessionId)) + { + _fallbackSessionId = Guid.NewGuid().ToString(); + } + + return _fallbackSessionId; + } + } + + /// + /// Clears the persisted session id (mainly for tests). + /// + public static void ResetSessionId() + { + try + { + // Clear the project-specific session ID + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + if (EditorPrefs.HasKey(projectSpecificKey)) + { + EditorPrefs.DeleteKey(projectSpecificKey); + } + + if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey)) + { + EditorPrefs.DeleteKey(SessionPrefKey); + _legacyKeyCleared = true; + } + + _fallbackSessionId = null; + } + catch + { + // Ignore + } + } + + private static void EnsureIdentityCache() + { + // When Application.dataPath is unavailable (e.g., batch mode) we fall back to + // hashing the current working directory/Assets path so each project still + // derives a deterministic, per-project session id rather than sharing "default". + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + UpdateIdentityCache(); + + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + string fallback = TryComputeFallbackProjectHash(); + if (!string.IsNullOrEmpty(fallback)) + { + _cachedProjectHash = fallback; + } + } + + private static string TryComputeFallbackProjectHash() + { + try + { + string workingDirectory = Directory.GetCurrentDirectory(); + if (string.IsNullOrEmpty(workingDirectory)) + { + return "default"; + } + + // Normalise trailing separators so hashes remain stable + workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return ComputeProjectHash(Path.Combine(workingDirectory, "Assets")); + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta new file mode 100644 index 0000000..b7879e1 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 936e878ce1275453bae5e0cf03bd9d30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PropertyConversion.cs b/MCPForUnity/Editor/Helpers/PropertyConversion.cs new file mode 100644 index 0000000..0e3af0a --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PropertyConversion.cs @@ -0,0 +1,93 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified property conversion from JSON to Unity types. + /// Uses UnityJsonSerializer for consistent type handling. + /// + public static class PropertyConversion + { + /// + /// Converts a JToken to the specified target type using Unity type converters. + /// + /// The JSON token to convert + /// The target type to convert to + /// The converted object, or null if conversion fails + public static object ConvertToType(JToken token, Type targetType) + { + if (token == null || token.Type == JTokenType.Null) + { + if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) + { + McpLog.Warn($"[PropertyConversion] Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); + return Activator.CreateInstance(targetType); + } + return null; + } + + try + { + // Use the shared Unity serializer with custom converters + return token.ToObject(targetType, UnityJsonSerializer.Instance); + } + catch (Exception ex) + { + McpLog.Error($"Error converting token to {targetType.FullName}: {ex.Message}\nToken: {token.ToString(Formatting.None)}"); + throw; + } + } + + /// + /// Tries to convert a JToken to the specified target type. + /// Returns null and logs warning on failure (does not throw). + /// + public static object TryConvertToType(JToken token, Type targetType) + { + try + { + return ConvertToType(token, targetType); + } + catch + { + return null; + } + } + + /// + /// Generic version of ConvertToType. + /// + public static T ConvertTo(JToken token) + { + return (T)ConvertToType(token, typeof(T)); + } + + /// + /// Converts a JToken to a Unity asset by loading from path. + /// + /// JToken containing asset path + /// Expected asset type + /// The loaded asset, or null if not found + public static UnityEngine.Object LoadAssetFromToken(JToken token, Type targetType) + { + if (token == null || token.Type != JTokenType.String) + return null; + + string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType); + + if (loadedAsset == null) + { + McpLog.Warn($"[PropertyConversion] Could not load asset of type {targetType.Name} from path: {assetPath}"); + } + + return loadedAsset; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta b/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta new file mode 100644 index 0000000..098528c --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4b4187d5b338a453fbe0baceaeea6bcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs b/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs new file mode 100644 index 0000000..502dc36 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class RenderPipelineUtility + { + internal enum PipelineKind + { + BuiltIn, + Universal, + HighDefinition, + Custom + } + + internal enum VFXComponentType + { + ParticleSystem, + LineRenderer, + TrailRenderer + } + + private static Dictionary s_DefaultVFXMaterials = new Dictionary(); + private static Dictionary s_DefaultSceneMaterials = new Dictionary(); + + private static readonly string[] BuiltInLitShaders = { "Standard", "Legacy Shaders/Diffuse" }; + private static readonly string[] BuiltInUnlitShaders = { "Unlit/Color", "Unlit/Texture" }; + private static readonly string[] BuiltInParticleShaders = { "Particles/Standard Unlit", "Particles/Alpha Blended", "Particles/Additive" }; + private static readonly string[] UrpLitShaders = { "Universal Render Pipeline/Lit", "Universal Render Pipeline/Simple Lit" }; + private static readonly string[] UrpUnlitShaders = { "Universal Render Pipeline/Unlit" }; + private static readonly string[] UrpParticleShaders = { + "Universal Render Pipeline/Particles/Unlit", + "Universal Render Pipeline/Particles/Simple Lit", + "Universal Render Pipeline/Particles/Lit", + }; + private static readonly string[] HdrpLitShaders = { "HDRP/Lit", "High Definition Render Pipeline/Lit" }; + private static readonly string[] HdrpUnlitShaders = { "HDRP/Unlit", "High Definition Render Pipeline/Unlit" }; + + internal static PipelineKind GetActivePipeline() + { + var asset = GraphicsSettings.currentRenderPipeline; + if (asset == null) + { + return PipelineKind.BuiltIn; + } + + var typeName = asset.GetType().FullName ?? string.Empty; + if (typeName.IndexOf("HighDefinition", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("HDRP", StringComparison.OrdinalIgnoreCase) >= 0) + { + return PipelineKind.HighDefinition; + } + + if (typeName.IndexOf("Universal", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("URP", StringComparison.OrdinalIgnoreCase) >= 0) + { + return PipelineKind.Universal; + } + + return PipelineKind.Custom; + } + + internal static Shader ResolveShader(string requestedNameOrAlias) + { + var pipeline = GetActivePipeline(); + + if (!string.IsNullOrWhiteSpace(requestedNameOrAlias)) + { + var alias = requestedNameOrAlias.Trim(); + var aliasMatch = ResolveAlias(alias, pipeline); + if (aliasMatch != null) + { + WarnIfPipelineMismatch(aliasMatch.name, pipeline); + return aliasMatch; + } + + var direct = Shader.Find(alias); + if (direct != null) + { + WarnIfPipelineMismatch(direct.name, pipeline); + return direct; + } + + McpLog.Warn($"Shader '{alias}' not found. Falling back to {pipeline} defaults."); + } + + var fallback = ResolveDefaultLitShader(pipeline) + ?? ResolveDefaultLitShader(PipelineKind.BuiltIn) + ?? Shader.Find("Unlit/Color"); + + if (fallback != null) + { + WarnIfPipelineMismatch(fallback.name, pipeline); + } + + return fallback; + } + + internal static Shader ResolveDefaultLitShader(PipelineKind pipeline) + { + return pipeline switch + { + PipelineKind.HighDefinition => TryFindShader(HdrpLitShaders) ?? TryFindShader(UrpLitShaders), + PipelineKind.Universal => TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders), + PipelineKind.Custom => TryFindShader(BuiltInLitShaders) ?? TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders), + _ => TryFindShader(BuiltInLitShaders) ?? Shader.Find("Unlit/Color") + }; + } + + internal static Shader ResolveDefaultUnlitShader(PipelineKind pipeline) + { + return pipeline switch + { + PipelineKind.HighDefinition => TryFindShader(HdrpUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders), + PipelineKind.Universal => TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders), + PipelineKind.Custom => TryFindShader(BuiltInUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders), + _ => TryFindShader(BuiltInUnlitShaders) + }; + } + + private static Shader ResolveAlias(string alias, PipelineKind pipeline) + { + if (string.Equals(alias, "lit", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "default", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "default_lit", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "standard", StringComparison.OrdinalIgnoreCase)) + { + return ResolveDefaultLitShader(pipeline); + } + + if (string.Equals(alias, "unlit", StringComparison.OrdinalIgnoreCase)) + { + return ResolveDefaultUnlitShader(pipeline); + } + + if (string.Equals(alias, "urp_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(UrpLitShaders); + } + + if (string.Equals(alias, "hdrp_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(HdrpLitShaders); + } + + if (string.Equals(alias, "built_in_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(BuiltInLitShaders); + } + + return null; + } + + private static Shader TryFindShader(params string[] shaderNames) + { + foreach (var shaderName in shaderNames) + { + var shader = Shader.Find(shaderName); + if (shader != null) + { + return shader; + } + } + return null; + } + + private static void WarnIfPipelineMismatch(string shaderName, PipelineKind activePipeline) + { + if (string.IsNullOrEmpty(shaderName)) + { + return; + } + + var lowerName = shaderName.ToLowerInvariant(); + bool shaderLooksUrp = lowerName.Contains("universal render pipeline") || lowerName.Contains("urp/"); + bool shaderLooksHdrp = lowerName.Contains("high definition render pipeline") || lowerName.Contains("hdrp/"); + bool shaderLooksSrp = shaderLooksUrp || shaderLooksHdrp; + bool shaderLooksBuiltin = LooksLikeBuiltInShader(lowerName, shaderLooksSrp); + + switch (activePipeline) + { + case PipelineKind.HighDefinition: + if (shaderLooksUrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks URP-based. Asset may appear incorrect."); + } + else if (shaderLooksBuiltin && !shaderLooksHdrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks Built-in. Consider using an HDRP shader for correct results."); + } + break; + case PipelineKind.Universal: + if (shaderLooksHdrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks HDRP-based. Asset may appear incorrect."); + } + else if (shaderLooksBuiltin && !shaderLooksUrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks Built-in. Consider using a URP shader for correct results."); + } + break; + case PipelineKind.BuiltIn: + if (shaderLooksSrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is Built-in but shader '{shaderName}' targets URP/HDRP. Asset may not render as expected."); + } + break; + } + } + + internal static bool IsMaterialInvalidForActivePipeline(Material material, out string reason) + { + reason = null; + if (material == null) + { + reason = "missing_material"; + return true; + } + + Shader shader = material.shader; + if (shader == null) + { + reason = "missing_shader"; + return true; + } + + if (IsErrorShader(shader)) + { + reason = "error_shader"; + return true; + } + + var pipeline = GetActivePipeline(); + if (IsPipelineMismatch(shader.name, pipeline)) + { + reason = "pipeline_mismatch"; + return true; + } + + return false; + } + + private static bool IsErrorShader(Shader shader) + { + if (shader == null) + { + return true; + } + + if (shader == Shader.Find("Hidden/InternalErrorShader")) + { + return true; + } + + string shaderName = shader.name ?? string.Empty; + return shaderName.IndexOf("InternalErrorShader", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool IsPipelineMismatch(string shaderName, PipelineKind activePipeline) + { + if (string.IsNullOrEmpty(shaderName)) + { + return true; + } + + string lowerName = shaderName.ToLowerInvariant(); + bool shaderLooksUrp = lowerName.Contains("universal render pipeline") || lowerName.Contains("urp/"); + bool shaderLooksHdrp = lowerName.Contains("high definition render pipeline") || lowerName.Contains("hdrp/"); + bool shaderLooksSrp = shaderLooksUrp || shaderLooksHdrp; + bool shaderLooksBuiltin = LooksLikeBuiltInShader(lowerName, shaderLooksSrp); + + return activePipeline switch + { + PipelineKind.HighDefinition => shaderLooksUrp || (shaderLooksBuiltin && !shaderLooksHdrp), + PipelineKind.Universal => shaderLooksHdrp || (shaderLooksBuiltin && !shaderLooksUrp), + PipelineKind.BuiltIn => shaderLooksSrp, + _ => false, + }; + } + + internal static Material GetOrCreateDefaultVFXMaterial(VFXComponentType componentType) + { + var pipeline = GetActivePipeline(); + string cacheKey = $"{pipeline}_{componentType}"; + + if (s_DefaultVFXMaterials.TryGetValue(cacheKey, out Material cachedMaterial) && cachedMaterial != null) + { + return cachedMaterial; + } + + Material material = null; + + if (pipeline == PipelineKind.BuiltIn) + { + string builtinPath = componentType == VFXComponentType.ParticleSystem + ? "Default-Particle.mat" + : "Default-Line.mat"; + + material = AssetDatabase.GetBuiltinExtraResource(builtinPath); + } + + if (material == null) + { + Shader shader = ResolveDefaultVFXShader(pipeline, componentType); + if (shader == null) + { + shader = Shader.Find("Unlit/Color"); + } + + if (shader != null) + { + material = new Material(shader); + material.name = $"Auto_Default_{componentType}_{pipeline}"; + + // Set default color (white is standard for VFX) + if (material.HasProperty("_Color")) + { + material.SetColor("_Color", Color.white); + } + if (material.HasProperty("_BaseColor")) + { + material.SetColor("_BaseColor", Color.white); + } + + if (componentType == VFXComponentType.ParticleSystem) + { + material.renderQueue = 3000; + if (material.HasProperty("_Mode")) + { + material.SetFloat("_Mode", 2); + } + if (material.HasProperty("_SrcBlend")) + { + material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + } + if (material.HasProperty("_DstBlend")) + { + material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + } + if (material.HasProperty("_ZWrite")) + { + material.SetFloat("_ZWrite", 0); + } + } + + McpLog.Info($"[RenderPipelineUtility] Created default VFX material for {componentType} using {shader.name}"); + } + } + + if (material != null) + { + s_DefaultVFXMaterials[cacheKey] = material; + } + + return material; + } + + private static Shader ResolveDefaultVFXShader(PipelineKind pipeline, VFXComponentType componentType) + { + if (componentType == VFXComponentType.ParticleSystem) + { + return pipeline switch + { + PipelineKind.Universal => TryFindShader(UrpParticleShaders) ?? ResolveDefaultUnlitShader(pipeline), + PipelineKind.HighDefinition => TryFindShader(HdrpUnlitShaders) ?? ResolveDefaultUnlitShader(pipeline), + PipelineKind.BuiltIn => TryFindShader(BuiltInParticleShaders) ?? ResolveDefaultUnlitShader(pipeline), + PipelineKind.Custom => TryFindShader(UrpParticleShaders) + ?? TryFindShader(BuiltInParticleShaders) + ?? TryFindShader(HdrpUnlitShaders) + ?? ResolveDefaultUnlitShader(pipeline), + _ => ResolveDefaultUnlitShader(pipeline), + }; + } + + return ResolveDefaultUnlitShader(pipeline); + } + + private static bool LooksLikeBuiltInShader(string lowerName, bool shaderLooksSrp) + { + if (string.IsNullOrEmpty(lowerName)) + { + return false; + } + + if (lowerName == "standard" || + lowerName.StartsWith("legacy shaders/", StringComparison.Ordinal) || + lowerName.StartsWith("mobile/", StringComparison.Ordinal)) + { + return true; + } + + // Built-in non-SRP shader families commonly seen on particles/old content. + if (!shaderLooksSrp && + (lowerName.StartsWith("particles/", StringComparison.Ordinal) || + lowerName.StartsWith("unlit/", StringComparison.Ordinal))) + { + return true; + } + + return false; + } + + internal static Material GetOrCreateDefaultSceneMaterial() + { + var pipeline = GetActivePipeline(); + string cacheKey = $"{pipeline}_scene"; + if (s_DefaultSceneMaterials.TryGetValue(cacheKey, out Material cached) && cached != null) + { + return cached; + } + + Material material = null; + Shader shader = ResolveDefaultLitShader(pipeline) ?? ResolveDefaultUnlitShader(pipeline); + if (shader == null) + { + shader = Shader.Find("Unlit/Color"); + } + + if (shader != null) + { + material = new Material(shader); + material.name = $"Auto_Default_Scene_{pipeline}"; + if (material.HasProperty("_Color")) + { + material.SetColor("_Color", Color.white); + } + if (material.HasProperty("_BaseColor")) + { + material.SetColor("_BaseColor", Color.white); + } + McpLog.Info($"[RenderPipelineUtility] Created default scene material using {shader.name}"); + } + + if (material != null) + { + s_DefaultSceneMaterials[cacheKey] = material; + } + + return material; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta b/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta new file mode 100644 index 0000000..8e7273b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a0a1cfd55ab4bc99c74c52854f6bdf3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/RendererHelpers.cs b/MCPForUnity/Editor/Helpers/RendererHelpers.cs new file mode 100644 index 0000000..3110e40 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/RendererHelpers.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for common Renderer property operations. + /// Used by ManageVFX for ParticleSystem, LineRenderer, and TrailRenderer components. + /// + public static class RendererHelpers + { + public readonly struct EnsureMaterialResult + { + public EnsureMaterialResult(bool materialReplaced, string replacementReason) + { + MaterialReplaced = materialReplaced; + ReplacementReason = replacementReason ?? string.Empty; + } + + public bool MaterialReplaced { get; } + public string ReplacementReason { get; } + } + + /// + /// Ensures a renderer has a material assigned. If not, auto-assigns a default material + /// based on the render pipeline and component type. + /// + /// The renderer to check + public static EnsureMaterialResult EnsureMaterial(Renderer renderer) + { + if (renderer == null) + { + return new EnsureMaterialResult(false, "renderer_missing"); + } + + var existingMaterial = renderer.sharedMaterial; + string replacementReason = string.Empty; + bool pipelineInvalid = RenderPipelineUtility.IsMaterialInvalidForActivePipeline(existingMaterial, out string pipelineReason); + if (existingMaterial != null && !pipelineInvalid && IsUsableMaterial(existingMaterial)) + { + return new EnsureMaterialResult(false, string.Empty); + } + + if (existingMaterial != null) + { + var shaderName = existingMaterial.shader != null ? existingMaterial.shader.name : "(null)"; + McpLog.Warn($"[RendererHelpers] Replacing invalid VFX material '{existingMaterial.name}' (shader: {shaderName})."); + replacementReason = !string.IsNullOrWhiteSpace(pipelineReason) ? pipelineReason : "invalid_material"; + } + else + { + replacementReason = "missing_material"; + } + + Material replacement = ResolveReplacementMaterial(renderer); + if (replacement != null) + { + Undo.RecordObject(renderer, "Assign default renderer material"); + renderer.sharedMaterial = replacement; + EditorUtility.SetDirty(renderer); + return new EnsureMaterialResult(true, replacementReason); + } + + return new EnsureMaterialResult(false, replacementReason); + } + + private static bool IsUsableMaterial(Material material) + { + if (material == null) + { + return false; + } + + var shader = material.shader; + if (shader == null) + { + return false; + } + + var shaderName = shader.name ?? string.Empty; + if (shaderName.IndexOf("InternalErrorShader", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + + return shader.isSupported; + } + + private static Material ResolveReplacementMaterial(Renderer renderer) + { + if (renderer is ParticleSystemRenderer) + { + return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.ParticleSystem); + } + if (renderer is LineRenderer) + { + return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.LineRenderer); + } + if (renderer is TrailRenderer) + { + return RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(RenderPipelineUtility.VFXComponentType.TrailRenderer); + } + return RenderPipelineUtility.GetOrCreateDefaultSceneMaterial(); + } + + /// + /// Applies common Renderer properties (shadows, lighting, probes, sorting, rendering layer). + /// Used by ParticleSetRenderer, LineSetProperties, TrailSetProperties. + /// + public static void ApplyCommonRendererProperties(Renderer renderer, JObject @params, List changes) + { + // Shadows + if (@params["shadowCastingMode"] != null && Enum.TryParse(@params["shadowCastingMode"].ToString(), true, out var shadowMode)) + { renderer.shadowCastingMode = shadowMode; changes.Add("shadowCastingMode"); } + if (@params["receiveShadows"] != null) { renderer.receiveShadows = @params["receiveShadows"].ToObject(); changes.Add("receiveShadows"); } + // Note: shadowBias is only available on specific renderer types (e.g., ParticleSystemRenderer), not base Renderer + + // Lighting and probes + if (@params["lightProbeUsage"] != null && Enum.TryParse(@params["lightProbeUsage"].ToString(), true, out var probeUsage)) + { renderer.lightProbeUsage = probeUsage; changes.Add("lightProbeUsage"); } + if (@params["reflectionProbeUsage"] != null && Enum.TryParse(@params["reflectionProbeUsage"].ToString(), true, out var reflectionUsage)) + { renderer.reflectionProbeUsage = reflectionUsage; changes.Add("reflectionProbeUsage"); } + + // Motion vectors + if (@params["motionVectorGenerationMode"] != null && Enum.TryParse(@params["motionVectorGenerationMode"].ToString(), true, out var motionMode)) + { renderer.motionVectorGenerationMode = motionMode; changes.Add("motionVectorGenerationMode"); } + + // Sorting + if (@params["sortingOrder"] != null) { renderer.sortingOrder = @params["sortingOrder"].ToObject(); changes.Add("sortingOrder"); } + if (@params["sortingLayerName"] != null) { renderer.sortingLayerName = @params["sortingLayerName"].ToString(); changes.Add("sortingLayerName"); } + if (@params["sortingLayerID"] != null) { renderer.sortingLayerID = @params["sortingLayerID"].ToObject(); changes.Add("sortingLayerID"); } + + // Rendering layer mask (for SRP) + if (@params["renderingLayerMask"] != null) { renderer.renderingLayerMask = @params["renderingLayerMask"].ToObject(); changes.Add("renderingLayerMask"); } + } + + /// + /// Gets common Renderer properties for GetInfo methods. + /// + public static object GetCommonRendererInfo(Renderer renderer) + { + return new + { + shadowCastingMode = renderer.shadowCastingMode.ToString(), + receiveShadows = renderer.receiveShadows, + lightProbeUsage = renderer.lightProbeUsage.ToString(), + reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(), + sortingOrder = renderer.sortingOrder, + sortingLayerName = renderer.sortingLayerName, + renderingLayerMask = renderer.renderingLayerMask + }; + } + + + /// + /// Sets width properties for LineRenderer or TrailRenderer. + /// + /// JSON parameters containing width, startWidth, endWidth, widthCurve, widthMultiplier + /// List to track changed properties + /// Action to set start width + /// Action to set end width + /// Action to set width curve + /// Action to set width multiplier + /// Function to parse animation curve from JToken + public static void ApplyWidthProperties(JObject @params, List changes, + Action setStartWidth, Action setEndWidth, + Action setWidthCurve, Action setWidthMultiplier, + Func parseAnimationCurve) + { + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + setStartWidth(w); + setEndWidth(w); + changes.Add("width"); + } + if (@params["startWidth"] != null) { setStartWidth(@params["startWidth"].ToObject()); changes.Add("startWidth"); } + if (@params["endWidth"] != null) { setEndWidth(@params["endWidth"].ToObject()); changes.Add("endWidth"); } + if (@params["widthCurve"] != null) { setWidthCurve(parseAnimationCurve(@params["widthCurve"], 1f)); changes.Add("widthCurve"); } + if (@params["widthMultiplier"] != null) { setWidthMultiplier(@params["widthMultiplier"].ToObject()); changes.Add("widthMultiplier"); } + } + + /// + /// Sets color properties for LineRenderer or TrailRenderer. + /// + /// JSON parameters containing color, startColor, endColor, gradient + /// List to track changed properties + /// Action to set start color + /// Action to set end color + /// Action to set gradient + /// Function to parse color from JToken + /// Function to parse gradient from JToken + /// If true, sets end color alpha to 0 when using single color + public static void ApplyColorProperties(JObject @params, List changes, + Action setStartColor, Action setEndColor, + Action setGradient, + Func parseColor, Func parseGradient, + bool fadeEndAlpha = false) + { + if (@params["color"] != null) + { + Color c = parseColor(@params["color"]); + setStartColor(c); + setEndColor(fadeEndAlpha ? new Color(c.r, c.g, c.b, 0f) : c); + changes.Add("color"); + } + if (@params["startColor"] != null) { setStartColor(parseColor(@params["startColor"])); changes.Add("startColor"); } + if (@params["endColor"] != null) { setEndColor(parseColor(@params["endColor"])); changes.Add("endColor"); } + if (@params["gradient"] != null) { setGradient(parseGradient(@params["gradient"])); changes.Add("gradient"); } + } + + + /// + /// Sets material for a Renderer. + /// + /// The renderer to set material on + /// JSON parameters containing materialPath + /// Name for the undo operation + /// Function to find material by path + /// If true, auto-assigns default material when materialPath is not provided + public static object SetRendererMaterial(Renderer renderer, JObject @params, string undoName, Func findMaterial, bool autoAssignDefault = true) + { + if (renderer == null) return new { success = false, message = "Renderer not found" }; + + string path = @params["materialPath"]?.ToString(); + + if (string.IsNullOrEmpty(path)) + { + if (!autoAssignDefault) + { + return new { success = false, message = "materialPath required" }; + } + + RenderPipelineUtility.VFXComponentType? componentType = null; + if (renderer is ParticleSystemRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.ParticleSystem; + } + else if (renderer is LineRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.LineRenderer; + } + else if (renderer is TrailRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.TrailRenderer; + } + + if (componentType.HasValue) + { + Material defaultMat = RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(componentType.Value); + if (defaultMat != null) + { + Undo.RecordObject(renderer, undoName); + renderer.sharedMaterial = defaultMat; + EditorUtility.SetDirty(renderer); + return new { success = true, message = $"Auto-assigned default material: {defaultMat.name}" }; + } + } + + return new { success = false, message = "materialPath required" }; + } + + Material mat = findMaterial(path); + if (mat == null) return new { success = false, message = $"Material not found: {path}" }; + + Undo.RecordObject(renderer, undoName); + renderer.sharedMaterial = mat; + EditorUtility.SetDirty(renderer); + + return new { success = true, message = $"Set material to {mat.name}" }; + } + + + /// + /// Applies Line/Trail specific properties (loop, alignment, textureMode, etc.). + /// + public static void ApplyLineTrailProperties(JObject @params, List changes, + Action setLoop, Action setUseWorldSpace, + Action setNumCornerVertices, Action setNumCapVertices, + Action setAlignment, Action setTextureMode, + Action setGenerateLightingData) + { + if (@params["loop"] != null && setLoop != null) { setLoop(@params["loop"].ToObject()); changes.Add("loop"); } + if (@params["useWorldSpace"] != null && setUseWorldSpace != null) { setUseWorldSpace(@params["useWorldSpace"].ToObject()); changes.Add("useWorldSpace"); } + if (@params["numCornerVertices"] != null && setNumCornerVertices != null) { setNumCornerVertices(@params["numCornerVertices"].ToObject()); changes.Add("numCornerVertices"); } + if (@params["numCapVertices"] != null && setNumCapVertices != null) { setNumCapVertices(@params["numCapVertices"].ToObject()); changes.Add("numCapVertices"); } + if (@params["alignment"] != null && setAlignment != null && Enum.TryParse(@params["alignment"].ToString(), true, out var align)) { setAlignment(align); changes.Add("alignment"); } + if (@params["textureMode"] != null && setTextureMode != null && Enum.TryParse(@params["textureMode"].ToString(), true, out var texMode)) { setTextureMode(texMode); changes.Add("textureMode"); } + if (@params["generateLightingData"] != null && setGenerateLightingData != null) { setGenerateLightingData(@params["generateLightingData"].ToObject()); changes.Add("generateLightingData"); } + } + + /// + /// Applies sensible default values to a newly-created ParticleSystem. + /// Unity's raw defaults (startSize=1, startSpeed=5, startLifetime=5, maxParticles=1000) + /// produce oversized particle clouds in most scenes. These gentler defaults are a better + /// starting point that callers can override with particle_set_main or set_property. + /// + public static void SetSensibleParticleDefaults(ParticleSystem ps) + { + if (ps == null) return; + + var main = ps.main; + main.startSize = new ParticleSystem.MinMaxCurve(0.1f); + main.startSpeed = new ParticleSystem.MinMaxCurve(1f); + main.startLifetime = new ParticleSystem.MinMaxCurve(2f); + main.maxParticles = 100; + main.playOnAwake = false; + main.simulationSpace = ParticleSystemSimulationSpace.World; + + var emission = ps.emission; + emission.rateOverTime = new ParticleSystem.MinMaxCurve(20f); + + var shape = ps.shape; + shape.radius = 0.25f; + } + + } +} diff --git a/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta b/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta new file mode 100644 index 0000000..db81f32 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 8f3a7e2d5c1b4a9e6d0f8c3b2a1e5d7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/MCPForUnity/Editor/Helpers/Response.cs b/MCPForUnity/Editor/Helpers/Response.cs new file mode 100644 index 0000000..6c6c299 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Response.cs @@ -0,0 +1,108 @@ +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Helpers +{ + public interface IMcpResponse + { + [JsonProperty("success")] + bool Success { get; } + } + + public sealed class SuccessResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("message")] + public string Message { get; } + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public SuccessResponse(string message, object data = null) + { + Message = message; + Data = data; + } + } + + public sealed class ErrorResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => false; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] + public string Code { get; } + + [JsonIgnore] + public string code => Code; + + [JsonProperty("error")] + public string Error { get; } + + [JsonIgnore] + public string error => Error; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public ErrorResponse(string messageOrCode, object data = null) + { + Code = messageOrCode; + Error = messageOrCode; + Data = data; + } + } + + public sealed class PendingResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("_mcp_status")] + public string Status => "pending"; + + [JsonIgnore] + public string _mcp_status => Status; + + [JsonProperty("_mcp_poll_interval")] + public double PollIntervalSeconds { get; } + + [JsonIgnore] + public double _mcp_poll_interval => PollIntervalSeconds; + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; } + + [JsonIgnore] + public string message => Message; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null) + { + Message = string.IsNullOrEmpty(message) ? null : message; + PollIntervalSeconds = pollIntervalSeconds; + Data = data; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/Response.cs.meta b/MCPForUnity/Editor/Helpers/Response.cs.meta new file mode 100644 index 0000000..349444a --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Response.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8a93c1537f546c6b3b24b903d5f9395 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs new file mode 100644 index 0000000..b12c974 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs @@ -0,0 +1,49 @@ +using MCPForUnity.Runtime.Helpers; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user EditorPrefs override for the default screenshot output folder. + /// Resolution priority used by callers: + /// 1. Per-call output_folder tool parameter + /// 2. (this preference) + /// 3. built-in fallback + /// + public static class ScreenshotPreferences + { + public const string EditorPrefsKey = "MCPForUnity_ScreenshotsFolder"; + + /// + /// User-configured default folder, or empty string when unset. + /// Stored as a project-relative path (e.g. "Assets/Screenshots", "Captures"). + /// + public static string DefaultFolder + { + get => EditorPrefs.GetString(EditorPrefsKey, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(value)) + { + EditorPrefs.DeleteKey(EditorPrefsKey); + } + else + { + EditorPrefs.SetString(EditorPrefsKey, value.Trim()); + } + } + } + + /// + /// Resolves the effective folder: caller override → user pref → built-in default. + /// Returns a project-relative path string suitable for . + /// + public static string Resolve(string callerOverride) + { + if (!string.IsNullOrWhiteSpace(callerOverride)) return callerOverride.Trim(); + string pref = DefaultFolder; + if (!string.IsNullOrWhiteSpace(pref)) return pref; + return ScreenshotUtility.DefaultFolder; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta new file mode 100644 index 0000000..c166831 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ScreenshotPreferences.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7c4f1a8e9b3d4d2eaf5c1d7b9e2f4a6c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/StringCaseUtility.cs b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs new file mode 100644 index 0000000..04b498a --- /dev/null +++ b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for converting between naming conventions (snake_case, camelCase). + /// Consolidates previously duplicated implementations from ToolParams, ManageVFX, + /// BatchExecute, CommandRegistry, and ToolDiscoveryService. + /// + public static class StringCaseUtility + { + /// + /// Checks whether a type belongs to the built-in MCP for Unity package. + /// Returns true when the type's namespace starts with + /// or its assembly is MCPForUnity.Editor. + /// + public static bool IsBuiltInMcpType(Type type, string assemblyName, string builtInNamespacePrefix) + { + if (type != null && !string.IsNullOrEmpty(type.Namespace) + && type.Namespace.StartsWith(builtInNamespacePrefix, StringComparison.Ordinal)) + { + return true; + } + + if (!string.IsNullOrEmpty(assemblyName) + && assemblyName.Equals("MCPForUnity.Editor", StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + /// + /// Converts a camelCase string to snake_case. + /// Example: "searchMethod" -> "search_method", "param1Value" -> "param1_value" + /// + /// The camelCase string to convert + /// The snake_case equivalent, or original string if null/empty + public static string ToSnakeCase(string str) + { + if (string.IsNullOrEmpty(str)) + return str; + + return Regex.Replace(str, "([a-z0-9])([A-Z])", "$1_$2").ToLowerInvariant(); + } + + /// + /// Converts a snake_case string to camelCase. + /// Example: "search_method" -> "searchMethod" + /// + /// The snake_case string to convert + /// The camelCase equivalent, or original string if null/empty or no underscores + public static string ToCamelCase(string str) + { + if (string.IsNullOrEmpty(str) || !str.Contains("_")) + return str; + + var parts = str.Split('_'); + if (parts.Length == 0) + return str; + + // First part stays lowercase, rest get capitalized + var first = parts[0]; + var rest = string.Concat(parts.Skip(1).Select(part => + string.IsNullOrEmpty(part) ? "" : char.ToUpperInvariant(part[0]) + part.Substring(1))); + + return first + rest; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta new file mode 100644 index 0000000..62b2fcb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f22b312318ade42c4bb6b5dfddacecfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs new file mode 100644 index 0000000..6e092d4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unity Bridge telemetry helper for collecting usage analytics + /// Following privacy-first approach with easy opt-out mechanisms + /// + public static class TelemetryHelper + { + private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled; + private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid; + private static Action> s_sender; + + /// + /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) + /// + public static bool IsEnabled + { + get + { + // Check environment variables first + var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(envDisable) && + (envDisable.ToLower() == "true" || envDisable == "1")) + { + return false; + } + + var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(unityMcpDisable) && + (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) + { + return false; + } + + // Honor protocol-wide opt-out as well + var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(mcpDisable) && + (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) + { + return false; + } + + // Check EditorPrefs + return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); + } + } + + /// + /// Get or generate customer UUID for anonymous tracking + /// + public static string GetCustomerUUID() + { + var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); + if (string.IsNullOrEmpty(uuid)) + { + uuid = System.Guid.NewGuid().ToString(); + UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); + } + return uuid; + } + + /// + /// Disable telemetry (stored in EditorPrefs) + /// + public static void DisableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); + } + + /// + /// Enable telemetry (stored in EditorPrefs) + /// + public static void EnableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); + } + + /// + /// Send telemetry data to MCP server for processing + /// This is a lightweight bridge - the actual telemetry logic is in the MCP server + /// + public static void RecordEvent(string eventType, Dictionary data = null) + { + if (!IsEnabled) + return; + + try + { + var telemetryData = new Dictionary + { + ["event_type"] = eventType, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ["customer_uuid"] = GetCustomerUUID(), + ["unity_version"] = Application.unityVersion, + ["platform"] = Application.platform.ToString(), + ["source"] = "unity_bridge" + }; + + if (data != null) + { + telemetryData["data"] = data; + } + + // Send to MCP server via existing bridge communication + // The MCP server will handle actual telemetry transmission + SendTelemetryToMcpServer(telemetryData); + } + catch (Exception e) + { + // Never let telemetry errors interfere with functionality + if (IsDebugEnabled()) + { + McpLog.Warn($"Telemetry error (non-blocking): {e.Message}"); + } + } + } + + /// + /// Allows the bridge to register a concrete sender for telemetry payloads. + /// + public static void RegisterTelemetrySender(Action> sender) + { + Interlocked.Exchange(ref s_sender, sender); + } + + public static void UnregisterTelemetrySender() + { + Interlocked.Exchange(ref s_sender, null); + } + + /// + /// Record bridge startup event + /// + public static void RecordBridgeStartup() + { + RecordEvent("bridge_startup", new Dictionary + { + ["bridge_version"] = AssetPathUtility.GetPackageVersion(), + ["auto_connect"] = StdioBridgeHost.IsAutoConnectMode() + }); + } + + /// + /// Record bridge connection event + /// + public static void RecordBridgeConnection(bool success, string error = null) + { + var data = new Dictionary + { + ["success"] = success + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("bridge_connection", data); + } + + /// + /// Record tool execution from Unity side + /// + public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) + { + var data = new Dictionary + { + ["tool_name"] = toolName, + ["success"] = success, + ["duration_ms"] = Math.Round(durationMs, 2) + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("tool_execution_unity", data); + } + + private static void SendTelemetryToMcpServer(Dictionary telemetryData) + { + var sender = Volatile.Read(ref s_sender); + if (sender != null) + { + try + { + sender(telemetryData); + return; + } + catch (Exception e) + { + if (IsDebugEnabled()) + { + McpLog.Warn($"Telemetry sender error (non-blocking): {e.Message}"); + } + } + } + + // Fallback: log when debug is enabled + if (IsDebugEnabled()) + { + McpLog.Info($"Telemetry: {telemetryData["event_type"]}"); + } + } + + private static bool IsDebugEnabled() + { + try + { + return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta new file mode 100644 index 0000000..0c67960 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/TextureOps.cs b/MCPForUnity/Editor/Helpers/TextureOps.cs new file mode 100644 index 0000000..b062b47 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TextureOps.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class TextureOps + { + public static byte[] EncodeTexture(Texture2D texture, string assetPath) + { + if (texture == null) + return null; + + string extension = Path.GetExtension(assetPath); + if (string.IsNullOrEmpty(extension)) + { + McpLog.Warn($"[TextureOps] No file extension for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + + switch (extension.ToLowerInvariant()) + { + case ".png": + return texture.EncodeToPNG(); + case ".jpg": + case ".jpeg": + return texture.EncodeToJPG(); + default: + McpLog.Warn($"[TextureOps] Unsupported extension '{extension}' for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + } + + public static void FillTexture(Texture2D texture, Color32 color) + { + if (texture == null) + return; + + Color32[] pixels = new Color32[texture.width * texture.height]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = color; + } + texture.SetPixels32(pixels); + } + + public static Color32 ParseColor32(JArray colorArray) + { + if (colorArray == null || colorArray.Count < 3) + return new Color32(255, 255, 255, 255); + + byte r = (byte)Mathf.Clamp(colorArray[0].ToObject(), 0, 255); + byte g = (byte)Mathf.Clamp(colorArray[1].ToObject(), 0, 255); + byte b = (byte)Mathf.Clamp(colorArray[2].ToObject(), 0, 255); + byte a = colorArray.Count > 3 ? (byte)Mathf.Clamp(colorArray[3].ToObject(), 0, 255) : (byte)255; + + return new Color32(r, g, b, a); + } + + public static List ParsePalette(JArray paletteArray) + { + if (paletteArray == null) + return null; + + List palette = new List(); + foreach (var item in paletteArray) + { + if (item is JArray colorArray) + { + palette.Add(ParseColor32(colorArray)); + } + } + return palette.Count > 0 ? palette : null; + } + + public static void ApplyPixelData(Texture2D texture, JToken pixelsToken, int width, int height) + { + ApplyPixelDataToRegion(texture, pixelsToken, 0, 0, width, height); + } + + public static void ApplyPixelDataToRegion(Texture2D texture, JToken pixelsToken, int offsetX, int offsetY, int regionWidth, int regionHeight) + { + if (texture == null || pixelsToken == null) + return; + + int textureWidth = texture.width; + int textureHeight = texture.height; + + if (pixelsToken is JArray pixelArray) + { + int index = 0; + for (int y = 0; y < regionHeight && index < pixelArray.Count; y++) + { + for (int x = 0; x < regionWidth && index < pixelArray.Count; x++) + { + var pixelColor = pixelArray[index] as JArray; + if (pixelColor != null) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + texture.SetPixel(px, py, ParseColor32(pixelColor)); + } + } + index++; + } + } + + int expectedCount = regionWidth * regionHeight; + if (pixelArray.Count != expectedCount) + { + McpLog.Warn($"[TextureOps] Pixel array size mismatch: expected {expectedCount} entries, got {pixelArray.Count}"); + } + } + else if (pixelsToken.Type == JTokenType.String) + { + string pixelString = pixelsToken.ToString(); + string base64 = pixelString.StartsWith("base64:") ? pixelString.Substring(7) : pixelString; + if (!pixelString.StartsWith("base64:")) + { + McpLog.Warn("[TextureOps] Base64 pixel data missing 'base64:' prefix; attempting to decode."); + } + + byte[] rawData = Convert.FromBase64String(base64); + + // Assume RGBA32 format: 4 bytes per pixel + int expectedBytes = regionWidth * regionHeight * 4; + if (rawData.Length == expectedBytes) + { + int pixelIndex = 0; + for (int y = 0; y < regionHeight; y++) + { + for (int x = 0; x < regionWidth; x++) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + int byteIndex = pixelIndex * 4; + Color32 color = new Color32( + rawData[byteIndex], + rawData[byteIndex + 1], + rawData[byteIndex + 2], + rawData[byteIndex + 3] + ); + texture.SetPixel(px, py, color); + } + pixelIndex++; + } + } + } + else + { + McpLog.Warn($"[TextureOps] Base64 data size mismatch: expected {expectedBytes} bytes, got {rawData.Length}"); + } + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/TextureOps.cs.meta b/MCPForUnity/Editor/Helpers/TextureOps.cs.meta new file mode 100644 index 0000000..6dc7290 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TextureOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 864ea682d797466a84b6b951f6c4e4ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ToolParams.cs b/MCPForUnity/Editor/Helpers/ToolParams.cs new file mode 100644 index 0000000..98db908 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ToolParams.cs @@ -0,0 +1,255 @@ +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified parameter validation and extraction wrapper for MCP tools. + /// Eliminates repetitive IsNullOrEmpty checks and provides consistent error messages. + /// + public class ToolParams + { + private readonly JObject _params; + + public ToolParams(JObject @params) + { + _params = @params ?? throw new ArgumentNullException(nameof(@params)); + } + + /// + /// Get required string parameter. Returns ErrorResponse if missing or empty. + /// + public Result GetRequired(string key, string errorMessage = null) + { + var value = GetString(key); + if (string.IsNullOrEmpty(value)) + { + return Result.Error( + errorMessage ?? $"'{key}' parameter is required." + ); + } + return Result.Success(value); + } + + /// + /// Get optional string parameter with default value. + /// Supports both snake_case and camelCase automatically. + /// + public string Get(string key, string defaultValue = null) + { + return GetString(key) ?? defaultValue; + } + + /// + /// Get optional int parameter. + /// + public int? GetInt(string key, int? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return int.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Get optional bool parameter. + /// Supports both snake_case and camelCase automatically. + /// + public bool GetBool(string key, bool defaultValue = false) + { + return ParamCoercion.CoerceBool(GetToken(key), defaultValue); + } + + /// + /// Get optional float parameter. + /// + public float? GetFloat(string key, float? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return float.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Check if parameter exists (even if null). + /// Supports both snake_case and camelCase automatically. + /// + public bool Has(string key) + { + return GetToken(key) != null; + } + + /// + /// Get raw JToken for complex types. + /// Supports both snake_case and camelCase automatically. + /// + public JToken GetRaw(string key) + { + return GetToken(key); + } + + /// + /// Get optional string array parameter, handling various MCP serialization formats: + /// plain strings, JSON arrays, stringified JSON arrays, and double-serialized arrays. + /// Supports both snake_case and camelCase automatically. + /// + public string[] GetStringArray(string key) + { + return CoerceStringArray(GetToken(key)); + } + + /// + /// Coerces a JToken to a string array, handling various MCP serialization formats: + /// plain strings, JSON arrays, stringified JSON arrays, and double-serialized arrays. + /// + internal static string[] CoerceStringArray(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return null; + + if (token.Type == JTokenType.String) + { + var value = token.ToString(); + if (string.IsNullOrWhiteSpace(value)) return null; + // Handle stringified JSON arrays (e.g. "[\"name1\", \"name2\"]") + var trimmed = value.Trim(); + if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) + { + try + { + var parsed = JArray.Parse(trimmed); + var values = parsed.Values() + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + return values.Length > 0 ? values : null; + } + catch (JsonException) { /* not a valid JSON array, treat as plain string */ } + } + return new[] { value }; + } + + if (token.Type == JTokenType.Array) + { + var array = token as JArray; + if (array == null || array.Count == 0) return null; + // Handle double-serialized arrays: MCP bridge may send ["[\"name1\"]"] + // where the inner string is a stringified JSON array + if (array.Count == 1 && array[0].Type == JTokenType.String) + { + var inner = array[0].ToString().Trim(); + if (inner.StartsWith("[") && inner.EndsWith("]")) + { + try + { + array = JArray.Parse(inner); + } + catch (JsonException) { /* use original array */ } + } + } + // Handle single-level nested arrays: [[name1, name2]] + // Multi-element outer arrays (e.g. [["a"], ["b"]]) are not unwrapped + // as that format is not produced by known MCP clients. + else if (array.Count == 1 && array[0].Type == JTokenType.Array) + { + array = array[0] as JArray ?? array; + } + var values = array + .Values() + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + return values.Length > 0 ? values : null; + } + + return null; + } + + /// + /// Get raw JToken with snake_case/camelCase fallback. + /// + private JToken GetToken(string key) + { + // Try exact match first + var token = _params[key]; + if (token != null) return token; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + token = _params[snakeKey]; + if (token != null) return token; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + token = _params[camelKey]; + } + + return token; + } + + private string GetString(string key) + { + // Try exact match first + var value = _params[key]?.ToString(); + if (value != null) return value; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + value = _params[snakeKey]?.ToString(); + if (value != null) return value; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + value = _params[camelKey]?.ToString(); + } + + return value; + } + + private static string ToSnakeCase(string str) => StringCaseUtility.ToSnakeCase(str); + + private static string ToCamelCase(string str) => StringCaseUtility.ToCamelCase(str); + } + + /// + /// Result type for operations that can fail with an error message. + /// + public class Result + { + public bool IsSuccess { get; } + public T Value { get; } + public string ErrorMessage { get; } + + private Result(bool isSuccess, T value, string errorMessage) + { + IsSuccess = isSuccess; + Value = value; + ErrorMessage = errorMessage; + } + + public static Result Success(T value) => new Result(true, value, null); + public static Result Error(string errorMessage) => new Result(false, default, errorMessage); + + /// + /// Get value or return ErrorResponse. + /// + public object GetOrError(out T value) + { + if (IsSuccess) + { + value = Value; + return null; + } + value = default; + return new ErrorResponse(ErrorMessage); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ToolParams.cs.meta b/MCPForUnity/Editor/Helpers/ToolParams.cs.meta new file mode 100644 index 0000000..e335bb4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ToolParams.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 404b09ea3e2714e1babd16f5705ac788 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs b/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs new file mode 100644 index 0000000..83b650f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using MCPForUnity.Runtime.Serialization; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared JsonSerializer with Unity type converters. + /// Extracted from ManageGameObject to eliminate cross-tool dependencies. + /// + public static class UnityJsonSerializer + { + /// + /// Shared JsonSerializer instance with converters for Unity types. + /// Use this for all JToken-to-Unity-type conversions. + /// + public static readonly JsonSerializer Instance = JsonSerializer.Create(new JsonSerializerSettings + { + Converters = new List + { + new Vector2Converter(), + new Vector3Converter(), + new Vector4Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() + } + }); + } +} + diff --git a/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta b/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta new file mode 100644 index 0000000..5c4391a --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 24d94c9c030bd4ff1ab208c748f26b01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs new file mode 100644 index 0000000..7ed1bbe --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Runtime.Helpers; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +using UnityEditor.Compilation; +#endif + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified type resolution for Unity types (Components, ScriptableObjects, etc.). + /// Extracted from ComponentResolver in ManageGameObject and ResolveType in ManageScriptableObject. + /// Features: caching, prioritizes Player assemblies over Editor assemblies, uses TypeCache. + /// + public static class UnityTypeResolver + { + private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); + private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); + + /// + /// Resolves a type by name, with optional base type constraint. + /// Caches results for performance. Prefers runtime assemblies over Editor assemblies. + /// + /// The short name or fully-qualified name of the type + /// The resolved type, or null if not found + /// Error message if resolution failed + /// Optional base type constraint (e.g., typeof(Component)) + /// True if type was resolved successfully + public static bool TryResolve(string typeName, out Type type, out string error, Type requiredBaseType = null) + { + error = string.Empty; + type = null; + + if (string.IsNullOrWhiteSpace(typeName)) + { + error = "Type name cannot be null or empty"; + return false; + } + + // Check caches + if (CacheByFqn.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType)) + return true; + if (!typeName.Contains(".") && CacheByName.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType)) + return true; + + // Try direct Type.GetType + type = Type.GetType(typeName, throwOnError: false); + if (type != null && PassesConstraint(type, requiredBaseType)) + { + Cache(type); + return true; + } + + // Search loaded assemblies (prefer Player assemblies) + var candidates = FindCandidates(typeName, requiredBaseType); + if (candidates.Count == 1) + { + type = candidates[0]; + Cache(type); + return true; + } + if (candidates.Count > 1) + { + error = FormatAmbiguityError(typeName, candidates); + type = null; + return false; + } + +#if UNITY_EDITOR + // Last resort: TypeCache (fast index) + if (requiredBaseType != null) + { + var tc = TypeCache.GetTypesDerivedFrom(requiredBaseType) + .Where(t => NamesMatch(t, typeName)); + candidates = DisambiguateByIdentity(PreferPlayer(tc).ToList()); + if (candidates.Count == 1) + { + type = candidates[0]; + Cache(type); + return true; + } + if (candidates.Count > 1) + { + error = FormatAmbiguityError(typeName, candidates); + type = null; + return false; + } + } +#endif + + error = $"Type '{typeName}' not found in loaded runtime assemblies. " + + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; + type = null; + return false; + } + + /// + /// Convenience method to resolve a Component type. + /// + public static Type ResolveComponent(string typeName) + { + if (TryResolve(typeName, out Type type, out _, typeof(Component))) + return type; + return null; + } + + /// + /// Convenience method to resolve a ScriptableObject type. + /// + public static Type ResolveScriptableObject(string typeName) + { + if (TryResolve(typeName, out Type type, out _, typeof(ScriptableObject))) + return type; + return null; + } + + /// + /// Convenience method to resolve any type without constraints. + /// + public static Type ResolveAny(string typeName) + { + if (TryResolve(typeName, out Type type, out _, null)) + return type; + return null; + } + + // --- Private Helpers --- + + private static bool PassesConstraint(Type type, Type requiredBaseType) + { + if (type == null) return false; + if (requiredBaseType == null) return true; + return requiredBaseType.IsAssignableFrom(type); + } + + private static bool NamesMatch(Type t, string query) => + t.Name.Equals(query, StringComparison.Ordinal) || + (t.FullName?.Equals(query, StringComparison.Ordinal) ?? false); + + private static void Cache(Type t) + { + if (t == null) return; + if (t.FullName != null) CacheByFqn[t.FullName] = t; + CacheByName[t.Name] = t; + } + + private static List FindCandidates(string query, Type requiredBaseType) + { + bool isShort = !query.Contains('.'); + var loaded = UnityAssembliesCompat.GetLoadedAssemblies(); + +#if UNITY_EDITOR + // Names of Player (runtime) script assemblies + var playerAsmNames = new HashSet( + CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + var playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); + var editorAsms = loaded.Except(playerAsms); +#else + var playerAsms = loaded; + var editorAsms = Array.Empty(); +#endif + + Func match = isShort + ? (t => t.Name.Equals(query, StringComparison.Ordinal)) + : (t => t.FullName?.Equals(query, StringComparison.Ordinal) ?? false); + + var fromPlayer = playerAsms.SelectMany(SafeGetTypes) + .Where(t => PassesConstraint(t, requiredBaseType)) + .Where(match); + var fromEditor = editorAsms.SelectMany(SafeGetTypes) + .Where(t => PassesConstraint(t, requiredBaseType)) + .Where(match); + + // Prefer Player over Editor + var candidates = fromPlayer.ToList(); + if (candidates.Count == 0) + candidates = fromEditor.ToList(); + + return DisambiguateByIdentity(candidates); + } + + /// + /// Collapses spurious matches that are not a genuine user-facing ambiguity: + /// 1. De-dupe by FullName (same type surfaced via multiple assemblies / type-forwards). + /// 2. If still >1, prefer a single PUBLIC type (e.g. public BCL List`1 over an + /// internal type that happens to share the short name). + /// 3. If still >1, prefer a single core/BCL type (mscorlib / System.* / netstandard / + /// System.Private.CoreLib) so unqualified BCL generics resolve deterministically. + /// Each step only narrows when it yields exactly one survivor, so genuine clashes + /// between two public, non-BCL types (e.g. UnityEngine.UI.Button vs + /// UnityEngine.UIElements.Button) are still reported as ambiguous. + /// + private static List DisambiguateByIdentity(List candidates) + { + if (candidates.Count <= 1) return candidates; + + // 1. De-dupe by FullName (keep first occurrence; Player-preferred ordering preserved). + var deduped = candidates + .GroupBy(t => t.FullName ?? t.Name, StringComparer.Ordinal) + .Select(g => g.First()) + .ToList(); + if (deduped.Count <= 1) return deduped; + + // 2. Prefer a single public type. + var publicTypes = deduped.Where(t => t.IsPublic || t.IsNestedPublic).ToList(); + if (publicTypes.Count == 1) return publicTypes; + var pool = publicTypes.Count > 1 ? publicTypes : deduped; + + // 3. Prefer a single core/BCL type. + var coreTypes = pool.Where(IsCoreBclType).ToList(); + if (coreTypes.Count == 1) return coreTypes; + + return pool; + } + + private static bool IsCoreBclType(Type t) + { + string asmName = t.Assembly.GetName().Name; + if (string.IsNullOrEmpty(asmName)) return false; + return asmName.Equals("mscorlib", StringComparison.Ordinal) + || asmName.Equals("netstandard", StringComparison.Ordinal) + || asmName.Equals("System.Private.CoreLib", StringComparison.Ordinal) + || asmName.Equals("System", StringComparison.Ordinal) + || asmName.StartsWith("System.", StringComparison.Ordinal); + } + + private static IEnumerable SafeGetTypes(System.Reflection.Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null); } + catch { return Enumerable.Empty(); } + } + + private static IEnumerable PreferPlayer(IEnumerable types) + { +#if UNITY_EDITOR + var playerAsmNames = new HashSet( + CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + var list = types.ToList(); + var fromPlayer = list.Where(t => playerAsmNames.Contains(t.Assembly.GetName().Name)).ToList(); + return fromPlayer.Count > 0 ? fromPlayer : list; +#else + return types; +#endif + } + + private static string FormatAmbiguityError(string query, List candidates) + { + var names = string.Join(", ", candidates.Take(5).Select(t => t.FullName)); + if (candidates.Count > 5) names += $" ... ({candidates.Count - 5} more)"; + return $"Ambiguous type reference '{query}'. Found {candidates.Count} matches: [{names}]. Use a fully-qualified name."; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta new file mode 100644 index 0000000..c0eed2d --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2cdf06f869b124741af31f27b25742db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs b/MCPForUnity/Editor/Helpers/VectorParsing.cs new file mode 100644 index 0000000..0e81cca --- /dev/null +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs @@ -0,0 +1,731 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for parsing JSON tokens into Unity vector, math, and animation types. + /// Supports both array format [x, y, z] and object format {x: 1, y: 2, z: 3}. + /// + public static class VectorParsing + { + /// + /// Parses a JToken (array or object) into a Vector3. + /// + /// The JSON token to parse + /// The parsed Vector3 or null if parsing fails + public static Vector3? ParseVector3(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y, z] + if (token is JArray array && array.Count >= 3) + { + return new Vector3( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + + // Object format: {x: 1, y: 2, z: 3} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) + { + return new Vector3( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Vector3, returning a default value if parsing fails. + /// + public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default) + { + return ParseVector3(token) ?? defaultValue; + } + + /// + /// Parses a JToken (array or object) into a Vector2. + /// + /// The JSON token to parse + /// The parsed Vector2 or null if parsing fails + public static Vector2? ParseVector2(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y] + if (token is JArray array && array.Count >= 2) + { + return new Vector2( + array[0].ToObject(), + array[1].ToObject() + ); + } + + // Object format: {x: 1, y: 2} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) + { + return new Vector2( + obj["x"].ToObject(), + obj["y"].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Vector2 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Vector4. + /// + /// The JSON token to parse + /// The parsed Vector4 or null if parsing fails + public static Vector4? ParseVector4(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y, z, w] + if (token is JArray array && array.Count >= 4) + { + return new Vector4( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + + // Object format: {x: 1, y: 2, z: 3, w: 4} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && + obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Vector4( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject(), + obj["w"].ToObject() + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Vector4 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Quaternion. + /// Supports both euler angles [x, y, z] and quaternion components [x, y, z, w]. + /// Note: Raw quaternion components are NOT normalized. Callers should normalize if needed + /// for operations like interpolation where non-unit quaternions cause issues. + /// + /// The JSON token to parse + /// If true, treats 3-element arrays as euler angles + /// The parsed Quaternion or null if parsing fails + public static Quaternion? ParseQuaternion(JToken token, bool asEulerAngles = true) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JArray array) + { + // Quaternion components: [x, y, z, w] + if (array.Count >= 4) + { + return new Quaternion( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + + // Euler angles: [x, y, z] + if (array.Count >= 3 && asEulerAngles) + { + return Quaternion.Euler( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + } + + // Object format: {x: 0, y: 0, z: 0, w: 1} + if (token is JObject obj) + { + if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Quaternion( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject(), + obj["w"].ToObject() + ); + } + + // Euler format in object: {x: 45, y: 90, z: 0} (as euler angles) + if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && asEulerAngles) + { + return Quaternion.Euler( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Color. + /// Supports both [r, g, b, a] and {r: 1, g: 1, b: 1, a: 1} formats. + /// + /// The JSON token to parse + /// The parsed Color or null if parsing fails + public static Color? ParseColor(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [r, g, b, a] or [r, g, b] + if (token is JArray array) + { + if (array.Count >= 4) + { + return new Color( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + if (array.Count >= 3) + { + return new Color( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + 1f // Default alpha + ); + } + } + + // Object format: {r: 1, g: 1, b: 1, a: 1} + if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b")) + { + float a = obj.ContainsKey("a") ? obj["a"].ToObject() : 1f; + return new Color( + obj["r"].ToObject(), + obj["g"].ToObject(), + obj["b"].ToObject(), + a + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Color, returning Color.white if parsing fails and no default is specified. + /// + public static Color ParseColorOrDefault(JToken token) => ParseColor(token) ?? Color.white; + + /// + /// Parses a JToken into a Color, returning the specified default if parsing fails. + /// + public static Color ParseColorOrDefault(JToken token, Color defaultValue) => ParseColor(token) ?? defaultValue; + + /// + /// Parses a JToken into a Vector4, returning a default value if parsing fails. + /// Added for ManageVFX refactoring. + /// + public static Vector4 ParseVector4OrDefault(JToken token, Vector4 defaultValue = default) + { + return ParseVector4(token) ?? defaultValue; + } + + /// + /// Parses a JToken into a Gradient. + /// Supports formats: + /// - Simple: {startColor: [r,g,b,a], endColor: [r,g,b,a]} + /// - Full: {colorKeys: [{color: [r,g,b,a], time: 0.0}, ...], alphaKeys: [{alpha: 1.0, time: 0.0}, ...]} + /// Added for ManageVFX refactoring. + /// + /// The JSON token to parse + /// The parsed Gradient or null if parsing fails + public static Gradient ParseGradient(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + Gradient gradient = new Gradient(); + + if (token is JObject obj) + { + // Simple format: {startColor: ..., endColor: ...} + if (obj.ContainsKey("startColor")) + { + Color startColor = ParseColorOrDefault(obj["startColor"]); + Color endColor = ParseColorOrDefault(obj["endColor"] ?? obj["startColor"]); + float startAlpha = obj["startAlpha"]?.ToObject() ?? startColor.a; + float endAlpha = obj["endAlpha"]?.ToObject() ?? endColor.a; + + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(startColor, 0f), new GradientColorKey(endColor, 1f) }, + new GradientAlphaKey[] { new GradientAlphaKey(startAlpha, 0f), new GradientAlphaKey(endAlpha, 1f) } + ); + return gradient; + } + + // Full format: {colorKeys: [...], alphaKeys: [...]} + var colorKeys = new List(); + var alphaKeys = new List(); + + if (obj["colorKeys"] is JArray colorKeysArr) + { + foreach (var key in colorKeysArr) + { + Color color = ParseColorOrDefault(key["color"]); + float time = key["time"]?.ToObject() ?? 0f; + colorKeys.Add(new GradientColorKey(color, time)); + } + } + + if (obj["alphaKeys"] is JArray alphaKeysArr) + { + foreach (var key in alphaKeysArr) + { + float alpha = key["alpha"]?.ToObject() ?? 1f; + float time = key["time"]?.ToObject() ?? 0f; + alphaKeys.Add(new GradientAlphaKey(alpha, time)); + } + } + + // Ensure at least 2 keys + if (colorKeys.Count == 0) + { + colorKeys.Add(new GradientColorKey(Color.white, 0f)); + colorKeys.Add(new GradientColorKey(Color.white, 1f)); + } + + if (alphaKeys.Count == 0) + { + alphaKeys.Add(new GradientAlphaKey(1f, 0f)); + alphaKeys.Add(new GradientAlphaKey(1f, 1f)); + } + + gradient.SetKeys(colorKeys.ToArray(), alphaKeys.ToArray()); + return gradient; + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Gradient from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Gradient, returning a default gradient if parsing fails. + /// Added for ManageVFX refactoring. + /// + public static Gradient ParseGradientOrDefault(JToken token) + { + var result = ParseGradient(token); + if (result != null) return result; + + // Return default white gradient + var gradient = new Gradient(); + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) }, + new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) } + ); + return gradient; + } + + /// + /// Parses a JToken into an AnimationCurve. + /// + /// Supported formats: + /// + /// Constant: 1.0 (number) - Creates constant curve at that value + /// Simple: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0} + /// Full: {keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]} + /// + /// + /// Keyframe field defaults (for Full format): + /// + /// time (float): Default: 0 + /// value (float): Default: 1 (note: differs from ManageScriptableObject which uses 0) + /// inTangent (float): Default: 0 + /// outTangent (float): Default: 0 + /// + /// + /// Note: This method is used by ManageVFX. For ScriptableObject patching, + /// see which has slightly different defaults. + /// + /// The JSON token to parse + /// The parsed AnimationCurve or null if parsing fails + public static AnimationCurve ParseAnimationCurve(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Constant value: just a number + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + { + return AnimationCurve.Constant(0f, 1f, token.ToObject()); + } + + if (token is JObject obj) + { + // Full format: {keys: [...]} + if (obj["keys"] is JArray keys) + { + AnimationCurve curve = new AnimationCurve(); + foreach (var key in keys) + { + float time = key["time"]?.ToObject() ?? 0f; + float value = key["value"]?.ToObject() ?? 1f; + float inTangent = key["inTangent"]?.ToObject() ?? 0f; + float outTangent = key["outTangent"]?.ToObject() ?? 0f; + curve.AddKey(new Keyframe(time, value, inTangent, outTangent)); + } + return curve; + } + + // Simple format: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0} + if (obj.ContainsKey("start") || obj.ContainsKey("startValue") || obj.ContainsKey("end") || obj.ContainsKey("endValue")) + { + float startValue = obj["start"]?.ToObject() ?? obj["startValue"]?.ToObject() ?? 1f; + float endValue = obj["end"]?.ToObject() ?? obj["endValue"]?.ToObject() ?? 1f; + AnimationCurve curve = new AnimationCurve(); + curve.AddKey(0f, startValue); + curve.AddKey(1f, endValue); + return curve; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse AnimationCurve from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into an AnimationCurve, returning a constant curve if parsing fails. + /// Added for ManageVFX refactoring. + /// + /// The JSON token to parse + /// The constant value for the default curve + public static AnimationCurve ParseAnimationCurveOrDefault(JToken token, float defaultValue = 1f) + { + return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue); + } + + /// + /// Validates AnimationCurve JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Wrapped: { "keys": [ { "time": 0, "value": 1.0 }, ... ] } + /// Direct array: [ { "time": 0, "value": 1.0 }, ... ] + /// Null/empty: Valid (will set empty curve) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateAnimationCurveFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set empty curve)."; + return true; + } + + JArray keysArray = null; + + if (valueToken is JObject curveObj) + { + keysArray = curveObj["keys"] as JArray; + if (keysArray == null) + { + message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }"; + return false; + } + } + else if (valueToken is JArray directArray) + { + keysArray = directArray; + } + else + { + message = "AnimationCurve requires object with 'keys' or array of keyframes. " + + "Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }"; + return false; + } + + // Validate each keyframe + for (int i = 0; i < keysArray.Count; i++) + { + var keyToken = keysArray[i]; + if (keyToken is not JObject keyObj) + { + message = $"Keyframe at index {i} must be an object with 'time' and 'value'."; + return false; + } + + // Validate numeric fields if present + string[] numericFields = { "time", "value", "inSlope", "outSlope", "inTangent", "outTangent", "inWeight", "outWeight" }; + foreach (var field in numericFields) + { + if (!ParamCoercion.ValidateNumericField(keyObj, field, out var fieldError)) + { + message = $"Keyframe[{i}].{field}: {fieldError}"; + return false; + } + } + + if (!ParamCoercion.ValidateIntegerField(keyObj, "weightedMode", out var weightedModeError)) + { + message = $"Keyframe[{i}].weightedMode: {weightedModeError}"; + return false; + } + } + + message = $"Value format valid (AnimationCurve with {keysArray.Count} keyframes). " + + "Note: Missing keyframe fields default to 0 (time, value, inSlope, outSlope, inWeight, outWeight)."; + return true; + } + + /// + /// Validates Quaternion JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Euler array: [x, y, z] - 3 numeric elements + /// Raw quaternion: [x, y, z, w] - 4 numeric elements + /// Object: { "x": 0, "y": 0, "z": 0, "w": 1 } + /// Explicit euler: { "euler": [x, y, z] } + /// Null/empty: Valid (will set identity) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateQuaternionFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set identity quaternion)."; + return true; + } + + if (valueToken is JArray arr) + { + if (arr.Count == 3) + { + // Validate Euler angles [x, y, z] + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from Euler angles [x, y, z])."; + return true; + } + else if (arr.Count == 4) + { + // Validate raw quaternion [x, y, z, w] + for (int i = 0; i < 4; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Quaternion component at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from [x, y, z, w])."; + return true; + } + else + { + message = "Quaternion array must have 3 elements (Euler angles) or 4 elements (x, y, z, w)."; + return false; + } + } + else if (valueToken is JObject obj) + { + // Check for explicit euler property + if (obj["euler"] is JArray eulerArr) + { + if (eulerArr.Count != 3) + { + message = "Quaternion euler array must have exactly 3 elements [x, y, z]."; + return false; + } + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(eulerArr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from { euler: [x, y, z] })."; + return true; + } + + // Object format { x, y, z, w } + if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null) + { + if (!ParamCoercion.IsNumericToken(obj["x"]) || !ParamCoercion.IsNumericToken(obj["y"]) || + !ParamCoercion.IsNumericToken(obj["z"]) || !ParamCoercion.IsNumericToken(obj["w"])) + { + message = "Quaternion { x, y, z, w } fields must all be numbers."; + return false; + } + message = "Value format valid (Quaternion from { x, y, z, w })."; + return true; + } + + message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }."; + return false; + } + else + { + message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }."; + return false; + } + } + + /// + /// Parses a JToken into a Rect. + /// Supports {x, y, width, height} format. + /// + public static Rect? ParseRect(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JObject obj && + obj.ContainsKey("x") && obj.ContainsKey("y") && + obj.ContainsKey("width") && obj.ContainsKey("height")) + { + return new Rect( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["width"].ToObject(), + obj["height"].ToObject() + ); + } + + // Array format: [x, y, width, height] + if (token is JArray array && array.Count >= 4) + { + return new Rect( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Bounds. + /// Supports {center: {x,y,z}, size: {x,y,z}} format. + /// + public static Bounds? ParseBounds(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) + { + var center = ParseVector3(obj["center"]) ?? Vector3.zero; + var size = ParseVector3(obj["size"]) ?? Vector3.zero; + return new Bounds(center, size); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Bounds from '{token}': {ex.Message}"); + } + + return null; + } + } +} + diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta b/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta new file mode 100644 index 0000000..f9b8b5f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca2205caede3744aebda9f6da2fa2c22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef new file mode 100644 index 0000000..e8f0ecd --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -0,0 +1,25 @@ +{ + "name": "MCPForUnity.Editor", + "rootNamespace": "MCPForUnity.Editor", + "references": [ + "MCPForUnity.Runtime" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "overrideReferences": true, + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [ + { + "name": "com.unity.visualeffectgraph", + "expression": "0.0.0", + "define": "UNITY_VFX_GRAPH" + } + ], + "noEngineReferences": false +} \ No newline at end of file diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta new file mode 100644 index 0000000..b819bd4 --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 98f702da6ca044be59a864a9419c4eab +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/McpCiBoot.cs b/MCPForUnity/Editor/McpCiBoot.cs new file mode 100644 index 0000000..c8e8c19 --- /dev/null +++ b/MCPForUnity/Editor/McpCiBoot.cs @@ -0,0 +1,21 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor +{ + public static class McpCiBoot + { + public static void StartStdioForCi() + { + try + { + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false); + } + catch { /* ignore */ } + + StdioBridgeHost.StartAutoConnect(); + } + } +} diff --git a/MCPForUnity/Editor/McpCiBoot.cs.meta b/MCPForUnity/Editor/McpCiBoot.cs.meta new file mode 100644 index 0000000..7614d93 --- /dev/null +++ b/MCPForUnity/Editor/McpCiBoot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef9dca277ab34ba1b136d8dcd45de948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MenuItems.meta b/MCPForUnity/Editor/MenuItems.meta new file mode 100644 index 0000000..ad5fb5e --- /dev/null +++ b/MCPForUnity/Editor/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e7f37616736f4d3cbd8bdbc626f5ab9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs new file mode 100644 index 0000000..57fed91 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -0,0 +1,30 @@ +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Setup; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.MenuItems +{ + public static class MCPForUnityMenu + { + [MenuItem(ProductInfo.MenuRoot + "/Toggle MCP Window %#m", priority = 1)] + public static void ToggleMCPWindow() + { + MCPForUnityEditorWindow.ShowWindow(); + } + + [MenuItem(ProductInfo.MenuRoot + "/Local Setup Window", priority = 2)] + public static void ShowSetupWindow() + { + SetupWindowService.ShowSetupWindow(); + } + + + [MenuItem(ProductInfo.MenuRoot + "/Edit EditorPrefs", priority = 3)] + public static void ShowEditorPrefsWindow() + { + EditorPrefsWindow.ShowWindow(); + } + } +} diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta new file mode 100644 index 0000000..af82a27 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 42b27c415aa084fe6a9cc6cf03979d36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Migrations.meta b/MCPForUnity/Editor/Migrations.meta new file mode 100644 index 0000000..62d67f0 --- /dev/null +++ b/MCPForUnity/Editor/Migrations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs new file mode 100644 index 0000000..4fbeac8 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs @@ -0,0 +1,80 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Detects legacy embedded-server preferences and migrates configs to the new uvx/stdio path once. + /// + [InitializeOnLoad] + internal static class LegacyServerSrcMigration + { + private const string ServerSrcKey = EditorPrefKeys.ServerSrc; + private const string UseEmbeddedKey = EditorPrefKeys.UseEmbeddedServer; + + static LegacyServerSrcMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + bool hasServerSrc = EditorPrefs.HasKey(ServerSrcKey); + bool hasUseEmbedded = EditorPrefs.HasKey(UseEmbeddedKey); + + if (!hasServerSrc && !hasUseEmbedded) + { + return; + } + + try + { + McpLog.Info("Detected legacy embedded MCP server configuration. Updating all client configs..."); + + var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); + + if (summary.FailureCount > 0) + { + McpLog.Warn($"Legacy configuration migration finished with errors ({summary.GetSummaryMessage()}). details:"); + if (summary.Messages != null) + { + foreach (var message in summary.Messages) + { + McpLog.Warn($" {message}"); + } + } + McpLog.Warn("Legacy keys will be removed to prevent migration loop. Please configure failing clients manually."); + } + else + { + McpLog.Info($"Legacy configuration migration complete ({summary.GetSummaryMessage()})"); + } + + if (hasServerSrc) + { + EditorPrefs.DeleteKey(ServerSrcKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.ServerSrc"); + } + + if (hasUseEmbedded) + { + EditorPrefs.DeleteKey(UseEmbeddedKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.UseEmbeddedServer"); + } + } + catch (Exception ex) + { + McpLog.Error($"Legacy MCP server migration failed: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta new file mode 100644 index 0000000..ddc85c0 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4436b2149abf4b0d8014f81cd29a2bd0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs new file mode 100644 index 0000000..ee10509 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -0,0 +1,154 @@ +using System; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Keeps stdio MCP clients in sync with the current package version by rewriting their configs when the package updates. + /// + [InitializeOnLoad] + internal static class StdIoVersionMigration + { + private const string LastUpgradeKey = EditorPrefKeys.LastStdIoUpgradeVersion; + + static StdIoVersionMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + string currentVersion = AssetPathUtility.GetPackageVersion(); + if (string.IsNullOrEmpty(currentVersion) || string.Equals(currentVersion, "unknown", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string lastUpgradeVersion = string.Empty; + try { lastUpgradeVersion = EditorPrefs.GetString(LastUpgradeKey, string.Empty); } catch { } + + if (string.Equals(lastUpgradeVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return; // Already refreshed for this package version + } + + bool hadFailures = false; + bool touchedAny = false; + + var configurators = McpClientRegistry.All.OfType().ToList(); + foreach (var configurator in configurators) + { + try + { + if (!configurator.SupportsAutoConfigure) + continue; + + // Handle CLI-based configurators (e.g., Claude Code CLI) + // CheckStatus with attemptAutoRewrite=true will auto-reregister if version mismatch + if (configurator is ClaudeCliMcpConfigurator cliConfigurator) + { + var previousStatus = configurator.Status; + configurator.CheckStatus(attemptAutoRewrite: true); + if (configurator.Status != previousStatus) + { + touchedAny = true; + } + continue; + } + + // Handle JSON file-based configurators + if (!ConfigUsesStdIo(configurator.Client)) + continue; + + // Skip clients that don't support the current transport setting — + // Configure() would throw (e.g., Claude Desktop when HTTP is enabled). + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (useHttp && !configurator.Client.SupportsHttpTransport) + continue; + + MCPServiceLocator.Client.ConfigureClient(configurator); + touchedAny = true; + } + catch (Exception ex) + { + hadFailures = true; + McpLog.Warn($"Failed to refresh stdio config for {configurator.DisplayName}: {ex.Message}"); + } + } + + if (!touchedAny) + { + // Nothing needed refreshing; still record version so we don't rerun every launch + try { EditorPrefs.SetString(LastUpgradeKey, currentVersion); } catch { } + return; + } + + if (hadFailures) + { + McpLog.Warn("Stdio MCP upgrade encountered errors; will retry next session."); + return; + } + + try + { + EditorPrefs.SetString(LastUpgradeKey, currentVersion); + } + catch { } + + McpLog.Info($"Updated stdio MCP configs to package version {currentVersion}."); + } + + private static bool ConfigUsesStdIo(McpClient client) + { + return JsonConfigUsesStdIo(client); + } + + private static bool JsonConfigUsesStdIo(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + try + { + var root = JObject.Parse(File.ReadAllText(configPath)); + + JToken unityNode = null; + if (client.IsVsCodeLayout) + { + unityNode = root.SelectToken("servers.unityMCP") + ?? root.SelectToken("mcp.servers.unityMCP"); + } + else + { + unityNode = root.SelectToken("mcpServers.unityMCP"); + } + + if (unityNode == null) return false; + + return unityNode["command"] != null; + } + catch + { + return false; + } + } + + } +} diff --git a/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta new file mode 100644 index 0000000..872a357 --- /dev/null +++ b/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f1d589c8c8684e6f919ffb393c4b4db5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models.meta b/MCPForUnity/Editor/Models.meta new file mode 100644 index 0000000..8540456 --- /dev/null +++ b/MCPForUnity/Editor/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 16d3ab36890b6c14f9afeabee30e03e3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/Command.cs b/MCPForUnity/Editor/Models/Command.cs new file mode 100644 index 0000000..02a89d8 --- /dev/null +++ b/MCPForUnity/Editor/Models/Command.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Models +{ + /// + /// Represents a command received from the MCP client + /// + public class Command + { + /// + /// The type of command to execute + /// + public string type { get; set; } + + /// + /// The parameters for the command + /// + public JObject @params { get; set; } + } +} + diff --git a/MCPForUnity/Editor/Models/Command.cs.meta b/MCPForUnity/Editor/Models/Command.cs.meta new file mode 100644 index 0000000..63618f5 --- /dev/null +++ b/MCPForUnity/Editor/Models/Command.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6754c84e5deb74749bc3a19e0c9aa280 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/MCPConfigServer.cs b/MCPForUnity/Editor/Models/MCPConfigServer.cs new file mode 100644 index 0000000..25f1163 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServer.cs @@ -0,0 +1,23 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServer + { + [JsonProperty("command")] + public string command; + + [JsonProperty("args")] + public string[] args; + + // VSCode expects a transport type; include only when explicitly set + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string type; + + // URL for HTTP transport mode + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string url; + } +} diff --git a/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta b/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta new file mode 100644 index 0000000..0574c5a --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5fae9d995f514e9498e9613e2cdbeca9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/MCPConfigServers.cs b/MCPForUnity/Editor/Models/MCPConfigServers.cs new file mode 100644 index 0000000..d5065a1 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServers.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServers + { + [JsonProperty("unityMCP")] + public McpConfigServer unityMCP; + } +} diff --git a/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta b/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta new file mode 100644 index 0000000..1fb5f0b --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcb583553e8173b49be71a5c43bd9502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpClient.cs b/MCPForUnity/Editor/Models/McpClient.cs new file mode 100644 index 0000000..85b30c2 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpClient.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Models +{ + public class McpClient + { + public string name; + public string windowsConfigPath; + public string macConfigPath; + public string linuxConfigPath; + public string configStatus; + public McpStatus status = McpStatus.NotConfigured; + public ConfiguredTransport configuredTransport = ConfiguredTransport.Unknown; + + // Capability flags/config for JSON-based configurators + public bool IsVsCodeLayout; // Whether the config file follows VS Code layout (env object at root) + public bool SupportsHttpTransport = true; // Whether the MCP server supports HTTP transport + public bool EnsureEnvObject; // Whether to ensure the env object is present in the config + public bool StripEnvWhenNotRequired; // Whether to strip the env object when not required + public string HttpTypeValue; // Override for the HTTP transport "type" value (null => "http"; Cline/Roo => "streamableHttp"; Kilo => "remote") + public string StdioTypeValue; // Override for the stdio transport "type" value (null => "stdio"; Kilo => "local") + public string ServerContainerKey; // Top-level JSON container key for servers (null => "mcpServers"; Kilo => "mcp") + public string SchemaUrl; // Optional root "$schema" URL written into the config (e.g. Kilo's kilo.jsonc) + public string HttpUrlProperty = "url"; // The property name for the HTTP URL in the config + public Dictionary DefaultUnityFields = new(); + + // Helper method to convert the enum to a display string + public string GetStatusDisplayString() + { + return status switch + { + McpStatus.NotConfigured => "Not Configured", + McpStatus.Configured => "Configured", + McpStatus.Running => "Running", + McpStatus.Connected => "Connected", + McpStatus.IncorrectPath => "Incorrect Path", + McpStatus.CommunicationError => "Communication Error", + McpStatus.NoResponse => "No Response", + McpStatus.UnsupportedOS => "Unsupported OS", + McpStatus.MissingConfig => "Missing MCPForUnity Config", + McpStatus.Error => configStatus?.StartsWith("Error:") == true ? configStatus : "Error", + McpStatus.VersionMismatch => "Version Mismatch", + _ => "Unknown", + }; + } + + // Helper method to set both status enum and string for backward compatibility + public void SetStatus(McpStatus newStatus, string errorDetails = null) + { + status = newStatus; + + if ((newStatus == McpStatus.Error || newStatus == McpStatus.VersionMismatch) && !string.IsNullOrEmpty(errorDetails)) + { + configStatus = errorDetails; + } + else + { + configStatus = GetStatusDisplayString(); + } + } + } +} diff --git a/MCPForUnity/Editor/Models/McpClient.cs.meta b/MCPForUnity/Editor/Models/McpClient.cs.meta new file mode 100644 index 0000000..b08dcf3 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1afa56984aec0d41808edcebf805e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpConfig.cs b/MCPForUnity/Editor/Models/McpConfig.cs new file mode 100644 index 0000000..9ddf9d0 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpConfig.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfig + { + [JsonProperty("mcpServers")] + public McpConfigServers mcpServers; + } +} diff --git a/MCPForUnity/Editor/Models/McpConfig.cs.meta b/MCPForUnity/Editor/Models/McpConfig.cs.meta new file mode 100644 index 0000000..2a407c3 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c17c09908f0c1524daa8b6957ce1f7f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpStatus.cs b/MCPForUnity/Editor/Models/McpStatus.cs new file mode 100644 index 0000000..dfc04fd --- /dev/null +++ b/MCPForUnity/Editor/Models/McpStatus.cs @@ -0,0 +1,31 @@ +namespace MCPForUnity.Editor.Models +{ + // Enum representing the various status states for MCP clients + public enum McpStatus + { + NotConfigured, // Not set up yet + Configured, // Successfully configured + Running, // Service is running + Connected, // Successfully connected + IncorrectPath, // Configuration has incorrect paths + CommunicationError, // Connected but communication issues + NoResponse, // Connected but not responding + MissingConfig, // Config file exists but missing required elements + UnsupportedOS, // OS is not supported + Error, // General error state + VersionMismatch, // Configuration version doesn't match expected version + } + + /// + /// Represents the transport type a client is configured to use. + /// Used to detect mismatches between server and client transport settings. + /// + public enum ConfiguredTransport + { + Unknown, // Could not determine transport type + Stdio, // Client configured for stdio transport + Http, // Client configured for HTTP local transport + HttpRemote // Client configured for HTTP remote-hosted transport + } +} + diff --git a/MCPForUnity/Editor/Models/McpStatus.cs.meta b/MCPForUnity/Editor/Models/McpStatus.cs.meta new file mode 100644 index 0000000..e8e930d --- /dev/null +++ b/MCPForUnity/Editor/Models/McpStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa63057c9e5282d4887352578bf49971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources.meta b/MCPForUnity/Editor/Resources.meta new file mode 100644 index 0000000..8d921df --- /dev/null +++ b/MCPForUnity/Editor/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a6f5bafffbb0f48c2a33ad9470bb1e2d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor.meta b/MCPForUnity/Editor/Resources/Editor.meta new file mode 100644 index 0000000..5c252d1 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 266967ec2e1df44209bf46ec6037d61d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs new file mode 100644 index 0000000..13a5564 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs @@ -0,0 +1,64 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides information about the currently active editor tool. + /// + [McpForUnityResource("get_active_tool")] + public static class ActiveTool + { + public static object HandleCommand(JObject @params) + { + try + { + Tool currentTool = UnityEditor.Tools.current; + string toolName = currentTool.ToString(); + bool customToolActive = UnityEditor.Tools.current == Tool.Custom; + string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; + + var toolInfo = new + { + activeTool = activeToolName, + isCustom = customToolActive, + pivotMode = UnityEditor.Tools.pivotMode.ToString(), + pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), + handleRotation = new + { + x = UnityEditor.Tools.handleRotation.eulerAngles.x, + y = UnityEditor.Tools.handleRotation.eulerAngles.y, + z = UnityEditor.Tools.handleRotation.eulerAngles.z + }, + handlePosition = new + { + x = UnityEditor.Tools.handlePosition.x, + y = UnityEditor.Tools.handlePosition.y, + z = UnityEditor.Tools.handlePosition.z + } + }; + + return new SuccessResponse("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting active tool: {e.Message}"); + } + } + } + + // Helper class for custom tool names + internal static class EditorTools + { + public static string GetActiveToolName() + { + if (UnityEditor.Tools.current == Tool.Custom) + { + return "Unknown Custom Tool"; + } + return UnityEditor.Tools.current.ToString(); + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta new file mode 100644 index 0000000..a2f03ab --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e78b6227ab7742a8a4f679ee6a8a212 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/MCPForUnity/Editor/Resources/Editor/EditorState.cs new file mode 100644 index 0000000..b58c888 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -0,0 +1,27 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides dynamic editor state information that changes frequently. + /// + [McpForUnityResource("get_editor_state")] + public static class EditorState + { + public static object HandleCommand(JObject @params) + { + try + { + var snapshot = EditorStateCache.GetSnapshot(); + return new SuccessResponse("Retrieved editor state.", snapshot); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting editor state: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta b/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta new file mode 100644 index 0000000..c6c5efa --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f7c6df54e014c44fdb0cd3f65a479e37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs b/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs new file mode 100644 index 0000000..28bd0a0 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs @@ -0,0 +1,43 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor.SceneManagement; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Returns information about the currently open Prefab Stage (Isolation or + /// In-Context mode), or { isOpen = false } when no prefab is being edited. + /// + /// Wires up the existing mcpforunity://editor/prefab-stage resource + /// (Server/src/services/resources/prefab_stage.py) which dispatches the + /// get_prefab_stage command name expected by this handler. + /// + [McpForUnityResource("get_prefab_stage")] + public static class GetPrefabStage + { + public static object HandleCommand(JObject @params) + { + try + { + var stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + return new SuccessResponse("No prefab stage open.", new { isOpen = false }); + + var root = stage.prefabContentsRoot; + return new SuccessResponse("Retrieved prefab stage info.", new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = root != null ? root.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty, + }); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting prefab stage: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs.meta b/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs.meta new file mode 100644 index 0000000..ce4d233 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/GetPrefabStage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2bbcba512cf6473184c038e6e3926136 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs b/MCPForUnity/Editor/Resources/Editor/Selection.cs new file mode 100644 index 0000000..8cf4ef9 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides detailed information about the current editor selection. + /// + [McpForUnityResource("get_selection")] + public static class Selection + { + public static object HandleCommand(JObject @params) + { + try + { + var selectionInfo = new + { + activeObject = UnityEditor.Selection.activeObject?.name, + activeGameObject = UnityEditor.Selection.activeGameObject?.name, + activeTransform = UnityEditor.Selection.activeTransform?.name, + activeInstanceID = UnityEditor.Selection.activeObject?.GetInstanceIDCompat() ?? 0, + count = UnityEditor.Selection.count, + objects = UnityEditor.Selection.objects + .Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceIDCompat() + }) + .ToList(), + gameObjects = UnityEditor.Selection.gameObjects + .Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceIDCompat() + }) + .ToList(), + assetGUIDs = UnityEditor.Selection.assetGUIDs + }; + + return new SuccessResponse("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting selection: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta b/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta new file mode 100644 index 0000000..2066f11 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7ea869623e094599a70be086ab4fc0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/ToolStates.cs b/MCPForUnity/Editor/Resources/Editor/ToolStates.cs new file mode 100644 index 0000000..192e760 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ToolStates.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Returns the enabled/disabled state of all discovered tools, grouped by group name. + /// Used by the Python server (especially in stdio mode) to sync tool visibility. + /// + [McpForUnityResource("get_tool_states")] + public static class ToolStates + { + public static object HandleCommand(JObject @params) + { + try + { + var discovery = MCPServiceLocator.ToolDiscovery; + var allTools = discovery.DiscoverAllTools(); + + var toolsArray = new JArray(); + foreach (var tool in allTools) + { + var paramsArray = new JArray(); + if (tool.Parameters != null) + { + foreach (var p in tool.Parameters) + { + paramsArray.Add(new JObject + { + ["name"] = p.Name, + ["description"] = p.Description, + ["type"] = p.Type, + ["required"] = p.Required, + ["default_value"] = p.DefaultValue + }); + } + } + + toolsArray.Add(new JObject + { + ["name"] = tool.Name, + ["group"] = tool.Group ?? "core", + ["enabled"] = discovery.IsToolEnabled(tool.Name), + ["description"] = tool.Description, + ["auto_register"] = tool.AutoRegister, + ["is_built_in"] = tool.IsBuiltIn, + ["structured_output"] = tool.StructuredOutput, + ["requires_polling"] = tool.RequiresPolling, + ["poll_action"] = tool.PollAction ?? "status", + ["max_poll_seconds"] = tool.MaxPollSeconds, + ["parameters"] = paramsArray + }); + } + + var groups = allTools + .GroupBy(t => t.Group ?? "core") + .Select(g => new JObject + { + ["name"] = g.Key, + ["enabled_count"] = g.Count(t => discovery.IsToolEnabled(t.Name)), + ["total_count"] = g.Count() + }); + + var result = new JObject + { + ["tools"] = toolsArray, + ["groups"] = new JArray(groups) + }; + + return new SuccessResponse("Retrieved tool states.", result); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to retrieve tool states: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/ToolStates.cs.meta b/MCPForUnity/Editor/Resources/Editor/ToolStates.cs.meta new file mode 100644 index 0000000..25da174 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/ToolStates.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0f77d36b37ba4526ad30b3c84e3e752c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs b/MCPForUnity/Editor/Resources/Editor/Windows.cs new file mode 100644 index 0000000..675dd3b --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides list of all open editor windows. + /// + [McpForUnityResource("get_windows")] + public static class Windows + { + public static object HandleCommand(JObject @params) + { + try + { + EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll(); + var openWindows = new List(); + + foreach (EditorWindow window in allWindows) + { + if (window == null) + continue; + + try + { + openWindows.Add(new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height + }, + instanceID = window.GetInstanceIDCompat() + }); + } + catch (Exception ex) + { + McpLog.Warn($"Could not get info for window {window.GetType().Name}: {ex.Message}"); + } + } + + return new SuccessResponse("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting editor windows: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta b/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta new file mode 100644 index 0000000..57dd9ed --- /dev/null +++ b/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 58a341e64bea440b29deaf859aaea552 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs new file mode 100644 index 0000000..f86cd5f --- /dev/null +++ b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs @@ -0,0 +1,42 @@ +using System; + +namespace MCPForUnity.Editor.Resources +{ + /// + /// Marks a class as an MCP resource handler for auto-discovery. + /// The class must have a public static HandleCommand(JObject) method. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class McpForUnityResourceAttribute : Attribute + { + /// + /// The resource name used to route requests to this resource. + /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// + public string ResourceName { get; } + + /// + /// Human-readable description of what this resource provides. + /// + public string Description { get; set; } + + /// + /// Create an MCP resource attribute with auto-generated resource name. + /// The resource name will be derived from the class name (PascalCase → snake_case). + /// Example: ManageAsset → manage_asset + /// + public McpForUnityResourceAttribute() + { + ResourceName = null; // Will be auto-generated + } + + /// + /// Create an MCP resource attribute with explicit resource name. + /// + /// The resource name (e.g., "manage_asset") + public McpForUnityResourceAttribute(string resourceName) + { + ResourceName = resourceName; + } + } +} diff --git a/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta new file mode 100644 index 0000000..e887db0 --- /dev/null +++ b/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c2d60f570f3d4bd2a6a2c1293094be3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/MenuItems.meta b/MCPForUnity/Editor/Resources/MenuItems.meta new file mode 100644 index 0000000..df20ed6 --- /dev/null +++ b/MCPForUnity/Editor/Resources/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bca79cd3ef8ed466f9e50e2dc7850e46 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs new file mode 100644 index 0000000..f6a8428 --- /dev/null +++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.MenuItems +{ + /// + /// Provides a simple read-only resource that returns Unity menu items. + /// + [McpForUnityResource("get_menu_items")] + public static class GetMenuItems + { + private static List _cached; + + [InitializeOnLoadMethod] + private static void BuildCache() => Refresh(); + + public static object HandleCommand(JObject @params) + { + bool forceRefresh = @params?["refresh"]?.ToObject() ?? false; + string search = @params?["search"]?.ToString(); + + var items = GetMenuItemsInternal(forceRefresh); + + if (!string.IsNullOrEmpty(search)) + { + items = items + .Where(item => item.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + } + + string message = $"Retrieved {items.Count} menu items"; + return new SuccessResponse(message, items); + } + + internal static List GetMenuItemsInternal(bool forceRefresh) + { + if (forceRefresh || _cached == null) + { + Refresh(); + } + + return (_cached ?? new List()).ToList(); + } + + private static void Refresh() + { + try + { + var methods = TypeCache.GetMethodsWithAttribute(); + _cached = methods + .SelectMany(m => m + .GetCustomAttributes(typeof(MenuItem), false) + .OfType() + .Select(attr => attr.menuItem)) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + } + catch (Exception ex) + { + McpLog.Error($"[GetMenuItems] Failed to scan menu items: {ex}"); + _cached ??= new List(); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta new file mode 100644 index 0000000..fde7829 --- /dev/null +++ b/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04eeea61eb5c24033a88013845d25f23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project.meta b/MCPForUnity/Editor/Resources/Project.meta new file mode 100644 index 0000000..1adf044 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 538489f13d7914c4eba9a67e29001b43 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs b/MCPForUnity/Editor/Resources/Project/Layers.cs new file mode 100644 index 0000000..9e9ef7d --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides dictionary of layer indices to layer names. + /// + [McpForUnityResource("get_layers")] + public static class Layers + { + private const int TotalLayerCount = 32; + + public static object HandleCommand(JObject @params) + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) + { + layers.Add(i, layerName); + } + } + + return new SuccessResponse("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to retrieve layers: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/Layers.cs.meta b/MCPForUnity/Editor/Resources/Project/Layers.cs.meta new file mode 100644 index 0000000..427a7e9 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Layers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 959ee428299454ac19a636275208ca00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs new file mode 100644 index 0000000..857f6c6 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides static project configuration information. + /// + [McpForUnityResource("get_project_info")] + public static class ProjectInfo + { + public static object HandleCommand(JObject @params) + { + try + { + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + string projectName = Path.GetFileName(projectRoot); + + var info = new + { + projectRoot = projectRoot ?? "", + projectName = projectName ?? "", + unityVersion = Application.unityVersion, + platform = EditorUserBuildSettings.activeBuildTarget.ToString(), + assetsPath = assetsPath, + renderPipeline = RenderPipelineUtility.GetActivePipeline().ToString(), + activeInputHandler = GetActiveInputHandler(), + packages = new + { + ugui = IsPackageInstalled("com.unity.ugui"), + textmeshpro = IsPackageInstalled("com.unity.textmeshpro"), + inputsystem = IsPackageInstalled("com.unity.inputsystem"), + uiToolkit = true, + screenCapture = true, + } + }; + + return new SuccessResponse("Retrieved project info.", info); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting project info: {e.Message}"); + } + } + + /// + /// Reads PlayerSettings.activeInputHandler via reflection to avoid + /// compile-time dependency on the Input System package. + /// Returns "Old" (0), "New" (1), or "Both" (2). + /// + private static string GetActiveInputHandler() + { + try + { + var prop = typeof(PlayerSettings).GetProperty( + "activeInputHandler", + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + + if (prop == null) + return "Old"; + + int value = (int)prop.GetValue(null); + return value switch + { + 0 => "Old", + 1 => "New", + 2 => "Both", + _ => "Old" + }; + } + catch + { + return "Old"; + } + } + + private static bool IsPackageInstalled(string packageName) + { + try + { + return PackageInfo.FindForAssetPath("Packages/" + packageName) != null; + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta new file mode 100644 index 0000000..a8eaf02 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81b03415fcf93466e9ed667d19b58d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs b/MCPForUnity/Editor/Resources/Project/Tags.cs new file mode 100644 index 0000000..756f00d --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs @@ -0,0 +1,27 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditorInternal; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides list of all tags in the project. + /// + [McpForUnityResource("get_tags")] + public static class Tags + { + public static object HandleCommand(JObject @params) + { + try + { + string[] tags = InternalEditorUtility.tags; + return new SuccessResponse("Retrieved current tags.", tags); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to retrieve tags: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Project/Tags.cs.meta b/MCPForUnity/Editor/Resources/Project/Tags.cs.meta new file mode 100644 index 0000000..3529bea --- /dev/null +++ b/MCPForUnity/Editor/Resources/Project/Tags.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2179ac5d98f264d1681e7d5c0d0ed341 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene.meta b/MCPForUnity/Editor/Resources/Scene.meta new file mode 100644 index 0000000..20d08db --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 563f6050485b445449a1db200bfba51c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs new file mode 100644 index 0000000..a8850ca --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Cameras; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_cameras")] + public static class CamerasResource + { + public static object HandleCommand(JObject @params) + { + try + { + return CameraControl.ListCameras(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[CamerasResource] Error listing cameras: {e}"); + return new ErrorResponse($"Error listing cameras: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta new file mode 100644 index 0000000..e8fb6d0 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68c487cd2b284b09bcdce22f76127e95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs new file mode 100644 index 0000000..a5f5118 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Runtime.Helpers; + +namespace MCPForUnity.Editor.Resources.Scene +{ + /// + /// Resource handler for reading GameObject data. + /// Provides read-only access to GameObject information without component serialization. + /// + /// URI: unity://scene/gameobject/{instanceID} + /// + [McpForUnityResource("get_gameobject")] + public static class GameObjectResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + // Get instance ID from params + int? instanceID = null; + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + if (idToken != null) + { + instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + instanceID = null; + } + } + + if (!instanceID.HasValue) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + try + { + var go = GameObjectLookup.ResolveInstanceID(instanceID.Value) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + return new + { + success = true, + data = SerializeGameObject(go) + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectResource] Error getting GameObject: {e}"); + return new ErrorResponse($"Error getting GameObject: {e.Message}"); + } + } + + /// + /// Serializes a GameObject without component details. + /// For component data, use GetComponents or GetComponent resources. + /// + public static object SerializeGameObject(GameObject go) + { + if (go == null) + return null; + + var transform = go.transform; + + // Get component type names (not full serialization) + var componentTypes = go.GetComponents() + .Where(c => c != null) + .Select(c => c.GetType().Name) + .ToList(); + + // Get children instance IDs (not full serialization) + var childrenIds = new List(); + foreach (Transform child in transform) + { + childrenIds.Add(child.gameObject.GetInstanceIDCompat()); + } + + return new + { + instanceID = go.GetInstanceIDCompat(), + name = go.name, + tag = go.tag, + layer = go.layer, + layerName = LayerMask.LayerToName(go.layer), + active = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + transform = new + { + position = SerializeVector3(transform.position), + localPosition = SerializeVector3(transform.localPosition), + rotation = SerializeVector3(transform.eulerAngles), + localRotation = SerializeVector3(transform.localEulerAngles), + scale = SerializeVector3(transform.localScale), + lossyScale = SerializeVector3(transform.lossyScale) + }, + parent = transform.parent != null ? transform.parent.gameObject.GetInstanceIDCompat() : (int?)null, + children = childrenIds, + componentTypes = componentTypes, + path = GameObjectLookup.GetGameObjectPath(go) + }; + } + + private static object SerializeVector3(Vector3 v) + { + return new { x = v.x, y = v.y, z = v.z }; + } + } + + /// + /// Resource handler for reading all components on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/components + /// + [McpForUnityResource("get_gameobject_components")] + public static class GameObjectComponentsResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + int instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + // Pagination parameters + int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 25); + int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0); + bool includeProperties = ParamCoercion.CoerceBool(@params["includeProperties"] ?? @params["include_properties"], true); + + pageSize = Mathf.Clamp(pageSize, 1, 100); + + try + { + var go = GameObjectLookup.ResolveInstanceID(instanceID) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + var allComponents = go.GetComponents().Where(c => c != null).ToList(); + int total = allComponents.Count; + + var pagedComponents = allComponents.Skip(cursor).Take(pageSize).ToList(); + + var componentData = new List(); + foreach (var component in pagedComponents) + { + if (includeProperties) + { + componentData.Add(GameObjectSerializer.GetComponentData(component)); + } + else + { + componentData.Add(new + { + typeName = component.GetType().FullName, + instanceID = component.GetInstanceIDCompat() + }); + } + } + + int? nextCursor = cursor + pagedComponents.Count < total ? cursor + pagedComponents.Count : (int?)null; + + return new + { + success = true, + data = new + { + gameObjectID = instanceID, + gameObjectName = go.name, + components = componentData, + cursor = cursor, + pageSize = pageSize, + nextCursor = nextCursor, + totalCount = total, + hasMore = nextCursor.HasValue, + includeProperties = includeProperties + } + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectComponentsResource] Error getting components: {e}"); + return new ErrorResponse($"Error getting components: {e.Message}"); + } + } + } + + /// + /// Resource handler for reading a single component on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/component/{componentName} + /// + [McpForUnityResource("get_gameobject_component")] + public static class GameObjectComponentResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + int instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + string componentName = ParamCoercion.CoerceString(@params["componentName"] ?? @params["component_name"] ?? @params["component"], null); + if (string.IsNullOrEmpty(componentName)) + { + return new ErrorResponse("'componentName' parameter is required."); + } + + try + { + var go = GameObjectLookup.ResolveInstanceID(instanceID) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + // Find the component by type name + Component targetComponent = null; + foreach (var component in go.GetComponents()) + { + if (component == null) continue; + + var typeName = component.GetType().Name; + var fullTypeName = component.GetType().FullName; + + if (string.Equals(typeName, componentName, StringComparison.OrdinalIgnoreCase) || + string.Equals(fullTypeName, componentName, StringComparison.OrdinalIgnoreCase)) + { + targetComponent = component; + break; + } + } + + if (targetComponent == null) + { + return new ErrorResponse($"Component '{componentName}' not found on GameObject '{go.name}'."); + } + + return new + { + success = true, + data = new + { + gameObjectID = instanceID, + gameObjectName = go.name, + component = GameObjectSerializer.GetComponentData(targetComponent) + } + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectComponentResource] Error getting component: {e}"); + return new ErrorResponse($"Error getting component: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta new file mode 100644 index 0000000..5fb61f2 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ee79050d9f6d42798a0757cc7672517 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs new file mode 100644 index 0000000..11efa38 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_renderer_features")] + public static class RendererFeaturesResource + { + public static object HandleCommand(JObject @params) + { + try + { + return RendererFeatureOps.ListFeatures(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[RendererFeaturesResource] Error: {e}"); + return new ErrorResponse($"Error listing renderer features: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta new file mode 100644 index 0000000..a4b143b --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91dffec0c5224fca9ea78f7d92bfc569 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs new file mode 100644 index 0000000..58f2ebb --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_rendering_stats")] + public static class RenderingStatsResource + { + public static object HandleCommand(JObject @params) + { + try + { + return RenderingStatsOps.GetStats(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[RenderingStatsResource] Error: {e}"); + return new ErrorResponse($"Error getting rendering stats: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta new file mode 100644 index 0000000..5674f00 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a6c0a7ee8d9443a9aec534f04dbee225 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs new file mode 100644 index 0000000..08afdfe --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_volumes")] + public static class VolumesResource + { + public static object HandleCommand(JObject @params) + { + try + { + return VolumeOps.ListVolumes(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[VolumesResource] Error listing volumes: {e}"); + return new ErrorResponse($"Error listing volumes: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta new file mode 100644 index 0000000..0e4a7b0 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83cc61dc0e644cf2abd24ad611aa315c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Tests.meta b/MCPForUnity/Editor/Resources/Tests.meta new file mode 100644 index 0000000..0aa0bf0 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 412726d2e774048939b0d2bd4f11a503 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/MCPForUnity/Editor/Resources/Tests/GetTests.cs new file mode 100644 index 0000000..4fe48c7 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Resources.Tests +{ + /// + /// Provides access to Unity tests from the Test Framework with pagination and filtering support. + /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (optional): Filter by "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination + /// - page_number (optional): 1-based page number (converted to cursor) + /// + [McpForUnityResource("get_tests")] + public static class GetTests + { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + + public static async Task HandleCommand(JObject @params) + { + // Parse mode filter + TestMode? modeFilter = null; + string modeStr = @params?["mode"]?.ToString(); + if (!string.IsNullOrEmpty(modeStr)) + { + if (!ModeParser.TryParse(modeStr, out modeFilter, out var parseError)) + { + return new ErrorResponse(parseError); + } + } + + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + + McpLog.Info($"[GetTests] Retrieving tests (mode={modeFilter?.ToString() ?? "all"}, filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; + try + { + allTests = await MCPServiceLocator.Tests.GetTestsAsync(modeFilter).ConfigureAwait(true); + } + catch (Exception ex) + { + McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); + return new ErrorResponse("Failed to retrieve tests"); + } + + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); + + string message = !string.IsNullOrEmpty(nameFilter) + ? $"Retrieved {response.Items.Count} of {response.TotalCount} tests matching '{nameFilter}' (cursor {response.Cursor})" + : $"Retrieved {response.Items.Count} of {response.TotalCount} tests (cursor {response.Cursor})"; + + return new SuccessResponse(message, response); + } + } + + /// + /// DEPRECATED: Use get_tests with mode parameter instead. + /// Provides access to Unity tests for a specific mode (EditMode or PlayMode). + /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (required): "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination + /// + [McpForUnityResource("get_tests_for_mode")] + public static class GetTestsForMode + { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + + public static async Task HandleCommand(JObject @params) + { + string modeStr = @params?["mode"]?.ToString(); + if (string.IsNullOrEmpty(modeStr)) + { + return new ErrorResponse("'mode' parameter is required"); + } + + if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) + { + return new ErrorResponse(parseError); + } + + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + + McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value} (filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; + try + { + allTests = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); + } + catch (Exception ex) + { + McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); + return new ErrorResponse("Failed to retrieve tests"); + } + + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); + + string message = nameFilter != null + ? $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests matching '{nameFilter}'" + : $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests"; + + return new SuccessResponse(message, response); + } + } + + internal static class ModeParser + { + internal static bool TryParse(string modeStr, out TestMode? mode, out string error) + { + error = null; + mode = null; + + if (string.IsNullOrWhiteSpace(modeStr)) + { + error = "'mode' parameter cannot be empty"; + return false; + } + + if (modeStr.Equals("EditMode", StringComparison.OrdinalIgnoreCase)) + { + mode = TestMode.EditMode; + return true; + } + + if (modeStr.Equals("PlayMode", StringComparison.OrdinalIgnoreCase)) + { + mode = TestMode.PlayMode; + return true; + } + + error = $"Unknown test mode: '{modeStr}'. Use 'EditMode' or 'PlayMode'"; + return false; + } + } +} diff --git a/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta b/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta new file mode 100644 index 0000000..aa41973 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84183aaed077e4f25968269c952db2d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security.meta b/MCPForUnity/Editor/Security.meta new file mode 100644 index 0000000..981586a --- /dev/null +++ b/MCPForUnity/Editor/Security.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b6a5aa4def35486aa9c85c981cfa8d15 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore.meta b/MCPForUnity/Editor/Security/SecureKeyStore.meta new file mode 100644 index 0000000..342a413 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e3d4c873dcc741b3be3791b36dcd31bd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs new file mode 100644 index 0000000..3774b37 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs @@ -0,0 +1,220 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Cross-platform fallback key store used when no OS secret service is available + /// (e.g. Linux without libsecret, or headless CI). Encrypts each key at rest with + /// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC). The derivation passphrase is a + /// per-user random master secret (persisted once, 0600) combined with a machine id, + /// run through PBKDF2. Ciphertext lives under the user app-data dir — never under the + /// repo / Assets. This is weaker than an OS keychain (the master secret sits on disk) + /// but far better than plaintext, and it never leaks keys to git or the bridge. + /// + internal sealed class EncryptedFileKeyStore : ISecureKeyStore + { + private const int Iterations = 200_000; + private readonly string _dir; + + public EncryptedFileKeyStore() : this(DefaultDir()) { } + + /// Test seam: point the store at a throwaway directory. + internal EncryptedFileKeyStore(string storageDir) + { + _dir = storageDir; + Directory.CreateDirectory(_dir); + TryChmod(_dir, "700"); + } + + internal static string DefaultDir() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(appData)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + appData = Path.Combine(home, ".config"); + } + return Path.Combine(appData, "MCPForUnity", "AssetGenKeys"); + } + + private string KeyFile(string providerId) => Path.Combine(_dir, "key_" + providerId + ".bin"); + + public bool Has(string providerId) + => !string.IsNullOrEmpty(providerId) && File.Exists(KeyFile(providerId)); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + string path = KeyFile(providerId); + if (!File.Exists(path)) return false; + try + { + byte[] blob = Convert.FromBase64String(File.ReadAllText(path).Trim()); + apiKey = Encoding.UTF8.GetString(Decrypt(blob)); + return true; + } + catch + { + return false; + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + byte[] blob = Encrypt(Encoding.UTF8.GetBytes(apiKey)); + string path = KeyFile(providerId); + File.WriteAllText(path, Convert.ToBase64String(blob)); + TryChmod(path, "600"); + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + try + { + string path = KeyFile(providerId); + if (File.Exists(path)) File.Delete(path); + } + catch { /* best effort */ } + } + + // ---------- crypto ---------- + + private void DeriveKeys(out byte[] encKey, out byte[] macKey) + { + byte[] master = LoadOrCreate(Path.Combine(_dir, "secret.bin"), 32); + byte[] salt = LoadOrCreate(Path.Combine(_dir, "salt.bin"), 16); + string password = Convert.ToBase64String(master) + "|" + MachineId(); + using (var kdf = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256)) + { + byte[] material = kdf.GetBytes(64); + encKey = new byte[32]; + macKey = new byte[32]; + Buffer.BlockCopy(material, 0, encKey, 0, 32); + Buffer.BlockCopy(material, 32, macKey, 0, 32); + } + } + + private byte[] Encrypt(byte[] plaintext) + { + DeriveKeys(out byte[] encKey, out byte[] macKey); + byte[] iv = RandomBytes(16); + byte[] ct; + using (var aes = Aes.Create()) + { + aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; + using (var enc = aes.CreateEncryptor()) + ct = enc.TransformFinalBlock(plaintext, 0, plaintext.Length); + } + byte[] mac; + using (var h = new HMACSHA256(macKey)) + mac = h.ComputeHash(Concat(iv, ct)); + return Concat(iv, mac, ct); + } + + private byte[] Decrypt(byte[] blob) + { + if (blob.Length < 48) throw new CryptographicException("ciphertext too short"); + DeriveKeys(out byte[] encKey, out byte[] macKey); + byte[] iv = new byte[16]; + byte[] mac = new byte[32]; + byte[] ct = new byte[blob.Length - 48]; + Buffer.BlockCopy(blob, 0, iv, 0, 16); + Buffer.BlockCopy(blob, 16, mac, 0, 32); + Buffer.BlockCopy(blob, 48, ct, 0, ct.Length); + using (var h = new HMACSHA256(macKey)) + { + byte[] expected = h.ComputeHash(Concat(iv, ct)); + if (!FixedTimeEquals(expected, mac)) + throw new CryptographicException("MAC verification failed"); + } + using (var aes = Aes.Create()) + { + aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; + using (var dec = aes.CreateDecryptor()) + return dec.TransformFinalBlock(ct, 0, ct.Length); + } + } + + // ---------- helpers ---------- + + private byte[] LoadOrCreate(string path, int len) + { + if (File.Exists(path)) + { + byte[] existing = File.ReadAllBytes(path); + if (existing.Length == len) return existing; + } + byte[] fresh = RandomBytes(len); + File.WriteAllBytes(path, fresh); + TryChmod(path, "600"); + return fresh; + } + + private static string MachineId() + { + try + { + string id = SystemInfo.deviceUniqueIdentifier; + if (!string.IsNullOrEmpty(id) && id != SystemInfo.unsupportedIdentifier) return id; + } + catch { /* not in a Unity runtime context */ } + return Environment.MachineName ?? "unknown-machine"; + } + + private static byte[] RandomBytes(int n) + { + byte[] b = new byte[n]; + using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(b); + return b; + } + + private static byte[] Concat(params byte[][] parts) + { + int total = 0; + foreach (byte[] p in parts) total += p.Length; + byte[] result = new byte[total]; + int offset = 0; + foreach (byte[] p in parts) + { + Buffer.BlockCopy(p, 0, result, offset, p.Length); + offset += p.Length; + } + return result; + } + + private static bool FixedTimeEquals(byte[] a, byte[] b) + { + if (a == null || b == null || a.Length != b.Length) return false; + int diff = 0; + for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + + private static void TryChmod(string path, string mode) + { + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) return; + var psi = new System.Diagnostics.ProcessStartInfo("/bin/chmod") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + psi.ArgumentList.Add(mode); + psi.ArgumentList.Add(path); + using (var p = System.Diagnostics.Process.Start(psi)) p?.WaitForExit(2000); + } + catch { /* hardening is best-effort */ } + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta new file mode 100644 index 0000000..86648ae --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be82d82467544ffa8bbc8c64a8020636 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs new file mode 100644 index 0000000..c4c296d --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs @@ -0,0 +1,26 @@ +using System; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Read-only environment-variable override for provider keys, intended for CI/headless + /// and power users. Resolution order is env → secure store (see ). + /// Env values are never written back to any store. + /// + internal static class EnvKeyOverride + { + /// e.g. "tripo" → "MCPFORUNITY_TRIPO_API_KEY". + internal static string EnvVarName(string providerId) + => "MCPFORUNITY_" + (providerId ?? string.Empty).ToUpperInvariant() + "_API_KEY"; + + internal static bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + string v = Environment.GetEnvironmentVariable(EnvVarName(providerId)); + if (string.IsNullOrEmpty(v)) return false; + apiKey = v; + return true; + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta new file mode 100644 index 0000000..2cc8623 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12d89d2f3dd846d3975bd5beb190a741 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs new file mode 100644 index 0000000..3675368 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs @@ -0,0 +1,25 @@ +namespace MCPForUnity.Editor.Security +{ + /// + /// At-rest storage for AI asset-generation provider API keys. + /// + /// Keys are written ONLY to the OS secure store (Keychain / Credential Manager / + /// libsecret) or an encrypted-file fallback. They are never placed in EditorPrefs, + /// never sent over the MCP bridge, never returned by any MCP tool, and never logged. + /// reports existence only — there is no API that returns all keys. + /// + public interface ISecureKeyStore + { + /// Resolve the key for a provider id (e.g. "tripo"). Returns false when absent. + bool TryGet(string providerId, out string apiKey); + + /// Store (or overwrite) the key for a provider id. Empty/null deletes it. + void Set(string providerId, string apiKey); + + /// Remove the stored key for a provider id (no-op when absent). + void Delete(string providerId); + + /// True when a key exists for the provider id. Never returns the value. + bool Has(string providerId); + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta new file mode 100644 index 0000000..cc1994e --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc35b98fa68548c5ab34badae87b99d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs new file mode 100644 index 0000000..96710e8 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Linux libsecret-backed key store via the `secret-tool` CLI. The secret value is + /// passed on stdin (never as a process argument). Falls back to + /// when secret-tool is not installed. + /// + internal sealed class LinuxSecretToolKeyStore : ISecureKeyStore + { + private const string Service = SecureKeyStoreConstants.ServiceName; + + internal static bool IsAvailable() + { + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("--version"); + using (var p = Process.Start(psi)) + { + p.WaitForExit(2000); + return p.ExitCode == 0; + } + } + catch { return false; } + } + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("lookup"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) + { + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + if (p.ExitCode != 0) return false; + apiKey = (outp ?? string.Empty).TrimEnd('\n', '\r'); + return !string.IsNullOrEmpty(apiKey); + } + } + catch { return false; } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + try + { + var psi = NewPsi(redirectIn: true); + psi.ArgumentList.Add("store"); + psi.ArgumentList.Add("--label=MCPForUnity AssetGen"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) + { + p.StandardInput.Write(apiKey); + p.StandardInput.Close(); + p.WaitForExit(5000); + } + } + catch { /* best effort */ } + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + try + { + var psi = NewPsi(); + psi.ArgumentList.Add("clear"); + psi.ArgumentList.Add("service"); psi.ArgumentList.Add(Service); + psi.ArgumentList.Add("account"); psi.ArgumentList.Add(providerId); + using (var p = Process.Start(psi)) p.WaitForExit(5000); + } + catch { /* best effort */ } + } + + private static ProcessStartInfo NewPsi(bool redirectIn = false) + { + var psi = new ProcessStartInfo("/usr/bin/env") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = redirectIn, + }; + psi.ArgumentList.Add("secret-tool"); + return psi; + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta new file mode 100644 index 0000000..0dbc99c --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eddf52a01df445629b408e5a7593356e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs new file mode 100644 index 0000000..9cf8624 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs @@ -0,0 +1,67 @@ +using System; +using System.Diagnostics; + +namespace MCPForUnity.Editor.Security +{ + /// + /// macOS Keychain-backed key store via /usr/bin/security generic passwords. + /// Service = MCPForUnity.AssetGen, account = provider id. + /// + internal sealed class MacKeychainKeyStore : ISecureKeyStore + { + private const string Security = "/usr/bin/security"; + private const string Service = SecureKeyStoreConstants.ServiceName; + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + (int code, string stdout, _) = Run("find-generic-password", "-s", Service, "-a", providerId, "-w"); + if (code != 0) return false; + apiKey = (stdout ?? string.Empty).TrimEnd('\n', '\r'); + return !string.IsNullOrEmpty(apiKey); + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + // -U overwrites an existing item for this service/account. + Run("add-generic-password", "-U", "-s", Service, "-a", providerId, "-w", apiKey); + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + Run("delete-generic-password", "-s", Service, "-a", providerId); + } + + private static (int code, string stdout, string stderr) Run(params string[] args) + { + try + { + var psi = new ProcessStartInfo(Security) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string a in args) psi.ArgumentList.Add(a); + using (var p = Process.Start(psi)) + { + string outp = p.StandardOutput.ReadToEnd(); + string err = p.StandardError.ReadToEnd(); + p.WaitForExit(5000); + return (p.ExitCode, outp, err); + } + } + catch (Exception e) + { + return (-1, null, e.Message); + } + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta new file mode 100644 index 0000000..c9112d3 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fca6d32a6be24c138e8c32ae824602f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs new file mode 100644 index 0000000..43b230c --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Scrubs secrets out of text before it is logged or returned. Use on every error/log + /// path that might contain an auth header or a key value. + /// + public static class SecretRedactor + { + private const string Mask = "***REDACTED***"; + + // Authorization-scheme tokens: "Bearer xxx", "Key xxx", "Token xxx". + private static readonly Regex SchemeToken = new Regex( + @"\b(Bearer|Key|Token)\s+\S{6,}", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Redact auth-scheme tokens from arbitrary text (cheap; no store reads). + public static string Scrub(string text) + { + if (string.IsNullOrEmpty(text)) return text; + return SchemeToken.Replace(text, m => m.Groups[1].Value + " " + Mask); + } + + /// Redact a specific known secret value as well as auth-scheme tokens. + public static string Scrub(string text, string secret) + { + if (string.IsNullOrEmpty(text)) return text; + if (!string.IsNullOrEmpty(secret) && secret.Length >= 4) + { + text = text.Replace(secret, Mask); + } + return Scrub(text); + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta new file mode 100644 index 0000000..28e456d --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08d3717562bc41cca65e51e8eb165084 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs new file mode 100644 index 0000000..c4c650c --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs @@ -0,0 +1,65 @@ +using UnityEngine; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Entry point for at-rest provider key storage. Selects the best OS secure store for + /// the current platform and layers the read-only environment-variable override on top. + /// Keys never transit the MCP bridge; only the C# editor side reads them, at call time. + /// + public static class SecureKeyStore + { + private static ISecureKeyStore _current; + + public static ISecureKeyStore Current => _current ??= Build(); + + /// Test seam: substitute an in-memory or temp-dir store. + internal static void OverrideForTests(ISecureKeyStore store) => _current = store; + + /// Test seam: clear the cached instance. + internal static void ResetForTests() => _current = null; + + private static ISecureKeyStore Build() => new EnvOverlayKeyStore(SelectPlatform()); + + private static ISecureKeyStore SelectPlatform() + { + switch (Application.platform) + { + case RuntimePlatform.OSXEditor: + return new MacKeychainKeyStore(); + case RuntimePlatform.WindowsEditor: + return new WindowsCredentialKeyStore(); + case RuntimePlatform.LinuxEditor: + return LinuxSecretToolKeyStore.IsAvailable() + ? (ISecureKeyStore)new LinuxSecretToolKeyStore() + : new EncryptedFileKeyStore(); + default: + return new EncryptedFileKeyStore(); + } + } + } + + /// + /// Wraps a platform store so an MCPFORUNITY_<PROVIDER>_API_KEY environment + /// variable takes precedence on reads. Writes always go to the underlying store. + /// + internal sealed class EnvOverlayKeyStore : ISecureKeyStore + { + private readonly ISecureKeyStore _inner; + + public EnvOverlayKeyStore(ISecureKeyStore inner) { _inner = inner; } + + public bool TryGet(string providerId, out string apiKey) + { + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + return _inner.TryGet(providerId, out apiKey); + } + + public bool Has(string providerId) + => EnvKeyOverride.TryGet(providerId, out _) || _inner.Has(providerId); + + public void Set(string providerId, string apiKey) => _inner.Set(providerId, apiKey); + + public void Delete(string providerId) => _inner.Delete(providerId); + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta new file mode 100644 index 0000000..202e7f5 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e75b60b25da4b9b9c0dc0df42fa1017 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs new file mode 100644 index 0000000..7d3c2b2 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs @@ -0,0 +1,15 @@ +namespace MCPForUnity.Editor.Security +{ + /// Shared constants for the secure key store. + internal static class SecureKeyStoreConstants + { + /// Service / target namespace used in the OS secure store. + internal const string ServiceName = "MCPForUnity.AssetGen"; + + /// Known asset-generation provider ids (lowercase). + internal static readonly string[] ProviderIds = + { + "tripo", "meshy", "sketchfab", "fal", "openrouter" + }; + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta new file mode 100644 index 0000000..6431c91 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59ac5388ff204ea99427dfab8a0370f0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs new file mode 100644 index 0000000..ac7f407 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs @@ -0,0 +1,106 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace MCPForUnity.Editor.Security +{ + /// + /// Windows Credential Manager-backed key store (DPAPI-protected by the OS) via advapi32 + /// Cred* APIs. Target = "MCPForUnity.AssetGen:<provider>", blob = UTF-8 key bytes. + /// + internal sealed class WindowsCredentialKeyStore : ISecureKeyStore + { + private const int CRED_TYPE_GENERIC = 1; + private const int CRED_PERSIST_LOCAL_MACHINE = 2; + + private static string Target(string providerId) + => SecureKeyStoreConstants.ServiceName + ":" + providerId; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")] + private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")] + private static extern bool CredWrite([In] ref CREDENTIAL credential, int flags); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")] + private static extern bool CredDelete(string target, int type, int flags); + + [DllImport("advapi32.dll", EntryPoint = "CredFree")] + private static extern void CredFree(IntPtr buffer); + + public bool Has(string providerId) => TryGet(providerId, out _); + + public bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrEmpty(providerId)) return false; + if (!CredRead(Target(providerId), CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return false; + try + { + var cred = Marshal.PtrToStructure(ptr); + if (cred.CredentialBlobSize <= 0 || cred.CredentialBlob == IntPtr.Zero) return false; + byte[] bytes = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize); + apiKey = Encoding.UTF8.GetString(bytes); + return !string.IsNullOrEmpty(apiKey); + } + finally + { + CredFree(ptr); + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrEmpty(providerId)) return; + if (string.IsNullOrEmpty(apiKey)) { Delete(providerId); return; } + byte[] blob = Encoding.UTF8.GetBytes(apiKey); + IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length); + try + { + Marshal.Copy(blob, 0, blobPtr, blob.Length); + var cred = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = Target(providerId), + CredentialBlobSize = blob.Length, + CredentialBlob = blobPtr, + Persist = CRED_PERSIST_LOCAL_MACHINE, + UserName = providerId, + }; + if (!CredWrite(ref cred, 0)) + { + throw new InvalidOperationException( + "CredWrite failed (Win32 " + Marshal.GetLastWin32Error() + ")"); + } + } + finally + { + Marshal.FreeHGlobal(blobPtr); + } + } + + public void Delete(string providerId) + { + if (string.IsNullOrEmpty(providerId)) return; + CredDelete(Target(providerId), CRED_TYPE_GENERIC, 0); + } + } +} diff --git a/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta new file mode 100644 index 0000000..799d421 --- /dev/null +++ b/MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c777dcfa11b446897576292db1982a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services.meta b/MCPForUnity/Editor/Services.meta new file mode 100644 index 0000000..e800dea --- /dev/null +++ b/MCPForUnity/Editor/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2ab6b1cc527214416b21e07b96164f24 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen.meta b/MCPForUnity/Editor/Services/AssetGen.meta new file mode 100644 index 0000000..375e015 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2796223ccc4747ffa205b1bac8b2ceef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs new file mode 100644 index 0000000..1f23fa5 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using MCPForUnity.Editor.Services.AssetGen.Import; +using MCPForUnity.Editor.Services.AssetGen.Providers; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen +{ + public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Canceled } + + /// + /// Snapshot of a generation/import job. Persisted to SessionState so a `status` query + /// still works after an unrelated domain reload. NEVER carries a key or secret. + /// + public sealed class AssetGenJob + { + public string JobId; + public string Kind; // model | image | marketplace + public string Provider; + public string Action; + public AssetGenJobState State; + public float Progress; + public string Format; + public float TargetSize = 1f; + public string AnimationType; // FBX/OBJ rig mode: null/none | generic | humanoid | legacy + public string AssetPath; + public string AssetGuid; + public string Error; + } + + /// + /// Drives asset-generation jobs on the Unity main thread via EditorApplication.update. Each + /// job runs a generic submit → poll → (download | inline) → import state machine; the model + /// and image paths supply their own submit/poll/import delegates. Because UnityWebRequest + /// completes on the main thread, polling Task.IsCompleted from the update loop is + /// main-thread-safe — we never block or use threadpool waits. The provider key is read once at + /// submit time, captured only inside the submit/poll closures — never stored on the job, + /// persisted, or logged. + /// + [InitializeOnLoad] + public static class AssetGenJobManager + { + private const string JobKeyPrefix = "MCPForUnity.AssetGen.Job."; + private const string JobIndexKey = "MCPForUnity.AssetGen.JobIndex"; + + // Test seams (overridable; defaults are the production implementations). + internal static IHttpTransport TransportOverrideForTests; + internal static Func ImportOverrideForTests; + internal static double PollIntervalSeconds = 3.0; + internal static double TimeoutSeconds = 600.0; + + private static readonly Dictionary Jobs = new(); + private static readonly Dictionary Runners = new(); + private static readonly List _tickIds = new(); + private static bool _ticking; + + static AssetGenJobManager() + { + try + { + string index = SessionState.GetString(JobIndexKey, string.Empty); + if (string.IsNullOrEmpty(index)) return; + foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + string json = SessionState.GetString(JobKeyPrefix + id, string.Empty); + if (string.IsNullOrEmpty(json)) continue; + var job = JsonConvert.DeserializeObject(json); + if (job == null) continue; + if (!IsTerminal(job.State)) + { + job.State = AssetGenJobState.Failed; + job.Error = "Interrupted by an editor reload; please retry."; + Persist(job); + } + Jobs[id] = job; + } + } + catch { /* recovery is best-effort */ } + } + + public static AssetGenJob StartModelGeneration(ModelGenRequest req) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + string provider = string.IsNullOrEmpty(req.Provider) ? "tripo" : req.Provider; + IModelProviderAdapter adapter = AssetGenProviders.Model(provider); // throws NotSupportedException if unimplemented + + var job = NewJob("model", provider, "generate"); + job.Format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format; + job.TargetSize = req.TargetSize <= 0 ? 1f : req.TargetSize; + + if (!TryResolveKey(provider, job, out string apiKey)) return job; + + IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), + ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, + Transport = transport, + OutputFolder = req.OutputFolder, + Ext = (job.Format ?? "glb").TrimStart('.'), + Name = NameFrom(req.Name, req.Prompt, job.JobId), + Subfolder = "Models", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob StartImageGeneration(ImageGenRequest req) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider; + IImageProviderAdapter adapter = AssetGenProviders.Image(provider); // throws NotSupportedException if unimplemented + + var job = NewJob("image", provider, "generate"); + job.Format = "png"; + + if (!TryResolveKey(provider, job, out string apiKey)) return job; + + IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + bool asSprite = req.AsSprite; + bool transparent = req.Transparent; + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct), + PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct), + ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)), + Transport = transport, + OutputFolder = req.OutputFolder, + Ext = "png", + Name = NameFrom(req.Name, req.Prompt, job.JobId), + Subfolder = "Images", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob StartMarketplaceImport(string uid, float targetSize, string name, string outputFolder) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentException("uid required"); + var adapter = AssetGenProviders.Marketplace("sketchfab"); // throws NotSupported if unimplemented + var job = NewJob("marketplace", "sketchfab", "import"); + job.TargetSize = targetSize <= 0 ? 1f : targetSize; + if (!TryResolveKey("sketchfab", job, out string apiKey)) return job; + var transport = TransportOverrideForTests ?? new UnityWebRequestTransport(); + var runner = new Runner + { + Job = job, + SubmitFn = ct => adapter.ResolveDownloadUrlAsync(uid, apiKey, transport, ct), // returns the zip/gltf URL as providerJobId + PollFn = (pid, ct) => Task.FromResult(new ProviderPollResult { State = ProviderPollState.Succeeded, Progress = 1f, DownloadUrl = pid, ResultExt = "zip" }), + ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto, + Transport = transport, + OutputFolder = outputFolder, + Ext = "zip", + Name = NameFrom(name, uid, job.JobId), + Subfolder = "Sketchfab", + }; + Register(job, runner); + return job; + } + + public static AssetGenJob GetJob(string jobId) + => string.IsNullOrEmpty(jobId) ? null : (Jobs.TryGetValue(jobId, out var j) ? j : null); + + /// Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys. + public static IReadOnlyList RecentJobs(int max = 20) + { + var all = new List(Jobs.Values); + int start = Math.Max(0, all.Count - max); + var slice = all.GetRange(start, all.Count - start); + slice.Reverse(); + return slice; + } + + public static bool Cancel(string jobId) + { + if (string.IsNullOrEmpty(jobId)) return false; + if (Runners.TryGetValue(jobId, out var r)) + { + r.Canceled = true; + try { r.Cts.Cancel(); } catch { } + return true; + } + if (Jobs.TryGetValue(jobId, out var job) && job.State == AssetGenJobState.Queued) + { + job.State = AssetGenJobState.Canceled; + Persist(job); + return true; + } + return false; + } + + // ---------- runner ---------- + + private enum RunnerPhase { Submit, AwaitSubmit, Poll, AwaitPoll, Download, AwaitDownload, Import } + + private sealed class Runner + { + public AssetGenJob Job; + public Func> SubmitFn; + public Func> PollFn; + public Func ImportFn; + public IHttpTransport Transport; + public string OutputFolder; + public string Ext; + public string OverrideExt; + public string Name; + public string Subfolder; + + public CancellationTokenSource Cts = new(); + public RunnerPhase Phase = RunnerPhase.Submit; + public double StartedAt; + public double NextPollAt; + public string ProviderJobId; + public string DownloadUrl; + public string LocalPath; + public Task SubmitTask; + public Task PollTask; + public Task DownloadTask; + public bool Canceled; + } + + private static bool TryResolveKey(string provider, AssetGenJob job, out string apiKey) + { + if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey)) + { + job.State = AssetGenJobState.Failed; + job.Error = AssetGenProviders.MissingKeyMessage(provider); + Jobs[job.JobId] = job; + Persist(job); + return false; + } + return true; + } + + private static void Register(AssetGenJob job, Runner runner) + { + runner.StartedAt = Now(); + Jobs[job.JobId] = job; + Runners[job.JobId] = runner; + Persist(job); + EnsureTicking(); + } + + private static void EnsureTicking() + { + if (_ticking) return; + EditorApplication.update += Tick; + _ticking = true; + } + + private static void Tick() + { + if (Runners.Count == 0) + { + EditorApplication.update -= Tick; + _ticking = false; + return; + } + // Snapshot keys into a reused buffer so Advance can mutate Runners mid-iteration + // without churning the GC on every editor-update frame. + _tickIds.Clear(); + _tickIds.AddRange(Runners.Keys); + foreach (string id in _tickIds) + { + if (Runners.TryGetValue(id, out var r)) Advance(r); + } + } + + /// Advance one job one step. Returns true when the job is terminal. + internal static bool TryAdvanceForTests(string jobId) + { + if (Runners.TryGetValue(jobId, out var r)) + { + Advance(r); + return IsTerminal(r.Job.State); + } + return Jobs.TryGetValue(jobId, out var j) && IsTerminal(j.State); + } + + private static void Advance(Runner r) + { + if (IsTerminal(r.Job.State)) { Finalize(r); return; } + if (r.Canceled) { r.Job.State = AssetGenJobState.Canceled; Persist(r.Job); Finalize(r); return; } + if (Now() - r.StartedAt > TimeoutSeconds) { Fail(r, $"Timed out after {TimeoutSeconds:0}s."); return; } + + try + { + switch (r.Phase) + { + case RunnerPhase.Submit: + r.Job.State = AssetGenJobState.Running; + Persist(r.Job); + r.SubmitTask = r.SubmitFn(r.Cts.Token); + r.Phase = RunnerPhase.AwaitSubmit; + break; + + case RunnerPhase.AwaitSubmit: + if (!r.SubmitTask.IsCompleted) break; + if (Faulted(r.SubmitTask, out string subErr)) { Fail(r, subErr); break; } + r.ProviderJobId = r.SubmitTask.Result; + if (string.IsNullOrEmpty(r.ProviderJobId)) { Fail(r, "Provider returned no job id."); break; } + r.NextPollAt = Now(); + r.Phase = RunnerPhase.Poll; + break; + + case RunnerPhase.Poll: + if (Now() < r.NextPollAt) break; + r.PollTask = r.PollFn(r.ProviderJobId, r.Cts.Token); + r.Phase = RunnerPhase.AwaitPoll; + break; + + case RunnerPhase.AwaitPoll: + if (!r.PollTask.IsCompleted) break; + if (Faulted(r.PollTask, out string pollErr)) { Fail(r, pollErr); break; } + ProviderPollResult pr = r.PollTask.Result; + r.Job.Progress = Mathf.Clamp01(pr.Progress); + Persist(r.Job); + if (pr.State == ProviderPollState.Succeeded) + { + r.OverrideExt = pr.ResultExt; + if (pr.InlineData != null && pr.InlineData.Length > 0) + { + r.LocalPath = WriteFile(r, pr.InlineData); + r.Job.State = AssetGenJobState.Importing; + Persist(r.Job); + r.Phase = RunnerPhase.Import; + } + else if (!string.IsNullOrEmpty(pr.DownloadUrl)) + { + r.DownloadUrl = pr.DownloadUrl; + r.Phase = RunnerPhase.Download; + } + else + { + Fail(r, "Provider succeeded but returned no result data."); + } + } + else if (pr.State == ProviderPollState.Failed) + { + Fail(r, string.IsNullOrEmpty(pr.Error) ? "Provider reported failure." : pr.Error); + } + else + { + r.NextPollAt = Now() + PollIntervalSeconds; + r.Phase = RunnerPhase.Poll; + } + break; + + case RunnerPhase.Download: + // The download URL comes from an untrusted provider response. Only fetch + // http(s) — refuse file://, ftp://, etc. so a malicious response can't read + // a local file into the project or hit an internal host. + if (!IsAllowedDownloadUrl(r.DownloadUrl)) + { + Fail(r, "Refusing to fetch a non-http(s) download URL returned by the provider."); + break; + } + r.DownloadTask = r.Transport.SendAsync( + new HttpRequestSpec { Method = "GET", Url = r.DownloadUrl }, r.Cts.Token); + r.Phase = RunnerPhase.AwaitDownload; + break; + + case RunnerPhase.AwaitDownload: + if (!r.DownloadTask.IsCompleted) break; + if (Faulted(r.DownloadTask, out string dlErr)) { Fail(r, dlErr); break; } + HttpResult res = r.DownloadTask.Result; + if (res == null || !res.IsSuccess || res.Body == null || res.Body.Length == 0) + { + Fail(r, $"Download failed (HTTP {res?.Status})."); + break; + } + r.LocalPath = WriteFile(r, res.Body); + r.Job.State = AssetGenJobState.Importing; + Persist(r.Job); + r.Phase = RunnerPhase.Import; + break; + + case RunnerPhase.Import: + // The result file was just written via File.WriteAllBytes (outside the + // AssetDatabase). Refresh so Unity registers it before we import it, + // mirroring ImportModelFile. Skipped under the test import seam. + if (ImportOverrideForTests == null) AssetDatabase.Refresh(); + AssetGenJob imported = r.ImportFn(r.Job, r.LocalPath); + if (imported != null) r.Job = imported; + if (r.Job.State != AssetGenJobState.Failed) + { + r.Job.State = AssetGenJobState.Done; + r.Job.Progress = 1f; + } + Persist(r.Job); + Finalize(r); + break; + } + } + catch (Exception e) + { + Fail(r, SecretRedactor.Scrub(e.Message)); + } + } + + private static string WriteFile(Runner r, byte[] bytes) + { + string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext; + string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant(); + string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder + : (AssetGenPrefs.OutputRoot + "/" + r.Subfolder); + if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root)) + root = AssetGenPrefs.DefaultOutputRoot + "/" + r.Subfolder; + string absRoot = AssetGenPaths.ToAbsolute(root); + Directory.CreateDirectory(absRoot); + string baseName = SanitizeName(r.Name); + string fileName = baseName + "." + ext; + string abs = Path.Combine(absRoot, fileName); + int n = 1; + while (File.Exists(abs)) { fileName = baseName + "_" + n++ + "." + ext; abs = Path.Combine(absRoot, fileName); } + File.WriteAllBytes(abs, bytes); + return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/'); + } + + private static string NameFrom(string explicitName, string prompt, string jobId) + { + if (!string.IsNullOrWhiteSpace(explicitName)) return explicitName; + if (!string.IsNullOrWhiteSpace(prompt)) return prompt; + return "asset_" + jobId.Substring(0, 8); + } + + private static string SanitizeName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return "asset"; + var sb = new System.Text.StringBuilder(); + foreach (char c in raw.Trim()) + { + if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c); + else if (c == ' ') sb.Append('_'); + if (sb.Length >= 48) break; + } + string s = sb.ToString().Trim('_', '-'); + return string.IsNullOrEmpty(s) ? "asset" : s; + } + + private static void Fail(Runner r, string message) + { + r.Job.State = AssetGenJobState.Failed; + r.Job.Error = SecretRedactor.Scrub(string.IsNullOrEmpty(message) ? "Generation failed." : message); + Persist(r.Job); + Finalize(r); + } + + private static void Finalize(Runner r) + { + Runners.Remove(r.Job.JobId); + try { r.Cts?.Dispose(); } catch { } + } + + private static AssetGenJob NewJob(string kind, string provider, string action) + { + return new AssetGenJob + { + JobId = Guid.NewGuid().ToString("N"), + Kind = kind, + Provider = provider, + Action = action, + State = AssetGenJobState.Queued, + Progress = 0f, + }; + } + + private static void Persist(AssetGenJob job) + { + try + { + SessionState.SetString(JobKeyPrefix + job.JobId, JsonConvert.SerializeObject(job)); + string index = SessionState.GetString(JobIndexKey, string.Empty); + var ids = new HashSet(index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + if (ids.Add(job.JobId)) + SessionState.SetString(JobIndexKey, string.Join(",", ids)); + } + catch { /* persistence is best-effort */ } + } + + private static bool Faulted(Task t, out string error) + { + if (t.IsFaulted) + { + Exception ex = t.Exception?.GetBaseException(); + error = SecretRedactor.Scrub(ex?.Message ?? "request failed"); + return true; + } + if (t.IsCanceled) { error = "Canceled."; return true; } + error = null; + return false; + } + + private static bool IsTerminal(AssetGenJobState s) + => s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled; + + /// Only http(s) download URLs are allowed; provider responses are untrusted. + private static bool IsAllowedDownloadUrl(string url) + => Uri.TryCreate(url, UriKind.Absolute, out Uri u) + && (u.Scheme == Uri.UriSchemeHttps || u.Scheme == Uri.UriSchemeHttp); + + private static double Now() => EditorApplication.timeSinceStartup; + + internal static void ResetForTests() + { + foreach (var r in new List(Runners.Values)) + { + try { r.Cts?.Cancel(); r.Cts?.Dispose(); } catch { } + } + Runners.Clear(); + string index = SessionState.GetString(JobIndexKey, string.Empty); + foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + SessionState.EraseString(JobKeyPrefix + id); + SessionState.EraseString(JobIndexKey); + Jobs.Clear(); + TransportOverrideForTests = null; + ImportOverrideForTests = null; + PollIntervalSeconds = 3.0; + TimeoutSeconds = 600.0; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta new file mode 100644 index 0000000..55d49ac --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1955ee1b47384a8a880f00d384fff433 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http.meta b/MCPForUnity/Editor/Services/AssetGen/Http.meta new file mode 100644 index 0000000..e4d7290 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 29756f9999cd4779b156e75b9370ca8a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs new file mode 100644 index 0000000..cbcf59c --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Test double for . Records every request it is handed and + /// returns a canned response, so provider adapters can be exercised without a network. + /// Match responses either with the delegate (full control) or with + /// (first entry whose key is contained in the request URL). + /// + public sealed class FakeHttpTransport : IHttpTransport + { + public List RecordedRequests { get; } = new List(); + + /// Highest-priority responder; return null to fall through to . + public Func Handler { get; set; } + + /// Canned responses keyed by a substring expected to appear in the request URL. + public Dictionary ByUrlSubstring { get; } = new Dictionary(); + + public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + { + RecordedRequests.Add(spec); + + HttpResult result = Handler?.Invoke(spec); + + if (result == null && spec?.Url != null) + { + foreach (var kv in ByUrlSubstring) + { + if (spec.Url.IndexOf(kv.Key, StringComparison.Ordinal) >= 0) + { + result = kv.Value; + break; + } + } + } + + if (result == null) + { + result = new HttpResult + { + Status = 500, + IsSuccess = false, + Text = "FakeHttpTransport: no canned response matched " + (spec?.Url ?? "") + }; + } + + return Task.FromResult(result); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta new file mode 100644 index 0000000..0dc9f9d --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/FakeHttpTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46a96ea76bac45cab5025a37f56ba671 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs new file mode 100644 index 0000000..f009236 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Transport-agnostic description of a single HTTP request. Provider adapters build one + /// of these and hand it to an ; this keeps adapters free of + /// any direct dependency on UnityWebRequest so they can be unit-tested without a network. + /// + public sealed class HttpRequestSpec + { + public string Method; + public string Url; + public Dictionary Headers = new Dictionary(); + public byte[] Body; + public string ContentType; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta new file mode 100644 index 0000000..72bf78f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpRequestSpec.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 82093efd40c44dc3bd83e481484ef827 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs new file mode 100644 index 0000000..2b654c2 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs @@ -0,0 +1,19 @@ +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Transport-agnostic result of an . is + /// the numeric HTTP status code; reflects the transport's own view + /// of success (e.g. UnityWebRequest.Result.Success), which adapters combine with their own + /// body-level checks. + /// + public sealed class HttpResult + { + public int Status; + public byte[] Body; + public string Text; + public bool IsSuccess; + + /// True when the transport reports success or the status code is 2xx. + public bool Ok => IsSuccess || (Status >= 200 && Status < 300); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta new file mode 100644 index 0000000..6d401c2 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/HttpResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 507ef9b8ac9d4f42be51a3bc9203eeff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs new file mode 100644 index 0000000..09d6061 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// The HTTP seam that provider adapters depend on. Production uses + /// ; tests inject so + /// adapter request/response shaping can be verified without touching the network. + /// + public interface IHttpTransport + { + Task SendAsync(HttpRequestSpec spec, CancellationToken ct); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta new file mode 100644 index 0000000..815afd8 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/IHttpTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 746824fdb60a436b880f22fee8cdb177 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs new file mode 100644 index 0000000..395fc0b --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine.Networking; + +namespace MCPForUnity.Editor.Services.AssetGen.Http +{ + /// + /// Production backed by UnityWebRequest. Must be invoked on the + /// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is + /// awaited via a wired to the async op's completed + /// callback, so the call never blocks the editor loop. + /// + public sealed class UnityWebRequestTransport : IHttpTransport + { + public Task SendAsync(HttpRequestSpec spec, CancellationToken ct) + { + if (spec == null) throw new ArgumentNullException(nameof(spec)); + + var tcs = new TaskCompletionSource(); + + var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET) + { + downloadHandler = new DownloadHandlerBuffer() + }; + if (spec.Body != null) + { + request.uploadHandler = new UploadHandlerRaw(spec.Body); + } + if (!string.IsNullOrEmpty(spec.ContentType)) + { + request.SetRequestHeader("Content-Type", spec.ContentType); + } + if (spec.Headers != null) + { + foreach (var kv in spec.Headers) + { + request.SetRequestHeader(kv.Key, kv.Value); + } + } + + CancellationTokenRegistration ctReg = default; + if (ct.CanBeCanceled) + { + ctReg = ct.Register(() => + { + try { request.Abort(); } catch { /* ignore */ } + tcs.TrySetCanceled(); + }); + } + + var op = request.SendWebRequest(); + op.completed += _ => + { + try + { + var result = new HttpResult + { + Status = (int)request.responseCode, + Body = request.downloadHandler?.data, + Text = request.downloadHandler?.text, + IsSuccess = request.result == UnityWebRequest.Result.Success + }; + tcs.TrySetResult(result); + } + catch (Exception e) + { + tcs.TrySetException(e); + } + finally + { + ctReg.Dispose(); + request.Dispose(); + } + }; + + return tcs.Task; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta new file mode 100644 index 0000000..fd636e6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Http/UnityWebRequestTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 333575db1d8f487a9f2d32fa78dbb009 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import.meta b/MCPForUnity/Editor/Services/AssetGen/Import.meta new file mode 100644 index 0000000..4718cdc --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8915ae71731f43f1a1c03cc6f901a39d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs new file mode 100644 index 0000000..c1cfb08 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using UnityEditor; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Imports a generated 2D image (PNG, already under Assets/) and applies TextureImporter + /// settings: Sprite vs Default, alpha-is-transparency, and sRGB (color) vs linear (data maps). + /// + public static class ImageImportPipeline + { + public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool asSprite, bool transparent, bool isColor) + { + if (job == null) return null; + try + { + if (string.IsNullOrEmpty(localFilePath)) + return Fail(job, "No file to import."); + + if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) + return Fail(job, "Generated file is not under the Assets folder."); + + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); + + if (AssetImporter.GetAtPath(rel) is TextureImporter importer) + { + importer.textureType = asSprite ? TextureImporterType.Sprite : TextureImporterType.Default; + importer.alphaIsTransparency = transparent; + importer.sRGBTexture = isColor; // color maps sRGB; normal/roughness/metallic would be linear + if (asSprite) + { + importer.spriteImportMode = SpriteImportMode.Single; + importer.mipmapEnabled = false; + } + importer.SaveAndReimport(); + } + + job.AssetPath = rel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(rel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the image but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + catch (Exception e) + { + return Fail(job, SecretRedactor.Scrub(e.Message)); + } + } + + private static AssetGenJob Fail(AssetGenJob job, string message) + { + job.State = AssetGenJobState.Failed; + job.Error = message; + return job; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta new file mode 100644 index 0000000..ae11c95 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ImageImportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5088530e3bc348e1b6539464588bc8bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs new file mode 100644 index 0000000..02f1ab0 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Imports a downloaded model file (already under Assets/) into the project and applies + /// import settings. GLB/glTF require the optional glTFast package (installed from the + /// Dependencies tab); FBX/OBJ use Unity's built-in ModelImporter. Optionally normalizes + /// the model's scale to a target size. + /// + public static class ModelImportPipeline + { + // Inert asset types permitted out of an UNTRUSTED provider archive (Sketchfab et al.). + // Anything else — scripts, assemblies, asmdefs — is skipped on extraction so it can never + // compile or load inside the Editor. See SafeZipExtractor for the enforcement. + private static readonly HashSet ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl", + ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr", + ".ktx", ".ktx2", ".basis", ".dds", + }; + + public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath) + { + if (job == null) return null; + try + { + if (string.IsNullOrEmpty(localFilePath)) + return Fail(job, "No file to import."); + + if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel)) + return Fail(job, "Generated file is not under the Assets folder."); + + string ext = Path.GetExtension(rel).ToLowerInvariant(); + if (ext == ".zip") + return ImportArchive(job, rel); + + return ImportModelFile(job, rel, ext); + } + catch (Exception e) + { + return Fail(job, SecretRedactor.Scrub(e.Message)); + } + } + + private static AssetGenJob ImportModelFile(AssetGenJob job, string rel, string ext) + { + bool isGltf = ext == ".glb" || ext == ".gltf"; + + if (isGltf && !IsGltfastAvailable()) + { + return Fail(job, + "GLB import requires glTFast. Install it from the MCP for Unity → Dependencies tab, or choose FBX output."); + } + + AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate); + + if (!isGltf) + ApplyModelImporterSettings(rel, job); + + job.AssetPath = rel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(rel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the file but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + + /// + /// Unpack a downloaded archive (Sketchfab ships .zip) into a sibling folder named + /// after the archive, import it, then locate the first model file inside and import that. + /// FBX/OBJ are preferred over glTF; a glTF-only archive still requires glTFast. + /// + private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel) + { + string zipAbs = AssetGenPaths.ToAbsolute(zipRel); + if (!File.Exists(zipAbs)) + return Fail(job, "Downloaded archive was not found on disk."); + + string folderRel = zipRel.Substring(0, zipRel.Length - ".zip".Length); + string folderAbs = AssetGenPaths.ToAbsolute(folderRel); + + Directory.CreateDirectory(folderAbs); + // Provider archives are untrusted: only inert model/texture files are written under + // Assets/ — scripts/assemblies are skipped so they can't be compiled on import. + SafeZipExtractor.ExtractTo(zipAbs, folderAbs, ArchiveAllowedExtensions); + + AssetDatabase.Refresh(); + AssetDatabase.ImportAsset(folderRel, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate); + + string modelRel = FindFirstModel(folderAbs); + if (string.IsNullOrEmpty(modelRel)) + return Fail(job, "Archive extracted but no model file (.fbx/.obj/.glb/.gltf) was found inside."); + + string ext = Path.GetExtension(modelRel).ToLowerInvariant(); + bool isGltf = ext == ".glb" || ext == ".gltf"; + if (isGltf && !IsGltfastAvailable()) + { + return Fail(job, + "This model is glTF (.glb/.gltf), which requires glTFast. Install it from the MCP for Unity → Dependencies tab."); + } + + AssetDatabase.ImportAsset(modelRel, ImportAssetOptions.ForceUpdate); + + if (!isGltf) + ApplyModelImporterSettings(modelRel, job); + + job.AssetPath = modelRel; + job.AssetGuid = AssetDatabase.AssetPathToGUID(modelRel); + if (string.IsNullOrEmpty(job.AssetGuid)) + return Fail(job, "Imported the extracted model but Unity did not register it as an asset."); + + if (job.State != AssetGenJobState.Failed) + job.State = AssetGenJobState.Done; + return job; + } + + /// + /// Walk the extracted directory for a model file, preferring FBX/OBJ (built-in importer) + /// over glTF (needs glTFast). Returns a project-relative path or null when none found. + /// + private static string FindFirstModel(string folderAbs) + { + string[] all; + try { all = Directory.GetFiles(folderAbs, "*", SearchOption.AllDirectories); } + catch { return null; } + + string firstGltf = null; + foreach (string abs in all) + { + string e = Path.GetExtension(abs).ToLowerInvariant(); + if (e == ".fbx" || e == ".obj") + return AssetGenPaths.ToProjectRelative(abs); + if (firstGltf == null && (e == ".glb" || e == ".gltf")) + firstGltf = abs; + } + return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf); + } + + private static void ApplyModelImporterSettings(string rel, AssetGenJob job) + { + if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return; + importer.useFileScale = true; + importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard; + importer.animationType = ParseAnimationType(job?.AnimationType); + + if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f) + { + float maxDim = ComputeMaxDimension(rel); + if (maxDim > 0.0001f) + { + float scale = job.TargetSize / maxDim; + if (scale > 0f && Math.Abs(scale - 1f) > 0.01f) + { + importer.useFileScale = false; + importer.globalScale = Mathf.Clamp(importer.globalScale * scale, 0.0001f, 1_000_000f); + } + } + } + importer.SaveAndReimport(); + } + + /// + /// Map the caller's rig mode to a . Defaults to + /// None (no rig) when unset — pass "generic"/"humanoid" to surface a rigged FBX/OBJ's + /// AnimationClips, which the None default otherwise hides. "legacy" selects Unity's + /// legacy Animation system — rarely needed. + /// + internal static ModelImporterAnimationType ParseAnimationType(string value) + { + switch ((value ?? string.Empty).Trim().ToLowerInvariant()) + { + case "generic": return ModelImporterAnimationType.Generic; + case "humanoid": + case "human": return ModelImporterAnimationType.Human; + case "legacy": return ModelImporterAnimationType.Legacy; + default: return ModelImporterAnimationType.None; + } + } + + private static float ComputeMaxDimension(string rel) + { + try + { + var go = AssetDatabase.LoadAssetAtPath(rel); + if (go == null) return 0f; + + bool any = false; + Bounds acc = new Bounds(Vector3.zero, Vector3.zero); + foreach (var mf in go.GetComponentsInChildren(true)) + { + if (mf.sharedMesh == null) continue; + if (!any) { acc = mf.sharedMesh.bounds; any = true; } + else acc.Encapsulate(mf.sharedMesh.bounds); + } + foreach (var smr in go.GetComponentsInChildren(true)) + { + if (smr.sharedMesh == null) continue; + if (!any) { acc = smr.sharedMesh.bounds; any = true; } + else acc.Encapsulate(smr.sharedMesh.bounds); + } + if (!any) return 0f; + Vector3 s = acc.size; + return Mathf.Max(s.x, Mathf.Max(s.y, s.z)); + } + catch { return 0f; } + } + + private static bool? _gltfastAvailable; + + /// + /// True when the glTFast package is present. Cached after the first probe — the result only + /// changes on a package install/uninstall, which triggers a domain reload that resets this + /// static. Shared with the Asset Gen settings tab so the reflection scan runs at most once. + /// + internal static bool IsGltfastAvailable() + { + if (_gltfastAvailable.HasValue) return _gltfastAvailable.Value; + bool found = Type.GetType("GLTFast.GltfImport, glTFast") != null; + if (!found) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try { if (asm.GetType("GLTFast.GltfImport") != null) { found = true; break; } } + catch { /* dynamic/!resolvable assembly */ } + } + } + _gltfastAvailable = found; + return found; + } + + private static AssetGenJob Fail(AssetGenJob job, string message) + { + job.State = AssetGenJobState.Failed; + job.Error = message; + return job; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta new file mode 100644 index 0000000..53b3980 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/ModelImportPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d63a324540d4a4a904e8b345ccf81bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs new file mode 100644 index 0000000..b5ef2b3 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; + +namespace MCPForUnity.Editor.Services.AssetGen.Import +{ + /// + /// Extracts a .zip into a destination directory while rejecting Zip-Slip path traversal: + /// every entry's resolved target must stay inside destDir. Directory entries are + /// created; file entries are written by copying the entry stream (no reliance on the + /// ZipFileExtensions helper). Used to unpack marketplace model archives (e.g. Sketchfab). + /// + /// When is supplied, file entries whose extension is not + /// on the allowlist are SKIPPED (not written). Callers that extract UNTRUSTED archives into the + /// Assets tree MUST pass an allowlist of inert asset types so executable content (.cs/.dll/ + /// .asmdef) can never land under Assets/ and be compiled/loaded by the Editor. + /// + public static class SafeZipExtractor + { + public static void ExtractTo(string zipPath, string destDir, ISet allowedExtensions = null) + { + if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath)); + if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir)); + + Directory.CreateDirectory(destDir); + string destFull = Path.GetFullPath(destDir); + string prefix = destFull.EndsWith(Path.DirectorySeparatorChar.ToString()) + ? destFull + : destFull + Path.DirectorySeparatorChar; + + using (FileStream fs = File.OpenRead(zipPath)) + using (var archive = new ZipArchive(fs, ZipArchiveMode.Read)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + string name = entry.FullName; + if (string.IsNullOrEmpty(name)) continue; + + // Reject traversal / absolute paths up front. + if (name.Contains("..") || Path.IsPathRooted(name)) + throw new IOException($"Unsafe zip entry rejected: {name}"); + + string target = Path.GetFullPath(Path.Combine(destDir, name)); + if (!target.StartsWith(prefix, StringComparison.Ordinal)) + throw new IOException($"Unsafe zip entry escapes destination: {name}"); + + // A directory entry has an empty Name (FullName ends with a separator). + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(target); + continue; + } + + // Allowlist gate: skip anything that isn't an inert asset type the caller permits. + if (allowedExtensions != null && allowedExtensions.Count > 0 + && !allowedExtensions.Contains(Path.GetExtension(entry.Name).ToLowerInvariant())) + { + continue; + } + + string parent = Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent); + + using (Stream src = entry.Open()) + using (FileStream dst = File.Create(target)) + { + src.CopyTo(dst); + } + } + } + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta new file mode 100644 index 0000000..572c6ca --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Import/SafeZipExtractor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dcd4c25a7b44e96b22ea605ffd73409 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers.meta b/MCPForUnity/Editor/Services/AssetGen/Providers.meta new file mode 100644 index 0000000..dca2b3a --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3b451683dd204e6e9d28fd7dbe4676a6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs new file mode 100644 index 0000000..132a558 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Security; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter + /// (model: tripo/meshy; image: fal/openrouter; marketplace: sketchfab); unknown ids throw + /// . advertises providers and reports + /// Configured existence only — never a key value. + /// + public static class AssetGenProviders + { + public static IModelProviderAdapter Model(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "tripo": + return new TripoAdapter(); + case "meshy": + return new MeshyAdapter(); + default: + throw new NotSupportedException($"Unknown model provider '{id}'."); + } + } + + public static IImageProviderAdapter Image(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "fal": + return new FalAdapter(); + case "openrouter": + return new OpenRouterAdapter(); + default: + throw new NotSupportedException($"Unknown image provider '{id}'."); + } + } + + public static IMarketplaceProviderAdapter Marketplace(string id) + { + switch ((id ?? string.Empty).ToLowerInvariant()) + { + case "sketchfab": + return new SketchfabAdapter(); + default: + throw new NotSupportedException($"Unknown marketplace provider '{id}'."); + } + } + + public static IReadOnlyList List() + { + return new List + { + new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } }, + new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } }, + new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } }, + }; + } + + private static bool IsConfigured(string id) + { + try { return SecureKeyStore.Current.Has(id); } + catch { return false; } + } + + /// + /// Standard "no key" message: points the user at the Asset Generation tab and the env override. + /// Shared by the asset-gen tools and the job manager so the wording stays in one place. + /// + public static string MissingKeyMessage(string provider) + => $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " + + $"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY)."; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta new file mode 100644 index 0000000..6ac1295 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/AssetGenProviders.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ab11255ce314c7193df3bca43286f83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs new file mode 100644 index 0000000..d888a0f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs @@ -0,0 +1,162 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header + /// "Authorization: Key <key>"), polls the request's status_url, then fetches the result and + /// returns the first image URL for the job manager to download. + /// + public sealed class FalAdapter : IImageProviderAdapter + { + private const string QueueBase = "https://queue.fal.run/"; + // FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives: + // fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality). + private const string DefaultModel = "fal-ai/flux-2"; + + public string Id => "fal"; + + public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + + var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 }; + string url; + if (image) + { + // image→image / editing lives on the model's /edit endpoint and takes an image_urls + // array; each entry accepts a hosted URL or an inline base64 data URI (local image_path). + url = QueueBase + model + "/edit"; + string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body["image_urls"] = new JArray(imageRef); + } + else + { + url = QueueBase + model; + } + // Forward explicit output dimensions for text→image only; fal's image_size accepts a + // {width,height} object. (/edit derives size from the source image and may reject it. + // FLUX has no transparency param — transparent backgrounds aren't a generation-time option.) + if (!image && req.Width > 0 && req.Height > 0) + body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height }; + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = url, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Key " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "submit"); + + // Prefer response_url; fall back to building it from request_id. + string responseUrl = json["response_url"]?.ToString(); + if (string.IsNullOrEmpty(responseUrl)) + { + string requestId = json["request_id"]?.ToString(); + if (string.IsNullOrEmpty(requestId)) + throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); + // Queue request URLs are namespaced by owner/app without the action sub-path, + // so build from the base model id (not `url`, which may end in /edit). + responseUrl = QueueBase + model + "/requests/" + requestId; + } + return responseUrl; + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + string responseUrl = providerJobId; + + var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" }; + statusSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult statusRes = await http.SendAsync(statusSpec, ct); + JObject statusJson = ParseOk(statusRes, apiKey, "status"); + + string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant(); + var result = new ProviderPollResult(); + switch (status) + { + case "COMPLETED": + case "OK": + result.State = ProviderPollState.Succeeded; + break; + case "IN_PROGRESS": + result.State = ProviderPollState.Running; + return result; + case "IN_QUEUE": + result.State = ProviderPollState.Queued; + return result; + case "ERROR": + case "FAILED": + result.State = ProviderPollState.Failed; + result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey); + return result; + default: + result.State = ProviderPollState.Running; + return result; + } + + // Completed: fetch the result payload and extract the first image URL. + var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl }; + resultSpec.Headers["Authorization"] = "Key " + apiKey; + HttpResult resultRes = await http.SendAsync(resultSpec, ct); + JObject resultJson = ParseOk(resultRes, apiKey, "result"); + + result.Progress = 1f; + result.DownloadUrl = ExtractImageUrl(resultJson); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "fal completed but no image URL was present in the result."; + } + return result; + } + + private static string ExtractImageUrl(JObject result) + { + JToken images = result["images"]; + if (images is JArray arr && arr.Count > 0) + { + string u = arr[0]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + string single = result["image"]?["url"]?.ToString(); + return string.IsNullOrEmpty(single) ? null : single; + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta new file mode 100644 index 0000000..1432534 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/FalAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a36ad2c4285c4799ae9eb4495813a44c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs new file mode 100644 index 0000000..561923c --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.AssetGen.Http; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side + /// job id; poll reports progress and, on success, the download URL. The api key is passed in + /// at call time and never cached on the adapter. + /// + public interface IModelProviderAdapter + { + string Id { get; } + Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct); + Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); + } + + /// A generative 2D image provider (fal, OpenRouter, ...). Phase 7. + public interface IImageProviderAdapter + { + string Id { get; } + Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct); + Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct); + } + + /// A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6. + public interface IMarketplaceProviderAdapter + { + string Id { get; } + Task SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct); + Task PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct); + Task ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct); + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta new file mode 100644 index 0000000..90dbcd3 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/IProviderAdapters.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f76130e504cd4423abbe82bbb718d25b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs new file mode 100644 index 0000000..ce81358 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode + /// it as a base64 data: URI — the inline form fal, Meshy, and OpenRouter accept for image + /// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately. + /// + internal static class LocalImage + { + // Extensions that can be inlined as a data URI for provider image input. + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + { ".png", ".jpg", ".jpeg", ".webp", ".gif" }; + + /// + /// Resolve an image path under the project's Assets folder to an existing absolute file of + /// a supported image type. Returns false with a user-facing for + /// an unsafe path, a missing file, or an unsupported extension, so the handler can fail + /// fast before any provider request is made. + /// + public static bool ResolveExisting(string path, out string absPath, out string error) + { + absPath = null; + error = null; + if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; } + if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel)) + { + error = "image_path must point to a file under the project's Assets folder."; + return false; + } + string abs = AssetGenPaths.ToAbsolute(rel); + if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; } + if (!SupportedExtensions.Contains(Path.GetExtension(abs))) + { + error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif."; + return false; + } + absPath = abs; + return true; + } + + /// + /// Read a local image and return a "data:image/<mime>;base64,..." URI. Throws + /// for an unsupported extension. + /// + public static string ToDataUri(string absPath) + { + if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel)) + throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder."); + absPath = AssetGenPaths.ToAbsolute(rel); + string mime = MimeFromExtension(Path.GetExtension(absPath)); + byte[] bytes = File.ReadAllBytes(absPath); + return "data:" + mime + ";base64," + Convert.ToBase64String(bytes); + } + + private static string MimeFromExtension(string ext) + { + switch ((ext ?? string.Empty).ToLowerInvariant()) + { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".webp": return "image/webp"; + case ".gif": return "image/gif"; + default: + throw new NotSupportedException( + $"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif."); + } + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta new file mode 100644 index 0000000..e17c9ef --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/LocalImage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f8290c406df4661b9ad2ff6cd324871 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs new file mode 100644 index 0000000..58a8a18 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs @@ -0,0 +1,208 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry + /// only); when textures are requested it then issues a "refine" task and surfaces the textured + /// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each + /// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and + /// never logged; every error is run through . + /// + public sealed class MeshyAdapter : IModelProviderAdapter + { + private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d"; + private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d"; + + public string Id => "meshy"; + + // Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow. + private string _format = "glb"; + private bool _isImage; + private bool _wantTexture = true; + private string _refineTaskId; + private bool _refineSubmitted; + + public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + _format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant(); + _wantTexture = req.Texture; + _isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + + JObject body; + string url; + if (_isImage) + { + // image→3D textures in a single call (no separate refine task). image_url accepts a + // hosted URL or an inline base64 data URI (for a local image_path). + url = ImageEndpoint; + string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); + body = new JObject + { + ["image_url"] = imageRef, + ["ai_model"] = "meshy-6", + ["should_texture"] = _wantTexture + }; + } + else + { + // text→3D preview is geometry only; texturing happens via a follow-up refine task. + url = TextEndpoint; + body = new JObject + { + ["mode"] = "preview", + ["prompt"] = req.Prompt ?? string.Empty, + ["ai_model"] = "meshy-6" + }; + } + + return await PostTask(url, body, apiKey, http, ct, "submit"); + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + bool refinePhase = _refineSubmitted; + string pollId = refinePhase ? _refineTaskId : providerJobId; + // image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d. + string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint; + + var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "poll"); + + ProviderPollState state = MapState(json["status"]?.ToString()); + var result = new ProviderPollResult { State = state }; + + // Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1). + bool twoPhase = !_isImage && _wantTexture; + float raw = 0f; + JToken prog = json["progress"]; + if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value() / 100f); + result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f); + + if (state == ProviderPollState.Succeeded) + { + // Preview just finished and textures were requested: start the refine task and keep + // polling it; never surface the untextured preview result. + if (twoPhase && !refinePhase) + { + var refineBody = new JObject + { + ["mode"] = "refine", + ["preview_task_id"] = providerJobId, + ["ai_model"] = "meshy-6" + }; + _refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine"); + _refineSubmitted = true; + result.State = ProviderPollState.Running; + result.Progress = 0.5f; + return result; + } + + result.Progress = 1f; + result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "Meshy reported success but no model URL was present in the response."; + } + } + else if (state == ProviderPollState.Failed) + { + string err = json["task_error"]?["message"]?.ToString() + ?? json["message"]?.ToString() + ?? "Meshy task failed."; + result.Error = SecretRedactor.Scrub(err, apiKey); + } + + return result; + } + + /// POST a task body and return its result task id (or null). + private static async Task PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase) + { + var spec = new HttpRequestSpec + { + Method = "POST", + Url = url, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, phase); + string id = json["result"]?.ToString(); + if (string.IsNullOrEmpty(id)) + throw new Exception(SecretRedactor.Scrub( + $"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey)); + return id; + } + + private string ExtractModelUrl(JObject urls) + { + if (urls == null) return null; + string byFormat = urls[_format]?.ToString(); + if (!string.IsNullOrEmpty(byFormat)) return byFormat; + string glb = urls["glb"]?.ToString(); + if (!string.IsNullOrEmpty(glb)) return glb; + string fbx = urls["fbx"]?.ToString(); + return string.IsNullOrEmpty(fbx) ? null : fbx; + } + + private static ProviderPollState MapState(string status) + { + switch ((status ?? string.Empty).ToUpperInvariant()) + { + case "SUCCEEDED": + return ProviderPollState.Succeeded; + case "FAILED": + case "EXPIRED": + case "CANCELED": + case "CANCELLED": + return ProviderPollState.Failed; + case "IN_PROGRESS": + return ProviderPollState.Running; + case "PENDING": + default: + return ProviderPollState.Queued; + } + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta new file mode 100644 index 0000000..0f54799 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/MeshyAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bb6ba98874ed46cca5f019c89b2edb0a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs new file mode 100644 index 0000000..52a8b0d --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs @@ -0,0 +1,155 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// OpenRouter image provider via the (synchronous) chat-completions endpoint with an + /// image-capable multimodal model. The image is returned inline (base64 data URL), so the + /// work happens in and returns it immediately. + /// One adapter instance handles a single job (the job manager captures it for submit+poll). + /// + public sealed class OpenRouterAdapter : IImageProviderAdapter + { + private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions"; + private const string DefaultModel = "google/gemini-2.5-flash-image"; + + public string Id => "openrouter"; + + private byte[] _inlineData; + private string _downloadUrl; + private string _error; + + public async Task SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model; + + // image->image: attach the reference image as an image_url content part alongside the + // text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a + // base64 data URI. Plain text->image uses a string content. + bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) + && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); + // image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path). + string imageRef = image + ? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath)) + : null; + JToken content = image + ? new JArray( + new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty }, + new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } }) + : (JToken)(req.Prompt ?? string.Empty); + + var body = new JObject + { + ["model"] = model, + ["modalities"] = new JArray("image", "text"), + ["messages"] = new JArray(new JObject + { + ["role"] = "user", + ["content"] = content + }) + }; + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = Endpoint, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey); + + string url = ExtractImageUrl(json); + if (string.IsNullOrEmpty(url)) + { + _error = "OpenRouter returned no image. The selected model may not support image output."; + return "ready"; + } + + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase); + if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; } + try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); } + catch { _error = "OpenRouter image was not valid base64."; } + } + else + { + _downloadUrl = url; + } + return "ready"; + } + + public Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + var result = new ProviderPollResult { Progress = 1f }; + if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl))) + { + result.State = ProviderPollState.Failed; + result.Error = _error ?? "OpenRouter produced no image."; + } + else + { + result.State = ProviderPollState.Succeeded; + result.InlineData = _inlineData; + result.DownloadUrl = _downloadUrl; + } + return Task.FromResult(result); + } + + private static string ExtractImageUrl(JObject json) + { + JToken message = json["choices"]?[0]?["message"]; + if (message == null) return null; + + // Preferred: message.images[].image_url.url + if (message["images"] is JArray imgs && imgs.Count > 0) + { + string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + // Fallback: a content array with image_url parts + if (message["content"] is JArray parts) + { + foreach (JToken part in parts) + { + string u = part?["image_url"]?["url"]?.ToString(); + if (!string.IsNullOrEmpty(u)) return u; + } + } + return null; + } + + private static JObject ParseOk(HttpResult res, string apiKey) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString() + ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta new file mode 100644 index 0000000..befadb5 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/OpenRouterAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55b1b120b06948f6aeada0381b51ce86 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs new file mode 100644 index 0000000..1539e49 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs @@ -0,0 +1,28 @@ +using System.Text; +using MCPForUnity.Editor.Services.AssetGen.Http; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Shared HTTP-response helpers for provider adapters: read the response text (falling back to + /// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages. + /// + internal static class ProviderHttp + { + /// Response text, falling back to a UTF-8 decode of the raw body when Text is empty. + public static string BodyText(HttpResult res) + { + string text = res?.Text; + if (string.IsNullOrEmpty(text) && res?.Body != null) + text = Encoding.UTF8.GetString(res.Body); + return text; + } + + /// Cap a (possibly null) string at 500 chars for inclusion in an error message. + public static string Truncate(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + return s.Length <= 500 ? s : s.Substring(0, 500) + "…"; + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta new file mode 100644 index 0000000..04c96d6 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderHttp.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1584e5cffb5b45e6816fec509f1bfc8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs new file mode 100644 index 0000000..4ee1175 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs @@ -0,0 +1,75 @@ +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// Normalized lifecycle state reported by a provider poll, across all providers. + public enum ProviderPollState + { + Queued, + Running, + Succeeded, + Failed + } + + /// + /// Outcome of a single provider poll. is normalized to 0..1. + /// On , points at the + /// result the C# side will download into the project. On + /// , carries a redacted message. + /// + public sealed class ProviderPollResult + { + public ProviderPollState State; + public float Progress; + public string DownloadUrl; + /// Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter), + /// so the job manager skips the download step. Takes precedence over . + public byte[] InlineData; + /// Overrides the downloaded file extension, e.g. "zip" for archive results. + public string ResultExt; + public string Error; + } + + /// Request to generate a 3D model. Shared by every model provider adapter. + public sealed class ModelGenRequest + { + public string Provider; + public string Mode; // text | image + public string Prompt; + public string ImagePath; + public string ImageUrl; + public string Format = "glb"; + public float TargetSize = 1f; + public bool Texture = true; + public string Tier; + public string Name; + public string OutputFolder; + } + + /// Request to generate a 2D image. Shared by every image provider adapter. + public sealed class ImageGenRequest + { + public string Provider; + public string Mode; // text | image + public string Prompt; + public string ImagePath; + public string ImageUrl; + public string Model; + public bool Transparent; + public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture + public int Width; + public int Height; + public string Name; + public string OutputFolder; + } + + /// + /// Public, key-free description of a provider for list_providers. Never carries a key + /// value — reports existence only. + /// + public sealed class ProviderInfo + { + public string Id; + public string Kind; // model | image | marketplace + public bool Configured; + public string[] Capabilities; + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta new file mode 100644 index 0000000..93158d3 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/ProviderModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 65be6fd6b6384effb45822a57e541f04 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs new file mode 100644 index 0000000..392dff1 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API + /// JSON to the caller; hits the model download endpoint + /// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the + /// "Token <key>" scheme; the key is supplied per call and never logged. + /// + public sealed class SketchfabAdapter : IMarketplaceProviderAdapter + { + private const string SearchEndpoint = "https://api.sketchfab.com/v3/search"; + private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models"; + + public string Id => "sketchfab"; + + public async Task SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (http == null) throw new ArgumentNullException(nameof(http)); + string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false") + + "&q=" + Uri.EscapeDataString(query ?? string.Empty); + if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories); + if (count.HasValue) url += "&count=" + count.Value; + if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor); + var spec = new HttpRequestSpec { Method = "GET", Url = url }; + spec.Headers["Authorization"] = "Token " + apiKey; + HttpResult res = await http.SendAsync(spec, ct); + // The raw response carries pagination (`cursors.next` / `next`) for the caller to page. + return RawOk(res, apiKey, "search"); + } + + public async Task PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid)); + if (http == null) throw new ArgumentNullException(nameof(http)); + var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid }; + spec.Headers["Authorization"] = "Token " + apiKey; + HttpResult res = await http.SendAsync(spec, ct); + return RawOk(res, apiKey, "preview"); + } + + public async Task ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid)); + if (http == null) throw new ArgumentNullException(nameof(http)); + var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" }; + spec.Headers["Authorization"] = "Token " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseOk(res, apiKey, "download"); + + string url = json["gltf"]?["url"]?.ToString(); + if (string.IsNullOrEmpty(url)) + { + throw new Exception(SecretRedactor.Scrub( + $"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey)); + } + return url; + } + + private static string RawOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + bool ok = res?.Ok == true; + if (!ok) + throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey)); + return text ?? string.Empty; + } + + private static JObject ParseOk(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } catch { /* non-JSON */ } + } + + bool ok = res?.Ok == true; + if (!ok) + { + string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey)); + } + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta new file mode 100644 index 0000000..031870f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/SketchfabAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 860350c25d8d490aaaa925d76ce29407 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs new file mode 100644 index 0000000..da9765f --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs @@ -0,0 +1,222 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.AssetGen.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.AssetGen.Providers +{ + /// + /// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and + /// polls for completion. The bearer key is supplied per call and never logged; every error + /// message is run through before it is surfaced. + /// + public sealed class TripoAdapter : IModelProviderAdapter + { + private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task"; + // Current recommended Tripo model (v3.1). Premium alternative: P1-20260311. + private const string ModelVersion = "v3.1-20260211"; + + public string Id => "tripo"; + + public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (req == null) throw new ArgumentNullException(nameof(req)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + // Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files, + // which isn't wired yet — fail clearly rather than silently falling back to text mode. + bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase); + if (imageMode && string.IsNullOrEmpty(req.ImageUrl)) + throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image)."); + + JObject body; + bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl); + if (image) + { + body = new JObject + { + ["type"] = "image_to_model", + ["file"] = new JObject + { + ["type"] = "url", + ["url"] = req.ImageUrl + } + }; + } + else + { + body = new JObject + { + ["type"] = "text_to_model", + ["prompt"] = req.Prompt ?? string.Empty, + ["model_version"] = ModelVersion + }; + } + + var spec = new HttpRequestSpec + { + Method = "POST", + Url = TaskEndpoint, + ContentType = "application/json", + Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseAndValidate(res, apiKey, "submit"); + + string taskId = json["data"]?["task_id"]?.ToString(); + if (string.IsNullOrEmpty(taskId)) + { + throw new Exception(SecretRedactor.Scrub( + "Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey)); + } + return taskId; + } + + public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) + { + if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); + if (http == null) throw new ArgumentNullException(nameof(http)); + + var spec = new HttpRequestSpec + { + Method = "GET", + Url = TaskEndpoint + "/" + providerJobId + }; + spec.Headers["Authorization"] = "Bearer " + apiKey; + + HttpResult res = await http.SendAsync(spec, ct); + JObject json = ParseAndValidate(res, apiKey, "poll"); + JObject data = json["data"] as JObject ?? new JObject(); + + var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) }; + + JToken progressTok = data["progress"]; + if (progressTok != null && progressTok.Type != JTokenType.Null) + { + result.Progress = Mathf.Clamp01(progressTok.Value() / 100f); + } + + if (result.State == ProviderPollState.Succeeded) + { + result.Progress = 1f; + result.DownloadUrl = ExtractDownloadUrl(data); + if (string.IsNullOrEmpty(result.DownloadUrl)) + { + result.State = ProviderPollState.Failed; + result.Error = "Tripo reported success but no model URL was present in the response."; + } + } + else if (result.State == ProviderPollState.Failed) + { + string err = data["error"]?.ToString() + ?? data["message"]?.ToString() + ?? json["message"]?.ToString() + ?? "Tripo task failed."; + result.Error = SecretRedactor.Scrub(err, apiKey); + } + + return result; + } + + private static ProviderPollState MapState(string status) + { + switch ((status ?? string.Empty).ToLowerInvariant()) + { + case "success": + case "succeeded": + return ProviderPollState.Succeeded; + case "failed": + case "error": + case "cancelled": + case "canceled": + case "banned": + case "expired": + return ProviderPollState.Failed; + case "running": + case "processing": + return ProviderPollState.Running; + default: + return ProviderPollState.Queued; + } + } + + /// + /// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a + /// textured / PBR model; fall back to the base model. Accepts both the newer flat form + /// (output.pbr_model = url string) and the older nested form + /// (result.pbr_model.url = object with a url field). + /// + private static string ExtractDownloadUrl(JObject data) + { + JObject output = data["output"] as JObject; + JObject resultObj = data["result"] as JObject; + + return UrlOf(output?["pbr_model"]) + ?? UrlOf(resultObj?["pbr_model"]) + ?? UrlOf(output?["model"]) + ?? UrlOf(resultObj?["model"]) + ?? UrlOf(output?["base_model"]) + ?? UrlOf(resultObj?["base_model"]); + } + + /// A field may be a plain URL string or an object carrying a "url" property. + private static string UrlOf(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return null; + if (token.Type == JTokenType.String) + { + string s = token.ToString(); + return string.IsNullOrEmpty(s) ? null : s; + } + if (token is JObject obj) + { + string u = obj["url"]?.ToString(); + return string.IsNullOrEmpty(u) ? null : u; + } + return null; + } + + /// + /// Validate transport success + Tripo's body-level code (0 == ok), parse the JSON, + /// and throw a redacted exception otherwise. + /// + private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase) + { + string text = ProviderHttp.BodyText(res); + + JObject json = null; + if (!string.IsNullOrEmpty(text)) + { + try { json = JObject.Parse(text); } + catch { /* non-JSON body; handled below */ } + } + + bool httpOk = res?.Ok == true; + + int code = 0; + JToken codeTok = json?["code"]; + if (codeTok != null && codeTok.Type != JTokenType.Null) + { + try { code = codeTok.Value(); } catch { code = -1; } + } + + if (!httpOk || code != 0) + { + string detail = json?["message"]?.ToString() + ?? json?["error"]?.ToString() + ?? ProviderHttp.Truncate(text); + throw new Exception(SecretRedactor.Scrub( + $"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey)); + } + + return json ?? new JObject(); + } + } +} diff --git a/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta new file mode 100644 index 0000000..45c5aa3 --- /dev/null +++ b/MCPForUnity/Editor/Services/AssetGen/Providers/TripoAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a0761a0b205e4041950b0ab29a3d6466 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs new file mode 100644 index 0000000..04583e7 --- /dev/null +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -0,0 +1,157 @@ + +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio). + /// + public class BridgeControlService : IBridgeControlService + { + private readonly TransportManager _transportManager; + private TransportMode _preferredMode = TransportMode.Http; + + public BridgeControlService() + { + _transportManager = MCPServiceLocator.TransportManager; + } + + private TransportMode ResolvePreferredMode() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio; + return _preferredMode; + } + + private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null) + { + bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true); + string transportLabel = string.IsNullOrWhiteSpace(state.TransportName) + ? mode.ToString().ToLowerInvariant() + : state.TransportName; + string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]"; + string message = messageOverride + ?? state.Error + ?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}"); + + return new BridgeVerificationResult + { + Success = pingSucceeded && handshakeValid, + HandshakeValid = handshakeValid, + PingSucceeded = pingSucceeded, + Message = message + }; + } + + public bool IsRunning + { + get + { + var mode = ResolvePreferredMode(); + return _transportManager.IsRunning(mode); + } + } + + public int CurrentPort + { + get + { + var mode = ResolvePreferredMode(); + var state = _transportManager.GetState(mode); + if (state.Port.HasValue) + { + return state.Port.Value; + } + + // Legacy fallback while the stdio bridge is still in play + return StdioBridgeHost.GetCurrentPort(); + } + } + + public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode(); + public TransportMode? ActiveMode => ResolvePreferredMode(); + + public async Task StartAsync() + { + var mode = ResolvePreferredMode(); + try + { + // Treat transports as mutually exclusive for user-driven session starts: + // stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP). + var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http; + try + { + await _transportManager.StopAsync(otherMode); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}"); + } + + // Legacy safety: stdio may have been started outside TransportManager state. + if (otherMode == TransportMode.Stdio) + { + try { StdioBridgeHost.Stop(); } catch { } + } + + bool started = await _transportManager.StartAsync(mode); + if (!started) + { + McpLog.Warn($"Failed to start MCP transport: {mode}"); + } + return started; + } + catch (Exception ex) + { + McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}"); + return false; + } + } + + public async Task StopAsync() + { + try + { + var mode = ResolvePreferredMode(); + await _transportManager.StopAsync(mode); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping MCP transport: {ex.Message}"); + } + } + + public async Task VerifyAsync() + { + var mode = ResolvePreferredMode(); + bool pingSucceeded = await _transportManager.VerifyAsync(mode); + var state = _transportManager.GetState(mode); + return BuildVerificationResult(state, mode, pingSucceeded); + } + + public BridgeVerificationResult Verify(int port) + { + var mode = ResolvePreferredMode(); + bool pingSucceeded = _transportManager.VerifyAsync(mode).GetAwaiter().GetResult(); + var state = _transportManager.GetState(mode); + + if (mode == TransportMode.Stdio) + { + bool handshakeValid = state.IsConnected && port == CurrentPort; + string message = handshakeValid + ? $"STDIO transport listening on port {CurrentPort}" + : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})"; + return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid); + } + + return BuildVerificationResult(state, mode, pingSucceeded); + } + + } +} diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs.meta b/MCPForUnity/Editor/Services/BridgeControlService.cs.meta new file mode 100644 index 0000000..93966a2 --- /dev/null +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ed4f9f69d84a945248dafc0f0b5a62dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/MCPForUnity/Editor/Services/ClientConfigurationService.cs new file mode 100644 index 0000000..d3eb866 --- /dev/null +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Implementation of client configuration service + /// + public class ClientConfigurationService : IClientConfigurationService + { + private readonly List configurators; + + public ClientConfigurationService() + { + configurators = McpClientRegistry.All.ToList(); + } + + public IReadOnlyList GetAllClients() => configurators; + + public void ConfigureClient(IMcpClientConfigurator configurator) + { + // When using a local server path, clean stale build artifacts first. + // This prevents old deleted .py files from being picked up by Python's auto-discovery. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + + ConfigureWithTransportCoercion(configurator); + } + + public ClientConfigurationSummary ConfigureAllDetectedClients() + { + // When using a local server path, clean stale build artifacts once before configuring all clients. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + + var summary = new ClientConfigurationSummary(); + foreach (var configurator in configurators) + { + if (!configurator.IsInstalled) + { + summary.SkippedCount++; + continue; + } + try + { + // Always re-run configuration so core fields stay current + configurator.CheckStatus(attemptAutoRewrite: false); + ConfigureWithTransportCoercion(configurator); + summary.SuccessCount++; + summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully"); + } + catch (Exception ex) + { + summary.FailureCount++; + summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}"); + } + } + + return summary; + } + + private static void ConfigureWithTransportCoercion(IMcpClientConfigurator configurator) + { + bool originalHttp = EditorConfigurationCache.Instance.UseHttpTransport; + try + { + CoerceTransportFor(configurator); + configurator.Configure(); + } + finally + { + EditorConfigurationCache.Instance.SetUseHttpTransport(originalHttp); + } + } + + public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true) + { + var previous = configurator.Status; + var current = configurator.CheckStatus(attemptAutoRewrite); + return current != previous; + } + + private static void CoerceTransportFor(IMcpClientConfigurator configurator) + { + var supported = configurator.SupportedTransports; + if (supported == null || supported.Count == 0) return; + + bool currentlyHttp = EditorConfigurationCache.Instance.UseHttpTransport; + var requested = currentlyHttp ? ConfiguredTransport.Http : ConfiguredTransport.Stdio; + + // Accept any HTTP variant (Http, HttpRemote) when the user wants HTTP — a client that + // only supports HttpRemote should not get coerced to stdio just because Http isn't + // explicitly listed. + if (SupportsRequested(supported, requested)) return; + + // Fall back in the direction of the user's intent: if they wanted HTTP, prefer any + // HTTP variant the client does support; otherwise prefer stdio. Honors the + // configurator's declared order when more than one option remains. + ConfiguredTransport chosen = PickFallback(supported, requested); + bool needHttp = IsHttpVariant(chosen); + if (EditorConfigurationCache.Instance.UseHttpTransport != needHttp) + { + EditorConfigurationCache.Instance.SetUseHttpTransport(needHttp); + McpLog.Info( + $"[{configurator.DisplayName}] auto-selected {chosen} transport (client does not support {requested})."); + } + } + + private static bool IsHttpVariant(ConfiguredTransport t) + => t == ConfiguredTransport.Http || t == ConfiguredTransport.HttpRemote; + + private static bool SupportsRequested(IReadOnlyList supported, ConfiguredTransport requested) + { + if (requested == ConfiguredTransport.Http) + { + foreach (var t in supported) + if (IsHttpVariant(t)) return true; + return false; + } + return supported.Contains(requested); + } + + private static ConfiguredTransport PickFallback(IReadOnlyList supported, ConfiguredTransport requested) + { + if (requested == ConfiguredTransport.Http) + { + foreach (var t in supported) + if (IsHttpVariant(t)) return t; + } + else + { + foreach (var t in supported) + if (t == ConfiguredTransport.Stdio) return t; + } + return supported[0]; + } + } +} diff --git a/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta b/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta new file mode 100644 index 0000000..ce6ade0 --- /dev/null +++ b/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76cad34d10fd24aaa95c4583c1f88fdf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs new file mode 100644 index 0000000..94f6a39 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -0,0 +1,321 @@ +using System; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Centralized cache for frequently-read EditorPrefs values. + /// Reduces scattered EditorPrefs.Get* calls and provides change notification. + /// + /// Usage: + /// var config = EditorConfigurationCache.Instance; + /// if (config.UseHttpTransport) { ... } + /// config.OnConfigurationChanged += (key) => { /* refresh UI */ }; + /// + public class EditorConfigurationCache + { + private static EditorConfigurationCache _instance; + private static readonly object _lock = new object(); + + /// + /// Singleton instance. Thread-safe lazy initialization. + /// + public static EditorConfigurationCache Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new EditorConfigurationCache(); + } + } + } + return _instance; + } + } + + /// + /// Event fired when any cached configuration value changes. + /// The string parameter is the EditorPrefKeys constant name that changed. + /// + public event Action OnConfigurationChanged; + + // Cached values - most frequently read + private bool _useHttpTransport; + private bool _debugLogs; + private bool _devModeForceServerRefresh; + private string _uvxPathOverride; + private string _gitUrlOverride; + private string _httpBaseUrl; + private string _httpRemoteBaseUrl; + private string _claudeCliPathOverride; + private string _httpTransportScope; + private int _unitySocketPort; + + /// + /// Whether to use HTTP transport (true) or Stdio transport (false). + /// Default: true + /// + public bool UseHttpTransport => _useHttpTransport; + + /// + /// Whether debug logging is enabled. + /// Default: false + /// + public bool DebugLogs => _debugLogs; + + /// + /// Whether to force server refresh in dev mode (--no-cache --refresh). + /// Default: false + /// + public bool DevModeForceServerRefresh => _devModeForceServerRefresh; + + /// + /// Custom path override for uvx executable. + /// Default: empty string (auto-detect) + /// + public string UvxPathOverride => _uvxPathOverride; + + /// + /// Custom Git URL override for server installation. + /// Default: empty string (use default) + /// + public string GitUrlOverride => _gitUrlOverride; + + /// + /// HTTP base URL for the local MCP server. + /// Default: empty string + /// + public string HttpBaseUrl => _httpBaseUrl; + + /// + /// HTTP base URL for the remote-hosted MCP server. + /// Default: empty string + /// + public string HttpRemoteBaseUrl => _httpRemoteBaseUrl; + + /// + /// Custom path override for Claude CLI executable. + /// Default: empty string (auto-detect) + /// + public string ClaudeCliPathOverride => _claudeCliPathOverride; + + /// + /// HTTP transport scope: "local" or "remote". + /// Default: empty string + /// + public string HttpTransportScope => _httpTransportScope; + + /// + /// Unity socket port for Stdio transport. + /// Default: 0 (auto-assign) + /// + public int UnitySocketPort => _unitySocketPort; + + private EditorConfigurationCache() + { + Refresh(); + } + + /// + /// Refresh all cached values from EditorPrefs. + /// Call this after bulk EditorPrefs changes or domain reload. + /// + public void Refresh() + { + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + } + + /// + /// Set UseHttpTransport and update cache + EditorPrefs atomically. + /// + public void SetUseHttpTransport(bool value) + { + if (_useHttpTransport != value) + { + _useHttpTransport = value; + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value); + OnConfigurationChanged?.Invoke(nameof(UseHttpTransport)); + } + } + + /// + /// Set DebugLogs and update cache + EditorPrefs atomically. + /// + public void SetDebugLogs(bool value) + { + if (_debugLogs != value) + { + _debugLogs = value; + EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value); + OnConfigurationChanged?.Invoke(nameof(DebugLogs)); + } + } + + /// + /// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically. + /// + public void SetDevModeForceServerRefresh(bool value) + { + if (_devModeForceServerRefresh != value) + { + _devModeForceServerRefresh = value; + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value); + OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh)); + } + } + + /// + /// Set UvxPathOverride and update cache + EditorPrefs atomically. + /// + public void SetUvxPathOverride(string value) + { + value = value ?? string.Empty; + if (_uvxPathOverride != value) + { + _uvxPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(UvxPathOverride)); + } + } + + /// + /// Set GitUrlOverride and update cache + EditorPrefs atomically. + /// + public void SetGitUrlOverride(string value) + { + value = value ?? string.Empty; + if (_gitUrlOverride != value) + { + _gitUrlOverride = value; + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value); + OnConfigurationChanged?.Invoke(nameof(GitUrlOverride)); + } + } + + /// + /// Set HttpBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpBaseUrl != value) + { + _httpBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl)); + } + } + + /// + /// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpRemoteBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpRemoteBaseUrl != value) + { + _httpRemoteBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl)); + } + } + + /// + /// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically. + /// + public void SetClaudeCliPathOverride(string value) + { + value = value ?? string.Empty; + if (_claudeCliPathOverride != value) + { + _claudeCliPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride)); + } + } + + /// + /// Set HttpTransportScope and update cache + EditorPrefs atomically. + /// + public void SetHttpTransportScope(string value) + { + value = value ?? string.Empty; + if (_httpTransportScope != value) + { + _httpTransportScope = value; + EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value); + OnConfigurationChanged?.Invoke(nameof(HttpTransportScope)); + } + } + + /// + /// Set UnitySocketPort and update cache + EditorPrefs atomically. + /// + public void SetUnitySocketPort(int value) + { + if (_unitySocketPort != value) + { + _unitySocketPort = value; + EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value); + OnConfigurationChanged?.Invoke(nameof(UnitySocketPort)); + } + } + + /// + /// Force refresh of a single cached value from EditorPrefs. + /// Useful when external code modifies EditorPrefs directly. + /// + public void InvalidateKey(string keyName) + { + switch (keyName) + { + case nameof(UseHttpTransport): + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + break; + case nameof(DebugLogs): + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + break; + case nameof(DevModeForceServerRefresh): + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + break; + case nameof(UvxPathOverride): + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + break; + case nameof(GitUrlOverride): + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + break; + case nameof(HttpBaseUrl): + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + break; + case nameof(HttpRemoteBaseUrl): + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); + break; + case nameof(ClaudeCliPathOverride): + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + break; + case nameof(HttpTransportScope): + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + break; + case nameof(UnitySocketPort): + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + break; + } + OnConfigurationChanged?.Invoke(keyName); + } + } +} diff --git a/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta new file mode 100644 index 0000000..a6416b4 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4a183ac9b63c408886bce40ae58f462 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs b/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs new file mode 100644 index 0000000..2511b03 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs @@ -0,0 +1,54 @@ +using System; +using MCPForUnity.Editor.Windows; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing the EditorPrefs window + /// Follows the Class-level Singleton pattern + /// + public class EditorPrefsWindowService + { + private static EditorPrefsWindowService _instance; + + /// + /// Get the singleton instance + /// + public static EditorPrefsWindowService Instance + { + get + { + if (_instance == null) + { + throw new Exception("EditorPrefsWindowService not initialized"); + } + return _instance; + } + } + + /// + /// Initialize the service + /// + public static void Initialize() + { + if (_instance == null) + { + _instance = new EditorPrefsWindowService(); + } + } + + private EditorPrefsWindowService() + { + // Private constructor for singleton + } + + /// + /// Show the EditorPrefs window + /// + public void ShowWindow() + { + EditorPrefsWindow.ShowWindow(); + } + } +} diff --git a/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta b/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta new file mode 100644 index 0000000..2dc46b1 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs new file mode 100644 index 0000000..54625c2 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -0,0 +1,573 @@ +using System; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy. + /// Updated on the main thread via Editor callbacks and periodic update ticks. + /// + [InitializeOnLoad] + internal static class EditorStateCache + { + private static readonly object LockObj = new(); + private static long _sequence; + private static long _observedUnixMs; + + private static bool _lastIsCompiling; + private static long? _lastCompileStartedUnixMs; + private static long? _lastCompileFinishedUnixMs; + + private static bool _domainReloadPending; + private static long? _domainReloadBeforeUnixMs; + private static long? _domainReloadAfterUnixMs; + + private static double _lastUpdateTimeSinceStartup; + private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s + + // State tracking to detect when snapshot actually changes (checked BEFORE building) + private static string _lastTrackedScenePath; + private static string _lastTrackedSceneName; + private static bool _lastTrackedIsFocused; + private static bool _lastTrackedIsPlaying; + private static bool _lastTrackedIsPaused; + private static bool _lastTrackedIsUpdating; + private static bool _lastTrackedTestsRunning; + private static string _lastTrackedActivityPhase; + + private static JObject _cached; + + private sealed class EditorStateSnapshot + { + [JsonProperty("schema_version")] + public string SchemaVersion { get; set; } + + [JsonProperty("observed_at_unix_ms")] + public long ObservedAtUnixMs { get; set; } + + [JsonProperty("sequence")] + public long Sequence { get; set; } + + [JsonProperty("unity")] + public EditorStateUnity Unity { get; set; } + + [JsonProperty("editor")] + public EditorStateEditor Editor { get; set; } + + [JsonProperty("activity")] + public EditorStateActivity Activity { get; set; } + + [JsonProperty("compilation")] + public EditorStateCompilation Compilation { get; set; } + + [JsonProperty("assets")] + public EditorStateAssets Assets { get; set; } + + [JsonProperty("tests")] + public EditorStateTests Tests { get; set; } + + [JsonProperty("transport")] + public EditorStateTransport Transport { get; set; } + + [JsonProperty("settings")] + public EditorStateSettings Settings { get; set; } + } + + private sealed class EditorStateUnity + { + [JsonProperty("instance_id")] + public string InstanceId { get; set; } + + [JsonProperty("unity_version")] + public string UnityVersion { get; set; } + + [JsonProperty("project_id")] + public string ProjectId { get; set; } + + [JsonProperty("platform")] + public string Platform { get; set; } + + [JsonProperty("is_batch_mode")] + public bool? IsBatchMode { get; set; } + } + + private sealed class EditorStateEditor + { + [JsonProperty("is_focused")] + public bool? IsFocused { get; set; } + + [JsonProperty("play_mode")] + public EditorStatePlayMode PlayMode { get; set; } + + [JsonProperty("active_scene")] + public EditorStateActiveScene ActiveScene { get; set; } + } + + private sealed class EditorStatePlayMode + { + [JsonProperty("is_playing")] + public bool? IsPlaying { get; set; } + + [JsonProperty("is_paused")] + public bool? IsPaused { get; set; } + + [JsonProperty("is_changing")] + public bool? IsChanging { get; set; } + } + + private sealed class EditorStateActiveScene + { + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("guid")] + public string Guid { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + } + + private sealed class EditorStateActivity + { + [JsonProperty("phase")] + public string Phase { get; set; } + + [JsonProperty("since_unix_ms")] + public long SinceUnixMs { get; set; } + + [JsonProperty("reasons")] + public string[] Reasons { get; set; } + } + + private sealed class EditorStateCompilation + { + [JsonProperty("is_compiling")] + public bool? IsCompiling { get; set; } + + [JsonProperty("is_domain_reload_pending")] + public bool? IsDomainReloadPending { get; set; } + + [JsonProperty("last_compile_started_unix_ms")] + public long? LastCompileStartedUnixMs { get; set; } + + [JsonProperty("last_compile_finished_unix_ms")] + public long? LastCompileFinishedUnixMs { get; set; } + + [JsonProperty("last_domain_reload_before_unix_ms")] + public long? LastDomainReloadBeforeUnixMs { get; set; } + + [JsonProperty("last_domain_reload_after_unix_ms")] + public long? LastDomainReloadAfterUnixMs { get; set; } + } + + private sealed class EditorStateAssets + { + [JsonProperty("is_updating")] + public bool? IsUpdating { get; set; } + + [JsonProperty("external_changes_dirty")] + public bool? ExternalChangesDirty { get; set; } + + [JsonProperty("external_changes_last_seen_unix_ms")] + public long? ExternalChangesLastSeenUnixMs { get; set; } + + [JsonProperty("external_changes_dirty_since_unix_ms")] + public long? ExternalChangesDirtySinceUnixMs { get; set; } + + [JsonProperty("external_changes_last_cleared_unix_ms")] + public long? ExternalChangesLastClearedUnixMs { get; set; } + + [JsonProperty("refresh")] + public EditorStateRefresh Refresh { get; set; } + } + + private sealed class EditorStateRefresh + { + [JsonProperty("is_refresh_in_progress")] + public bool? IsRefreshInProgress { get; set; } + + [JsonProperty("last_refresh_requested_unix_ms")] + public long? LastRefreshRequestedUnixMs { get; set; } + + [JsonProperty("last_refresh_finished_unix_ms")] + public long? LastRefreshFinishedUnixMs { get; set; } + } + + private sealed class EditorStateTests + { + [JsonProperty("is_running")] + public bool? IsRunning { get; set; } + + [JsonProperty("mode")] + public string Mode { get; set; } + + [JsonProperty("current_job_id")] + public string CurrentJobId { get; set; } + + [JsonProperty("started_unix_ms")] + public long? StartedUnixMs { get; set; } + + [JsonProperty("started_by")] + public string StartedBy { get; set; } + + [JsonProperty("last_run")] + public EditorStateLastRun LastRun { get; set; } + } + + private sealed class EditorStateLastRun + { + [JsonProperty("finished_unix_ms")] + public long? FinishedUnixMs { get; set; } + + [JsonProperty("result")] + public string Result { get; set; } + + [JsonProperty("counts")] + public object Counts { get; set; } + } + + private sealed class EditorStateTransport + { + [JsonProperty("unity_bridge_connected")] + public bool? UnityBridgeConnected { get; set; } + + [JsonProperty("last_message_unix_ms")] + public long? LastMessageUnixMs { get; set; } + } + + private sealed class EditorStateSettings + { + [JsonProperty("batch_execute_max_commands")] + public int BatchExecuteMaxCommands { get; set; } + } + + static EditorStateCache() + { + try + { + _sequence = 0; + _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _cached = BuildSnapshot("init"); + + EditorApplication.update += OnUpdate; + EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode"); + + // Tracks whether an assembly compilation is actually running, for + // GetActualIsCompiling's Play-mode check. Statics reset on domain reload + // and this [InitializeOnLoad] ctor re-subscribes, so the flag is per-domain. + UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => _pipelineCompilationRunning = true; + UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => _pipelineCompilationRunning = false; + + AssemblyReloadEvents.beforeAssemblyReload += () => + { + _domainReloadPending = true; + _domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ForceUpdate("before_domain_reload"); + }; + AssemblyReloadEvents.afterAssemblyReload += () => + { + _domainReloadPending = false; + _domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ForceUpdate("after_domain_reload"); + }; + } + catch (Exception ex) + { + McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}"); + } + } + + private static void OnUpdate() + { + // Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients. + double now = EditorApplication.timeSinceStartup; + // Use GetActualIsCompiling() to avoid Play mode false positives (issue #582) + bool isCompiling = GetActualIsCompiling(); + + // Check for compilation edge transitions (always update on these) + bool compilationEdge = isCompiling != _lastIsCompiling; + + if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds) + { + return; + } + + // Fast state-change detection BEFORE building snapshot. + // This avoids the expensive BuildSnapshot() call entirely when nothing changed. + // These checks are much cheaper than building a full JSON snapshot. + var scene = EditorSceneManager.GetActiveScene(); + string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path; + string sceneName = scene.name ?? string.Empty; + bool isFocused = InternalEditorUtility.isApplicationActive; + bool isPlaying = EditorApplication.isPlaying; + bool isPaused = EditorApplication.isPaused; + bool isUpdating = EditorApplication.isUpdating; + bool testsRunning = TestRunStatus.IsRunning; + + var activityPhase = "idle"; + if (testsRunning) + { + activityPhase = "running_tests"; + } + else if (isCompiling) + { + activityPhase = "compiling"; + } + else if (_domainReloadPending) + { + activityPhase = "domain_reload"; + } + else if (isUpdating) + { + activityPhase = "asset_import"; + } + else if (EditorApplication.isPlayingOrWillChangePlaymode) + { + activityPhase = "playmode_transition"; + } + + bool hasChanges = compilationEdge + || _lastTrackedScenePath != scenePath + || _lastTrackedSceneName != sceneName + || _lastTrackedIsFocused != isFocused + || _lastTrackedIsPlaying != isPlaying + || _lastTrackedIsPaused != isPaused + || _lastTrackedIsUpdating != isUpdating + || _lastTrackedTestsRunning != testsRunning + || _lastTrackedActivityPhase != activityPhase; + + if (!hasChanges) + { + // No state change - skip the expensive BuildSnapshot entirely. + // This is the key optimization that prevents the 28ms GC spikes. + return; + } + + // Update tracked state + _lastTrackedScenePath = scenePath; + _lastTrackedSceneName = sceneName; + _lastTrackedIsFocused = isFocused; + _lastTrackedIsPlaying = isPlaying; + _lastTrackedIsPaused = isPaused; + _lastTrackedIsUpdating = isUpdating; + _lastTrackedTestsRunning = testsRunning; + _lastTrackedActivityPhase = activityPhase; + + _lastUpdateTimeSinceStartup = now; + ForceUpdate("tick"); + } + + private static void ForceUpdate(string reason) + { + lock (LockObj) + { + _cached = BuildSnapshot(reason); + } + } + + private static JObject BuildSnapshot(string reason) + { + _sequence++; + _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + bool isCompiling = GetActualIsCompiling(); + if (isCompiling && !_lastIsCompiling) + { + _lastCompileStartedUnixMs = _observedUnixMs; + } + else if (!isCompiling && _lastIsCompiling) + { + _lastCompileFinishedUnixMs = _observedUnixMs; + } + _lastIsCompiling = isCompiling; + + var scene = EditorSceneManager.GetActiveScene(); + string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path; + string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null; + + bool testsRunning = TestRunStatus.IsRunning; + var testsMode = TestRunStatus.Mode?.ToString(); + string currentJobId = TestJobManager.CurrentJobId; + bool isFocused = InternalEditorUtility.isApplicationActive; + + var activityPhase = "idle"; + if (testsRunning) + { + activityPhase = "running_tests"; + } + else if (isCompiling) + { + activityPhase = "compiling"; + } + else if (_domainReloadPending) + { + activityPhase = "domain_reload"; + } + else if (EditorApplication.isUpdating) + { + activityPhase = "asset_import"; + } + else if (EditorApplication.isPlayingOrWillChangePlaymode) + { + activityPhase = "playmode_transition"; + } + + var snapshot = new EditorStateSnapshot + { + SchemaVersion = "unity-mcp/editor_state@2", + ObservedAtUnixMs = _observedUnixMs, + Sequence = _sequence, + Unity = new EditorStateUnity + { + InstanceId = null, + UnityVersion = Application.unityVersion, + ProjectId = null, + Platform = Application.platform.ToString(), + IsBatchMode = Application.isBatchMode + }, + Editor = new EditorStateEditor + { + IsFocused = isFocused, + PlayMode = new EditorStatePlayMode + { + IsPlaying = EditorApplication.isPlaying, + IsPaused = EditorApplication.isPaused, + IsChanging = EditorApplication.isPlayingOrWillChangePlaymode + }, + ActiveScene = new EditorStateActiveScene + { + Path = scenePath, + Guid = sceneGuid, + Name = scene.name ?? string.Empty + } + }, + Activity = new EditorStateActivity + { + Phase = activityPhase, + SinceUnixMs = _observedUnixMs, + Reasons = new[] { reason } + }, + Compilation = new EditorStateCompilation + { + IsCompiling = isCompiling, + IsDomainReloadPending = _domainReloadPending, + LastCompileStartedUnixMs = _lastCompileStartedUnixMs, + LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs, + LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs, + LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs + }, + Assets = new EditorStateAssets + { + IsUpdating = EditorApplication.isUpdating, + ExternalChangesDirty = false, + ExternalChangesLastSeenUnixMs = null, + ExternalChangesDirtySinceUnixMs = null, + ExternalChangesLastClearedUnixMs = null, + Refresh = new EditorStateRefresh + { + IsRefreshInProgress = false, + LastRefreshRequestedUnixMs = null, + LastRefreshFinishedUnixMs = null + } + }, + Tests = new EditorStateTests + { + IsRunning = testsRunning, + Mode = testsMode, + CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId, + StartedUnixMs = TestRunStatus.StartedUnixMs, + StartedBy = "unknown", + LastRun = TestRunStatus.FinishedUnixMs.HasValue + ? new EditorStateLastRun + { + FinishedUnixMs = TestRunStatus.FinishedUnixMs, + Result = "unknown", + Counts = null + } + : null + }, + Transport = new EditorStateTransport + { + UnityBridgeConnected = null, + LastMessageUnixMs = null + }, + Settings = new EditorStateSettings + { + BatchExecuteMaxCommands = Tools.BatchExecute.GetMaxCommandsPerBatch() + } + }; + + return JObject.FromObject(snapshot); + } + + public static JObject GetSnapshot() + { + lock (LockObj) + { + // Defensive: if something went wrong early, rebuild once. + if (_cached == null) + { + _cached = BuildSnapshot("rebuild"); + } + + // Always return a fresh clone to prevent mutation bugs. + // The main GC optimization comes from state-change detection (OnUpdate) + // which prevents unnecessary _cached rebuilds, not from caching the clone. + var clone = (JObject)_cached.DeepClone(); + + // When Unity is backgrounded, OnUpdate is throttled and the + // cached timestamp grows stale even though the data is current. + // Re-stamp only in that case so the server-side staleness check + // still fires for genuinely unresponsive editors when focused. + if (!InternalEditorUtility.isApplicationActive) + { + clone["observed_at_unix_ms"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + return clone; + } + } + + // Set/cleared by the CompilationPipeline.compilationStarted/Finished events + // subscribed in the static ctor. NOTE: CompilationPipeline.isCompiling does not + // exist on the supported Unity range (verified by reflection probe on 2021.3 and + // 6000.4 — neither public nor non-public), so the reflection this replaced never + // resolved and always fell back to the raw signal. + private static bool _pipelineCompilationRunning; + + /// + /// Returns the actual compilation state, working around a known Unity quirk where + /// EditorApplication.isCompiling can return false positives in Play mode (e.g. a + /// recompile deferred by Recompile-After-Finished-Playing keeps it true for the + /// whole play session). See: https://github.com/CoplayDev/unity-mcp/issues/549 + /// + internal static bool GetActualIsCompiling() + { + // If EditorApplication.isCompiling is false, Unity is definitely not compiling + if (!EditorApplication.isCompiling) + { + return false; + } + + // In Play mode, trust the event-tracked pipeline state instead: a deferred + // recompile keeps EditorApplication.isCompiling true without any compilation + // actually running. + if (EditorApplication.isPlaying) + { + return _pipelineCompilationRunning; + } + + // Outside Play mode the raw signal is reliable. + return true; + } + } +} + + diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs.meta b/MCPForUnity/Editor/Services/EditorStateCache.cs.meta new file mode 100644 index 0000000..21c5d01 --- /dev/null +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa7909967ce3c48c493181c978782a54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs new file mode 100644 index 0000000..ba1dc85 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs @@ -0,0 +1,357 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Automatically starts the HTTP MCP bridge on editor load when the user has opted in + /// via the "Auto-Start on Editor Load" toggle in Advanced Settings. + /// This complements HttpBridgeReloadHandler (which only resumes after domain reloads). + /// + [InitializeOnLoad] + internal static class HttpAutoStartHandler + { + internal const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized"; + + // Set while AutoStartAsync is in flight. A domain reload kills the in-flight task but + // leaves this set, so the next domain load can finish the connect phase without + // re-spawning the server (StartLocalHttpServer would first stop a still-booting process). + internal const string ConnectPendingKey = "HttpAutoStartHandler.ConnectPending"; + + // Bounds the per-frame retry when editor services keep throwing on a fresh launch. + // Plain static, so every domain reload grants a fresh budget. + private const int MaxServiceNotReadyRetries = 300; + private static int _serviceNotReadyRetries; + + internal enum TickDecision + { + DeferBusy, + DeferToResume, + Skip, + ShouldStart, + ShouldReconnect, + } + + static HttpAutoStartHandler() + { + if (Application.isBatchMode && + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) + { + return; + } + + bool latched = SessionState.GetBool(SessionInitKey, false); + bool connectPending = SessionState.GetBool(ConnectPendingKey, false); + + // Cheap pre-check so the common case (auto-start off, nothing pending) costs one + // EditorPrefs read per domain load instead of an update subscription. The pref is + // re-read every domain load, so enabling it takes effect at the next reload. + if (!latched && !connectPending && + !EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) + { + return; + } + + // Latched with nothing pending: this session already auto-started. + if (latched && !connectPending) + { + return; + } + + // Pending EditorApplication.delayCall/update delegates are wiped by domain reloads, + // so the deferred work must NOT latch up front — latching eagerly killed auto-start + // for the whole session whenever startup included a compile (#1229). An update tick + // is reload-safe because this ctor re-arms it on every domain load. + EditorApplication.update += WaitForEditorReady; + } + + /// + /// Drops a reload-interrupted auto-start connect. Called when the user takes manual + /// control of the bridge lifecycle, so no later domain load revives the connect. + /// + internal static void CancelPendingReconnect() => SessionState.EraseBool(ConnectPendingKey); + + private static void WaitForEditorReady() + { + switch (TickCore(HttpBridgeReloadHandler.IsEditorBusy())) + { + case TickDecision.DeferBusy: + case TickDecision.DeferToResume: + return; // stay registered, try again next tick + + case TickDecision.Skip: + EditorApplication.update -= WaitForEditorReady; + return; + + case TickDecision.ShouldStart: + if (!TryBeginAutoStart()) + { + DeferOrGiveUp(); + return; + } + SessionState.SetBool(SessionInitKey, true); + EditorApplication.update -= WaitForEditorReady; + return; + + case TickDecision.ShouldReconnect: + if (!TryBeginReconnect()) + { + DeferOrGiveUp(); + return; + } + EditorApplication.update -= WaitForEditorReady; + return; + } + } + + // Services may not be initialized on the first frames of a fresh launch; retry next + // tick, but not forever — a persistently broken environment shouldn't churn exceptions + // every frame for the whole session. A later domain reload retries with a fresh budget. + private static void DeferOrGiveUp() + { + if (++_serviceNotReadyRetries < MaxServiceNotReadyRetries) return; + EditorApplication.update -= WaitForEditorReady; + McpLog.Warn("[HTTP Auto-Start] Editor services unavailable; giving up until the next domain reload"); + } + + /// + /// Decision core for the editor-ready tick, separated so EditMode tests can drive it + /// without spawning servers. Never writes the session latch — the caller latches only + /// after the start work actually dispatches. + /// + internal static TickDecision TickCore(bool editorBusy) + { + if (editorBusy) return TickDecision.DeferBusy; + + bool connectPending = SessionState.GetBool(ConnectPendingKey, false); + if (!connectPending) + { + if (SessionState.GetBool(SessionInitKey, false)) return TickDecision.Skip; + + // Only check lightweight EditorPrefs here — heavier services are touched in + // TryBeginAutoStart once the editor is idle. No latch when disabled: the pref + // is re-read on the next domain load. + if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return TickDecision.Skip; + } + + // A pending reload-resume owns bridge revival — checked only when we would + // otherwise act, so a plain Skip never waits out the resume window. + if (HttpBridgeReloadHandler.IsResumePending) return TickDecision.DeferToResume; + + return connectPending ? TickDecision.ShouldReconnect : TickDecision.ShouldStart; + } + + /// + /// Returns true when the auto-start decision was actually made (including deliberate + /// early-outs). Returns false when services were not ready yet, so the caller leaves + /// the session latch unset and retries instead of consuming it. + /// + private static bool TryBeginAutoStart() + { + try + { + if (!EditorConfigurationCache.Instance.UseHttpTransport) return true; + + // Don't auto-start if bridge is already running. + if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return true; + + _ = AutoStartAsync(); + return true; + } + catch (Exception ex) + { + McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}"); + return false; + } + } + + /// + /// Returns false when services were not ready yet (caller retries). On true the + /// pending reconnect was either dispatched or deliberately dropped (auto-start + /// disabled, transport switched, bridge already running). + /// + internal static bool TryBeginReconnect() + { + bool proceed; + try + { + proceed = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false) + && EditorConfigurationCache.Instance.UseHttpTransport + && !MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http); + } + catch (Exception ex) + { + McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}"); + return false; + } + + if (!proceed) + { + SessionState.EraseBool(ConnectPendingKey); + return true; + } + + _ = ReconnectAsync(); + return true; + } + + private static async Task AutoStartAsync() + { + SessionState.SetBool(ConnectPendingKey, true); + try + { + bool isLocal = !HttpEndpointUtility.IsRemoteScope(); + + if (isLocal) + { + // For HTTP Local: launch the server process first, then connect the bridge. + // This mirrors what the UI "Start Server" button does. + if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch( + HttpEndpointUtility.GetLocalBaseUrl(), out string policyError)) + { + McpLog.Debug($"[HTTP Auto-Start] Local URL blocked by security policy: {policyError}"); + return; + } + + // Check if server is already reachable (e.g. user started it externally). + if (!MCPServiceLocator.Server.IsLocalHttpServerReachable()) + { + bool serverStarted = MCPServiceLocator.Server.StartLocalHttpServer(quiet: true); + if (!serverStarted) + { + McpLog.Warn("[HTTP Auto-Start] Failed to start local HTTP server"); + return; + } + } + + // Wait for the server to become reachable, then connect. + await WaitForServerAndConnectAsync(); + } + else + { + // For HTTP Remote: server is external, just connect the bridge. + await ConnectBridgeAsync(); + } + } + catch (Exception ex) + { + McpLog.Warn($"[HTTP Auto-Start] Failed: {ex.Message}"); + } + finally + { + // Reached on every terminal outcome. A domain reload that kills the task + // mid-flight skips this, leaving the key set for the reconnect path. + SessionState.EraseBool(ConnectPendingKey); + } + } + + /// + /// Finishes an auto-start whose connect phase was killed by a domain reload. + /// Connect-only: never spawns a server — the previous domain already did. + /// + private static async Task ReconnectAsync() + { + try + { + if (HttpEndpointUtility.IsRemoteScope()) + { + await ConnectBridgeAsync(); + return; + } + + await WaitForServerAndConnectAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"[HTTP Auto-Start] Post-reload reconnect failed: {ex.Message}"); + } + finally + { + SessionState.EraseBool(ConnectPendingKey); + } + } + + /// + /// Waits for the local HTTP server to accept connections, then connects the bridge. + /// Mirrors TryAutoStartSessionAsync in McpConnectionSection: while a managed launch + /// process is alive, keep polling reachability and declare failure only when it exits + /// without the port coming up. Without a launch handle (post-reload reconnect, or a + /// server started externally) there is nothing to watch die, so poll to the hard cap. + /// + private static async Task WaitForServerAndConnectAsync() + { + var server = MCPServiceLocator.Server; + string url = HttpEndpointUtility.GetLocalBaseUrl(); + var pollDelay = TimeSpan.FromMilliseconds(500); + var hardCap = TimeSpan.FromMinutes(5); + double startTime = EditorApplication.timeSinceStartup; + + while (true) + { + // Abort if user changed settings while we were waiting. + if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return; + if (!EditorConfigurationCache.Instance.UseHttpTransport) return; + if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return; + + if (server.IsLocalHttpServerReachable()) + { + McpLog.Info($"Server ready on {url}"); + bool started = await MCPServiceLocator.Bridge.StartAsync(); + if (started) + { + McpLog.Info("Session connected"); + MCPForUnityEditorWindow.RequestHealthVerification(); + return; + } + } + + double elapsed = EditorApplication.timeSinceStartup - startTime; + bool launchProcessDied = server.HasManagedServerLaunchHandle + && !server.IsManagedServerLaunchProcessAlive() + && elapsed > 1.0; + + if (launchProcessDied || elapsed > hardCap.TotalSeconds) + { + // Last-resort connect attempt in case reachability detection missed a live server. + if (await MCPServiceLocator.Bridge.StartAsync()) + { + McpLog.Info("Session connected"); + MCPForUnityEditorWindow.RequestHealthVerification(); + return; + } + + server.LogLocalHttpServerLaunchFailure(); + return; + } + + try { await Task.Delay(pollDelay); } + catch { return; } + } + } + + /// + /// Connects the bridge directly (for remote HTTP where the server is already running). + /// + private static async Task ConnectBridgeAsync() + { + string url = HttpEndpointUtility.GetRemoteBaseUrl(); + McpLog.Info($"Connecting to {url}…"); + bool started = await MCPServiceLocator.Bridge.StartAsync(); + if (started) + { + McpLog.Info("Connected"); + MCPForUnityEditorWindow.RequestHealthVerification(); + } + else + { + McpLog.Warn("Connection failed: could not connect to remote HTTP server"); + } + } + } +} diff --git a/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs.meta b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs.meta new file mode 100644 index 0000000..d42fc79 --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3d8f1790992fe0742938d8a879056ee6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs new file mode 100644 index 0000000..937483e --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs @@ -0,0 +1,223 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Windows; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge. + /// + [InitializeOnLoad] + internal static class HttpBridgeReloadHandler + { + // SessionState, not EditorPrefs: it survives domain reloads but dies with the editor + // process and is per-editor-instance. EditorPrefs is per-user machine-global, so a + // second open editor could consume or delete this editor's pending resume, and a + // crash mid-compile would leave a stale flag that resurrects the bridge on the next + // launch (#1229). + internal const string ResumeSessionKey = "MCPForUnity.ResumeHttpAfterReload"; + + private static readonly TimeSpan[] ResumeRetrySchedule = + { + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }; + + static HttpBridgeReloadHandler() + { + // Migration: the flag lived in EditorPrefs before it moved to SessionState — the + // key STRING is shared, so renaming ResumeSessionKey would silently break this + // cleanup. Once per session, not per reload: the EditorPrefs key is machine-global, + // and an older-version editor open concurrently still uses it for its own resume. + // Safe to delete this block a few releases after v10. + const string migratedKey = "MCPForUnity.ResumeHttpAfterReload.Migrated"; + if (!SessionState.GetBool(migratedKey, false)) + { + EditorPrefs.DeleteKey(ResumeSessionKey); + SessionState.SetBool(migratedKey, true); + } + + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + internal static bool IsResumePending => SessionState.GetBool(ResumeSessionKey, false); + + /// + /// Drops a pending reload-resume. Called when the user takes manual control of the + /// bridge lifecycle (Connect, End Session, transport switch, orphan cleanup); the + /// retry loop re-checks the flag per attempt, so this also aborts an in-flight loop. + /// + internal static void CancelPendingResume() => SessionState.EraseBool(ResumeSessionKey); + + private static void OnBeforeAssemblyReload() + { + try + { + OnBeforeAssemblyReloadCore(MCPServiceLocator.TransportManager); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}"); + } + } + + internal static void OnBeforeAssemblyReloadCore(TransportManager transport) + { + if (transport.IsRunning(TransportMode.Http)) + { + SessionState.SetBool(ResumeSessionKey, true); + + // beforeAssemblyReload is synchronous; force a synchronous teardown so we do not + // leave an orphaned socket due to an unfinished async close handshake. + transport.ForceStop(TransportMode.Http); + } + // When the bridge is not running, leave any pending flag alone: during a multi-pass + // compile the next reload lands before the deferred resume ran, and deleting the + // flag here is what used to lose the resume permanently (#1229). Explicit cancel + // paths (End Session, transport switch, orphan cleanup) erase the flag instead. + } + + private static void OnAfterAssemblyReload() + { + if (OnAfterAssemblyReloadCore()) + { + EditorApplication.update += ResumeTick; + } + } + + /// + /// Decision core, separated so EditMode tests can drive it. Returns true when a resume + /// should be scheduled. Does not consume the flag — it survives until the resume + /// succeeds, is cancelled, or exhausts its retries, so a further reload in the middle + /// of any deferral re-enters here instead of losing the resume. + /// + internal static bool OnAfterAssemblyReloadCore() + { + try + { + if (!SessionState.GetBool(ResumeSessionKey, false)) return false; + + // Only resume HTTP if it is still the selected transport. + if (!EditorConfigurationCache.Instance.UseHttpTransport) + { + SessionState.EraseBool(ResumeSessionKey); + return false; + } + + return true; + } + catch (Exception ex) + { + // Transport-config read failed (services racing the reload boundary): schedule + // the resume anyway rather than dropping it — the retry loop re-checks the + // transport per attempt and erases the flag itself on cancel/switch/exhaustion, + // so a stale flag cannot wedge the session. + McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}"); + return true; + } + } + + private static void ResumeTick() + { + if (IsEditorBusy()) return; + EditorApplication.update -= ResumeTick; + _ = ResumeHttpWithRetriesAsync(); + } + + /// + /// Busy gate for the deferral ticks. Uses the #549-aware compiling check because raw + /// EditorApplication.isCompiling stays true for a whole play session under the + /// "Recompile After Finished Playing" preference, which would block resume until + /// play mode exits. + /// + internal static bool IsEditorBusy() + => EditorStateCache.GetActualIsCompiling() || EditorApplication.isUpdating; + + // scheduleOverride lets EditMode tests pass an all-zero schedule so the loop + // completes synchronously (the test framework floor cannot run async tests). + internal static async Task ResumeHttpWithRetriesAsync(TimeSpan[] scheduleOverride = null) + { + TimeSpan[] schedule = scheduleOverride ?? ResumeRetrySchedule; + Exception lastException = null; + + for (int i = 0; i < schedule.Length; i++) + { + int attempt = i + 1; + McpLog.Debug($"[HTTP Reload] Resume attempt {attempt}/{schedule.Length}"); + + TimeSpan delay = schedule[i]; + if (delay > TimeSpan.Zero) + { + McpLog.Debug($"[HTTP Reload] Waiting {delay.TotalSeconds:0.#}s before resume attempt {attempt}"); + try { await Task.Delay(delay); } + catch { return; } + } + + // The flag doubles as the cancel signal (see CancelPendingResume). + if (!IsResumePending) return; + + try + { + // Inside the attempt try: a service read racing the reload boundary must + // burn a retry, not kill this fire-and-forget task with the flag still set + // (which would leave nothing scheduled to consume it until the next reload). + + // Abort retries if the user switched transports while we were waiting. + if (!EditorConfigurationCache.Instance.UseHttpTransport) + { + SessionState.EraseBool(ResumeSessionKey); + return; + } + + // Never bounce a session someone else established while we were waiting + // (WebSocketTransportClient.StartAsync tears down a live connection first). + if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) + { + SessionState.EraseBool(ResumeSessionKey); + return; + } + + bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); + if (started) + { + SessionState.EraseBool(ResumeSessionKey); + McpLog.Debug($"[HTTP Reload] Resume succeeded on attempt {attempt}"); + MCPForUnityEditorWindow.RequestHealthVerification(); + return; + } + + var state = MCPServiceLocator.TransportManager.GetState(TransportMode.Http); + string reason = string.IsNullOrWhiteSpace(state?.Error) ? "no error detail" : state.Error; + McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} failed: {reason}"); + } + catch (Exception ex) + { + lastException = ex; + McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} threw: {ex.Message}"); + } + } + + // Exhausted: erase the flag so later reload boundaries don't replay this failure + // loop for the rest of the session. This cannot swallow a multi-pass resume — a + // reload mid-loop kills the task before this line, leaving the flag set. + SessionState.EraseBool(ResumeSessionKey); + + if (lastException != null) + { + McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {lastException.Message}"); + } + else + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + } + } +} diff --git a/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta new file mode 100644 index 0000000..ae5e9ed --- /dev/null +++ b/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c0cf970a7b494a659be151dc0124296 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IBridgeControlService.cs b/MCPForUnity/Editor/Services/IBridgeControlService.cs new file mode 100644 index 0000000..7cc593e --- /dev/null +++ b/MCPForUnity/Editor/Services/IBridgeControlService.cs @@ -0,0 +1,82 @@ +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.Transport; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for controlling the MCP for Unity Bridge connection + /// + public interface IBridgeControlService + { + /// + /// Gets whether the bridge is currently running + /// + bool IsRunning { get; } + + /// + /// Gets the current port the bridge is listening on + /// + int CurrentPort { get; } + + /// + /// Gets whether the bridge is in auto-connect mode + /// + bool IsAutoConnectMode { get; } + + /// + /// Gets the currently active transport mode, if any + /// + TransportMode? ActiveMode { get; } + + /// + /// Starts the MCP for Unity Bridge asynchronously + /// + /// True if the bridge started successfully + Task StartAsync(); + + /// + /// Stops the MCP for Unity Bridge asynchronously + /// + Task StopAsync(); + + /// + /// Verifies the bridge connection by sending a ping and waiting for a pong response + /// + /// The port to verify + /// Verification result with detailed status + BridgeVerificationResult Verify(int port); + + /// + /// Verifies the connection asynchronously (works for both HTTP and stdio transports) + /// + /// Verification result with detailed status + Task VerifyAsync(); + + } + + /// + /// Result of a bridge verification attempt + /// + public class BridgeVerificationResult + { + /// + /// Whether the verification was successful + /// + public bool Success { get; set; } + + /// + /// Human-readable message about the verification result + /// + public string Message { get; set; } + + /// + /// Whether the handshake was valid (FRAMING=1 protocol) + /// + public bool HandshakeValid { get; set; } + + /// + /// Whether the ping/pong exchange succeeded + /// + public bool PingSucceeded { get; set; } + } +} diff --git a/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta b/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta new file mode 100644 index 0000000..ec50da3 --- /dev/null +++ b/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b5d9f677f6f54fc59e6fe921b260c61 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IClientConfigurationService.cs b/MCPForUnity/Editor/Services/IClientConfigurationService.cs new file mode 100644 index 0000000..6172e8f --- /dev/null +++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for configuring MCP clients + /// + public interface IClientConfigurationService + { + /// + /// Configures a specific MCP client + /// + /// The client to configure + void ConfigureClient(IMcpClientConfigurator configurator); + + /// + /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) + /// + /// Summary of configuration results + ClientConfigurationSummary ConfigureAllDetectedClients(); + + /// + /// Checks the configuration status of a client + /// + /// The client to check + /// If true, attempts to auto-fix mismatched paths + /// True if status changed, false otherwise + bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true); + + /// Gets the registry of discovered configurators. + IReadOnlyList GetAllClients(); + } + + /// + /// Summary of configuration results for multiple clients + /// + public class ClientConfigurationSummary + { + /// + /// Number of clients successfully configured + /// + public int SuccessCount { get; set; } + + /// + /// Number of clients that failed to configure + /// + public int FailureCount { get; set; } + + /// + /// Number of clients skipped (already configured or tool not found) + /// + public int SkippedCount { get; set; } + + /// + /// Detailed messages for each client + /// + public System.Collections.Generic.List Messages { get; set; } = new(); + + /// + /// Gets a human-readable summary message + /// + public string GetSummaryMessage() + { + return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped"; + } + } +} diff --git a/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta b/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta new file mode 100644 index 0000000..5110b69 --- /dev/null +++ b/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aae139cfae7ac4044ac52e2658005ea1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IPackageDeploymentService.cs b/MCPForUnity/Editor/Services/IPackageDeploymentService.cs new file mode 100644 index 0000000..743834c --- /dev/null +++ b/MCPForUnity/Editor/Services/IPackageDeploymentService.cs @@ -0,0 +1,29 @@ +using System; + +namespace MCPForUnity.Editor.Services +{ + public interface IPackageDeploymentService + { + string GetStoredSourcePath(); + void SetStoredSourcePath(string path); + void ClearStoredSourcePath(); + + string GetTargetPath(); + string GetTargetDisplayPath(); + + string GetLastBackupPath(); + bool HasBackup(); + + PackageDeploymentResult DeployFromStoredSource(); + PackageDeploymentResult RestoreLastBackup(); + } + + public class PackageDeploymentResult + { + public bool Success { get; set; } + public string Message { get; set; } + public string SourcePath { get; set; } + public string TargetPath { get; set; } + public string BackupPath { get; set; } + } +} diff --git a/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta b/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta new file mode 100644 index 0000000..d1bf12d --- /dev/null +++ b/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IPackageUpdateService.cs b/MCPForUnity/Editor/Services/IPackageUpdateService.cs new file mode 100644 index 0000000..1a8daaf --- /dev/null +++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs @@ -0,0 +1,89 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for checking package updates and version information + /// + public interface IPackageUpdateService + { + /// + /// Checks if a newer version of the package is available + /// + /// The current package version + /// Update check result containing availability and latest version info + UpdateCheckResult CheckForUpdate(string currentVersion); + + /// + /// Returns a cached update result if one exists for today, or null if a network fetch is needed. + /// Main-thread only (reads EditorPrefs). + /// + UpdateCheckResult TryGetCachedResult(string currentVersion); + + /// + /// Performs only the network fetch and version comparison (no EditorPrefs access). + /// Safe to call from a background thread. + /// + UpdateCheckResult FetchAndCompare(string currentVersion); + + /// + /// Performs only the network fetch and version comparison using pre-computed installation info. + /// Use this overload when calling from a background thread to avoid main-thread-only API calls. + /// + UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch); + + /// + /// Caches a successful fetch result in EditorPrefs. Must be called from the main thread. + /// + void CacheFetchResult(string currentVersion, string fetchedVersion); + + /// + /// Compares two version strings to determine if the first is newer than the second + /// + /// First version string + /// Second version string + /// True if version1 is newer than version2 + bool IsNewerVersion(string version1, string version2); + + /// + /// Determines if the package was installed via Git or Asset Store + /// + /// True if installed via Git, false if Asset Store or unknown + bool IsGitInstallation(); + + /// + /// Determines the Git branch to check for updates (e.g. "main" or "beta"). + /// Must be called from the main thread (uses Unity PackageManager APIs). + /// + string GetGitUpdateBranch(string currentVersion); + + /// + /// Clears the cached update check data, forcing a fresh check on next request + /// + void ClearCache(); + } + + /// + /// Result of an update check operation + /// + public class UpdateCheckResult + { + /// + /// Whether an update is available + /// + public bool UpdateAvailable { get; set; } + + /// + /// The latest version available (null if check failed or no update) + /// + public string LatestVersion { get; set; } + + /// + /// Whether the check was successful (false if network error, etc.) + /// + public bool CheckSucceeded { get; set; } + + /// + /// Optional message about the check result + /// + public string Message { get; set; } + } +} diff --git a/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta b/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta new file mode 100644 index 0000000..d9e6845 --- /dev/null +++ b/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e94ae28f193184e4fb5068f62f4f00c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs b/MCPForUnity/Editor/Services/IPathResolverService.cs new file mode 100644 index 0000000..d57581d --- /dev/null +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -0,0 +1,77 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for resolving paths to required tools and supporting user overrides + /// + public interface IPathResolverService + { + /// + /// Gets the uvx package manager path (respects override if set) + /// + /// Path to the uvx executable, or null if not found + string GetUvxPath(); + + /// + /// Gets the Claude CLI path (respects override if set) + /// + /// Path to the claude executable, or null if not found + string GetClaudeCliPath(); + + /// + /// Checks if Python is detected on the system + /// + /// True if Python is found + bool IsPythonDetected(); + + /// + /// Checks if Claude CLI is detected on the system + /// + /// True if Claude CLI is found + bool IsClaudeCliDetected(); + + /// + /// Sets an override for the uvx path + /// + /// Path to override with + void SetUvxPathOverride(string path); + + /// + /// Sets an override for the Claude CLI path + /// + /// Path to override with + void SetClaudeCliPathOverride(string path); + + /// + /// Clears the uvx path override + /// + void ClearUvxPathOverride(); + + /// + /// Clears the Claude CLI path override + /// + void ClearClaudeCliPathOverride(); + + /// + /// Gets whether a uvx path override is active + /// + bool HasUvxPathOverride { get; } + + /// + /// Gets whether a Claude CLI path override is active + /// + bool HasClaudeCliPathOverride { get; } + + /// + /// Gets whether the uvx path used a fallback from override to system path + /// + bool HasUvxPathFallback { get; } + + /// + /// Validates the provided uv executable by running "--version" and parsing the output. + /// + /// Absolute or relative path to the uv/uvx executable. + /// Parsed version string if successful. + /// True when the executable runs and returns a uv version string. + bool TryValidateUvxExecutable(string uvPath, out string version); + } +} diff --git a/MCPForUnity/Editor/Services/IPathResolverService.cs.meta b/MCPForUnity/Editor/Services/IPathResolverService.cs.meta new file mode 100644 index 0000000..7563453 --- /dev/null +++ b/MCPForUnity/Editor/Services/IPathResolverService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e8d388be507345aeb0eaf27fbd3c022 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IPlatformService.cs b/MCPForUnity/Editor/Services/IPlatformService.cs new file mode 100644 index 0000000..ec686b2 --- /dev/null +++ b/MCPForUnity/Editor/Services/IPlatformService.cs @@ -0,0 +1,20 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for platform detection and platform-specific environment access + /// + public interface IPlatformService + { + /// + /// Checks if the current platform is Windows + /// + /// True if running on Windows + bool IsWindows(); + + /// + /// Gets the SystemRoot environment variable (Windows-specific) + /// + /// SystemRoot path, or null if not available + string GetSystemRoot(); + } +} diff --git a/MCPForUnity/Editor/Services/IPlatformService.cs.meta b/MCPForUnity/Editor/Services/IPlatformService.cs.meta new file mode 100644 index 0000000..e501f58 --- /dev/null +++ b/MCPForUnity/Editor/Services/IPlatformService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs b/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs new file mode 100644 index 0000000..6595fc8 --- /dev/null +++ b/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered resource + /// + public class ResourceMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + public string AssemblyName { get; set; } + public bool IsBuiltIn { get; set; } + } + + /// + /// Service for discovering MCP resources via reflection + /// + public interface IResourceDiscoveryService + { + /// + /// Discovers all resources marked with [McpForUnityResource] + /// + List DiscoverAllResources(); + + /// + /// Gets metadata for a specific resource + /// + ResourceMetadata GetResourceMetadata(string resourceName); + + /// + /// Returns only the resources currently enabled + /// + List GetEnabledResources(); + + /// + /// Checks whether a resource is currently enabled + /// + bool IsResourceEnabled(string resourceName); + + /// + /// Updates the enabled state for a resource + /// + void SetResourceEnabled(string resourceName, bool enabled); + + /// + /// Invalidates the resource discovery cache + /// + void InvalidateCache(); + } +} diff --git a/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta new file mode 100644 index 0000000..171d2b2 --- /dev/null +++ b/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7afb4739669224c74b4b4d706e6bbb49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs new file mode 100644 index 0000000..60f90b0 --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -0,0 +1,89 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Interface for server management operations + /// + public interface IServerManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + bool ClearUvxCache(); + + /// + /// Start the local HTTP server headless (no terminal window), redirecting its output to a + /// per-port launch log. Stops any existing server on the port and clears stale artifacts first. + /// + /// When true, skip confirmation dialogs (used by auto-start). + /// True if server was started successfully, false otherwise + bool StartLocalHttpServer(bool quiet = false); + + /// + /// Gets the launch-log path for the configured local HTTP server port, or null if unavailable. + /// + string GetLocalHttpServerLaunchLogPath(); + + /// + /// Returns true while the most-recently launched headless server process is still alive. + /// Used by callers to keep waiting for reachability instead of declaring failure prematurely. + /// + bool IsManagedServerLaunchProcessAlive(); + + /// + /// True when this domain launched the local HTTP server and still holds its Process + /// handle. Handles do not survive domain reloads, so false also means "unknown" — + /// callers should wait rather than fail fast. + /// + bool HasManagedServerLaunchHandle { get; } + + /// + /// Writes a launch-failure report to the Console (Error): the tail of the launch log, + /// the log path, and a copy-command hint pointing at the Manual Server Launch foldout. + /// + void LogLocalHttpServerLaunchFailure(); + + /// + /// Stop the local HTTP server by finding the process listening on the configured port + /// + bool StopLocalHttpServer(); + + /// + /// Stop the Unity-managed local HTTP server if a handshake/pidfile exists, + /// even if the current transport selection has changed. + /// + bool StopManagedLocalHttpServer(); + + /// + /// Best-effort detection: returns true if a local MCP HTTP server appears to be running + /// on the configured local URL/port (used to drive UI state even if the session is not active). + /// + bool IsLocalHttpServerRunning(); + + /// + /// Fast reachability check: returns true if a local TCP listener is accepting connections + /// for the configured local URL/port (used for UI state without process inspection). + /// + bool IsLocalHttpServerReachable(); + + /// + /// Attempts to get the command that will be executed when starting the local HTTP server + /// + /// The command that will be executed when available + /// Reason why a command could not be produced + /// True if a command is available, false otherwise + bool TryGetLocalHttpServerCommand(out string command, out string error); + + /// + /// Check if the configured HTTP URL is a local address + /// + /// True if URL is local (localhost, 127.0.0.1, etc.) + bool IsLocalUrl(); + + /// + /// Check if the local HTTP server can be started + /// + /// True if HTTP transport is enabled and URL satisfies local launch security policy + bool CanStartLocalServer(); + } +} diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs.meta b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta new file mode 100644 index 0000000..9f12dc3 --- /dev/null +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d41bfc9780b774affa6afbffd081eb79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ITestRunnerService.cs b/MCPForUnity/Editor/Services/ITestRunnerService.cs new file mode 100644 index 0000000..c24d9e4 --- /dev/null +++ b/MCPForUnity/Editor/Services/ITestRunnerService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Options for filtering which tests to run. + /// All properties are optional - null or empty arrays are ignored. + /// + public class TestFilterOptions + { + /// + /// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod"). + /// + public string[] TestNames { get; set; } + + /// + /// Same as TestNames, except it allows for Regex. + /// + public string[] GroupNames { get; set; } + + /// + /// NUnit category names to filter by (tests marked with [Category] attribute). + /// + public string[] CategoryNames { get; set; } + + /// + /// Assembly names to filter tests by. + /// + public string[] AssemblyNames { get; set; } + } + + /// + /// Provides access to Unity Test Runner data and execution. + /// + public interface ITestRunnerService + { + /// + /// Retrieve the list of tests for the requested mode(s). + /// When is null, tests for both EditMode and PlayMode are returned. + /// + Task>> GetTestsAsync(TestMode? mode); + + /// + /// Execute tests for the supplied mode with optional filtering. + /// + /// The test mode (EditMode or PlayMode). + /// Optional filter options to run specific tests. Pass null to run all tests. + Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null); + } +} diff --git a/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta b/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta new file mode 100644 index 0000000..ea325e7 --- /dev/null +++ b/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d23bf32361ff444beaf3510818c94bae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs new file mode 100644 index 0000000..7878dfe --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered tool + /// + public class ToolMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public bool StructuredOutput { get; set; } + public List Parameters { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + public string AssemblyName { get; set; } + public bool AutoRegister { get; set; } = true; + public bool RequiresPolling { get; set; } = false; + public string PollAction { get; set; } = "status"; + public int MaxPollSeconds { get; set; } = 0; + public bool IsBuiltIn { get; set; } + public string Group { get; set; } = "core"; + } + + /// + /// Metadata for a tool parameter + /// + public class ParameterMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string Type { get; set; } // "string", "int", "bool", "float", etc. + public bool Required { get; set; } + public string DefaultValue { get; set; } + } + + /// + /// Service for discovering MCP tools via reflection + /// + public interface IToolDiscoveryService + { + /// + /// Discovers all tools marked with [McpForUnityTool] + /// + List DiscoverAllTools(); + + /// + /// Gets metadata for a specific tool + /// + ToolMetadata GetToolMetadata(string toolName); + + /// + /// Returns only the tools currently enabled for registration + /// + List GetEnabledTools(); + + /// + /// Checks whether a tool is currently enabled for registration + /// + bool IsToolEnabled(string toolName); + + /// + /// Updates the enabled state for a tool + /// + void SetToolEnabled(string toolName, bool enabled); + + /// + /// Invalidates the tool discovery cache + /// + void InvalidateCache(); + } +} diff --git a/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta new file mode 100644 index 0000000..a25b749 --- /dev/null +++ b/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 497592a93fd994b2cb9803e7c8636ff7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/MCPServiceLocator.cs b/MCPForUnity/Editor/Services/MCPServiceLocator.cs new file mode 100644 index 0000000..c8ceb4d --- /dev/null +++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs @@ -0,0 +1,98 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service locator for accessing MCP services without dependency injection + /// + public static class MCPServiceLocator + { + private static IBridgeControlService _bridgeService; + private static IClientConfigurationService _clientService; + private static IPathResolverService _pathService; + private static ITestRunnerService _testRunnerService; + private static IPackageUpdateService _packageUpdateService; + private static IPlatformService _platformService; + private static IToolDiscoveryService _toolDiscoveryService; + private static IResourceDiscoveryService _resourceDiscoveryService; + private static IServerManagementService _serverManagementService; + private static TransportManager _transportManager; + private static IPackageDeploymentService _packageDeploymentService; + + public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); + public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); + public static IPathResolverService Paths => _pathService ??= new PathResolverService(); + public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); + public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); + public static IPlatformService Platform => _platformService ??= new PlatformService(); + public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService(); + public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService(); + public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService(); + public static TransportManager TransportManager => _transportManager ??= new TransportManager(); + public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService(); + + /// + /// Registers a custom implementation for a service (useful for testing) + /// + /// The service interface type + /// The implementation to register + public static void Register(T implementation) where T : class + { + if (implementation is IBridgeControlService b) + _bridgeService = b; + else if (implementation is IClientConfigurationService c) + _clientService = c; + else if (implementation is IPathResolverService p) + _pathService = p; + else if (implementation is ITestRunnerService t) + _testRunnerService = t; + else if (implementation is IPackageUpdateService pu) + _packageUpdateService = pu; + else if (implementation is IPlatformService ps) + _platformService = ps; + else if (implementation is IToolDiscoveryService td) + _toolDiscoveryService = td; + else if (implementation is IResourceDiscoveryService rd) + _resourceDiscoveryService = rd; + else if (implementation is IServerManagementService sm) + _serverManagementService = sm; + else if (implementation is IPackageDeploymentService pd) + _packageDeploymentService = pd; + else if (implementation is TransportManager tm) + _transportManager = tm; + } + + /// + /// Resets all services to their default implementations (useful for testing) + /// + public static void Reset() + { + (_bridgeService as IDisposable)?.Dispose(); + (_clientService as IDisposable)?.Dispose(); + (_pathService as IDisposable)?.Dispose(); + (_testRunnerService as IDisposable)?.Dispose(); + (_packageUpdateService as IDisposable)?.Dispose(); + (_platformService as IDisposable)?.Dispose(); + (_toolDiscoveryService as IDisposable)?.Dispose(); + (_resourceDiscoveryService as IDisposable)?.Dispose(); + (_serverManagementService as IDisposable)?.Dispose(); + (_transportManager as IDisposable)?.Dispose(); + (_packageDeploymentService as IDisposable)?.Dispose(); + + _bridgeService = null; + _clientService = null; + _pathService = null; + _testRunnerService = null; + _packageUpdateService = null; + _platformService = null; + _toolDiscoveryService = null; + _resourceDiscoveryService = null; + _serverManagementService = null; + _transportManager = null; + _packageDeploymentService = null; + } + } +} diff --git a/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta b/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta new file mode 100644 index 0000000..cd9b402 --- /dev/null +++ b/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 276d6a9f9a1714ead91573945de78992 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs new file mode 100644 index 0000000..e757b86 --- /dev/null +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Best-effort cleanup when the Unity Editor is quitting. + /// - Stops active transports so clients don't see a "hung" session longer than necessary. + /// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a + /// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload. + /// + [InitializeOnLoad] + internal static class McpEditorShutdownCleanup + { + static McpEditorShutdownCleanup() + { + // Guard against duplicate subscriptions across domain reloads. + try { EditorApplication.quitting -= OnEditorQuitting; } catch { } + EditorApplication.quitting += OnEditorQuitting; + } + + // A -batchmode/CI instance resolves the interactive editor's server via the global + // pidfile+port handshake, so cleanup there would stop another user's server. Mirror the + // sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in. + internal static bool ShouldRunCleanup() => + ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")); + + internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) => + !isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv); + + private static void OnEditorQuitting() + { + if (!ShouldRunCleanup()) return; + + // 1) Stop transports (best-effort, bounded wait). + try + { + var transport = MCPServiceLocator.TransportManager; + + Task stopHttp = transport.StopAsync(TransportMode.Http); + Task stopStdio = transport.StopAsync(TransportMode.Stdio); + + try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { } + } + catch (Exception ex) + { + // Avoid hard failures on quit. + McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}"); + } + + // 2) Stop the local HTTP server this Unity instance launched (best-effort). + // Headless servers have no terminal window, so an unstopped one is an invisible orphan. + // StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake, + // so it never touches servers launched by other Unity instances. This runs on quit only; + // domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting). + try + { + MCPServiceLocator.Server.StopManagedLocalHttpServer(); + } + catch (Exception ex) + { + McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}"); + } + } + } +} + diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta new file mode 100644 index 0000000..a94395c --- /dev/null +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4150c04e0907c45d7b332260911a0567 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/PackageDeploymentService.cs b/MCPForUnity/Editor/Services/PackageDeploymentService.cs new file mode 100644 index 0000000..7c0e27c --- /dev/null +++ b/MCPForUnity/Editor/Services/PackageDeploymentService.cs @@ -0,0 +1,304 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Handles copying a local MCPForUnity folder into the current project's package location with backup/restore support. + /// + public class PackageDeploymentService : IPackageDeploymentService + { + private const string BackupRootFolderName = "MCPForUnityDeployBackups"; + + public string GetStoredSourcePath() + { + return EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty); + } + + public void SetStoredSourcePath(string path) + { + ValidateSource(path); + EditorPrefs.SetString(EditorPrefKeys.PackageDeploySourcePath, Path.GetFullPath(path)); + } + + public void ClearStoredSourcePath() + { + EditorPrefs.DeleteKey(EditorPrefKeys.PackageDeploySourcePath); + } + + public string GetTargetPath() + { + // Prefer Package Manager resolved path for the installed package + var packageInfo = PackageInfo.FindForAssembly(typeof(PackageDeploymentService).Assembly); + if (packageInfo != null) + { + if (!string.IsNullOrEmpty(packageInfo.resolvedPath) && Directory.Exists(packageInfo.resolvedPath)) + { + return packageInfo.resolvedPath; + } + + if (!string.IsNullOrEmpty(packageInfo.assetPath)) + { + string absoluteFromAsset = MakeAbsolute(packageInfo.assetPath); + if (Directory.Exists(absoluteFromAsset)) + { + return absoluteFromAsset; + } + } + } + + // Fallback to computed package root + string packageRoot = AssetPathUtility.GetMcpPackageRootPath(); + if (!string.IsNullOrEmpty(packageRoot)) + { + string absolutePath = MakeAbsolute(packageRoot); + if (Directory.Exists(absolutePath)) + { + return absolutePath; + } + } + + return null; + } + + public string GetTargetDisplayPath() + { + string target = GetTargetPath(); + if (string.IsNullOrEmpty(target)) + return "Not found (check Packages/manifest.json)"; + // Use forward slashes to avoid backslash escape sequence issues in UI text + return target.Replace('\\', '/'); + } + + public string GetLastBackupPath() + { + return EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty); + } + + public bool HasBackup() + { + string path = GetLastBackupPath(); + return !string.IsNullOrEmpty(path) && Directory.Exists(path); + } + + public PackageDeploymentResult DeployFromStoredSource() + { + string sourcePath = GetStoredSourcePath(); + if (string.IsNullOrEmpty(sourcePath)) + { + return Fail("Select a MCPForUnity folder first."); + } + + string validationError = ValidateSource(sourcePath, throwOnError: false); + if (!string.IsNullOrEmpty(validationError)) + { + return Fail(validationError); + } + + string targetPath = GetTargetPath(); + if (string.IsNullOrEmpty(targetPath)) + { + return Fail("Could not locate the installed MCP package. Check Packages/manifest.json."); + } + + if (PathsEqual(sourcePath, targetPath)) + { + return Fail("Source and target are the same. Choose a different MCPForUnity folder."); + } + + try + { + EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Creating backup...", 0.25f); + string backupPath = CreateBackup(targetPath); + + EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Replacing package contents...", 0.7f); + CopyCoreFolders(sourcePath, targetPath); + + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastBackupPath, backupPath); + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastTargetPath, targetPath); + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastSourcePath, sourcePath); + + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + return Success("Deployment completed.", sourcePath, targetPath, backupPath); + } + catch (Exception ex) + { + McpLog.Error($"Deployment failed: {ex.Message}"); + return Fail($"Deployment failed: {ex.Message}"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + public PackageDeploymentResult RestoreLastBackup() + { + string backupPath = GetLastBackupPath(); + string targetPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastTargetPath, string.Empty); + + if (string.IsNullOrEmpty(backupPath) || !Directory.Exists(backupPath)) + { + return Fail("No backup available to restore."); + } + + if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath)) + { + targetPath = GetTargetPath(); + } + + if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath)) + { + return Fail("Could not locate target package path."); + } + + try + { + EditorUtility.DisplayProgressBar("Restore MCP for Unity", "Restoring backup...", 0.5f); + ReplaceDirectory(backupPath, targetPath); + + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + return Success("Restore completed.", null, targetPath, backupPath); + } + catch (Exception ex) + { + McpLog.Error($"Restore failed: {ex.Message}"); + return Fail($"Restore failed: {ex.Message}"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + private void CopyCoreFolders(string sourceRoot, string targetRoot) + { + string sourceEditor = Path.Combine(sourceRoot, "Editor"); + string sourceRuntime = Path.Combine(sourceRoot, "Runtime"); + + ReplaceDirectory(sourceEditor, Path.Combine(targetRoot, "Editor")); + ReplaceDirectory(sourceRuntime, Path.Combine(targetRoot, "Runtime")); + } + + private static void ReplaceDirectory(string source, string destination) + { + if (Directory.Exists(destination)) + { + FileUtil.DeleteFileOrDirectory(destination); + } + + FileUtil.CopyFileOrDirectory(source, destination); + } + + private string CreateBackup(string targetPath) + { + string backupRoot = Path.Combine(GetProjectRoot(), "Library", BackupRootFolderName); + Directory.CreateDirectory(backupRoot); + + string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string backupPath = Path.Combine(backupRoot, $"backup_{stamp}"); + + if (Directory.Exists(backupPath)) + { + FileUtil.DeleteFileOrDirectory(backupPath); + } + + FileUtil.CopyFileOrDirectory(targetPath, backupPath); + return backupPath; + } + + private static string ValidateSource(string sourcePath, bool throwOnError = true) + { + if (string.IsNullOrEmpty(sourcePath)) + { + if (throwOnError) + { + throw new ArgumentException("Source path cannot be empty."); + } + + return "Source path is empty."; + } + + if (!Directory.Exists(sourcePath)) + { + if (throwOnError) + { + throw new ArgumentException("Selected folder does not exist."); + } + + return "Selected folder does not exist."; + } + + bool hasEditor = Directory.Exists(Path.Combine(sourcePath, "Editor")); + bool hasRuntime = Directory.Exists(Path.Combine(sourcePath, "Runtime")); + + if (!hasEditor || !hasRuntime) + { + string message = "Folder must contain Editor and Runtime subfolders."; + if (throwOnError) + { + throw new ArgumentException(message); + } + + return message; + } + + return null; + } + + private static string MakeAbsolute(string assetPath) + { + assetPath = assetPath.Replace('\\', '/'); + + if (assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath)); + } + + if (assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath)); + } + + return Path.GetFullPath(assetPath); + } + + private static string GetProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static bool PathsEqual(string a, string b) + { + string normA = Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normB = Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(normA, normB, StringComparison.OrdinalIgnoreCase); + } + + private static PackageDeploymentResult Success(string message, string source, string target, string backup) + { + return new PackageDeploymentResult + { + Success = true, + Message = message, + SourcePath = source, + TargetPath = target, + BackupPath = backup + }; + } + + private static PackageDeploymentResult Fail(string message) + { + return new PackageDeploymentResult + { + Success = false, + Message = message + }; + } + } +} diff --git a/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta b/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta new file mode 100644 index 0000000..e43e0cc --- /dev/null +++ b/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/PackageJobManager.cs b/MCPForUnity/Editor/Services/PackageJobManager.cs new file mode 100644 index 0000000..d2cba49 --- /dev/null +++ b/MCPForUnity/Editor/Services/PackageJobManager.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using UnityEditor; +using UnityEditor.PackageManager; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Services +{ + internal enum PackageJobStatus { Running, Succeeded, Failed } + + internal sealed class PackageJob + { + public string JobId { get; set; } + public PackageJobStatus Status { get; set; } + public string Operation { get; set; } + public string Package { get; set; } + public long StartedUnixMs { get; set; } + public long? FinishedUnixMs { get; set; } + public long LastUpdateUnixMs { get; set; } + public string Error { get; set; } + public string ResultVersion { get; set; } + public string ResultName { get; set; } + } + + internal static class PackageJobManager + { + private const string SessionKeyJobs = "MCPForUnity.PackageJobsV1"; + private const int MaxJobsToKeep = 10; + private const long DomainReloadTimeoutMs = 120_000; + + private static readonly object LockObj = new(); + private static readonly Dictionary Jobs = new(); + + static PackageJobManager() + { + TryRestoreFromSessionState(); + } + + private sealed class PersistedState + { + public List jobs { get; set; } + } + + private sealed class PersistedJob + { + public string job_id { get; set; } + public string status { get; set; } + public string operation { get; set; } + public string package_ { get; set; } + public long started_unix_ms { get; set; } + public long? finished_unix_ms { get; set; } + public long last_update_unix_ms { get; set; } + public string error { get; set; } + public string result_version { get; set; } + public string result_name { get; set; } + } + + private static PackageJobStatus ParseStatus(string status) + { + if (string.IsNullOrWhiteSpace(status)) + return PackageJobStatus.Running; + + return status.Trim().ToLowerInvariant() switch + { + "succeeded" => PackageJobStatus.Succeeded, + "failed" => PackageJobStatus.Failed, + _ => PackageJobStatus.Running + }; + } + + private static void TryRestoreFromSessionState() + { + try + { + string json = SessionState.GetString(SessionKeyJobs, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + return; + + var state = JsonConvert.DeserializeObject(json); + if (state?.jobs == null) + return; + + lock (LockObj) + { + Jobs.Clear(); + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + foreach (var pj in state.jobs) + { + if (pj == null || string.IsNullOrWhiteSpace(pj.job_id)) + continue; + + var job = new PackageJob + { + JobId = pj.job_id, + Status = ParseStatus(pj.status), + Operation = pj.operation, + Package = pj.package_, + StartedUnixMs = pj.started_unix_ms, + FinishedUnixMs = pj.finished_unix_ms, + LastUpdateUnixMs = pj.last_update_unix_ms, + Error = pj.error, + ResultVersion = pj.result_version, + ResultName = pj.result_name + }; + + // Domain reload recovery for running jobs + if (job.Status == PackageJobStatus.Running) + { + TryRecoverJob(job, now); + } + + Jobs[pj.job_id] = job; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[PackageJobManager] Failed to restore SessionState: {ex.Message}"); + } + } + + internal static void TryRecoverJob(PackageJob job, long nowMs) + { + try + { + string packageName = ExtractPackageName(job.Package); + var allPackages = PackageInfo.GetAllRegisteredPackages(); + var info = FindPackageInfo(allPackages, packageName, job.Package); + + if (job.Operation == "add" || job.Operation == "embed") + { + if (info != null) + { + job.Status = PackageJobStatus.Succeeded; + job.FinishedUnixMs = nowMs; + job.LastUpdateUnixMs = nowMs; + job.ResultVersion = info.version; + job.ResultName = info.name; + McpLog.Info($"[PackageJobManager] Recovered {job.Operation} job {job.JobId}: {info.name}@{info.version} installed."); + } + else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs) + { + job.Status = PackageJobStatus.Failed; + job.FinishedUnixMs = nowMs; + job.LastUpdateUnixMs = nowMs; + job.Error = $"Package {job.Operation} timed out after domain reload."; + McpLog.Warn($"[PackageJobManager] Timed out {job.Operation} job {job.JobId} for '{job.Package}'."); + } + } + else if (job.Operation == "remove") + { + if (info == null) + { + job.Status = PackageJobStatus.Succeeded; + job.FinishedUnixMs = nowMs; + job.LastUpdateUnixMs = nowMs; + McpLog.Info($"[PackageJobManager] Recovered remove job {job.JobId}: '{packageName}' is no longer installed."); + } + else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs) + { + job.Status = PackageJobStatus.Failed; + job.FinishedUnixMs = nowMs; + job.LastUpdateUnixMs = nowMs; + job.Error = "Package removal timed out after domain reload."; + McpLog.Warn($"[PackageJobManager] Timed out remove job {job.JobId} for '{job.Package}'."); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[PackageJobManager] Recovery check failed for job {job.JobId}: {ex.Message}"); + } + } + + /// + /// Find a PackageInfo by name, falling back to packageId or git/local source for non-standard identifiers. + /// + private static PackageInfo FindPackageInfo(PackageInfo[] allPackages, string packageName, string originalIdentifier) + { + // Direct name match (handles normal com.company.package identifiers) + var info = allPackages.FirstOrDefault(p => + string.Equals(p.name, packageName, StringComparison.OrdinalIgnoreCase)); + if (info != null) + return info; + + // For git URLs / file: paths, packageName == originalIdentifier and won't match .name. + // Try matching by packageId or source (git/local). + bool isGitOrFile = originalIdentifier.StartsWith("http", StringComparison.OrdinalIgnoreCase) + || originalIdentifier.StartsWith("git", StringComparison.OrdinalIgnoreCase) + || originalIdentifier.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || originalIdentifier.EndsWith(".git", StringComparison.OrdinalIgnoreCase); + + if (!isGitOrFile) + return null; + + return allPackages.FirstOrDefault(p => + p.source == PackageSource.Git || p.source == PackageSource.Local + ? p.packageId != null && p.packageId.Contains(originalIdentifier) + || p.resolvedPath != null && p.resolvedPath.Contains(originalIdentifier) + : false); + } + + internal static string ExtractPackageName(string packageIdentifier) + { + if (string.IsNullOrEmpty(packageIdentifier)) + return packageIdentifier; + + // Strip version: "com.unity.foo@1.0.0" -> "com.unity.foo" + int atIndex = packageIdentifier.IndexOf('@'); + if (atIndex > 0) + return packageIdentifier.Substring(0, atIndex); + + // Git URLs and file: paths — can't reliably extract name, return as-is + return packageIdentifier; + } + + internal static void PersistToSessionState() + { + try + { + PersistedState snapshot; + lock (LockObj) + { + var jobs = Jobs.Values + .OrderByDescending(j => j.LastUpdateUnixMs) + .Take(MaxJobsToKeep) + .Select(j => new PersistedJob + { + job_id = j.JobId, + status = j.Status.ToString().ToLowerInvariant(), + operation = j.Operation, + package_ = j.Package, + started_unix_ms = j.StartedUnixMs, + finished_unix_ms = j.FinishedUnixMs, + last_update_unix_ms = j.LastUpdateUnixMs, + error = j.Error, + result_version = j.ResultVersion, + result_name = j.ResultName + }) + .ToList(); + + snapshot = new PersistedState { jobs = jobs }; + } + + SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot)); + } + catch (Exception ex) + { + McpLog.Warn($"[PackageJobManager] Failed to persist SessionState: {ex.Message}"); + } + } + + public static string StartJob(string operation, string package) + { + string jobId = Guid.NewGuid().ToString("N"); + long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + var job = new PackageJob + { + JobId = jobId, + Status = PackageJobStatus.Running, + Operation = operation, + Package = package, + StartedUnixMs = started, + FinishedUnixMs = null, + LastUpdateUnixMs = started, + Error = null, + ResultVersion = null, + ResultName = null + }; + + lock (LockObj) + { + Jobs[jobId] = job; + } + PersistToSessionState(); + return jobId; + } + + public static void CompleteJob(string jobId, bool success, string error = null, + string version = null, string name = null) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (!Jobs.TryGetValue(jobId, out var job)) + return; + + job.Status = success ? PackageJobStatus.Succeeded : PackageJobStatus.Failed; + job.FinishedUnixMs = now; + job.LastUpdateUnixMs = now; + job.Error = error; + job.ResultVersion = version; + job.ResultName = name; + } + PersistToSessionState(); + } + + public static PackageJob GetJob(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + return null; + + lock (LockObj) + { + Jobs.TryGetValue(jobId, out var job); + return job; + } + } + + public static PackageJob GetLatestJob() + { + lock (LockObj) + { + return Jobs.Values + .OrderByDescending(j => j.StartedUnixMs) + .FirstOrDefault(); + } + } + + public static object ToSerializable(PackageJob job) + { + if (job == null) + return null; + + return new + { + job_id = job.JobId, + status = job.Status.ToString().ToLowerInvariant(), + operation = job.Operation, + package_ = job.Package, + started_unix_ms = job.StartedUnixMs, + finished_unix_ms = job.FinishedUnixMs, + last_update_unix_ms = job.LastUpdateUnixMs, + error = job.Error, + result_version = job.ResultVersion, + result_name = job.ResultName + }; + } + } +} diff --git a/MCPForUnity/Editor/Services/PackageJobManager.cs.meta b/MCPForUnity/Editor/Services/PackageJobManager.cs.meta new file mode 100644 index 0000000..140a694 --- /dev/null +++ b/MCPForUnity/Editor/Services/PackageJobManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c8e16aa625e01544beb2468fda53613 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/PackageUpdateService.cs b/MCPForUnity/Editor/Services/PackageUpdateService.cs new file mode 100644 index 0000000..8c441d9 --- /dev/null +++ b/MCPForUnity/Editor/Services/PackageUpdateService.cs @@ -0,0 +1,451 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for checking package updates from GitHub or Asset Store metadata + /// + public class PackageUpdateService : IPackageUpdateService + { + private const int DefaultRequestTimeoutMs = 3000; + private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck; + private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion; + private const string LastBetaCheckDateKey = EditorPrefKeys.LastUpdateCheck + ".beta"; + private const string CachedBetaVersionKey = EditorPrefKeys.LatestKnownVersion + ".beta"; + private const string LastAssetStoreCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck; + private const string CachedAssetStoreVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion; + private const string MainPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; + private const string BetaPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/beta/MCPForUnity/package.json"; + private const string AssetStoreVersionUrl = "https://gqoqjkkptwfbkwyssmnj.supabase.co/storage/v1/object/public/coplay-images/assetstoreversion.json"; + + /// + public UpdateCheckResult CheckForUpdate(string currentVersion) + { + bool isGitInstallation = IsGitInstallation(); + string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main"; + bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase); + + string lastCheckKey = isGitInstallation + ? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey) + : LastAssetStoreCheckDateKey; + string cachedVersionKey = isGitInstallation + ? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey) + : CachedAssetStoreVersionKey; + + string lastCheckDate = EditorPrefs.GetString(lastCheckKey, ""); + string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, ""); + + if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion)) + { + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = cachedLatestVersion, + UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion), + Message = "Using cached version check" + }; + } + + string latestVersion = isGitInstallation + ? FetchLatestVersionFromGitHub(gitBranch) + : FetchLatestVersionFromAssetStoreJson(); + + if (!string.IsNullOrEmpty(latestVersion)) + { + // Cache the result + EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd")); + EditorPrefs.SetString(cachedVersionKey, latestVersion); + + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = latestVersion, + UpdateAvailable = IsNewerVersion(latestVersion, currentVersion), + Message = "Successfully checked for updates" + }; + } + + return new UpdateCheckResult + { + CheckSucceeded = false, + UpdateAvailable = false, + Message = isGitInstallation + ? "Failed to check for updates (network issue or offline)" + : "Failed to check for Asset Store updates (network issue or offline)" + }; + } + + /// + public UpdateCheckResult TryGetCachedResult(string currentVersion) + { + bool isGitInstallation = IsGitInstallation(); + string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main"; + bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase); + + string lastCheckKey = isGitInstallation + ? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey) + : LastAssetStoreCheckDateKey; + string cachedVersionKey = isGitInstallation + ? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey) + : CachedAssetStoreVersionKey; + + string lastCheckDate = EditorPrefs.GetString(lastCheckKey, ""); + string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, ""); + + if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion)) + { + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = cachedLatestVersion, + UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion), + Message = "Using cached version check" + }; + } + + return null; + } + + /// + public UpdateCheckResult FetchAndCompare(string currentVersion) + { + bool isGitInstallation = IsGitInstallation(); + string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main"; + return FetchAndCompare(currentVersion, isGitInstallation, gitBranch); + } + + /// + public UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch) + { + string latestVersion = isGitInstallation + ? FetchLatestVersionFromGitHub(gitBranch) + : FetchLatestVersionFromAssetStoreJson(); + + if (!string.IsNullOrEmpty(latestVersion)) + { + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = latestVersion, + UpdateAvailable = IsNewerVersion(latestVersion, currentVersion), + Message = "Successfully checked for updates" + }; + } + + return new UpdateCheckResult + { + CheckSucceeded = false, + UpdateAvailable = false, + Message = isGitInstallation + ? "Failed to check for updates (network issue or offline)" + : "Failed to check for Asset Store updates (network issue or offline)" + }; + } + + /// + public void CacheFetchResult(string currentVersion, string fetchedVersion) + { + if (string.IsNullOrEmpty(fetchedVersion)) return; + + bool isGitInstallation = IsGitInstallation(); + string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main"; + bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase); + + string lastCheckKey = isGitInstallation + ? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey) + : LastAssetStoreCheckDateKey; + string cachedVersionKey = isGitInstallation + ? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey) + : CachedAssetStoreVersionKey; + + EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd")); + EditorPrefs.SetString(cachedVersionKey, fetchedVersion); + } + + /// + public bool IsNewerVersion(string version1, string version2) + { + if (!TryParseVersion(version1, out var left) || !TryParseVersion(version2, out var right)) + { + return false; + } + + return CompareVersions(left, right) > 0; + } + + private static int CompareVersions(ParsedVersion left, ParsedVersion right) + { + int cmp = left.Major.CompareTo(right.Major); + if (cmp != 0) return cmp; + + cmp = left.Minor.CompareTo(right.Minor); + if (cmp != 0) return cmp; + + cmp = left.Patch.CompareTo(right.Patch); + if (cmp != 0) return cmp; + + // Stable is newer than prerelease when core version matches. + if (!left.IsPrerelease && right.IsPrerelease) return 1; + if (left.IsPrerelease && !right.IsPrerelease) return -1; + if (!left.IsPrerelease && !right.IsPrerelease) return 0; + + cmp = GetPrereleaseRank(left.PrereleaseLabel).CompareTo(GetPrereleaseRank(right.PrereleaseLabel)); + if (cmp != 0) return cmp; + + cmp = left.PrereleaseNumber.CompareTo(right.PrereleaseNumber); + if (cmp != 0) return cmp; + + return string.Compare(left.PrereleaseLabel, right.PrereleaseLabel, StringComparison.OrdinalIgnoreCase); + } + + private static int GetPrereleaseRank(string label) + { + if (string.IsNullOrEmpty(label)) + { + return 0; + } + + switch (label.ToLowerInvariant()) + { + case "a": + case "alpha": + return 1; + case "b": + case "beta": + return 2; + case "rc": + return 3; + case "preview": + case "pre": + return 4; + default: + return 5; + } + } + + private static bool TryParseVersion(string version, out ParsedVersion parsed) + { + parsed = default; + if (string.IsNullOrWhiteSpace(version)) + { + return false; + } + + string normalized = version.Trim().TrimStart('v', 'V'); + var match = Regex.Match( + normalized, + @"^(?\d+)\.(?\d+)\.(?\d+)(?:-(?