Repository: CoplayDev/unity-mcp Branch: beta Commit: ec25df8f791e Files: 1717 Total size: 6.8 MB Directory structure: gitextract_6xex_klh/ ├── .dockerignore ├── .github/ │ ├── actions/ │ │ ├── publish-docker/ │ │ │ └── action.yml │ │ └── publish-pypi/ │ │ └── action.yml │ ├── pull_request_template.md │ ├── scripts/ │ │ └── mark_skipped.py │ └── workflows/ │ ├── beta-release.yml │ ├── claude-nl-suite.yml │ ├── github-repo-stats.yml │ ├── python-tests.yml │ ├── release.yml │ └── unity-tests.yml ├── .gitignore ├── .mcpbignore ├── CLAUDE.md ├── CustomTools/ │ └── RoslynRuntimeCompilation/ │ ├── ManageRuntimeCompilation.cs │ ├── ManageRuntimeCompilation.cs.meta │ ├── RoslynRuntimeCompiler.cs │ └── RoslynRuntimeCompiler.cs.meta ├── LICENSE ├── MCPForUnity/ │ ├── Editor/ │ │ ├── AssemblyInfo.cs │ │ ├── AssemblyInfo.cs.meta │ │ ├── Clients/ │ │ │ ├── Configurators/ │ │ │ │ ├── AntigravityConfigurator.cs │ │ │ │ ├── AntigravityConfigurator.cs.meta │ │ │ │ ├── CherryStudioConfigurator.cs │ │ │ │ ├── CherryStudioConfigurator.cs.meta │ │ │ │ ├── ClaudeCodeConfigurator.cs │ │ │ │ ├── ClaudeCodeConfigurator.cs.meta │ │ │ │ ├── ClaudeDesktopConfigurator.cs │ │ │ │ ├── ClaudeDesktopConfigurator.cs.meta │ │ │ │ ├── ClineConfigurator.cs │ │ │ │ ├── ClineConfigurator.cs.meta │ │ │ │ ├── CodeBuddyCliConfigurator.cs │ │ │ │ ├── CodeBuddyCliConfigurator.cs.meta │ │ │ │ ├── CodexConfigurator.cs │ │ │ │ ├── CodexConfigurator.cs.meta │ │ │ │ ├── CopilotCliConfigurator.cs │ │ │ │ ├── CopilotCliConfigurator.cs.meta │ │ │ │ ├── CursorConfigurator.cs │ │ │ │ ├── CursorConfigurator.cs.meta │ │ │ │ ├── GeminiCliConfigurator.cs │ │ │ │ ├── GeminiCliConfigurator.cs.meta │ │ │ │ ├── KiloCodeConfigurator.cs │ │ │ │ ├── KiloCodeConfigurator.cs.meta │ │ │ │ ├── KiroConfigurator.cs │ │ │ │ ├── KiroConfigurator.cs.meta │ │ │ │ ├── OpenCodeConfigurator.cs │ │ │ │ ├── OpenCodeConfigurator.cs.meta │ │ │ │ ├── QwenCodeConfigurator.cs │ │ │ │ ├── QwenCodeConfigurator.cs.meta │ │ │ │ ├── RiderConfigurator.cs │ │ │ │ ├── RiderConfigurator.cs.meta │ │ │ │ ├── TraeConfigurator.cs │ │ │ │ ├── TraeConfigurator.cs.meta │ │ │ │ ├── VSCodeConfigurator.cs │ │ │ │ ├── VSCodeConfigurator.cs.meta │ │ │ │ ├── VSCodeInsidersConfigurator.cs │ │ │ │ ├── VSCodeInsidersConfigurator.cs.meta │ │ │ │ ├── WindsurfConfigurator.cs │ │ │ │ └── WindsurfConfigurator.cs.meta │ │ │ ├── Configurators.meta │ │ │ ├── IMcpClientConfigurator.cs │ │ │ ├── IMcpClientConfigurator.cs.meta │ │ │ ├── McpClientConfiguratorBase.cs │ │ │ ├── McpClientConfiguratorBase.cs.meta │ │ │ ├── McpClientRegistry.cs │ │ │ └── McpClientRegistry.cs.meta │ │ ├── Clients.meta │ │ ├── Constants/ │ │ │ ├── AuthConstants.cs │ │ │ ├── AuthConstants.cs.meta │ │ │ ├── EditorPrefKeys.cs │ │ │ ├── EditorPrefKeys.cs.meta │ │ │ ├── HealthStatus.cs │ │ │ └── HealthStatus.cs.meta │ │ ├── Constants.meta │ │ ├── Dependencies/ │ │ │ ├── DependencyManager.cs │ │ │ ├── DependencyManager.cs.meta │ │ │ ├── Models/ │ │ │ │ ├── DependencyCheckResult.cs │ │ │ │ ├── DependencyCheckResult.cs.meta │ │ │ │ ├── DependencyStatus.cs │ │ │ │ └── DependencyStatus.cs.meta │ │ │ ├── Models.meta │ │ │ ├── PlatformDetectors/ │ │ │ │ ├── IPlatformDetector.cs │ │ │ │ ├── IPlatformDetector.cs.meta │ │ │ │ ├── LinuxPlatformDetector.cs │ │ │ │ ├── LinuxPlatformDetector.cs.meta │ │ │ │ ├── MacOSPlatformDetector.cs │ │ │ │ ├── MacOSPlatformDetector.cs.meta │ │ │ │ ├── PlatformDetectorBase.cs │ │ │ │ ├── PlatformDetectorBase.cs.meta │ │ │ │ ├── WindowsPlatformDetector.cs │ │ │ │ └── WindowsPlatformDetector.cs.meta │ │ │ └── PlatformDetectors.meta │ │ ├── Dependencies.meta │ │ ├── External/ │ │ │ ├── Tommy.cs │ │ │ └── Tommy.cs.meta │ │ ├── External.meta │ │ ├── Helpers/ │ │ │ ├── AssetPathUtility.cs │ │ │ ├── AssetPathUtility.cs.meta │ │ │ ├── CodexConfigHelper.cs │ │ │ ├── CodexConfigHelper.cs.meta │ │ │ ├── ComponentOps.cs │ │ │ ├── ComponentOps.cs.meta │ │ │ ├── ConfigJsonBuilder.cs │ │ │ ├── ConfigJsonBuilder.cs.meta │ │ │ ├── EditorWindowScreenshotUtility.cs │ │ │ ├── EditorWindowScreenshotUtility.cs.meta │ │ │ ├── ExecPath.cs │ │ │ ├── ExecPath.cs.meta │ │ │ ├── GameObjectLookup.cs │ │ │ ├── GameObjectLookup.cs.meta │ │ │ ├── GameObjectSerializer.cs │ │ │ ├── GameObjectSerializer.cs.meta │ │ │ ├── HttpEndpointUtility.cs │ │ │ ├── HttpEndpointUtility.cs.meta │ │ │ ├── MaterialOps.cs │ │ │ ├── MaterialOps.cs.meta │ │ │ ├── McpConfigurationHelper.cs │ │ │ ├── McpConfigurationHelper.cs.meta │ │ │ ├── McpJobStateStore.cs │ │ │ ├── McpJobStateStore.cs.meta │ │ │ ├── McpLog.cs │ │ │ ├── McpLog.cs.meta │ │ │ ├── McpLogRecord.cs │ │ │ ├── McpLogRecord.cs.meta │ │ │ ├── ObjectResolver.cs │ │ │ ├── ObjectResolver.cs.meta │ │ │ ├── Pagination.cs │ │ │ ├── Pagination.cs.meta │ │ │ ├── ParamCoercion.cs │ │ │ ├── ParamCoercion.cs.meta │ │ │ ├── PortManager.cs │ │ │ ├── PortManager.cs.meta │ │ │ ├── PrefabUtilityHelper.cs │ │ │ ├── PrefabUtilityHelper.cs.meta │ │ │ ├── ProjectIdentityUtility.cs │ │ │ ├── ProjectIdentityUtility.cs.meta │ │ │ ├── PropertyConversion.cs │ │ │ ├── PropertyConversion.cs.meta │ │ │ ├── RenderPipelineUtility.cs │ │ │ ├── RenderPipelineUtility.cs.meta │ │ │ ├── RendererHelpers.cs │ │ │ ├── RendererHelpers.cs.meta │ │ │ ├── Response.cs │ │ │ ├── Response.cs.meta │ │ │ ├── StringCaseUtility.cs │ │ │ ├── StringCaseUtility.cs.meta │ │ │ ├── TelemetryHelper.cs │ │ │ ├── TelemetryHelper.cs.meta │ │ │ ├── TextureOps.cs │ │ │ ├── TextureOps.cs.meta │ │ │ ├── ToolParams.cs │ │ │ ├── ToolParams.cs.meta │ │ │ ├── UnityJsonSerializer.cs │ │ │ ├── UnityJsonSerializer.cs.meta │ │ │ ├── UnityTypeResolver.cs │ │ │ ├── UnityTypeResolver.cs.meta │ │ │ ├── VectorParsing.cs │ │ │ └── VectorParsing.cs.meta │ │ ├── Helpers.meta │ │ ├── MCPForUnity.Editor.asmdef │ │ ├── MCPForUnity.Editor.asmdef.meta │ │ ├── McpCiBoot.cs │ │ ├── McpCiBoot.cs.meta │ │ ├── MenuItems/ │ │ │ ├── MCPForUnityMenu.cs │ │ │ └── MCPForUnityMenu.cs.meta │ │ ├── MenuItems.meta │ │ ├── Migrations/ │ │ │ ├── LegacyServerSrcMigration.cs │ │ │ ├── LegacyServerSrcMigration.cs.meta │ │ │ ├── StdIoVersionMigration.cs │ │ │ └── StdIoVersionMigration.cs.meta │ │ ├── Migrations.meta │ │ ├── Models/ │ │ │ ├── Command.cs │ │ │ ├── Command.cs.meta │ │ │ ├── MCPConfigServer.cs │ │ │ ├── MCPConfigServer.cs.meta │ │ │ ├── MCPConfigServers.cs │ │ │ ├── MCPConfigServers.cs.meta │ │ │ ├── McpClient.cs │ │ │ ├── McpClient.cs.meta │ │ │ ├── McpConfig.cs │ │ │ ├── McpConfig.cs.meta │ │ │ ├── McpStatus.cs │ │ │ └── McpStatus.cs.meta │ │ ├── Models.meta │ │ ├── Resources/ │ │ │ ├── Editor/ │ │ │ │ ├── ActiveTool.cs │ │ │ │ ├── ActiveTool.cs.meta │ │ │ │ ├── EditorState.cs │ │ │ │ ├── EditorState.cs.meta │ │ │ │ ├── Selection.cs │ │ │ │ ├── Selection.cs.meta │ │ │ │ ├── ToolStates.cs │ │ │ │ ├── ToolStates.cs.meta │ │ │ │ ├── Windows.cs │ │ │ │ └── Windows.cs.meta │ │ │ ├── Editor.meta │ │ │ ├── McpForUnityResourceAttribute.cs │ │ │ ├── McpForUnityResourceAttribute.cs.meta │ │ │ ├── MenuItems/ │ │ │ │ ├── GetMenuItems.cs │ │ │ │ └── GetMenuItems.cs.meta │ │ │ ├── MenuItems.meta │ │ │ ├── Project/ │ │ │ │ ├── Layers.cs │ │ │ │ ├── Layers.cs.meta │ │ │ │ ├── ProjectInfo.cs │ │ │ │ ├── ProjectInfo.cs.meta │ │ │ │ ├── Tags.cs │ │ │ │ └── Tags.cs.meta │ │ │ ├── Project.meta │ │ │ ├── Scene/ │ │ │ │ ├── CamerasResource.cs │ │ │ │ ├── CamerasResource.cs.meta │ │ │ │ ├── GameObjectResource.cs │ │ │ │ ├── GameObjectResource.cs.meta │ │ │ │ ├── RendererFeaturesResource.cs │ │ │ │ ├── RendererFeaturesResource.cs.meta │ │ │ │ ├── RenderingStatsResource.cs │ │ │ │ ├── RenderingStatsResource.cs.meta │ │ │ │ ├── VolumesResource.cs │ │ │ │ └── VolumesResource.cs.meta │ │ │ ├── Scene.meta │ │ │ ├── Tests/ │ │ │ │ ├── GetTests.cs │ │ │ │ └── GetTests.cs.meta │ │ │ └── Tests.meta │ │ ├── Resources.meta │ │ ├── Services/ │ │ │ ├── BridgeControlService.cs │ │ │ ├── BridgeControlService.cs.meta │ │ │ ├── ClientConfigurationService.cs │ │ │ ├── ClientConfigurationService.cs.meta │ │ │ ├── EditorConfigurationCache.cs │ │ │ ├── EditorConfigurationCache.cs.meta │ │ │ ├── EditorPrefsWindowService.cs │ │ │ ├── EditorPrefsWindowService.cs.meta │ │ │ ├── EditorStateCache.cs │ │ │ ├── EditorStateCache.cs.meta │ │ │ ├── HttpAutoStartHandler.cs │ │ │ ├── HttpAutoStartHandler.cs.meta │ │ │ ├── HttpBridgeReloadHandler.cs │ │ │ ├── HttpBridgeReloadHandler.cs.meta │ │ │ ├── IBridgeControlService.cs │ │ │ ├── IBridgeControlService.cs.meta │ │ │ ├── IClientConfigurationService.cs │ │ │ ├── IClientConfigurationService.cs.meta │ │ │ ├── IPackageDeploymentService.cs │ │ │ ├── IPackageDeploymentService.cs.meta │ │ │ ├── IPackageUpdateService.cs │ │ │ ├── IPackageUpdateService.cs.meta │ │ │ ├── IPathResolverService.cs │ │ │ ├── IPathResolverService.cs.meta │ │ │ ├── IPlatformService.cs │ │ │ ├── IPlatformService.cs.meta │ │ │ ├── IResourceDiscoveryService.cs │ │ │ ├── IResourceDiscoveryService.cs.meta │ │ │ ├── IServerManagementService.cs │ │ │ ├── IServerManagementService.cs.meta │ │ │ ├── ITestRunnerService.cs │ │ │ ├── ITestRunnerService.cs.meta │ │ │ ├── IToolDiscoveryService.cs │ │ │ ├── IToolDiscoveryService.cs.meta │ │ │ ├── MCPServiceLocator.cs │ │ │ ├── MCPServiceLocator.cs.meta │ │ │ ├── McpEditorShutdownCleanup.cs │ │ │ ├── McpEditorShutdownCleanup.cs.meta │ │ │ ├── PackageDeploymentService.cs │ │ │ ├── PackageDeploymentService.cs.meta │ │ │ ├── PackageJobManager.cs │ │ │ ├── PackageJobManager.cs.meta │ │ │ ├── PackageUpdateService.cs │ │ │ ├── PackageUpdateService.cs.meta │ │ │ ├── PathResolverService.cs │ │ │ ├── PathResolverService.cs.meta │ │ │ ├── PlatformService.cs │ │ │ ├── PlatformService.cs.meta │ │ │ ├── ResourceDiscoveryService.cs │ │ │ ├── ResourceDiscoveryService.cs.meta │ │ │ ├── Server/ │ │ │ │ ├── IPidFileManager.cs │ │ │ │ ├── IPidFileManager.cs.meta │ │ │ │ ├── IProcessDetector.cs │ │ │ │ ├── IProcessDetector.cs.meta │ │ │ │ ├── IProcessTerminator.cs │ │ │ │ ├── IProcessTerminator.cs.meta │ │ │ │ ├── IServerCommandBuilder.cs │ │ │ │ ├── IServerCommandBuilder.cs.meta │ │ │ │ ├── ITerminalLauncher.cs │ │ │ │ ├── ITerminalLauncher.cs.meta │ │ │ │ ├── PidFileManager.cs │ │ │ │ ├── PidFileManager.cs.meta │ │ │ │ ├── ProcessDetector.cs │ │ │ │ ├── ProcessDetector.cs.meta │ │ │ │ ├── ProcessTerminator.cs │ │ │ │ ├── ProcessTerminator.cs.meta │ │ │ │ ├── ServerCommandBuilder.cs │ │ │ │ ├── ServerCommandBuilder.cs.meta │ │ │ │ ├── TerminalLauncher.cs │ │ │ │ └── TerminalLauncher.cs.meta │ │ │ ├── Server.meta │ │ │ ├── ServerManagementService.cs │ │ │ ├── ServerManagementService.cs.meta │ │ │ ├── StdioBridgeReloadHandler.cs │ │ │ ├── StdioBridgeReloadHandler.cs.meta │ │ │ ├── TestJobManager.cs │ │ │ ├── TestJobManager.cs.meta │ │ │ ├── TestRunStatus.cs │ │ │ ├── TestRunStatus.cs.meta │ │ │ ├── TestRunnerNoThrottle.cs │ │ │ ├── TestRunnerNoThrottle.cs.meta │ │ │ ├── TestRunnerService.cs │ │ │ ├── TestRunnerService.cs.meta │ │ │ ├── ToolDiscoveryService.cs │ │ │ ├── ToolDiscoveryService.cs.meta │ │ │ ├── Transport/ │ │ │ │ ├── IMcpTransportClient.cs │ │ │ │ ├── IMcpTransportClient.cs.meta │ │ │ │ ├── TransportCommandDispatcher.cs │ │ │ │ ├── TransportCommandDispatcher.cs.meta │ │ │ │ ├── TransportManager.cs │ │ │ │ ├── TransportManager.cs.meta │ │ │ │ ├── TransportState.cs │ │ │ │ ├── TransportState.cs.meta │ │ │ │ ├── Transports/ │ │ │ │ │ ├── StdioBridgeHost.cs │ │ │ │ │ ├── StdioBridgeHost.cs.meta │ │ │ │ │ ├── StdioTransportClient.cs │ │ │ │ │ ├── StdioTransportClient.cs.meta │ │ │ │ │ ├── WebSocketTransportClient.cs │ │ │ │ │ └── WebSocketTransportClient.cs.meta │ │ │ │ └── Transports.meta │ │ │ └── Transport.meta │ │ ├── Services.meta │ │ ├── Setup/ │ │ │ ├── McpForUnitySkillInstaller.cs │ │ │ ├── McpForUnitySkillInstaller.cs.meta │ │ │ ├── RoslynInstaller.cs │ │ │ ├── RoslynInstaller.cs.meta │ │ │ ├── SetupWindowService.cs │ │ │ ├── SetupWindowService.cs.meta │ │ │ ├── SkillSyncService.cs │ │ │ └── SkillSyncService.cs.meta │ │ ├── Setup.meta │ │ ├── Tools/ │ │ │ ├── Animation/ │ │ │ │ ├── AnimatorControl.cs │ │ │ │ ├── AnimatorControl.cs.meta │ │ │ │ ├── AnimatorRead.cs │ │ │ │ ├── AnimatorRead.cs.meta │ │ │ │ ├── ClipCreate.cs │ │ │ │ ├── ClipCreate.cs.meta │ │ │ │ ├── ClipPresets.cs │ │ │ │ ├── ClipPresets.cs.meta │ │ │ │ ├── ControllerBlendTrees.cs │ │ │ │ ├── ControllerBlendTrees.cs.meta │ │ │ │ ├── ControllerCreate.cs │ │ │ │ ├── ControllerCreate.cs.meta │ │ │ │ ├── ControllerLayers.cs │ │ │ │ ├── ControllerLayers.cs.meta │ │ │ │ ├── ManageAnimation.cs │ │ │ │ └── ManageAnimation.cs.meta │ │ │ ├── Animation.meta │ │ │ ├── BatchExecute.cs │ │ │ ├── BatchExecute.cs.meta │ │ │ ├── Cameras/ │ │ │ │ ├── CameraConfigure.cs │ │ │ │ ├── CameraConfigure.cs.meta │ │ │ │ ├── CameraControl.cs │ │ │ │ ├── CameraControl.cs.meta │ │ │ │ ├── CameraCreate.cs │ │ │ │ ├── CameraCreate.cs.meta │ │ │ │ ├── CameraHelpers.cs │ │ │ │ ├── CameraHelpers.cs.meta │ │ │ │ ├── ManageCamera.cs │ │ │ │ └── ManageCamera.cs.meta │ │ │ ├── Cameras.meta │ │ │ ├── CommandRegistry.cs │ │ │ ├── CommandRegistry.cs.meta │ │ │ ├── ExecuteMenuItem.cs │ │ │ ├── ExecuteMenuItem.cs.meta │ │ │ ├── FindGameObjects.cs │ │ │ ├── FindGameObjects.cs.meta │ │ │ ├── GameObjects/ │ │ │ │ ├── ComponentResolver.cs │ │ │ │ ├── ComponentResolver.cs.meta │ │ │ │ ├── GameObjectComponentHelpers.cs │ │ │ │ ├── GameObjectComponentHelpers.cs.meta │ │ │ │ ├── GameObjectCreate.cs │ │ │ │ ├── GameObjectCreate.cs.meta │ │ │ │ ├── GameObjectDelete.cs │ │ │ │ ├── GameObjectDelete.cs.meta │ │ │ │ ├── GameObjectDuplicate.cs │ │ │ │ ├── GameObjectDuplicate.cs.meta │ │ │ │ ├── GameObjectHandlers.cs │ │ │ │ ├── GameObjectHandlers.cs.meta │ │ │ │ ├── GameObjectLookAt.cs │ │ │ │ ├── GameObjectLookAt.cs.meta │ │ │ │ ├── GameObjectModify.cs │ │ │ │ ├── GameObjectModify.cs.meta │ │ │ │ ├── GameObjectMoveRelative.cs │ │ │ │ ├── GameObjectMoveRelative.cs.meta │ │ │ │ ├── ManageGameObject.cs │ │ │ │ ├── ManageGameObject.cs.meta │ │ │ │ ├── ManageGameObjectCommon.cs │ │ │ │ └── ManageGameObjectCommon.cs.meta │ │ │ ├── GameObjects.meta │ │ │ ├── GetTestJob.cs │ │ │ ├── GetTestJob.cs.meta │ │ │ ├── Graphics/ │ │ │ │ ├── GraphicsHelpers.cs │ │ │ │ ├── GraphicsHelpers.cs.meta │ │ │ │ ├── LightBakingOps.cs │ │ │ │ ├── LightBakingOps.cs.meta │ │ │ │ ├── ManageGraphics.cs │ │ │ │ ├── ManageGraphics.cs.meta │ │ │ │ ├── RenderPipelineOps.cs │ │ │ │ ├── RenderPipelineOps.cs.meta │ │ │ │ ├── RendererFeatureOps.cs │ │ │ │ ├── RendererFeatureOps.cs.meta │ │ │ │ ├── RenderingStatsOps.cs │ │ │ │ ├── RenderingStatsOps.cs.meta │ │ │ │ ├── SkyboxOps.cs │ │ │ │ ├── SkyboxOps.cs.meta │ │ │ │ ├── VolumeOps.cs │ │ │ │ └── VolumeOps.cs.meta │ │ │ ├── Graphics.meta │ │ │ ├── JsonUtil.cs │ │ │ ├── JsonUtil.cs.meta │ │ │ ├── ManageAsset.cs │ │ │ ├── ManageAsset.cs.meta │ │ │ ├── ManageComponents.cs │ │ │ ├── ManageComponents.cs.meta │ │ │ ├── ManageEditor.cs │ │ │ ├── ManageEditor.cs.meta │ │ │ ├── ManageMaterial.cs │ │ │ ├── ManageMaterial.cs.meta │ │ │ ├── ManagePackages.cs │ │ │ ├── ManagePackages.cs.meta │ │ │ ├── ManageScene.cs │ │ │ ├── ManageScene.cs.meta │ │ │ ├── ManageScript.cs │ │ │ ├── ManageScript.cs.meta │ │ │ ├── ManageScriptableObject.cs │ │ │ ├── ManageScriptableObject.cs.meta │ │ │ ├── ManageShader.cs │ │ │ ├── ManageShader.cs.meta │ │ │ ├── ManageTexture.cs │ │ │ ├── ManageTexture.cs.meta │ │ │ ├── ManageUI.cs │ │ │ ├── ManageUI.cs.meta │ │ │ ├── McpForUnityToolAttribute.cs │ │ │ ├── McpForUnityToolAttribute.cs.meta │ │ │ ├── Prefabs/ │ │ │ │ ├── ManagePrefabs.cs │ │ │ │ └── ManagePrefabs.cs.meta │ │ │ ├── Prefabs.meta │ │ │ ├── ProBuilder/ │ │ │ │ ├── ManageProBuilder.cs │ │ │ │ ├── ManageProBuilder.cs.meta │ │ │ │ ├── ProBuilderMeshUtils.cs │ │ │ │ ├── ProBuilderMeshUtils.cs.meta │ │ │ │ ├── ProBuilderSmoothing.cs │ │ │ │ └── ProBuilderSmoothing.cs.meta │ │ │ ├── ProBuilder.meta │ │ │ ├── ReadConsole.cs │ │ │ ├── ReadConsole.cs.meta │ │ │ ├── RefreshUnity.cs │ │ │ ├── RefreshUnity.cs.meta │ │ │ ├── RunTests.cs │ │ │ ├── RunTests.cs.meta │ │ │ ├── UnityReflect.cs │ │ │ ├── UnityReflect.cs.meta │ │ │ ├── Vfx/ │ │ │ │ ├── LineCreate.cs │ │ │ │ ├── LineCreate.cs.meta │ │ │ │ ├── LineRead.cs │ │ │ │ ├── LineRead.cs.meta │ │ │ │ ├── LineWrite.cs │ │ │ │ ├── LineWrite.cs.meta │ │ │ │ ├── ManageVFX.cs │ │ │ │ ├── ManageVFX.cs.meta │ │ │ │ ├── ManageVfxCommon.cs │ │ │ │ ├── ManageVfxCommon.cs.meta │ │ │ │ ├── ParticleCommon.cs │ │ │ │ ├── ParticleCommon.cs.meta │ │ │ │ ├── ParticleControl.cs │ │ │ │ ├── ParticleControl.cs.meta │ │ │ │ ├── ParticleRead.cs │ │ │ │ ├── ParticleRead.cs.meta │ │ │ │ ├── ParticleWrite.cs │ │ │ │ ├── ParticleWrite.cs.meta │ │ │ │ ├── TrailControl.cs │ │ │ │ ├── TrailControl.cs.meta │ │ │ │ ├── TrailRead.cs │ │ │ │ ├── TrailRead.cs.meta │ │ │ │ ├── TrailWrite.cs │ │ │ │ ├── TrailWrite.cs.meta │ │ │ │ ├── VfxGraphAssets.cs │ │ │ │ ├── VfxGraphAssets.cs.meta │ │ │ │ ├── VfxGraphCommon.cs │ │ │ │ ├── VfxGraphCommon.cs.meta │ │ │ │ ├── VfxGraphControl.cs │ │ │ │ ├── VfxGraphControl.cs.meta │ │ │ │ ├── VfxGraphRead.cs │ │ │ │ ├── VfxGraphRead.cs.meta │ │ │ │ ├── VfxGraphWrite.cs │ │ │ │ └── VfxGraphWrite.cs.meta │ │ │ └── Vfx.meta │ │ ├── Tools.meta │ │ ├── Windows/ │ │ │ ├── Components/ │ │ │ │ ├── Advanced/ │ │ │ │ │ ├── McpAdvancedSection.cs │ │ │ │ │ ├── McpAdvancedSection.cs.meta │ │ │ │ │ ├── McpAdvancedSection.uxml │ │ │ │ │ └── McpAdvancedSection.uxml.meta │ │ │ │ ├── Advanced.meta │ │ │ │ ├── ClientConfig/ │ │ │ │ │ ├── McpClientConfigSection.cs │ │ │ │ │ ├── McpClientConfigSection.cs.meta │ │ │ │ │ ├── McpClientConfigSection.uxml │ │ │ │ │ └── McpClientConfigSection.uxml.meta │ │ │ │ ├── ClientConfig.meta │ │ │ │ ├── Common.uss │ │ │ │ ├── Common.uss.meta │ │ │ │ ├── Connection/ │ │ │ │ │ ├── McpConnectionSection.cs │ │ │ │ │ ├── McpConnectionSection.cs.meta │ │ │ │ │ ├── McpConnectionSection.uxml │ │ │ │ │ └── McpConnectionSection.uxml.meta │ │ │ │ ├── Connection.meta │ │ │ │ ├── Resources/ │ │ │ │ │ ├── McpResourcesSection.cs │ │ │ │ │ ├── McpResourcesSection.cs.meta │ │ │ │ │ ├── McpResourcesSection.uxml │ │ │ │ │ └── McpResourcesSection.uxml.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Tools/ │ │ │ │ │ ├── McpToolsSection.cs │ │ │ │ │ ├── McpToolsSection.cs.meta │ │ │ │ │ ├── McpToolsSection.uxml │ │ │ │ │ └── McpToolsSection.uxml.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Validation/ │ │ │ │ │ ├── McpValidationSection.cs │ │ │ │ │ ├── McpValidationSection.cs.meta │ │ │ │ │ ├── McpValidationSection.uxml │ │ │ │ │ └── McpValidationSection.uxml.meta │ │ │ │ └── Validation.meta │ │ │ ├── Components.meta │ │ │ ├── EditorPrefs/ │ │ │ │ ├── EditorPrefItem.uxml │ │ │ │ ├── EditorPrefItem.uxml.meta │ │ │ │ ├── EditorPrefsWindow.cs │ │ │ │ ├── EditorPrefsWindow.cs.meta │ │ │ │ ├── EditorPrefsWindow.uss │ │ │ │ ├── EditorPrefsWindow.uss.meta │ │ │ │ ├── EditorPrefsWindow.uxml │ │ │ │ └── EditorPrefsWindow.uxml.meta │ │ │ ├── EditorPrefs.meta │ │ │ ├── MCPForUnityEditorWindow.cs │ │ │ ├── MCPForUnityEditorWindow.cs.meta │ │ │ ├── MCPForUnityEditorWindow.uss │ │ │ ├── MCPForUnityEditorWindow.uss.meta │ │ │ ├── MCPForUnityEditorWindow.uxml │ │ │ ├── MCPForUnityEditorWindow.uxml.meta │ │ │ ├── MCPSetupWindow.cs │ │ │ ├── MCPSetupWindow.cs.meta │ │ │ ├── MCPSetupWindow.uss │ │ │ ├── MCPSetupWindow.uss.meta │ │ │ ├── MCPSetupWindow.uxml │ │ │ └── MCPSetupWindow.uxml.meta │ │ └── Windows.meta │ ├── Editor.meta │ ├── README.md │ ├── README.md.meta │ ├── Runtime/ │ │ ├── Helpers/ │ │ │ ├── ScreenshotUtility.cs │ │ │ └── ScreenshotUtility.cs.meta │ │ ├── Helpers.meta │ │ ├── MCPForUnity.Runtime.asmdef │ │ ├── MCPForUnity.Runtime.asmdef.meta │ │ ├── Serialization/ │ │ │ ├── UnityTypeConverters.cs │ │ │ └── UnityTypeConverters.cs.meta │ │ └── Serialization.meta │ ├── Runtime.meta │ ├── package.json │ └── package.json.meta ├── README.md ├── Server/ │ ├── DOCKER_OVERVIEW.md │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── __init__.py │ ├── pyproject.toml │ ├── pyrightconfig.json │ ├── src/ │ │ ├── __init__.py │ │ ├── cli/ │ │ │ ├── CLI_USAGE_GUIDE.md │ │ │ ├── __init__.py │ │ │ ├── commands/ │ │ │ │ ├── __init__.py │ │ │ │ ├── animation.py │ │ │ │ ├── asset.py │ │ │ │ ├── audio.py │ │ │ │ ├── batch.py │ │ │ │ ├── camera.py │ │ │ │ ├── code.py │ │ │ │ ├── component.py │ │ │ │ ├── docs.py │ │ │ │ ├── editor.py │ │ │ │ ├── gameobject.py │ │ │ │ ├── graphics.py │ │ │ │ ├── instance.py │ │ │ │ ├── lighting.py │ │ │ │ ├── material.py │ │ │ │ ├── packages.py │ │ │ │ ├── prefab.py │ │ │ │ ├── probuilder.py │ │ │ │ ├── reflect.py │ │ │ │ ├── scene.py │ │ │ │ ├── script.py │ │ │ │ ├── shader.py │ │ │ │ ├── texture.py │ │ │ │ ├── tool.py │ │ │ │ ├── ui.py │ │ │ │ └── vfx.py │ │ │ ├── main.py │ │ │ └── utils/ │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── confirmation.py │ │ │ ├── connection.py │ │ │ ├── constants.py │ │ │ ├── output.py │ │ │ ├── parsers.py │ │ │ └── suggestions.py │ │ ├── core/ │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ ├── constants.py │ │ │ ├── logging_decorator.py │ │ │ ├── telemetry.py │ │ │ └── telemetry_decorator.py │ │ ├── main.py │ │ ├── models/ │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ └── unity_response.py │ │ ├── services/ │ │ │ ├── __init__.py │ │ │ ├── api_key_service.py │ │ │ ├── custom_tool_service.py │ │ │ ├── registry/ │ │ │ │ ├── __init__.py │ │ │ │ ├── resource_registry.py │ │ │ │ └── tool_registry.py │ │ │ ├── resources/ │ │ │ │ ├── __init__.py │ │ │ │ ├── active_tool.py │ │ │ │ ├── cameras.py │ │ │ │ ├── custom_tools.py │ │ │ │ ├── editor_state.py │ │ │ │ ├── gameobject.py │ │ │ │ ├── layers.py │ │ │ │ ├── menu_items.py │ │ │ │ ├── prefab.py │ │ │ │ ├── prefab_stage.py │ │ │ │ ├── project_info.py │ │ │ │ ├── renderer_features.py │ │ │ │ ├── rendering_stats.py │ │ │ │ ├── selection.py │ │ │ │ ├── tags.py │ │ │ │ ├── tests.py │ │ │ │ ├── tool_groups.py │ │ │ │ ├── unity_instances.py │ │ │ │ ├── volumes.py │ │ │ │ └── windows.py │ │ │ ├── state/ │ │ │ │ └── external_changes_scanner.py │ │ │ └── tools/ │ │ │ ├── __init__.py │ │ │ ├── batch_execute.py │ │ │ ├── debug_request_context.py │ │ │ ├── execute_custom_tool.py │ │ │ ├── execute_menu_item.py │ │ │ ├── find_gameobjects.py │ │ │ ├── find_in_file.py │ │ │ ├── manage_animation.py │ │ │ ├── manage_asset.py │ │ │ ├── manage_camera.py │ │ │ ├── manage_components.py │ │ │ ├── manage_editor.py │ │ │ ├── manage_gameobject.py │ │ │ ├── manage_graphics.py │ │ │ ├── manage_material.py │ │ │ ├── manage_packages.py │ │ │ ├── manage_prefabs.py │ │ │ ├── manage_probuilder.py │ │ │ ├── manage_scene.py │ │ │ ├── manage_script.py │ │ │ ├── manage_scriptable_object.py │ │ │ ├── manage_shader.py │ │ │ ├── manage_texture.py │ │ │ ├── manage_tools.py │ │ │ ├── manage_ui.py │ │ │ ├── manage_vfx.py │ │ │ ├── preflight.py │ │ │ ├── read_console.py │ │ │ ├── refresh_unity.py │ │ │ ├── run_tests.py │ │ │ ├── script_apply_edits.py │ │ │ ├── set_active_instance.py │ │ │ ├── unity_docs.py │ │ │ ├── unity_reflect.py │ │ │ └── utils.py │ │ ├── transport/ │ │ │ ├── __init__.py │ │ │ ├── legacy/ │ │ │ │ ├── port_discovery.py │ │ │ │ ├── stdio_port_registry.py │ │ │ │ └── unity_connection.py │ │ │ ├── models.py │ │ │ ├── plugin_hub.py │ │ │ ├── plugin_registry.py │ │ │ ├── unity_instance_middleware.py │ │ │ └── unity_transport.py │ │ └── utils/ │ │ ├── focus_nudge.py │ │ └── module_discovery.py │ └── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── integration/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_api_key_service.py │ │ ├── test_auth_config_startup.py │ │ ├── test_debug_request_context_diagnostics.py │ │ ├── test_domain_reload_resilience.py │ │ ├── test_edit_normalization_and_noop.py │ │ ├── test_edit_strict_and_warnings.py │ │ ├── test_editor_state_v2_contract.py │ │ ├── test_external_changes_scanner.py │ │ ├── test_find_gameobjects.py │ │ ├── test_gameobject_resources.py │ │ ├── test_get_sha.py │ │ ├── test_helpers.py │ │ ├── test_improved_anchor_matching.py │ │ ├── test_inline_unity_instance.py │ │ ├── test_instance_autoselect.py │ │ ├── test_instance_routing_comprehensive.py │ │ ├── test_instance_targeting_resolution.py │ │ ├── test_json_parsing_simple.py │ │ ├── test_logging_stdout.py │ │ ├── test_manage_asset_json_parsing.py │ │ ├── test_manage_asset_param_coercion.py │ │ ├── test_manage_components.py │ │ ├── test_manage_gameobject_look_at.py │ │ ├── test_manage_gameobject_param_coercion.py │ │ ├── test_manage_scene_paging_params.py │ │ ├── test_manage_script_uri.py │ │ ├── test_manage_scriptable_object_tool.py │ │ ├── test_manage_texture.py │ │ ├── test_manage_ui.py │ │ ├── test_middleware_auth_integration.py │ │ ├── test_multi_user_session_isolation.py │ │ ├── test_plugin_hub_websocket_auth.py │ │ ├── test_plugin_registry_user_isolation.py │ │ ├── test_read_console_truncate.py │ │ ├── test_read_resource_minimal.py │ │ ├── test_refresh_unity_registration.py │ │ ├── test_refresh_unity_retry_recovery.py │ │ ├── test_resolve_user_id.py │ │ ├── test_run_tests_async.py │ │ ├── test_script_apply_edits_local.py │ │ ├── test_script_tools.py │ │ ├── test_telemetry_endpoint_validation.py │ │ ├── test_telemetry_queue_worker.py │ │ ├── test_telemetry_subaction.py │ │ ├── test_tool_signatures_paging.py │ │ ├── test_transport_framing.py │ │ ├── test_transport_smoke.py │ │ ├── test_validate_script_summary.py │ │ └── test_wait_for_editor_ready.py │ ├── pytest.ini │ ├── test_cli.py │ ├── test_cli_commands_characterization.py │ ├── test_core_infrastructure_characterization.py │ ├── test_custom_tool_service_user_scope.py │ ├── test_focus_nudge.py │ ├── test_manage_animation.py │ ├── test_manage_camera.py │ ├── test_manage_graphics.py │ ├── test_manage_prefabs.py │ ├── test_manage_probuilder.py │ ├── test_manage_vfx_actions.py │ ├── test_models_characterization.py │ ├── test_param_normalizer.py │ ├── test_tool_registry_metadata.py │ ├── test_transport_characterization.py │ ├── test_unity_docs.py │ ├── test_unity_reflect.py │ └── test_utilities_characterization.py ├── TestProjects/ │ ├── AssetStoreUploads/ │ │ ├── .gitignore │ │ ├── Assets/ │ │ │ ├── Readme.asset.meta │ │ │ ├── Scenes/ │ │ │ │ ├── SampleScene.unity │ │ │ │ └── SampleScene.unity.meta │ │ │ ├── Scenes.meta │ │ │ ├── Settings/ │ │ │ │ ├── SampleSceneProfile.asset.meta │ │ │ │ ├── URP-Balanced-Renderer.asset.meta │ │ │ │ ├── URP-Balanced.asset.meta │ │ │ │ ├── URP-HighFidelity-Renderer.asset.meta │ │ │ │ ├── URP-HighFidelity.asset.meta │ │ │ │ ├── URP-Performant-Renderer.asset.meta │ │ │ │ └── URP-Performant.asset.meta │ │ │ ├── Settings.meta │ │ │ ├── TutorialInfo/ │ │ │ │ ├── Icons/ │ │ │ │ │ └── URP.png.meta │ │ │ │ ├── Icons.meta │ │ │ │ ├── Layout.wlt │ │ │ │ ├── Layout.wlt.meta │ │ │ │ ├── Scripts/ │ │ │ │ │ ├── Editor/ │ │ │ │ │ │ ├── ReadmeEditor.cs │ │ │ │ │ │ └── ReadmeEditor.cs.meta │ │ │ │ │ ├── Editor.meta │ │ │ │ │ ├── Readme.cs │ │ │ │ │ └── Readme.cs.meta │ │ │ │ └── Scripts.meta │ │ │ ├── TutorialInfo.meta │ │ │ └── UniversalRenderPipelineGlobalSettings.asset.meta │ │ ├── Packages/ │ │ │ ├── com.unity.asset-store-tools/ │ │ │ │ ├── CHANGELOG.md │ │ │ │ ├── CHANGELOG.md.meta │ │ │ │ ├── Editor/ │ │ │ │ │ ├── Api/ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ ├── AuthenticationBase.cs │ │ │ │ │ │ │ ├── AuthenticationBase.cs.meta │ │ │ │ │ │ │ ├── IAssetStoreApi.cs │ │ │ │ │ │ │ ├── IAssetStoreApi.cs.meta │ │ │ │ │ │ │ ├── IAssetStoreClient.cs │ │ │ │ │ │ │ ├── IAssetStoreClient.cs.meta │ │ │ │ │ │ │ ├── IAuthenticationType.cs │ │ │ │ │ │ │ ├── IAuthenticationType.cs.meta │ │ │ │ │ │ │ ├── IPackageUploader.cs │ │ │ │ │ │ │ ├── IPackageUploader.cs.meta │ │ │ │ │ │ │ ├── PackageUploaderBase.cs │ │ │ │ │ │ │ └── PackageUploaderBase.cs.meta │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ ├── ApiUtility.cs │ │ │ │ │ │ ├── ApiUtility.cs.meta │ │ │ │ │ │ ├── AssetStoreApi.cs │ │ │ │ │ │ ├── AssetStoreApi.cs.meta │ │ │ │ │ │ ├── AssetStoreClient.cs │ │ │ │ │ │ ├── AssetStoreClient.cs.meta │ │ │ │ │ │ ├── CloudTokenAuthentication.cs │ │ │ │ │ │ ├── CloudTokenAuthentication.cs.meta │ │ │ │ │ │ ├── CredentialsAuthentication.cs │ │ │ │ │ │ ├── CredentialsAuthentication.cs.meta │ │ │ │ │ │ ├── Models/ │ │ │ │ │ │ │ ├── Category.cs │ │ │ │ │ │ │ ├── Category.cs.meta │ │ │ │ │ │ │ ├── Package.cs │ │ │ │ │ │ │ ├── Package.cs.meta │ │ │ │ │ │ │ ├── PackageAdditionalData.cs │ │ │ │ │ │ │ ├── PackageAdditionalData.cs.meta │ │ │ │ │ │ │ ├── User.cs │ │ │ │ │ │ │ └── User.cs.meta │ │ │ │ │ │ ├── Models.meta │ │ │ │ │ │ ├── Responses/ │ │ │ │ │ │ │ ├── AssetStoreResponse.cs │ │ │ │ │ │ │ ├── AssetStoreResponse.cs.meta │ │ │ │ │ │ │ ├── AssetStoreToolsVersionResponse.cs │ │ │ │ │ │ │ ├── AssetStoreToolsVersionResponse.cs.meta │ │ │ │ │ │ │ ├── AuthenticationResponse.cs │ │ │ │ │ │ │ ├── AuthenticationResponse.cs.meta │ │ │ │ │ │ │ ├── CategoryDataResponse.cs │ │ │ │ │ │ │ ├── CategoryDataResponse.cs.meta │ │ │ │ │ │ │ ├── PackageThumbnailResponse.cs │ │ │ │ │ │ │ ├── PackageThumbnailResponse.cs.meta │ │ │ │ │ │ │ ├── PackageUploadedUnityVersionDataResponse.cs │ │ │ │ │ │ │ ├── PackageUploadedUnityVersionDataResponse.cs.meta │ │ │ │ │ │ │ ├── PackagesAdditionalDataResponse.cs │ │ │ │ │ │ │ ├── PackagesAdditionalDataResponse.cs.meta │ │ │ │ │ │ │ ├── PackagesDataResponse.cs │ │ │ │ │ │ │ ├── PackagesDataResponse.cs.meta │ │ │ │ │ │ │ ├── RefreshedPackageDataResponse.cs │ │ │ │ │ │ │ ├── RefreshedPackageDataResponse.cs.meta │ │ │ │ │ │ │ ├── UploadResponse.cs │ │ │ │ │ │ │ └── UploadResponse.cs.meta │ │ │ │ │ │ ├── Responses.meta │ │ │ │ │ │ ├── SessionAuthentication.cs │ │ │ │ │ │ ├── SessionAuthentication.cs.meta │ │ │ │ │ │ ├── UnityPackageUploader.cs │ │ │ │ │ │ ├── UnityPackageUploader.cs.meta │ │ │ │ │ │ ├── UploadStatus.cs │ │ │ │ │ │ └── UploadStatus.cs.meta │ │ │ │ │ ├── Api.meta │ │ │ │ │ ├── AssemblyInfo.cs │ │ │ │ │ ├── AssemblyInfo.cs.meta │ │ │ │ │ ├── AssetStoreTools.cs │ │ │ │ │ ├── AssetStoreTools.cs.meta │ │ │ │ │ ├── AssetStoreToolsWindow.cs │ │ │ │ │ ├── AssetStoreToolsWindow.cs.meta │ │ │ │ │ ├── Constants.cs │ │ │ │ │ ├── Constants.cs.meta │ │ │ │ │ ├── Exporter/ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ ├── IPackageExporter.cs │ │ │ │ │ │ │ ├── IPackageExporter.cs.meta │ │ │ │ │ │ │ ├── IPreviewInjector.cs │ │ │ │ │ │ │ ├── IPreviewInjector.cs.meta │ │ │ │ │ │ │ ├── PackageExporterBase.cs │ │ │ │ │ │ │ ├── PackageExporterBase.cs.meta │ │ │ │ │ │ │ ├── PackageExporterSettings.cs │ │ │ │ │ │ │ └── PackageExporterSettings.cs.meta │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ ├── DefaultExporterSettings.cs │ │ │ │ │ │ ├── DefaultExporterSettings.cs.meta │ │ │ │ │ │ ├── DefaultPackageExporter.cs │ │ │ │ │ │ ├── DefaultPackageExporter.cs.meta │ │ │ │ │ │ ├── LegacyExporterSettings.cs │ │ │ │ │ │ ├── LegacyExporterSettings.cs.meta │ │ │ │ │ │ ├── LegacyPackageExporter.cs │ │ │ │ │ │ ├── LegacyPackageExporter.cs.meta │ │ │ │ │ │ ├── PackageExporterResult.cs │ │ │ │ │ │ ├── PackageExporterResult.cs.meta │ │ │ │ │ │ ├── PreviewInjector.cs │ │ │ │ │ │ └── PreviewInjector.cs.meta │ │ │ │ │ ├── Exporter.meta │ │ │ │ │ ├── Previews/ │ │ │ │ │ │ ├── Scripts/ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ ├── CustomPreviewGenerationSettings.cs │ │ │ │ │ │ │ │ ├── CustomPreviewGenerationSettings.cs.meta │ │ │ │ │ │ │ │ ├── FileNameFormat.cs │ │ │ │ │ │ │ │ ├── FileNameFormat.cs.meta │ │ │ │ │ │ │ │ ├── GenerationType.cs │ │ │ │ │ │ │ │ ├── GenerationType.cs.meta │ │ │ │ │ │ │ │ ├── NativePreviewGenerationSettings.cs │ │ │ │ │ │ │ │ ├── NativePreviewGenerationSettings.cs.meta │ │ │ │ │ │ │ │ ├── PreviewDatabase.cs │ │ │ │ │ │ │ │ ├── PreviewDatabase.cs.meta │ │ │ │ │ │ │ │ ├── PreviewFormat.cs │ │ │ │ │ │ │ │ ├── PreviewFormat.cs.meta │ │ │ │ │ │ │ │ ├── PreviewGenerationResult.cs │ │ │ │ │ │ │ │ ├── PreviewGenerationResult.cs.meta │ │ │ │ │ │ │ │ ├── PreviewGenerationSettings.cs │ │ │ │ │ │ │ │ ├── PreviewGenerationSettings.cs.meta │ │ │ │ │ │ │ │ ├── PreviewMetadata.cs │ │ │ │ │ │ │ │ └── PreviewMetadata.cs.meta │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ ├── Generators/ │ │ │ │ │ │ │ │ ├── Custom/ │ │ │ │ │ │ │ │ │ ├── AudioChannel.cs │ │ │ │ │ │ │ │ │ ├── AudioChannel.cs.meta │ │ │ │ │ │ │ │ │ ├── AudioChannelCoordinate.cs │ │ │ │ │ │ │ │ │ ├── AudioChannelCoordinate.cs.meta │ │ │ │ │ │ │ │ │ ├── Screenshotters/ │ │ │ │ │ │ │ │ │ │ ├── ISceneScreenshotter.cs │ │ │ │ │ │ │ │ │ │ ├── ISceneScreenshotter.cs.meta │ │ │ │ │ │ │ │ │ │ ├── MaterialScreenshotter.cs │ │ │ │ │ │ │ │ │ │ ├── MaterialScreenshotter.cs.meta │ │ │ │ │ │ │ │ │ │ ├── MeshScreenshotter.cs │ │ │ │ │ │ │ │ │ │ ├── MeshScreenshotter.cs.meta │ │ │ │ │ │ │ │ │ │ ├── SceneScreenshotterBase.cs │ │ │ │ │ │ │ │ │ │ ├── SceneScreenshotterBase.cs.meta │ │ │ │ │ │ │ │ │ │ ├── SceneScreenshotterSettings.cs │ │ │ │ │ │ │ │ │ │ └── SceneScreenshotterSettings.cs.meta │ │ │ │ │ │ │ │ │ ├── Screenshotters.meta │ │ │ │ │ │ │ │ │ ├── TypeGenerators/ │ │ │ │ │ │ │ │ │ │ ├── AudioTypeGeneratorSettings.cs │ │ │ │ │ │ │ │ │ │ ├── AudioTypeGeneratorSettings.cs.meta │ │ │ │ │ │ │ │ │ │ ├── AudioTypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── AudioTypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ITypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── ITypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── MaterialTypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── MaterialTypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ModelTypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── ModelTypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── PrefabTypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── PrefabTypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TextureTypeGeneratorSettings.cs │ │ │ │ │ │ │ │ │ │ ├── TextureTypeGeneratorSettings.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TextureTypePreviewGenerator.cs │ │ │ │ │ │ │ │ │ │ ├── TextureTypePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TypeGeneratorSettings.cs │ │ │ │ │ │ │ │ │ │ ├── TypeGeneratorSettings.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TypePreviewGeneratorBase.cs │ │ │ │ │ │ │ │ │ │ ├── TypePreviewGeneratorBase.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TypePreviewGeneratorFromScene.cs │ │ │ │ │ │ │ │ │ │ ├── TypePreviewGeneratorFromScene.cs.meta │ │ │ │ │ │ │ │ │ │ ├── TypePreviewGeneratorFromSceneSettings.cs │ │ │ │ │ │ │ │ │ │ └── TypePreviewGeneratorFromSceneSettings.cs.meta │ │ │ │ │ │ │ │ │ └── TypeGenerators.meta │ │ │ │ │ │ │ │ ├── Custom.meta │ │ │ │ │ │ │ │ ├── CustomPreviewGenerator.cs │ │ │ │ │ │ │ │ ├── CustomPreviewGenerator.cs.meta │ │ │ │ │ │ │ │ ├── IPreviewGenerator.cs │ │ │ │ │ │ │ │ ├── IPreviewGenerator.cs.meta │ │ │ │ │ │ │ │ ├── NativePreviewGenerator.cs │ │ │ │ │ │ │ │ ├── NativePreviewGenerator.cs.meta │ │ │ │ │ │ │ │ ├── PreviewGeneratorBase.cs │ │ │ │ │ │ │ │ └── PreviewGeneratorBase.cs.meta │ │ │ │ │ │ │ ├── Generators.meta │ │ │ │ │ │ │ ├── Services/ │ │ │ │ │ │ │ │ ├── Caching/ │ │ │ │ │ │ │ │ │ ├── CachingService.cs │ │ │ │ │ │ │ │ │ ├── CachingService.cs.meta │ │ │ │ │ │ │ │ │ ├── ICachingService.cs │ │ │ │ │ │ │ │ │ └── ICachingService.cs.meta │ │ │ │ │ │ │ │ ├── Caching.meta │ │ │ │ │ │ │ │ ├── IPreviewService.cs │ │ │ │ │ │ │ │ ├── IPreviewService.cs.meta │ │ │ │ │ │ │ │ ├── PreviewServiceProvider.cs │ │ │ │ │ │ │ │ └── PreviewServiceProvider.cs.meta │ │ │ │ │ │ │ ├── Services.meta │ │ │ │ │ │ │ ├── UI/ │ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ │ ├── AssetPreview.cs │ │ │ │ │ │ │ │ │ ├── AssetPreview.cs.meta │ │ │ │ │ │ │ │ │ ├── AssetPreviewCollection.cs │ │ │ │ │ │ │ │ │ ├── AssetPreviewCollection.cs.meta │ │ │ │ │ │ │ │ │ ├── IAssetPreview.cs │ │ │ │ │ │ │ │ │ ├── IAssetPreview.cs.meta │ │ │ │ │ │ │ │ │ ├── IAssetPreviewCollection.cs │ │ │ │ │ │ │ │ │ ├── IAssetPreviewCollection.cs.meta │ │ │ │ │ │ │ │ │ ├── IPreviewGeneratorSettings.cs │ │ │ │ │ │ │ │ │ ├── IPreviewGeneratorSettings.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewGeneratorSettings.cs │ │ │ │ │ │ │ │ │ └── PreviewGeneratorSettings.cs.meta │ │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ │ ├── Elements/ │ │ │ │ │ │ │ │ │ ├── AssetPreviewElement.cs │ │ │ │ │ │ │ │ │ ├── AssetPreviewElement.cs.meta │ │ │ │ │ │ │ │ │ ├── GridListElement.cs │ │ │ │ │ │ │ │ │ ├── GridListElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewCollectionElement.cs │ │ │ │ │ │ │ │ │ ├── PreviewCollectionElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewGenerateButtonElement.cs │ │ │ │ │ │ │ │ │ ├── PreviewGenerateButtonElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewGeneratorPathsElement.cs │ │ │ │ │ │ │ │ │ ├── PreviewGeneratorPathsElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewGeneratorSettingsElement.cs │ │ │ │ │ │ │ │ │ ├── PreviewGeneratorSettingsElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewWindowDescriptionElement.cs │ │ │ │ │ │ │ │ │ └── PreviewWindowDescriptionElement.cs.meta │ │ │ │ │ │ │ │ ├── Elements.meta │ │ │ │ │ │ │ │ ├── PreviewGeneratorWindow.cs │ │ │ │ │ │ │ │ ├── PreviewGeneratorWindow.cs.meta │ │ │ │ │ │ │ │ ├── Views/ │ │ │ │ │ │ │ │ │ ├── PreviewListView.cs │ │ │ │ │ │ │ │ │ └── PreviewListView.cs.meta │ │ │ │ │ │ │ │ └── Views.meta │ │ │ │ │ │ │ ├── UI.meta │ │ │ │ │ │ │ ├── Utility/ │ │ │ │ │ │ │ │ ├── GraphicsUtility.cs │ │ │ │ │ │ │ │ ├── GraphicsUtility.cs.meta │ │ │ │ │ │ │ │ ├── PreviewConvertUtility.cs │ │ │ │ │ │ │ │ ├── PreviewConvertUtility.cs.meta │ │ │ │ │ │ │ │ ├── PreviewSceneUtility.cs │ │ │ │ │ │ │ │ ├── PreviewSceneUtility.cs.meta │ │ │ │ │ │ │ │ ├── RenderPipeline.cs │ │ │ │ │ │ │ │ ├── RenderPipeline.cs.meta │ │ │ │ │ │ │ │ ├── RenderPipelineUtility.cs │ │ │ │ │ │ │ │ └── RenderPipelineUtility.cs.meta │ │ │ │ │ │ │ └── Utility.meta │ │ │ │ │ │ ├── Scripts.meta │ │ │ │ │ │ ├── Styles/ │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ └── Styles.meta │ │ │ │ │ ├── Previews.meta │ │ │ │ │ ├── Unity.AssetStoreTools.Editor.asmdef │ │ │ │ │ ├── Unity.AssetStoreTools.Editor.asmdef.meta │ │ │ │ │ ├── Uploader/ │ │ │ │ │ │ ├── Icons/ │ │ │ │ │ │ │ ├── account-dark.png.meta │ │ │ │ │ │ │ ├── account-light.png.meta │ │ │ │ │ │ │ ├── open-in-browser.png.meta │ │ │ │ │ │ │ ├── publisher-portal-dark.png.meta │ │ │ │ │ │ │ └── publisher-portal-light.png.meta │ │ │ │ │ │ ├── Icons.meta │ │ │ │ │ │ ├── Scripts/ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ │ │ ├── IPackage.cs │ │ │ │ │ │ │ │ │ ├── IPackage.cs.meta │ │ │ │ │ │ │ │ │ ├── IPackageContent.cs │ │ │ │ │ │ │ │ │ ├── IPackageContent.cs.meta │ │ │ │ │ │ │ │ │ ├── IPackageGroup.cs │ │ │ │ │ │ │ │ │ ├── IPackageGroup.cs.meta │ │ │ │ │ │ │ │ │ ├── IWorkflow.cs │ │ │ │ │ │ │ │ │ ├── IWorkflow.cs.meta │ │ │ │ │ │ │ │ │ ├── IWorkflowServices.cs │ │ │ │ │ │ │ │ │ ├── IWorkflowServices.cs.meta │ │ │ │ │ │ │ │ │ ├── WorkflowBase.cs │ │ │ │ │ │ │ │ │ └── WorkflowBase.cs.meta │ │ │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ │ │ ├── AssetsWorkflow.cs │ │ │ │ │ │ │ │ ├── AssetsWorkflow.cs.meta │ │ │ │ │ │ │ │ ├── HybridPackageWorkflow.cs │ │ │ │ │ │ │ │ ├── HybridPackageWorkflow.cs.meta │ │ │ │ │ │ │ │ ├── Package.cs │ │ │ │ │ │ │ │ ├── Package.cs.meta │ │ │ │ │ │ │ │ ├── PackageContent.cs │ │ │ │ │ │ │ │ ├── PackageContent.cs.meta │ │ │ │ │ │ │ │ ├── PackageGroup.cs │ │ │ │ │ │ │ │ ├── PackageGroup.cs.meta │ │ │ │ │ │ │ │ ├── PackageSorting.cs │ │ │ │ │ │ │ │ ├── PackageSorting.cs.meta │ │ │ │ │ │ │ │ ├── Serialization/ │ │ │ │ │ │ │ │ │ ├── AssetPath.cs │ │ │ │ │ │ │ │ │ ├── AssetPath.cs.meta │ │ │ │ │ │ │ │ │ ├── AssetsWorkflowStateData.cs │ │ │ │ │ │ │ │ │ ├── AssetsWorkflowStateData.cs.meta │ │ │ │ │ │ │ │ │ ├── HybridPackageWorkflowState.cs │ │ │ │ │ │ │ │ │ ├── HybridPackageWorkflowState.cs.meta │ │ │ │ │ │ │ │ │ ├── UnityPackageWorkflowStateData.cs │ │ │ │ │ │ │ │ │ ├── UnityPackageWorkflowStateData.cs.meta │ │ │ │ │ │ │ │ │ ├── WorkflowStateData.cs │ │ │ │ │ │ │ │ │ └── WorkflowStateData.cs.meta │ │ │ │ │ │ │ │ ├── Serialization.meta │ │ │ │ │ │ │ │ ├── UnityPackageWorkflow.cs │ │ │ │ │ │ │ │ ├── UnityPackageWorkflow.cs.meta │ │ │ │ │ │ │ │ ├── WorkflowServices.cs │ │ │ │ │ │ │ │ └── WorkflowServices.cs.meta │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ ├── Services/ │ │ │ │ │ │ │ │ ├── Analytics/ │ │ │ │ │ │ │ │ │ ├── AnalyticsService.cs │ │ │ │ │ │ │ │ │ ├── AnalyticsService.cs.meta │ │ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ │ │ ├── AuthenticationAnalytic.cs │ │ │ │ │ │ │ │ │ │ ├── AuthenticationAnalytic.cs.meta │ │ │ │ │ │ │ │ │ │ ├── BaseAnalytic.cs │ │ │ │ │ │ │ │ │ │ ├── BaseAnalytic.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IAssetStoreAnalytic.cs │ │ │ │ │ │ │ │ │ │ ├── IAssetStoreAnalytic.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IAssetStoreAnalyticData.cs │ │ │ │ │ │ │ │ │ │ ├── IAssetStoreAnalyticData.cs.meta │ │ │ │ │ │ │ │ │ │ ├── PackageUploadAnalytic.cs │ │ │ │ │ │ │ │ │ │ ├── PackageUploadAnalytic.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ValidationResultsSerializer.cs │ │ │ │ │ │ │ │ │ │ └── ValidationResultsSerializer.cs.meta │ │ │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ │ │ ├── IAnalyticsService.cs │ │ │ │ │ │ │ │ │ └── IAnalyticsService.cs.meta │ │ │ │ │ │ │ │ ├── Analytics.meta │ │ │ │ │ │ │ │ ├── Api/ │ │ │ │ │ │ │ │ │ ├── AuthenticationService.cs │ │ │ │ │ │ │ │ │ ├── AuthenticationService.cs.meta │ │ │ │ │ │ │ │ │ ├── IAuthenticationService.cs │ │ │ │ │ │ │ │ │ ├── IAuthenticationService.cs.meta │ │ │ │ │ │ │ │ │ ├── IPackageDownloadingService.cs │ │ │ │ │ │ │ │ │ ├── IPackageDownloadingService.cs.meta │ │ │ │ │ │ │ │ │ ├── IPackageUploadingService.cs │ │ │ │ │ │ │ │ │ ├── IPackageUploadingService.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageDownloadingService.cs │ │ │ │ │ │ │ │ │ ├── PackageDownloadingService.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageUploadingService.cs │ │ │ │ │ │ │ │ │ └── PackageUploadingService.cs.meta │ │ │ │ │ │ │ │ ├── Api.meta │ │ │ │ │ │ │ │ ├── Caching/ │ │ │ │ │ │ │ │ │ ├── CachingService.cs │ │ │ │ │ │ │ │ │ ├── CachingService.cs.meta │ │ │ │ │ │ │ │ │ ├── ICachingService.cs │ │ │ │ │ │ │ │ │ └── ICachingService.cs.meta │ │ │ │ │ │ │ │ ├── Caching.meta │ │ │ │ │ │ │ │ ├── IUploaderService.cs │ │ │ │ │ │ │ │ ├── IUploaderService.cs.meta │ │ │ │ │ │ │ │ ├── PackageFactory/ │ │ │ │ │ │ │ │ │ ├── IPackageFactoryService.cs │ │ │ │ │ │ │ │ │ ├── IPackageFactoryService.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageFactoryService.cs │ │ │ │ │ │ │ │ │ └── PackageFactoryService.cs.meta │ │ │ │ │ │ │ │ ├── PackageFactory.meta │ │ │ │ │ │ │ │ ├── UploaderServiceProvider.cs │ │ │ │ │ │ │ │ └── UploaderServiceProvider.cs.meta │ │ │ │ │ │ │ ├── Services.meta │ │ │ │ │ │ │ ├── UI/ │ │ │ │ │ │ │ │ ├── Elements/ │ │ │ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ │ │ │ ├── ValidationElementBase.cs │ │ │ │ │ │ │ │ │ │ ├── ValidationElementBase.cs.meta │ │ │ │ │ │ │ │ │ │ ├── WorkflowElementBase.cs │ │ │ │ │ │ │ │ │ │ └── WorkflowElementBase.cs.meta │ │ │ │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ │ │ │ ├── AccountToolbar.cs │ │ │ │ │ │ │ │ │ ├── AccountToolbar.cs.meta │ │ │ │ │ │ │ │ │ ├── AssetsWorkflowElement.cs │ │ │ │ │ │ │ │ │ ├── AssetsWorkflowElement.cs.meta │ │ │ │ │ │ │ │ │ ├── CurrentProjectValidationElement.cs │ │ │ │ │ │ │ │ │ ├── CurrentProjectValidationElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ExternalProjectValidationElement.cs │ │ │ │ │ │ │ │ │ ├── ExternalProjectValidationElement.cs.meta │ │ │ │ │ │ │ │ │ ├── HybridPackageWorkflowElement.cs │ │ │ │ │ │ │ │ │ ├── HybridPackageWorkflowElement.cs.meta │ │ │ │ │ │ │ │ │ ├── LoadingSpinner.cs │ │ │ │ │ │ │ │ │ ├── LoadingSpinner.cs.meta │ │ │ │ │ │ │ │ │ ├── MultiToggleSelectionElement.cs │ │ │ │ │ │ │ │ │ ├── MultiToggleSelectionElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageContentElement.cs │ │ │ │ │ │ │ │ │ ├── PackageContentElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageElement.cs │ │ │ │ │ │ │ │ │ ├── PackageElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageGroupElement.cs │ │ │ │ │ │ │ │ │ ├── PackageGroupElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageListToolbar.cs │ │ │ │ │ │ │ │ │ ├── PackageListToolbar.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageUploadElement.cs │ │ │ │ │ │ │ │ │ ├── PackageUploadElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PathSelectionElement.cs │ │ │ │ │ │ │ │ │ ├── PathSelectionElement.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewGenerationElement.cs │ │ │ │ │ │ │ │ │ ├── PreviewGenerationElement.cs.meta │ │ │ │ │ │ │ │ │ ├── UnityPackageWorkflowElement.cs │ │ │ │ │ │ │ │ │ └── UnityPackageWorkflowElement.cs.meta │ │ │ │ │ │ │ │ ├── Elements.meta │ │ │ │ │ │ │ │ ├── Views/ │ │ │ │ │ │ │ │ │ ├── LoginView.cs │ │ │ │ │ │ │ │ │ ├── LoginView.cs.meta │ │ │ │ │ │ │ │ │ ├── PackageListView.cs │ │ │ │ │ │ │ │ │ └── PackageListView.cs.meta │ │ │ │ │ │ │ │ └── Views.meta │ │ │ │ │ │ │ └── UI.meta │ │ │ │ │ │ ├── Scripts.meta │ │ │ │ │ │ ├── Styles/ │ │ │ │ │ │ │ ├── LoginView/ │ │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ │ ├── LoginView.meta │ │ │ │ │ │ │ ├── PackageListView/ │ │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ │ ├── PackageListView.meta │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ ├── Styles.meta │ │ │ │ │ │ ├── UploaderWindow.cs │ │ │ │ │ │ └── UploaderWindow.cs.meta │ │ │ │ │ ├── Uploader.meta │ │ │ │ │ ├── Utility/ │ │ │ │ │ │ ├── ASDebug.cs │ │ │ │ │ │ ├── ASDebug.cs.meta │ │ │ │ │ │ ├── ASToolsPreferences.cs │ │ │ │ │ │ ├── ASToolsPreferences.cs.meta │ │ │ │ │ │ ├── ASToolsUpdater.cs │ │ │ │ │ │ ├── ASToolsUpdater.cs.meta │ │ │ │ │ │ ├── CacheUtil.cs │ │ │ │ │ │ ├── CacheUtil.cs.meta │ │ │ │ │ │ ├── FileUtility.cs │ │ │ │ │ │ ├── FileUtility.cs.meta │ │ │ │ │ │ ├── LegacyToolsRemover.cs │ │ │ │ │ │ ├── LegacyToolsRemover.cs.meta │ │ │ │ │ │ ├── PackageUtility.cs │ │ │ │ │ │ ├── PackageUtility.cs.meta │ │ │ │ │ │ ├── ServiceProvider.cs │ │ │ │ │ │ ├── ServiceProvider.cs.meta │ │ │ │ │ │ ├── StyleSelector.cs │ │ │ │ │ │ ├── StyleSelector.cs.meta │ │ │ │ │ │ ├── Styles/ │ │ │ │ │ │ │ ├── Updater/ │ │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ │ └── Updater.meta │ │ │ │ │ │ ├── Styles.meta │ │ │ │ │ │ ├── SymlinkUtil.cs │ │ │ │ │ │ └── SymlinkUtil.cs.meta │ │ │ │ │ ├── Utility.meta │ │ │ │ │ ├── Validator/ │ │ │ │ │ │ ├── Icons/ │ │ │ │ │ │ │ ├── error.png.meta │ │ │ │ │ │ │ ├── error_d.png.meta │ │ │ │ │ │ │ ├── success.png.meta │ │ │ │ │ │ │ ├── success_d.png.meta │ │ │ │ │ │ │ ├── undefined.png.meta │ │ │ │ │ │ │ ├── undefined_d.png.meta │ │ │ │ │ │ │ ├── warning.png.meta │ │ │ │ │ │ │ └── warning_d.png.meta │ │ │ │ │ │ ├── Icons.meta │ │ │ │ │ │ ├── Scripts/ │ │ │ │ │ │ │ ├── Categories/ │ │ │ │ │ │ │ │ ├── CategoryEvaluator.cs │ │ │ │ │ │ │ │ ├── CategoryEvaluator.cs.meta │ │ │ │ │ │ │ │ ├── ValidatorCategory.cs │ │ │ │ │ │ │ │ └── ValidatorCategory.cs.meta │ │ │ │ │ │ │ ├── Categories.meta │ │ │ │ │ │ │ ├── CurrentProjectValidator.cs │ │ │ │ │ │ │ ├── CurrentProjectValidator.cs.meta │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ ├── CurrentProjectValidationSettings.cs │ │ │ │ │ │ │ │ ├── CurrentProjectValidationSettings.cs.meta │ │ │ │ │ │ │ │ ├── ExternalProjectValidationSettings.cs │ │ │ │ │ │ │ │ ├── ExternalProjectValidationSettings.cs.meta │ │ │ │ │ │ │ │ ├── MessageActions/ │ │ │ │ │ │ │ │ │ ├── HighlightObjectAction.cs │ │ │ │ │ │ │ │ │ ├── HighlightObjectAction.cs.meta │ │ │ │ │ │ │ │ │ ├── IMessageAction.cs │ │ │ │ │ │ │ │ │ ├── IMessageAction.cs.meta │ │ │ │ │ │ │ │ │ ├── OpenAssetAction.cs │ │ │ │ │ │ │ │ │ └── OpenAssetAction.cs.meta │ │ │ │ │ │ │ │ ├── MessageActions.meta │ │ │ │ │ │ │ │ ├── TestResult.cs │ │ │ │ │ │ │ │ ├── TestResult.cs.meta │ │ │ │ │ │ │ │ ├── TestResultMessage.cs │ │ │ │ │ │ │ │ ├── TestResultMessage.cs.meta │ │ │ │ │ │ │ │ ├── TestResultObject.cs │ │ │ │ │ │ │ │ ├── TestResultObject.cs.meta │ │ │ │ │ │ │ │ ├── TestResultStatus.cs │ │ │ │ │ │ │ │ ├── TestResultStatus.cs.meta │ │ │ │ │ │ │ │ ├── ValidationResult.cs │ │ │ │ │ │ │ │ ├── ValidationResult.cs.meta │ │ │ │ │ │ │ │ ├── ValidationSettings.cs │ │ │ │ │ │ │ │ ├── ValidationSettings.cs.meta │ │ │ │ │ │ │ │ ├── ValidationStatus.cs │ │ │ │ │ │ │ │ ├── ValidationStatus.cs.meta │ │ │ │ │ │ │ │ ├── ValidationType.cs │ │ │ │ │ │ │ │ └── ValidationType.cs.meta │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ ├── ExternalProjectValidator.cs │ │ │ │ │ │ │ ├── ExternalProjectValidator.cs.meta │ │ │ │ │ │ │ ├── IValidator.cs │ │ │ │ │ │ │ ├── IValidator.cs.meta │ │ │ │ │ │ │ ├── Services/ │ │ │ │ │ │ │ │ ├── CachingService/ │ │ │ │ │ │ │ │ │ ├── CachingService.cs │ │ │ │ │ │ │ │ │ ├── CachingService.cs.meta │ │ │ │ │ │ │ │ │ ├── ICachingService.cs │ │ │ │ │ │ │ │ │ ├── ICachingService.cs.meta │ │ │ │ │ │ │ │ │ ├── PreviewDatabaseContractResolver.cs │ │ │ │ │ │ │ │ │ └── PreviewDatabaseContractResolver.cs.meta │ │ │ │ │ │ │ │ ├── CachingService.meta │ │ │ │ │ │ │ │ ├── IValidatorService.cs │ │ │ │ │ │ │ │ ├── IValidatorService.cs.meta │ │ │ │ │ │ │ │ ├── Validation/ │ │ │ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ │ │ │ ├── IAssetUtilityService.cs │ │ │ │ │ │ │ │ │ │ ├── IAssetUtilityService.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IFileSignatureUtilityService.cs │ │ │ │ │ │ │ │ │ │ ├── IFileSignatureUtilityService.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IMeshUtilityService.cs │ │ │ │ │ │ │ │ │ │ ├── IMeshUtilityService.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IModelUtilityService.cs │ │ │ │ │ │ │ │ │ │ ├── IModelUtilityService.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ISceneUtilityService.cs │ │ │ │ │ │ │ │ │ │ ├── ISceneUtilityService.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IScriptUtilityService.cs │ │ │ │ │ │ │ │ │ │ └── IScriptUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ │ │ │ ├── AssetUtilityService.cs │ │ │ │ │ │ │ │ │ ├── AssetUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ │ │ ├── ArchiveType.cs │ │ │ │ │ │ │ │ │ │ ├── ArchiveType.cs.meta │ │ │ │ │ │ │ │ │ │ ├── AssetEnumerator.cs │ │ │ │ │ │ │ │ │ │ ├── AssetEnumerator.cs.meta │ │ │ │ │ │ │ │ │ │ ├── AssetType.cs │ │ │ │ │ │ │ │ │ │ ├── AssetType.cs.meta │ │ │ │ │ │ │ │ │ │ ├── LogEntry.cs │ │ │ │ │ │ │ │ │ │ └── LogEntry.cs.meta │ │ │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ │ │ ├── FileSignatureUtilityService.cs │ │ │ │ │ │ │ │ │ ├── FileSignatureUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── MeshUtilityService.cs │ │ │ │ │ │ │ │ │ ├── MeshUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── ModelUtilityService.cs │ │ │ │ │ │ │ │ │ ├── ModelUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── SceneUtilityService.cs │ │ │ │ │ │ │ │ │ ├── SceneUtilityService.cs.meta │ │ │ │ │ │ │ │ │ ├── ScriptUtilityService.cs │ │ │ │ │ │ │ │ │ └── ScriptUtilityService.cs.meta │ │ │ │ │ │ │ │ ├── Validation.meta │ │ │ │ │ │ │ │ ├── ValidatorServiceProvider.cs │ │ │ │ │ │ │ │ └── ValidatorServiceProvider.cs.meta │ │ │ │ │ │ │ ├── Services.meta │ │ │ │ │ │ │ ├── Test Definitions/ │ │ │ │ │ │ │ │ ├── AutomatedTest.cs │ │ │ │ │ │ │ │ ├── AutomatedTest.cs.meta │ │ │ │ │ │ │ │ ├── GenericTestConfig.cs │ │ │ │ │ │ │ │ ├── GenericTestConfig.cs.meta │ │ │ │ │ │ │ │ ├── ITestConfig.cs │ │ │ │ │ │ │ │ ├── ITestConfig.cs.meta │ │ │ │ │ │ │ │ ├── ITestScript.cs │ │ │ │ │ │ │ │ ├── ITestScript.cs.meta │ │ │ │ │ │ │ │ ├── Scriptable Objects/ │ │ │ │ │ │ │ │ │ ├── AutomatedTestScriptableObject.cs │ │ │ │ │ │ │ │ │ ├── AutomatedTestScriptableObject.cs.meta │ │ │ │ │ │ │ │ │ ├── Editor/ │ │ │ │ │ │ │ │ │ │ ├── ValidationTestScriptableObjectInspector.cs │ │ │ │ │ │ │ │ │ │ └── ValidationTestScriptableObjectInspector.cs.meta │ │ │ │ │ │ │ │ │ ├── Editor.meta │ │ │ │ │ │ │ │ │ ├── ValidationTestScriptableObject.cs │ │ │ │ │ │ │ │ │ └── ValidationTestScriptableObject.cs.meta │ │ │ │ │ │ │ │ ├── Scriptable Objects.meta │ │ │ │ │ │ │ │ ├── ValidationTest.cs │ │ │ │ │ │ │ │ └── ValidationTest.cs.meta │ │ │ │ │ │ │ ├── Test Definitions.meta │ │ │ │ │ │ │ ├── Test Methods/ │ │ │ │ │ │ │ │ ├── Generic/ │ │ │ │ │ │ │ │ │ ├── CheckAnimationClips.cs │ │ │ │ │ │ │ │ │ ├── CheckAnimationClips.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckAudioClipping.cs │ │ │ │ │ │ │ │ │ ├── CheckAudioClipping.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckColliders.cs │ │ │ │ │ │ │ │ │ ├── CheckColliders.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckCompressedFiles.cs │ │ │ │ │ │ │ │ │ ├── CheckCompressedFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckEmptyPrefabs.cs │ │ │ │ │ │ │ │ │ ├── CheckEmptyPrefabs.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckFileMenuNames.cs │ │ │ │ │ │ │ │ │ ├── CheckFileMenuNames.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckLODs.cs │ │ │ │ │ │ │ │ │ ├── CheckLODs.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckLineEndings.cs │ │ │ │ │ │ │ │ │ ├── CheckLineEndings.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckMeshPrefabs.cs │ │ │ │ │ │ │ │ │ ├── CheckMeshPrefabs.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckMissingComponentsinAssets.cs │ │ │ │ │ │ │ │ │ ├── CheckMissingComponentsinAssets.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckMissingComponentsinScenes.cs │ │ │ │ │ │ │ │ │ ├── CheckMissingComponentsinScenes.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckModelImportLogs.cs │ │ │ │ │ │ │ │ │ ├── CheckModelImportLogs.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckModelOrientation.cs │ │ │ │ │ │ │ │ │ ├── CheckModelOrientation.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckModelTypes.cs │ │ │ │ │ │ │ │ │ ├── CheckModelTypes.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckNormalMapTextures.cs │ │ │ │ │ │ │ │ │ ├── CheckNormalMapTextures.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckPackageNaming.cs │ │ │ │ │ │ │ │ │ ├── CheckPackageNaming.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckParticleSystems.cs │ │ │ │ │ │ │ │ │ ├── CheckParticleSystems.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckPathLengths.cs │ │ │ │ │ │ │ │ │ ├── CheckPathLengths.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckPrefabTransforms.cs │ │ │ │ │ │ │ │ │ ├── CheckPrefabTransforms.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckScriptCompilation.cs │ │ │ │ │ │ │ │ │ ├── CheckScriptCompilation.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckShaderCompilation.cs │ │ │ │ │ │ │ │ │ ├── CheckShaderCompilation.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckTextureDimensions.cs │ │ │ │ │ │ │ │ │ ├── CheckTextureDimensions.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckTypeNamespaces.cs │ │ │ │ │ │ │ │ │ ├── CheckTypeNamespaces.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveExecutableFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveExecutableFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveJPGFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveJPGFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveJavaScriptFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveJavaScriptFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveLossyAudioFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveLossyAudioFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveMixamoFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveMixamoFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveSpeedTreeFiles.cs │ │ │ │ │ │ │ │ │ ├── RemoveSpeedTreeFiles.cs.meta │ │ │ │ │ │ │ │ │ ├── RemoveVideoFiles.cs │ │ │ │ │ │ │ │ │ └── RemoveVideoFiles.cs.meta │ │ │ │ │ │ │ │ ├── Generic.meta │ │ │ │ │ │ │ │ ├── UnityPackage/ │ │ │ │ │ │ │ │ │ ├── CheckDemoScenes.cs │ │ │ │ │ │ │ │ │ ├── CheckDemoScenes.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckDocumentation.cs │ │ │ │ │ │ │ │ │ ├── CheckDocumentation.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckPackageSize.cs │ │ │ │ │ │ │ │ │ ├── CheckPackageSize.cs.meta │ │ │ │ │ │ │ │ │ ├── CheckProjectTemplateAssets.cs │ │ │ │ │ │ │ │ │ └── CheckProjectTemplateAssets.cs.meta │ │ │ │ │ │ │ │ └── UnityPackage.meta │ │ │ │ │ │ │ ├── Test Methods.meta │ │ │ │ │ │ │ ├── UI/ │ │ │ │ │ │ │ │ ├── Data/ │ │ │ │ │ │ │ │ │ ├── Abstractions/ │ │ │ │ │ │ │ │ │ │ ├── IValidatorResults.cs │ │ │ │ │ │ │ │ │ │ ├── IValidatorResults.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IValidatorSettings.cs │ │ │ │ │ │ │ │ │ │ ├── IValidatorSettings.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IValidatorTest.cs │ │ │ │ │ │ │ │ │ │ ├── IValidatorTest.cs.meta │ │ │ │ │ │ │ │ │ │ ├── IValidatorTestGroup.cs │ │ │ │ │ │ │ │ │ │ └── IValidatorTestGroup.cs.meta │ │ │ │ │ │ │ │ │ ├── Abstractions.meta │ │ │ │ │ │ │ │ │ ├── Serialization/ │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateData.cs │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateData.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateDataContractResolver.cs │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateDataContractResolver.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateResults.cs │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateResults.cs.meta │ │ │ │ │ │ │ │ │ │ ├── ValidatorStateSettings.cs │ │ │ │ │ │ │ │ │ │ └── ValidatorStateSettings.cs.meta │ │ │ │ │ │ │ │ │ ├── Serialization.meta │ │ │ │ │ │ │ │ │ ├── ValidatorResults.cs │ │ │ │ │ │ │ │ │ ├── ValidatorResults.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorSettings.cs │ │ │ │ │ │ │ │ │ ├── ValidatorSettings.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorTest.cs │ │ │ │ │ │ │ │ │ ├── ValidatorTest.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorTestGroup.cs │ │ │ │ │ │ │ │ │ └── ValidatorTestGroup.cs.meta │ │ │ │ │ │ │ │ ├── Data.meta │ │ │ │ │ │ │ │ ├── Elements/ │ │ │ │ │ │ │ │ │ ├── ValidatorButtonElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorButtonElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorDescriptionElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorDescriptionElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorPathsElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorPathsElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorResultsElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorResultsElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorSettingsElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorSettingsElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorTestElement.cs │ │ │ │ │ │ │ │ │ ├── ValidatorTestElement.cs.meta │ │ │ │ │ │ │ │ │ ├── ValidatorTestGroupElement.cs │ │ │ │ │ │ │ │ │ └── ValidatorTestGroupElement.cs.meta │ │ │ │ │ │ │ │ ├── Elements.meta │ │ │ │ │ │ │ │ ├── ValidatorWindow.cs │ │ │ │ │ │ │ │ ├── ValidatorWindow.cs.meta │ │ │ │ │ │ │ │ ├── Views/ │ │ │ │ │ │ │ │ │ ├── ValidatorTestsView.cs │ │ │ │ │ │ │ │ │ └── ValidatorTestsView.cs.meta │ │ │ │ │ │ │ │ └── Views.meta │ │ │ │ │ │ │ ├── UI.meta │ │ │ │ │ │ │ ├── Utility/ │ │ │ │ │ │ │ │ ├── ValidatorUtility.cs │ │ │ │ │ │ │ │ └── ValidatorUtility.cs.meta │ │ │ │ │ │ │ ├── Utility.meta │ │ │ │ │ │ │ ├── ValidatorBase.cs │ │ │ │ │ │ │ └── ValidatorBase.cs.meta │ │ │ │ │ │ ├── Scripts.meta │ │ │ │ │ │ ├── Styles/ │ │ │ │ │ │ │ ├── Style.uss │ │ │ │ │ │ │ ├── Style.uss.meta │ │ │ │ │ │ │ ├── ThemeDark.uss │ │ │ │ │ │ │ ├── ThemeDark.uss.meta │ │ │ │ │ │ │ ├── ThemeLight.uss │ │ │ │ │ │ │ └── ThemeLight.uss.meta │ │ │ │ │ │ ├── Styles.meta │ │ │ │ │ │ ├── Tests/ │ │ │ │ │ │ │ ├── Generic/ │ │ │ │ │ │ │ │ ├── Check Animation Clips.asset.meta │ │ │ │ │ │ │ │ ├── Check Audio Clipping.asset.meta │ │ │ │ │ │ │ │ ├── Check Colliders.asset.meta │ │ │ │ │ │ │ │ ├── Check Compressed Files.asset.meta │ │ │ │ │ │ │ │ ├── Check Empty Prefabs.asset.meta │ │ │ │ │ │ │ │ ├── Check File Menu Names.asset.meta │ │ │ │ │ │ │ │ ├── Check LODs.asset.meta │ │ │ │ │ │ │ │ ├── Check Line Endings.asset.meta │ │ │ │ │ │ │ │ ├── Check Mesh Prefabs.asset.meta │ │ │ │ │ │ │ │ ├── Check Missing Components in Assets.asset.meta │ │ │ │ │ │ │ │ ├── Check Missing Components in Scenes.asset.meta │ │ │ │ │ │ │ │ ├── Check Model Import Logs.asset.meta │ │ │ │ │ │ │ │ ├── Check Model Orientation.asset.meta │ │ │ │ │ │ │ │ ├── Check Model Types.asset.meta │ │ │ │ │ │ │ │ ├── Check Normal Map Textures.asset.meta │ │ │ │ │ │ │ │ ├── Check Package Naming.asset.meta │ │ │ │ │ │ │ │ ├── Check Particle Systems.asset.meta │ │ │ │ │ │ │ │ ├── Check Path Lengths.asset.meta │ │ │ │ │ │ │ │ ├── Check Prefab Transforms.asset.meta │ │ │ │ │ │ │ │ ├── Check Script Compilation.asset.meta │ │ │ │ │ │ │ │ ├── Check Shader Compilation.asset.meta │ │ │ │ │ │ │ │ ├── Check Texture Dimensions.asset.meta │ │ │ │ │ │ │ │ ├── Check Type Namespaces.asset.meta │ │ │ │ │ │ │ │ ├── Remove Executable Files.asset.meta │ │ │ │ │ │ │ │ ├── Remove JPG Files.asset.meta │ │ │ │ │ │ │ │ ├── Remove JavaScript Files.asset.meta │ │ │ │ │ │ │ │ ├── Remove Lossy Audio Files.asset.meta │ │ │ │ │ │ │ │ ├── Remove Mixamo Files.asset.meta │ │ │ │ │ │ │ │ ├── Remove SpeedTree Files.asset.meta │ │ │ │ │ │ │ │ └── Remove Video Files.asset.meta │ │ │ │ │ │ │ ├── Generic.meta │ │ │ │ │ │ │ ├── UnityPackage/ │ │ │ │ │ │ │ │ ├── Check Demo Scenes.asset.meta │ │ │ │ │ │ │ │ ├── Check Documentation.asset.meta │ │ │ │ │ │ │ │ ├── Check Package Size.asset.meta │ │ │ │ │ │ │ │ └── Check Project Template Assets.asset.meta │ │ │ │ │ │ │ └── UnityPackage.meta │ │ │ │ │ │ └── Tests.meta │ │ │ │ │ └── Validator.meta │ │ │ │ ├── Editor.meta │ │ │ │ ├── LICENSE.md │ │ │ │ ├── LICENSE.md.meta │ │ │ │ ├── package.json │ │ │ │ └── package.json.meta │ │ │ ├── manifest.json │ │ │ └── packages-lock.json │ │ └── ProjectSettings/ │ │ ├── BurstAotSettings_StandaloneWindows.json │ │ ├── CommonBurstAotSettings.json │ │ ├── ProjectVersion.txt │ │ └── SceneTemplateSettings.json │ └── UnityMCPTests/ │ ├── .gitignore │ ├── Assets/ │ │ ├── Packages.meta │ │ ├── Scenes/ │ │ │ ├── SampleScene.unity │ │ │ ├── SampleScene.unity.meta │ │ │ ├── Test.unity/ │ │ │ │ ├── Test.unity │ │ │ │ └── Test.unity.meta │ │ │ └── Test.unity.meta │ │ ├── Scenes.meta │ │ ├── Scripts/ │ │ │ ├── Bouncer.cs │ │ │ ├── Bouncer.cs.meta │ │ │ ├── Hello.cs │ │ │ ├── Hello.cs.meta │ │ │ ├── LongUnityScriptClaudeTest.cs │ │ │ ├── LongUnityScriptClaudeTest.cs.meta │ │ │ ├── TestAsmdef/ │ │ │ │ ├── CustomComponent.cs │ │ │ │ ├── CustomComponent.cs.meta │ │ │ │ ├── TestAsmdef.asmdef │ │ │ │ ├── TestAsmdef.asmdef.meta │ │ │ │ ├── UnityEventTestComponent.cs │ │ │ │ └── UnityEventTestComponent.cs.meta │ │ │ └── TestAsmdef.meta │ │ ├── Scripts.meta │ │ ├── Temp.meta │ │ ├── TestMat.mat │ │ ├── TestMat.mat.meta │ │ ├── Tests/ │ │ │ ├── EditMode/ │ │ │ │ ├── Helpers/ │ │ │ │ │ ├── AssetPathUtilityOfflineTests.cs │ │ │ │ │ ├── AssetPathUtilityOfflineTests.cs.meta │ │ │ │ │ ├── CodexConfigHelperTests.cs │ │ │ │ │ ├── CodexConfigHelperTests.cs.meta │ │ │ │ │ ├── Matrix4x4ConverterTests.cs │ │ │ │ │ ├── Matrix4x4ConverterTests.cs.meta │ │ │ │ │ ├── PaginationTests.cs │ │ │ │ │ ├── PaginationTests.cs.meta │ │ │ │ │ ├── ToolParamsTests.cs │ │ │ │ │ ├── ToolParamsTests.cs.meta │ │ │ │ │ ├── WriteToConfigTests.cs │ │ │ │ │ └── WriteToConfigTests.cs.meta │ │ │ │ ├── Helpers.meta │ │ │ │ ├── MCPForUnityTests.Editor.asmdef │ │ │ │ ├── MCPForUnityTests.Editor.asmdef.meta │ │ │ │ ├── Resources/ │ │ │ │ │ ├── GetMenuItemsTests.cs │ │ │ │ │ └── GetMenuItemsTests.cs.meta │ │ │ │ ├── Resources.meta │ │ │ │ ├── Services/ │ │ │ │ │ ├── Characterization/ │ │ │ │ │ │ ├── ServerManagementServiceCharacterizationTests.cs │ │ │ │ │ │ ├── ServerManagementServiceCharacterizationTests.cs.meta │ │ │ │ │ │ ├── Services_Characterization.cs │ │ │ │ │ │ └── Services_Characterization.cs.meta │ │ │ │ │ ├── Characterization.meta │ │ │ │ │ ├── EditorConfigurationCacheTests.cs │ │ │ │ │ ├── EditorConfigurationCacheTests.cs.meta │ │ │ │ │ ├── PackageUpdateServiceTests.cs │ │ │ │ │ ├── PackageUpdateServiceTests.cs.meta │ │ │ │ │ ├── PortManagerTests.cs │ │ │ │ │ ├── PortManagerTests.cs.meta │ │ │ │ │ ├── Server/ │ │ │ │ │ │ ├── PidFileManagerTests.cs │ │ │ │ │ │ ├── PidFileManagerTests.cs.meta │ │ │ │ │ │ ├── ProcessDetectorTests.cs │ │ │ │ │ │ ├── ProcessDetectorTests.cs.meta │ │ │ │ │ │ ├── ProcessTerminatorTests.cs │ │ │ │ │ │ ├── ProcessTerminatorTests.cs.meta │ │ │ │ │ │ ├── ServerCommandBuilderTests.cs │ │ │ │ │ │ ├── ServerCommandBuilderTests.cs.meta │ │ │ │ │ │ ├── TerminalLauncherTests.cs │ │ │ │ │ │ └── TerminalLauncherTests.cs.meta │ │ │ │ │ ├── Server.meta │ │ │ │ │ ├── StdioBridgeReconnectTests.cs │ │ │ │ │ ├── StdioBridgeReconnectTests.cs.meta │ │ │ │ │ ├── ToolDiscoveryServiceTests.cs │ │ │ │ │ ├── ToolDiscoveryServiceTests.cs.meta │ │ │ │ │ ├── WebSocketTransportClientTests.cs │ │ │ │ │ └── WebSocketTransportClientTests.cs.meta │ │ │ │ ├── Services.meta │ │ │ │ ├── TestUtilities.cs │ │ │ │ ├── TestUtilities.cs.meta │ │ │ │ ├── Tools/ │ │ │ │ │ ├── AIPropertyMatchingTests.cs │ │ │ │ │ ├── AIPropertyMatchingTests.cs.meta │ │ │ │ │ ├── BatchExecuteKeyPreservationTests.cs │ │ │ │ │ ├── BatchExecuteKeyPreservationTests.cs.meta │ │ │ │ │ ├── Characterization/ │ │ │ │ │ │ ├── EditorTools_Characterization.cs │ │ │ │ │ │ └── EditorTools_Characterization.cs.meta │ │ │ │ │ ├── Characterization.meta │ │ │ │ │ ├── CommandRegistryTests.cs │ │ │ │ │ ├── CommandRegistryTests.cs.meta │ │ │ │ │ ├── ComponentOpsUnityEventTests.cs │ │ │ │ │ ├── ComponentOpsUnityEventTests.cs.meta │ │ │ │ │ ├── ComponentResolverTests.cs │ │ │ │ │ ├── ComponentResolverTests.cs.meta │ │ │ │ │ ├── DomainReloadResilienceTests.cs │ │ │ │ │ ├── DomainReloadResilienceTests.cs.meta │ │ │ │ │ ├── ExecuteMenuItemTests.cs │ │ │ │ │ ├── ExecuteMenuItemTests.cs.meta │ │ │ │ │ ├── Fixtures/ │ │ │ │ │ │ ├── ManageScriptableObjectTestDefinition.cs │ │ │ │ │ │ ├── ManageScriptableObjectTestDefinition.cs.meta │ │ │ │ │ │ ├── ManageScriptableObjectTestDefinitionBase.cs │ │ │ │ │ │ ├── ManageScriptableObjectTestDefinitionBase.cs.meta │ │ │ │ │ │ ├── StressTestSOs/ │ │ │ │ │ │ │ ├── ArrayStressSO.cs │ │ │ │ │ │ │ ├── ArrayStressSO.cs.meta │ │ │ │ │ │ │ ├── ComplexStressSO.cs │ │ │ │ │ │ │ ├── ComplexStressSO.cs.meta │ │ │ │ │ │ │ ├── DeepStressSO.cs │ │ │ │ │ │ │ └── DeepStressSO.cs.meta │ │ │ │ │ │ └── StressTestSOs.meta │ │ │ │ │ ├── Fixtures.meta │ │ │ │ │ ├── GameObjectAPIStressTests.cs │ │ │ │ │ ├── GameObjectAPIStressTests.cs.meta │ │ │ │ │ ├── GameObjectComponentHelpersErrorTests.cs │ │ │ │ │ ├── GameObjectComponentHelpersErrorTests.cs.meta │ │ │ │ │ ├── MCPToolParameterTests.cs │ │ │ │ │ ├── MCPToolParameterTests.cs.meta │ │ │ │ │ ├── ManageAnimationTests.cs │ │ │ │ │ ├── ManageAnimationTests.cs.meta │ │ │ │ │ ├── ManageGameObjectCreateTests.cs │ │ │ │ │ ├── ManageGameObjectCreateTests.cs.meta │ │ │ │ │ ├── ManageGameObjectDeleteTests.cs │ │ │ │ │ ├── ManageGameObjectDeleteTests.cs.meta │ │ │ │ │ ├── ManageGameObjectModifyTests.cs │ │ │ │ │ ├── ManageGameObjectModifyTests.cs.meta │ │ │ │ │ ├── ManageGameObjectTests.cs │ │ │ │ │ ├── ManageGameObjectTests.cs.meta │ │ │ │ │ ├── ManageGraphicsTests.cs │ │ │ │ │ ├── ManageMaterialPropertiesTests.cs │ │ │ │ │ ├── ManageMaterialPropertiesTests.cs.meta │ │ │ │ │ ├── ManageMaterialReproTests.cs │ │ │ │ │ ├── ManageMaterialReproTests.cs.meta │ │ │ │ │ ├── ManageMaterialStressTests.cs │ │ │ │ │ ├── ManageMaterialStressTests.cs.meta │ │ │ │ │ ├── ManageMaterialTests.cs │ │ │ │ │ ├── ManageMaterialTests.cs.meta │ │ │ │ │ ├── ManagePrefabsCrudTests.cs │ │ │ │ │ ├── ManagePrefabsCrudTests.cs.meta │ │ │ │ │ ├── ManageProBuilderTests.cs │ │ │ │ │ ├── ManageProBuilderTests.cs.meta │ │ │ │ │ ├── ManageSceneHierarchyPagingTests.cs │ │ │ │ │ ├── ManageSceneHierarchyPagingTests.cs.meta │ │ │ │ │ ├── ManageScriptDelimiterTests.cs │ │ │ │ │ ├── ManageScriptDelimiterTests.cs.meta │ │ │ │ │ ├── ManageScriptValidationTests.cs │ │ │ │ │ ├── ManageScriptValidationTests.cs.meta │ │ │ │ │ ├── ManageScriptableObjectStressTests.cs │ │ │ │ │ ├── ManageScriptableObjectStressTests.cs.meta │ │ │ │ │ ├── ManageScriptableObjectTests.cs │ │ │ │ │ ├── ManageScriptableObjectTests.cs.meta │ │ │ │ │ ├── ManageUITests.cs │ │ │ │ │ ├── ManageUITests.cs.meta │ │ │ │ │ ├── MaterialDirectPropertiesTests.cs │ │ │ │ │ ├── MaterialDirectPropertiesTests.cs.meta │ │ │ │ │ ├── MaterialMeshInstantiationTests.cs │ │ │ │ │ ├── MaterialMeshInstantiationTests.cs.meta │ │ │ │ │ ├── MaterialParameterToolTests.cs │ │ │ │ │ ├── MaterialParameterToolTests.cs.meta │ │ │ │ │ ├── PropertyConversionErrorHandlingTests.cs │ │ │ │ │ ├── PropertyConversionErrorHandlingTests.cs.meta │ │ │ │ │ ├── PropertyConversion_ArrayForFloat_Test.cs │ │ │ │ │ ├── PropertyConversion_ArrayForFloat_Test.cs.meta │ │ │ │ │ ├── ReadConsoleTests.cs │ │ │ │ │ ├── ReadConsoleTests.cs.meta │ │ │ │ │ ├── RunTestsTests.cs │ │ │ │ │ ├── RunTestsTests.cs.meta │ │ │ │ │ ├── UIDocumentSerializationTests.cs │ │ │ │ │ ├── UIDocumentSerializationTests.cs.meta │ │ │ │ │ ├── UnityReflectTests.cs │ │ │ │ │ └── UnityReflectTests.cs.meta │ │ │ │ ├── Tools.meta │ │ │ │ ├── Windows/ │ │ │ │ │ ├── Characterization/ │ │ │ │ │ │ ├── Windows_Characterization.cs │ │ │ │ │ │ └── Windows_Characterization.cs.meta │ │ │ │ │ └── Characterization.meta │ │ │ │ └── Windows.meta │ │ │ ├── EditMode.meta │ │ │ ├── Editor.meta │ │ │ ├── PlayMode/ │ │ │ │ ├── MCPForUnityTests.PlayMode.asmdef │ │ │ │ ├── MCPForUnityTests.PlayMode.asmdef.meta │ │ │ │ ├── PlayModeBasicTests.cs │ │ │ │ └── PlayModeBasicTests.cs.meta │ │ │ └── PlayMode.meta │ │ ├── Tests.meta │ │ ├── UI Toolkit/ │ │ │ ├── UnityThemes/ │ │ │ │ ├── UnityDefaultRuntimeTheme.tss │ │ │ │ └── UnityDefaultRuntimeTheme.tss.meta │ │ │ └── UnityThemes.meta │ │ └── UI Toolkit.meta │ ├── Packages/ │ │ └── manifest.json │ └── ProjectSettings/ │ ├── Packages/ │ │ └── com.unity.testtools.codecoverage/ │ │ └── Settings.json │ ├── ProjectVersion.txt │ └── SceneTemplateSettings.json ├── docker-compose.yml ├── docs/ │ ├── development/ │ │ ├── README-DEV-zh.md │ │ └── README-DEV.md │ ├── feature-roadmap-2026.md │ ├── guides/ │ │ ├── CLI_EXAMPLE.md │ │ ├── CLI_USAGE.md │ │ ├── CURSOR_HELP.md │ │ ├── MCP_CLIENT_CONFIGURATORS.md │ │ ├── RELEASING.md │ │ └── REMOTE_SERVER_AUTH.md │ ├── i18n/ │ │ └── README-zh.md │ ├── migrations/ │ │ ├── v5_MIGRATION.md │ │ ├── v6_NEW_UI_CHANGES.md │ │ └── v8_NEW_NETWORKING_SETUP.md │ └── reference/ │ ├── CUSTOM_TOOLS.md │ ├── REMOTE_SERVER_AUTH_ARCHITECTURE.md │ └── TELEMETRY.md ├── manifest.json ├── mcp_source.py ├── scripts/ │ └── validate-nlt-coverage.sh ├── tools/ │ ├── UPDATE_DOCS_PROMPT.md │ ├── docker_publish.sh │ ├── generate_mcpb.py │ ├── prepare_unity_asset_store_release.py │ ├── pypi_publish.sh │ ├── stress_editor_state.py │ ├── stress_mcp.py │ ├── tests/ │ │ ├── __init__.py │ │ └── test_build_release_characterization.py │ ├── update_fork.bat │ ├── update_fork.sh │ └── update_versions.py └── unity-mcp-skill/ ├── SKILL.md └── references/ ├── probuilder-guide.md ├── resources-reference.md ├── tools-reference.md └── workflows.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ Server/build .git .venv __pycache__ *.pyc .DS_Store ================================================ FILE: .github/actions/publish-docker/action.yml ================================================ 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 ================================================ FILE: .github/actions/publish-pypi/action.yml ================================================ 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/ ================================================ FILE: .github/pull_request_template.md ================================================ ## Description ## Type of Change Save your change type - 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 ## Testing/Screenshots/Recordings ## 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 updates following the guide at `tools/UPDATE_DOCS.md` ## Related Issues ## Additional Notes ================================================ FILE: .github/scripts/mark_skipped.py ================================================ #!/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)) ================================================ FILE: .github/workflows/beta-release.yml ================================================ name: Beta Release (PyPI Pre-release) concurrency: group: beta-release cancel-in-progress: true on: push: branches: - beta paths: - "Server/**" - "MCPForUnity/**" jobs: update_unity_beta_version: name: Update Unity package to beta version runs-on: ubuntu-latest # 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 # 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/ ================================================ FILE: .github/workflows/claude-nl-suite.yml ================================================ 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 }} ================================================ FILE: .github/workflows/github-repo-stats.yml ================================================ 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 }} ================================================ FILE: .github/workflows/python-tests.yml ================================================ name: Python Tests on: push: branches: ["**"] paths: - Server/** - .github/workflows/python-tests.yml workflow_dispatch: {} jobs: test: name: Run Python Tests runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - 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: 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/ ================================================ FILE: .github/workflows/release.yml ================================================ 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: bump: name: Bump version, tag, and create release runs-on: ubuntu-latest 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 ================================================ FILE: .github/workflows/unity-tests.yml ================================================ name: Unity Tests on: workflow_dispatch: {} push: branches: ["**"] paths: - TestProjects/UnityMCPTests/** - MCPForUnity/Editor/** - .github/workflows/unity-tests.yml jobs: testAllModes: name: Test in ${{ matrix.testMode }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: projectPath: - TestProjects/UnityMCPTests testMode: - editmode unityVersion: - 2021.3.45f2 steps: - name: Checkout repository uses: actions/checkout@v4 with: lfs: true - 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 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 }} - uses: actions/upload-artifact@v4 if: always() && steps.detect.outputs.unity_ok == 'true' with: name: Test results for ${{ matrix.testMode }} path: ${{ steps.tests.outputs.artifactsPath }} ================================================ FILE: .gitignore ================================================ # AI-related files (user-specific) .cursorrules .cursorignore .windsurf .codeiumignore .kiro # Code-copy related files .clipignore # Python-generated files __pycache__/ __pycache__.meta 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 # Ignore the .claude directory, since it might contain local/project-level setting such as deny and allowlist. /.claude ================================================ FILE: .mcpbignore ================================================ # 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 ================================================ FILE: CLAUDE.md ================================================ # 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 ) 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); ``` ## 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 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 ================================================ FILE: CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs ================================================ #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; } } } ================================================ FILE: CustomTools/RoslynRuntimeCompilation/ManageRuntimeCompilation.cs.meta ================================================ fileFormatVersion: 2 guid: 1c3b2419382faa04481f4a631c510ee6 ================================================ FILE: CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs ================================================ // 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 ================================================ FILE: CustomTools/RoslynRuntimeCompilation/RoslynRuntimeCompiler.cs.meta ================================================ fileFormatVersion: 2 guid: 97f1198c66ce56043a3c8a5e05ba0150 ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: MCPForUnity/Editor/AssemblyInfo.cs ================================================ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] ================================================ FILE: MCPForUnity/Editor/AssemblyInfo.cs.meta ================================================ fileFormatVersion: 2 guid: be61633e00d934610ac1ff8192ffbe3d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs ================================================ 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 AntigravityConfigurator : JsonFileMcpConfigurator { public AntigravityConfigurator() : base(new McpClient { name = "Antigravity", windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), HttpUrlProperty = "serverUrl", DefaultUnityFields = { { "disabled", false } }, StripEnvWhenNotRequired = true }) { } public override IList GetInstallationSteps() => new List { "Open Antigravity", "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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 331b33961513042e3945d0a1d06615b5 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs ================================================ 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"; } } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 6de06c6bb0399154d840a1e4c84be869 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs ================================================ 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'" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: d0d22681fc594475db1c189f2d9abdf7 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs ================================================ 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 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" }; public override void Configure() { bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttp) { throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring."); } base.Configure(); } public override string GetManualSnippet() { bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (useHttp) { return "# Claude Desktop does not support HTTP transport.\n" + "# In Connect tab, change the Transport option from HTTP to stdio, then regenerate."; } return base.GetManualSnippet(); } } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: d5e5d87c9db57495f842dc366f1ebd65 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs ================================================ 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"), 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/ClineConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 6b8abf0951c7413d9ff97a053b0adf2d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 923728a98c8c74cfaa6e9203c408f34e MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: c7037ef8b168e49f79247cb31c3be75a MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 14a4b9a7f749248d496466c2a3a53e56 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: b708eda314746481fb8f4a1fb0652b03 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs ================================================ 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'" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: c5e9bbb45e552453ab5cb557a22d43e7 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: AssetOrigin: serializedVersion: 1 productId: 329908 packageName: MCP for Unity | AI Driven Development packageVersion: 9.0.3 assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/GeminiCliConfigurator.cs uploadId: 855486 ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs ================================================ 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", windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), IsVsCodeLayout = false }) { } public override IList GetInstallationSteps() => new List { "Install Kilo Code extension in VS Code", "Open Kilo Code settings (gear icon in sidebar)", "Navigate to MCP Servers section and click 'Edit Global MCP Settings'\nOR open the config file at the path above", "Paste the configuration JSON into the mcpServers object", "Save and restart VS Code" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 3286d62ffe5644f5ea60488fd7e6513d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: e9b73ff071a6043dda1f2ec7d682ef71 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs ================================================ 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"; 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; } string configuredUrl = unityMcp["url"]?.ToString(); string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); if (UrlsEqual(configuredUrl, expectedUrl)) { 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() => new JObject { ["type"] = "remote", ["url"] = HttpEndpointUtility.GetMcpRpcUrl(), ["enabled"] = true }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 489f99ffb7e6743e88e3203552c8b37b MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/QwenCodeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 46891bcdb00e468cbd04afbfb8f3095e MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 2511b0d05271d486bb61f8cc9fd11363 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: b3ab39e22ae0948ab94beae307f9902e MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: bcc7ead475a4d4ea2978151c217757b8 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs ================================================ 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" }; } } ================================================ FILE: MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: b528971e189f141d38db577f155bd222 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/Configurators.meta ================================================ fileFormatVersion: 2 guid: 59ff83375c2c74c8385c4a22549778dd folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs ================================================ 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; } /// 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.). void Configure(); /// 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(); } } ================================================ FILE: MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta ================================================ fileFormatVersion: 2 guid: f5a5078d9e6e14027a1abfebf4018634 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs ================================================ 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; 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(); 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 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 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; if (client.IsVsCodeLayout) { var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; if (vsConfig != null) { var unityToken = vsConfig["servers"]?["unityMCP"] ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; if (unityToken is JObject unityObj) { configExists = true; var argsToken = unityObj["args"]; if (argsToken is JArray) { args = argsToken.ToObject(); } var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; if (urlToken != null && urlToken.Type != JTokenType.Null) { configuredUrl = urlToken.ToString(); } } } } else { McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { args = standardConfig.mcpServers.unityMCP.args; configuredUrl = standardConfig.mcpServers.unityMCP.url; configExists = true; } } 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() { 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 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 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"; /// /// 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(); } private 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; } } } ================================================ FILE: MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta ================================================ fileFormatVersion: 2 guid: 8d408fd7733cb4a1eb80f785307db2ff MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients/McpClientRegistry.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta ================================================ fileFormatVersion: 2 guid: 4ce08555f995e4e848a826c63f18cb35 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Clients.meta ================================================ fileFormatVersion: 2 guid: c9d47f01d06964ee7843765d1bd71205 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Constants/AuthConstants.cs ================================================ namespace MCPForUnity.Editor.Constants { /// /// Protocol-level constants for API key authentication. /// internal static class AuthConstants { internal const string ApiKeyHeader = "X-API-Key"; } } ================================================ FILE: MCPForUnity/Editor/Constants/AuthConstants.cs.meta ================================================ fileFormatVersion: 2 guid: 96844bc39e9a94cf18b18f8127f3854f MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Constants/EditorPrefKeys.cs ================================================ 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 ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload"; 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 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 BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands"; internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled"; } } ================================================ FILE: MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta ================================================ fileFormatVersion: 2 guid: 7317786cfb9304b0db20ca73a774b9fa MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Constants/HealthStatus.cs ================================================ 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"; } } ================================================ FILE: MCPForUnity/Editor/Constants/HealthStatus.cs.meta ================================================ fileFormatVersion: 2 guid: c15ed2426f43860479f1b8a99a343d16 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Constants.meta ================================================ fileFormatVersion: 2 guid: f7e009cbf3e74f6c987331c2b438ec59 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/DependencyManager.cs ================================================ 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."); } } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta ================================================ fileFormatVersion: 2 guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs ================================================ 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; } } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta ================================================ fileFormatVersion: 2 guid: f6df82faa423f4e9ebb13a3dcee8ba19 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs ================================================ 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}"; } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta ================================================ fileFormatVersion: 2 guid: ddeeeca2f876f4083a84417404175199 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/Models.meta ================================================ fileFormatVersion: 2 guid: 4c0f2e87395b4c6c9df8c21b6d0fae13 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs ================================================ 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(); } } ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta ================================================ fileFormatVersion: 2 guid: 67d73d0e8caef4e60942f4419c6b76bf MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs ================================================ 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); } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta ================================================ fileFormatVersion: 2 guid: b682b492eb80d4ed6834b76f72c9f0f3 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs ================================================ 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); } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta ================================================ fileFormatVersion: 2 guid: c6f602b0a8ca848859197f9a949a7a5d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs ================================================ 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); } } ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta ================================================ fileFormatVersion: 2 guid: 44d715aedea2b8b41bf914433bbb2c49 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs ================================================ 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(); } } } ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta ================================================ fileFormatVersion: 2 guid: 1aedc29caa5704c07b487d20a27e9334 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies/PlatformDetectors.meta ================================================ fileFormatVersion: 2 guid: bdbaced669d14798a4ceeebfbff2b22c folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Dependencies.meta ================================================ fileFormatVersion: 2 guid: 221a4d6e595be6897a5b17b77aedd4d0 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/External/Tommy.cs ================================================ #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 } ================================================ FILE: MCPForUnity/Editor/External/Tommy.cs.meta ================================================ fileFormatVersion: 2 guid: ea652131dcdaa44ca8cb35cd1191be3f MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/External.meta ================================================ fileFormatVersion: 2 guid: c11944bcfb9ec4576bab52874b7df584 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/AssetPathUtility.cs ================================================ 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.IndexOf("/Editor/", StringComparison.Ordinal); if (editorIndex >= 0) { return scriptPath.Substring(0, editorIndex); } McpLog.Warn($"Could not determine package root from script path: {scriptPath}"); return null; } catch (Exception ex) { McpLog.Error($"Failed to get package root path: {ex.Message}"); return null; } } /// /// 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); } } } ================================================ FILE: MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta ================================================ fileFormatVersion: 2 guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/CodexConfigHelper.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta ================================================ fileFormatVersion: 2 guid: b3e68082ffc0b4cd39d3747673a4cc22 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ComponentOps.cs ================================================ using System; using System.Collections.Generic; using System.Reflection; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityEngine.Events; 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; } } private 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); foreach (var asset in allAssets) { if (asset is Sprite sprite && sprite.name == spriteName) { prop.objectReferenceValue = sprite; return true; } } 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); foreach (var asset in allAssets) { if (asset is Sprite sprite) { long spriteFileId = GetSpriteFileId(sprite); if (spriteFileId == targetFileId) { prop.objectReferenceValue = sprite; return true; } } } } 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); } // 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; // 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.GetInstanceID()}): {ex.Message}"); return 0; } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ComponentOps.cs.meta ================================================ fileFormatVersion: 2 guid: 13dead161bc4540eeb771961df437779 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs ================================================ 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; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); 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; JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); 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; bool isCline = client?.name == "Cline"; 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"); } // Cline expects streamableHttp for HTTP endpoints. if (isCline) { unity["type"] = "streamableHttp"; } else { // "type" is standard MCP protocol; include for all clients to avoid // clients that default to SSE when they see a URL without a type field. unity["type"] = "http"; } } 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. unity["type"] = "stdio"; } 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 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta ================================================ fileFormatVersion: 2 guid: 5c07c3369f73943919d9e086a81d1dcc MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs ================================================ 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 { private const string ScreenshotsFolderName = "Screenshots"; // 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 CaptureSceneViewViewportToAssets( SceneView sceneView, string fileName, int superSize, bool ensureUniqueFileName, bool includeImage, int maxResolution, out int viewportWidth, out int viewportHeight) { 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); 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.AssetsRelativePath, 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) { int size = Mathf.Max(1, superSize); string resolvedName = BuildFileName(fileName); string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); Directory.CreateDirectory(folder); string fullPath = Path.Combine(folder, resolvedName); if (ensureUniqueFileName) { fullPath = EnsureUnique(fullPath); } string normalizedFullPath = fullPath.Replace('\\', '/'); string assetsRelativePath = "Assets/" + normalizedFullPath.Substring(Application.dataPath.Length).TrimStart('/'); return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, 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); } } } ================================================ FILE: MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta ================================================ fileFormatVersion: 2 guid: b73350febfd6534436726d19b4d270fd MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ExecPath.cs ================================================ 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"), }; 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"), // 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"), }; 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 } } ================================================ FILE: MCPForUnity/Editor/Helpers/ExecPath.cs.meta ================================================ fileFormatVersion: 2 guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/GameObjectLookup.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; 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) { #if UNITY_6000_3_OR_NEWER return EditorUtility.EntityIdToObject(instanceId); #else return EditorUtility.InstanceIDToObject(instanceId); #endif } /// /// 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.GetInstanceID()); } 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.GetInstanceID(); } } 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.GetInstanceID(); } } } else { // Use GameObject.Find for active objects only (Unity API limitation) var found = GameObject.Find(path); if (found != null) { yield return found.GetInstanceID(); } } } 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.GetInstanceID(); } } 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.GetInstanceID(); } } 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.GetInstanceID(); 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta ================================================ fileFormatVersion: 2 guid: 4964205faa8dd4f8a960e58fd8c0d4f7 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/GameObjectSerializer.cs ================================================ 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; 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.GetInstanceID(), 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.GetInstanceID() ?? 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; } /// /// 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.GetInstanceID() } }; 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?.GetInstanceID() ?? 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.GetInstanceID()})"); return new Dictionary { { "typeName", componentType.FullName }, { "instanceID", tr.GetInstanceID() }, // 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.GetInstanceID() ?? 0 }, { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, { "childCount", tr.childCount }, // Include standard Object/Component properties { "name", tr.name }, { "tag", tr.tag }, { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 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.GetInstanceID() } } }; 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.GetInstanceID() }, { "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.GetInstanceID() }, { "properties", uiDocProperties } }; } // --- End Special handling for UIDocument --- var data = new Dictionary { { "typeName", componentType.FullName }, { "instanceID", c.GetInstanceID() } }; // --- 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; // 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 // 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 } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta ================================================ fileFormatVersion: 2 guid: 64b8ff807bc9a401c82015cbafccffac MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs ================================================ 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]; } return trimmed; } private static string AppendPathSegment(string baseUrl, string segment) { return $"{baseUrl.TrimEnd('/')}/{segment}"; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta ================================================ fileFormatVersion: 2 guid: 2051d90316ea345c09240c80c7138e3b MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/MaterialOps.cs ================================================ 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; } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/MaterialOps.cs.meta ================================================ fileFormatVersion: 2 guid: a59e8545e32664dae9a696d449f82c3d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs ================================================ 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 { } } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta ================================================ fileFormatVersion: 2 guid: e45ac2a13b4c1ba468b8e3aa67b292ca MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/McpJobStateStore.cs ================================================ 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); } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta ================================================ fileFormatVersion: 2 guid: 28912085dd68342f8a9fda8a43c83a59 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/McpLog.cs ================================================ 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}"); } } } ================================================ FILE: MCPForUnity/Editor/Helpers/McpLog.cs.meta ================================================ fileFormatVersion: 2 guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/McpLogRecord.cs ================================================ 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 LogPath = Path.Combine(Application.dataPath, "mcp.log"); private static readonly string ErrorLogPath = Path.Combine(Application.dataPath, "mcpError.log"); private const long MaxLogSizeBytes = 1024 * 1024; // 1 MB private static bool _sessionStarted; private static readonly object _logLock = new(); internal static bool IsEnabled { get => EditorPrefs.GetBool(EditorPrefKeys.LogRecordEnabled, false); set => EditorPrefs.SetBool(EditorPrefKeys.LogRecordEnabled, 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) { 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 } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/McpLogRecord.cs.meta ================================================ fileFormatVersion: 2 guid: 925ef3d40ecf53649a6af9e94df6114b ================================================ FILE: MCPForUnity/Editor/Helpers/ObjectResolver.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta ================================================ fileFormatVersion: 2 guid: ad678f7b0a2e6458bbdb38a15d857acf MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/Pagination.cs ================================================ 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 }; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/Pagination.cs.meta ================================================ fileFormatVersion: 2 guid: 745564d5894d74c0ca24db39c77bab2c MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ParamCoercion.cs ================================================ 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(); } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta ================================================ fileFormatVersion: 2 guid: db54fbbe3ac7f429fbf808f72831374a MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/PortManager.cs ================================================ 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; } /// /// 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"; } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/PortManager.cs.meta ================================================ fileFormatVersion: 2 guid: 28c39813a10b4331afc764a04089cbef MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta ================================================ fileFormatVersion: 2 guid: ebe2be77e64f4d4f811614b198210017 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs ================================================ 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"; } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta ================================================ fileFormatVersion: 2 guid: 936e878ce1275453bae5e0cf03bd9d30 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/PropertyConversion.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta ================================================ fileFormatVersion: 2 guid: 4b4187d5b338a453fbe0baceaeea6bcd MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta ================================================ fileFormatVersion: 2 guid: 5a0a1cfd55ab4bc99c74c52854f6bdf3 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/RendererHelpers.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta ================================================ fileFormatVersion: 2 guid: 8f3a7e2d5c1b4a9e6d0f8c3b2a1e5d7c MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/Response.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/Response.cs.meta ================================================ fileFormatVersion: 2 guid: 80c09a76b944f8c4691e06c4d76c4be8 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/StringCaseUtility.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta ================================================ fileFormatVersion: 2 guid: f22b312318ade42c4bb6b5dfddacecfa MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/TelemetryHelper.cs ================================================ 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; } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta ================================================ fileFormatVersion: 2 guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/TextureOps.cs ================================================ 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}"); } } } } } ================================================ FILE: MCPForUnity/Editor/Helpers/TextureOps.cs.meta ================================================ fileFormatVersion: 2 guid: 864ea682d797466a84b6b951f6c4e4ba MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/ToolParams.cs ================================================ 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); } } } ================================================ FILE: MCPForUnity/Editor/Helpers/ToolParams.cs.meta ================================================ fileFormatVersion: 2 guid: 404b09ea3e2714e1babd16f5705ac788 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs ================================================ 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() } }); } } ================================================ FILE: MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta ================================================ fileFormatVersion: 2 guid: 24d94c9c030bd4ff1ab208c748f26b01 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/UnityTypeResolver.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; 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 = 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 = AppDomain.CurrentDomain.GetAssemblies(); #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 candidates; } 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."; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta ================================================ fileFormatVersion: 2 guid: 2cdf06f869b124741af31f27b25742db MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers/VectorParsing.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Helpers/VectorParsing.cs.meta ================================================ fileFormatVersion: 2 guid: ca2205caede3744aebda9f6da2fa2c22 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Helpers.meta ================================================ fileFormatVersion: 2 guid: 94cb070dc5e15024da86150b27699ca0 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/MCPForUnity.Editor.asmdef ================================================ { "name": "MCPForUnity.Editor", "rootNamespace": "MCPForUnity.Editor", "references": [ "MCPForUnity.Runtime", "Newtonsoft.Json" ], "includePlatforms": [ "Editor" ], "excludePlatforms": [], "overrideReferences": false, "precompiledReferences": [ "Newtonsoft.Json.dll" ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], "noEngineReferences": false } ================================================ FILE: MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta ================================================ fileFormatVersion: 2 guid: 98f702da6ca044be59a864a9419c4eab AssemblyDefinitionImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/McpCiBoot.cs ================================================ 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(); } } } ================================================ FILE: MCPForUnity/Editor/McpCiBoot.cs.meta ================================================ fileFormatVersion: 2 guid: ef9dca277ab34ba1b136d8dcd45de948 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs ================================================ using MCPForUnity.Editor.Setup; using MCPForUnity.Editor.Windows; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.MenuItems { public static class MCPForUnityMenu { [MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 1)] public static void ToggleMCPWindow() { if (MCPForUnityEditorWindow.HasAnyOpenWindow()) { MCPForUnityEditorWindow.CloseAllOpenWindows(); } else { MCPForUnityEditorWindow.ShowWindow(); } } [MenuItem("Window/MCP For Unity/Local Setup Window", priority = 2)] public static void ShowSetupWindow() { SetupWindowService.ShowSetupWindow(); } [MenuItem("Window/MCP For Unity/Edit EditorPrefs", priority = 3)] public static void ShowEditorPrefsWindow() { EditorPrefsWindow.ShowWindow(); } } } ================================================ FILE: MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta ================================================ fileFormatVersion: 2 guid: 42b27c415aa084fe6a9cc6cf03979d36 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/MenuItems.meta ================================================ fileFormatVersion: 2 guid: 9e7f37616736f4d3cbd8bdbc626f5ab9 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta ================================================ fileFormatVersion: 2 guid: 4436b2149abf4b0d8014f81cd29a2bd0 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs ================================================ 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; } } } } ================================================ FILE: MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta ================================================ fileFormatVersion: 2 guid: f1d589c8c8684e6f919ffb393c4b4db5 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Migrations.meta ================================================ fileFormatVersion: 2 guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/Command.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Models/Command.cs.meta ================================================ fileFormatVersion: 2 guid: 6754c84e5deb74749bc3a19e0c9aa280 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/MCPConfigServer.cs ================================================ 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; } } ================================================ FILE: MCPForUnity/Editor/Models/MCPConfigServer.cs.meta ================================================ fileFormatVersion: 2 guid: 5fae9d995f514e9498e9613e2cdbeca9 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/MCPConfigServers.cs ================================================ using System; using Newtonsoft.Json; namespace MCPForUnity.Editor.Models { [Serializable] public class McpConfigServers { [JsonProperty("unityMCP")] public McpConfigServer unityMCP; } } ================================================ FILE: MCPForUnity/Editor/Models/MCPConfigServers.cs.meta ================================================ fileFormatVersion: 2 guid: bcb583553e8173b49be71a5c43bd9502 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/McpClient.cs ================================================ 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 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(); } } } } ================================================ FILE: MCPForUnity/Editor/Models/McpClient.cs.meta ================================================ fileFormatVersion: 2 guid: b1afa56984aec0d41808edcebf805e6a MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/McpConfig.cs ================================================ using System; using Newtonsoft.Json; namespace MCPForUnity.Editor.Models { [Serializable] public class McpConfig { [JsonProperty("mcpServers")] public McpConfigServers mcpServers; } } ================================================ FILE: MCPForUnity/Editor/Models/McpConfig.cs.meta ================================================ fileFormatVersion: 2 guid: c17c09908f0c1524daa8b6957ce1f7f5 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models/McpStatus.cs ================================================ 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 } } ================================================ FILE: MCPForUnity/Editor/Models/McpStatus.cs.meta ================================================ fileFormatVersion: 2 guid: aa63057c9e5282d4887352578bf49971 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Models.meta ================================================ fileFormatVersion: 2 guid: 16d3ab36890b6c14f9afeabee30e03e3 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor/ActiveTool.cs ================================================ 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(); } } } ================================================ FILE: MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta ================================================ fileFormatVersion: 2 guid: 6e78b6227ab7742a8a4f679ee6a8a212 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor/EditorState.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta ================================================ fileFormatVersion: 2 guid: f7c6df54e014c44fdb0cd3f65a479e37 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor/Selection.cs ================================================ using System; using System.Linq; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; 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?.GetInstanceID() ?? 0, count = UnityEditor.Selection.count, objects = UnityEditor.Selection.objects .Select(obj => new { name = obj?.name, type = obj?.GetType().FullName, instanceID = obj?.GetInstanceID() }) .ToList(), gameObjects = UnityEditor.Selection.gameObjects .Select(go => new { name = go?.name, instanceID = go?.GetInstanceID() }) .ToList(), assetGUIDs = UnityEditor.Selection.assetGUIDs }; return new SuccessResponse("Retrieved current selection details.", selectionInfo); } catch (Exception e) { return new ErrorResponse($"Error getting selection: {e.Message}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Editor/Selection.cs.meta ================================================ fileFormatVersion: 2 guid: c7ea869623e094599a70be086ab4fc0e MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor/ToolStates.cs ================================================ 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) { toolsArray.Add(new JObject { ["name"] = tool.Name, ["group"] = tool.Group ?? "core", ["enabled"] = discovery.IsToolEnabled(tool.Name) }); } 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Editor/ToolStates.cs.meta ================================================ fileFormatVersion: 2 guid: 0f77d36b37ba4526ad30b3c84e3e752c MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor/Windows.cs ================================================ using System; using System.Collections.Generic; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; 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.GetInstanceID() }); } 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Editor/Windows.cs.meta ================================================ fileFormatVersion: 2 guid: 58a341e64bea440b29deaf859aaea552 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Editor.meta ================================================ fileFormatVersion: 2 guid: 266967ec2e1df44209bf46ec6037d61d folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta ================================================ fileFormatVersion: 2 guid: 4c2d60f570f3d4bd2a6a2c1293094be3 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs ================================================ 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(); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta ================================================ fileFormatVersion: 2 guid: 04eeea61eb5c24033a88013845d25f23 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/MenuItems.meta ================================================ fileFormatVersion: 2 guid: bca79cd3ef8ed466f9e50e2dc7850e46 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Project/Layers.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Project/Layers.cs.meta ================================================ fileFormatVersion: 2 guid: 959ee428299454ac19a636275208ca00 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Project/ProjectInfo.cs ================================================ 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 = MCPForUnity.Runtime.Helpers.ScreenshotUtility.IsScreenCaptureModuleAvailable, } }; 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; } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta ================================================ fileFormatVersion: 2 guid: 81b03415fcf93466e9ed667d19b58d43 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Project/Tags.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Project/Tags.cs.meta ================================================ fileFormatVersion: 2 guid: 2179ac5d98f264d1681e7d5c0d0ed341 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Project.meta ================================================ fileFormatVersion: 2 guid: 538489f13d7914c4eba9a67e29001b43 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene/CamerasResource.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta ================================================ fileFormatVersion: 2 guid: 68c487cd2b284b09bcdce22f76127e95 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; 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.GetInstanceID()); } return new { instanceID = go.GetInstanceID(), 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.GetInstanceID() : (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.GetInstanceID() }); } } 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta ================================================ fileFormatVersion: 2 guid: 5ee79050d9f6d42798a0757cc7672517 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta ================================================ fileFormatVersion: 2 guid: 91dffec0c5224fca9ea78f7d92bfc569 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta ================================================ fileFormatVersion: 2 guid: a6c0a7ee8d9443a9aec534f04dbee225 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene/VolumesResource.cs ================================================ 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}"); } } } } ================================================ FILE: MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta ================================================ fileFormatVersion: 2 guid: 83cc61dc0e644cf2abd24ad611aa315c MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Scene.meta ================================================ fileFormatVersion: 2 guid: 563f6050485b445449a1db200bfba51c folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Tests/GetTests.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta ================================================ fileFormatVersion: 2 guid: 84183aaed077e4f25968269c952db2d7 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources/Tests.meta ================================================ fileFormatVersion: 2 guid: 412726d2e774048939b0d2bd4f11a503 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Resources.meta ================================================ fileFormatVersion: 2 guid: a6f5bafffbb0f48c2a33ad9470bb1e2d folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/BridgeControlService.cs ================================================ 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); } } } ================================================ FILE: MCPForUnity/Editor/Services/BridgeControlService.cs.meta ================================================ fileFormatVersion: 2 guid: ed4f9f69d84a945248dafc0f0b5a62dd MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/ClientConfigurationService.cs ================================================ 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(); } configurator.Configure(); } 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) { try { // Always re-run configuration so core fields stay current configurator.CheckStatus(attemptAutoRewrite: false); configurator.Configure(); summary.SuccessCount++; summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully"); } catch (Exception ex) { summary.FailureCount++; summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}"); } } return summary; } public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true) { var previous = configurator.Status; var current = configurator.CheckStatus(attemptAutoRewrite); return current != previous; } } } ================================================ FILE: MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta ================================================ fileFormatVersion: 2 guid: 76cad34d10fd24aaa95c4583c1f88fdf MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/EditorConfigurationCache.cs ================================================ 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); } } } ================================================ FILE: MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta ================================================ fileFormatVersion: 2 guid: b4a183ac9b63c408886bce40ae58f462 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/EditorPrefsWindowService.cs ================================================ 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(); } } } ================================================ FILE: MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta ================================================ fileFormatVersion: 2 guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/EditorStateCache.cs ================================================ 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"); 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; } } /// /// Returns the actual compilation state, working around a known Unity quirk where /// EditorApplication.isCompiling can return false positives in Play mode. /// See: https://github.com/CoplayDev/unity-mcp/issues/549 /// private static bool GetActualIsCompiling() { // If EditorApplication.isCompiling is false, Unity is definitely not compiling if (!EditorApplication.isCompiling) { return false; } // In Play mode, EditorApplication.isCompiling can have false positives. // Double-check with CompilationPipeline.isCompiling via reflection. if (EditorApplication.isPlaying) { try { Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", BindingFlags.Public | BindingFlags.Static); if (prop != null) { return (bool)prop.GetValue(null); } } catch { // If reflection fails, fall back to EditorApplication.isCompiling } } // Outside Play mode or if reflection failed, trust EditorApplication.isCompiling return true; } } } ================================================ FILE: MCPForUnity/Editor/Services/EditorStateCache.cs.meta ================================================ fileFormatVersion: 2 guid: aa7909967ce3c48c493181c978782a54 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/HttpAutoStartHandler.cs ================================================ 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 { private const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized"; static HttpAutoStartHandler() { // SessionState resets on editor process start but persists across domain reloads. // Only run once per session — let HttpBridgeReloadHandler handle reload-resume cases. if (SessionState.GetBool(SessionInitKey, false)) return; if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } // Only check lightweight EditorPrefs here — services like EditorConfigurationCache // and MCPServiceLocator may not be initialized yet on fresh editor launch. bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false); if (!autoStartEnabled) return; SessionState.SetBool(SessionInitKey, true); // Delay to let the editor and services finish initialization. EditorApplication.delayCall += OnEditorReady; } private static void OnEditorReady() { try { bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false); if (!autoStartEnabled) return; bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; if (!useHttp) return; // Don't auto-start if bridge is already running. if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return; _ = AutoStartAsync(); } catch (Exception ex) { McpLog.Debug($"[HTTP Auto-Start] Deferred check failed: {ex.Message}"); } } private static async Task AutoStartAsync() { 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}"); } } /// /// Waits for the local HTTP server to accept connections, then connects the bridge. /// Mirrors TryAutoStartSessionAsync in McpConnectionSection. /// private static async Task WaitForServerAndConnectAsync() { const int maxAttempts = 30; var shortDelay = TimeSpan.FromMilliseconds(500); var longDelay = TimeSpan.FromSeconds(3); for (int attempt = 0; attempt < maxAttempts; attempt++) { // 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; bool reachable = MCPServiceLocator.Server.IsLocalHttpServerReachable(); if (reachable) { bool started = await MCPServiceLocator.Bridge.StartAsync(); if (started) { McpLog.Info("[HTTP Auto-Start] Bridge started successfully"); MCPForUnityEditorWindow.RequestHealthVerification(); return; } } else if (attempt >= 20 && (attempt - 20) % 3 == 0) { // Last-resort: try connecting even if not detected (process detection may fail). bool started = await MCPServiceLocator.Bridge.StartAsync(); if (started) { McpLog.Info("[HTTP Auto-Start] Bridge started successfully (late connect)"); MCPForUnityEditorWindow.RequestHealthVerification(); return; } } var delay = attempt < 6 ? shortDelay : longDelay; try { await Task.Delay(delay); } catch { return; } } McpLog.Warn("[HTTP Auto-Start] Server did not become reachable after launch"); } /// /// Connects the bridge directly (for remote HTTP where the server is already running). /// private static async Task ConnectBridgeAsync() { bool started = await MCPServiceLocator.Bridge.StartAsync(); if (started) { McpLog.Info("[HTTP Auto-Start] Bridge started successfully (remote)"); MCPForUnityEditorWindow.RequestHealthVerification(); } else { McpLog.Warn("[HTTP Auto-Start] Failed to connect to remote HTTP server"); } } } } ================================================ FILE: MCPForUnity/Editor/Services/HttpAutoStartHandler.cs.meta ================================================ fileFormatVersion: 2 guid: 3d8f1790992fe0742938d8a879056ee6 ================================================ FILE: MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs ================================================ 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; namespace MCPForUnity.Editor.Services { /// /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge. /// [InitializeOnLoad] internal static class HttpBridgeReloadHandler { private static readonly TimeSpan[] ResumeRetrySchedule = { TimeSpan.Zero, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) }; static HttpBridgeReloadHandler() { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } private static void OnBeforeAssemblyReload() { try { var transport = MCPServiceLocator.TransportManager; bool shouldResume = transport.IsRunning(TransportMode.Http); if (shouldResume) { EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true); } else { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } if (shouldResume) { // 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); } } catch (Exception ex) { McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}"); } } private static void OnAfterAssemblyReload() { bool resume = false; try { // Only resume HTTP if it is still the selected transport. bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; resume = useHttp && EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false); if (resume) { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); } } catch (Exception ex) { McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}"); resume = false; } if (!resume) { return; } // If the editor is not compiling, attempt an immediate restart without relying on editor focus. bool isCompiling = EditorApplication.isCompiling; try { var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (prop != null) isCompiling |= (bool)prop.GetValue(null); } catch { } if (!isCompiling) { _ = ResumeHttpWithRetriesAsync(); return; } // Fallback when compiling: schedule on the editor loop EditorApplication.delayCall += () => { _ = ResumeHttpWithRetriesAsync(); }; } private static async Task ResumeHttpWithRetriesAsync() { Exception lastException = null; for (int i = 0; i < ResumeRetrySchedule.Length; i++) { int attempt = i + 1; McpLog.Debug($"[HTTP Reload] Resume attempt {attempt}/{ResumeRetrySchedule.Length}"); TimeSpan delay = ResumeRetrySchedule[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; } } // Abort retries if the user switched transports while we were waiting. if (!EditorConfigurationCache.Instance.UseHttpTransport) { return; } try { bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); if (started) { 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}"); } } 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"); } } } } ================================================ FILE: MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta ================================================ fileFormatVersion: 2 guid: 4c0cf970a7b494a659be151dc0124296 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IBridgeControlService.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Services/IBridgeControlService.cs.meta ================================================ fileFormatVersion: 2 guid: 6b5d9f677f6f54fc59e6fe921b260c61 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IClientConfigurationService.cs ================================================ 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"; } } } ================================================ FILE: MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta ================================================ fileFormatVersion: 2 guid: aae139cfae7ac4044ac52e2658005ea1 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IPackageDeploymentService.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta ================================================ fileFormatVersion: 2 guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IPackageUpdateService.cs ================================================ 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); /// /// 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(); /// /// 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; } } } ================================================ FILE: MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta ================================================ fileFormatVersion: 2 guid: e94ae28f193184e4fb5068f62f4f00c6 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IPathResolverService.cs ================================================ 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); } } ================================================ FILE: MCPForUnity/Editor/Services/IPathResolverService.cs.meta ================================================ fileFormatVersion: 2 guid: 1e8d388be507345aeb0eaf27fbd3c022 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IPlatformService.cs ================================================ 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(); } } ================================================ FILE: MCPForUnity/Editor/Services/IPlatformService.cs.meta ================================================ fileFormatVersion: 2 guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IResourceDiscoveryService.cs ================================================ 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(); } } ================================================ FILE: MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta ================================================ fileFormatVersion: 2 guid: 7afb4739669224c74b4b4d706e6bbb49 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IServerManagementService.cs ================================================ 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 in a new terminal window. /// Stops any existing server on the port and clears the uvx cache first. /// /// When true, skip confirmation dialogs (used by auto-start). /// True if server was started successfully, false otherwise bool StartLocalHttpServer(bool quiet = false); /// /// 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(); } } ================================================ FILE: MCPForUnity/Editor/Services/IServerManagementService.cs.meta ================================================ fileFormatVersion: 2 guid: d41bfc9780b774affa6afbffd081eb79 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/ITestRunnerService.cs ================================================ 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); } } ================================================ FILE: MCPForUnity/Editor/Services/ITestRunnerService.cs.meta ================================================ fileFormatVersion: 2 guid: d23bf32361ff444beaf3510818c94bae MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/IToolDiscoveryService.cs ================================================ 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 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(); } } ================================================ FILE: MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta ================================================ fileFormatVersion: 2 guid: 497592a93fd994b2cb9803e7c8636ff7 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/MCPServiceLocator.cs ================================================ 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; } } } ================================================ FILE: MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta ================================================ fileFormatVersion: 2 guid: 276d6a9f9a1714ead91573945de78992 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs ================================================ using System; using System.Threading.Tasks; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.Transport; using UnityEditor; 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. /// - If HTTP Local is selected, attempts to stop the local HTTP server (guarded by PID heuristics). /// [InitializeOnLoad] internal static class McpEditorShutdownCleanup { static McpEditorShutdownCleanup() { // Guard against duplicate subscriptions across domain reloads. try { EditorApplication.quitting -= OnEditorQuitting; } catch { } EditorApplication.quitting += OnEditorQuitting; } private static void OnEditorQuitting() { // 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 local HTTP server if it was Unity-managed (best-effort). try { bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; string scope = string.Empty; try { scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); } catch { } bool stopped = false; bool httpLocalSelected = useHttp && (string.Equals(scope, "local", StringComparison.OrdinalIgnoreCase) || (string.IsNullOrEmpty(scope) && MCPServiceLocator.Server.IsLocalUrl())); if (httpLocalSelected) { // StopLocalHttpServer is already guarded to only terminate processes that look like mcp-for-unity. // If it refuses to stop (e.g. URL was edited away from local), fall back to the Unity-managed stop. stopped = MCPServiceLocator.Server.StopLocalHttpServer(); } // Always attempt to stop a Unity-managed server if one exists. // This covers cases where the user switched transports (e.g. to stdio) or StopLocalHttpServer refused. if (!stopped) { MCPServiceLocator.Server.StopManagedLocalHttpServer(); } } catch (Exception ex) { McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}"); } } } } ================================================ FILE: MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta ================================================ fileFormatVersion: 2 guid: 4150c04e0907c45d7b332260911a0567 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/PackageDeploymentService.cs ================================================ 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 }; } } } ================================================ FILE: MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta ================================================ fileFormatVersion: 2 guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1 MonoImporter: externalObjects: {} serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: MCPForUnity/Editor/Services/PackageJobManager.cs ================================================ 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 }; } } } ================================================ FILE: MCPForUnity/Editor/Services/PackageJobManager.cs.meta ================================================ fileFormatVersion: 2 guid: 0c8e16aa625e01544beb2468fda53613 ================================================ FILE: MCPForUnity/Editor/Services/PackageUpdateService.cs ================================================ 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 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 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+)(?:-(?