From 7436359efddabd4a65ac06233d0623a1026fe7cc Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Sat, 12 Sep 2026 22:46:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0godot=20ai=E5=88=B0=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/godot_ai/LICENSE | 21 - addons/godot_ai/README.md | 53 - addons/godot_ai/client_configurator.gd | 1784 --------- addons/godot_ai/client_configurator.gd.uid | 1 - addons/godot_ai/clients/_atomic_write.gd | 206 - addons/godot_ai/clients/_atomic_write.gd.uid | 1 - addons/godot_ai/clients/_base.gd | 451 --- addons/godot_ai/clients/_base.gd.uid | 1 - addons/godot_ai/clients/_cli_exec.gd | 169 - addons/godot_ai/clients/_cli_exec.gd.uid | 1 - addons/godot_ai/clients/_cli_finder.gd | 181 - addons/godot_ai/clients/_cli_finder.gd.uid | 1 - addons/godot_ai/clients/_cli_strategy.gd | 372 -- addons/godot_ai/clients/_cli_strategy.gd.uid | 1 - addons/godot_ai/clients/_dsh_strategy.gd | 759 ---- addons/godot_ai/clients/_dsh_strategy.gd.uid | 1 - addons/godot_ai/clients/_json_strategy.gd | 977 ----- addons/godot_ai/clients/_json_strategy.gd.uid | 1 - addons/godot_ai/clients/_manual_command.gd | 383 -- .../godot_ai/clients/_manual_command.gd.uid | 1 - addons/godot_ai/clients/_path_template.gd | 206 - addons/godot_ai/clients/_path_template.gd.uid | 1 - addons/godot_ai/clients/_registry.gd | 156 - addons/godot_ai/clients/_registry.gd.uid | 1 - addons/godot_ai/clients/_toml_strategy.gd | 730 ---- addons/godot_ai/clients/_toml_strategy.gd.uid | 1 - addons/godot_ai/clients/_yaml_strategy.gd | 549 --- addons/godot_ai/clients/_yaml_strategy.gd.uid | 1 - addons/godot_ai/clients/antigravity.gd | 39 - addons/godot_ai/clients/antigravity.gd.uid | 1 - addons/godot_ai/clients/cherry_studio.gd | 18 - addons/godot_ai/clients/cherry_studio.gd.uid | 1 - addons/godot_ai/clients/claude_code.gd | 84 - addons/godot_ai/clients/claude_code.gd.uid | 1 - addons/godot_ai/clients/claude_desktop.gd | 38 - addons/godot_ai/clients/claude_desktop.gd.uid | 1 - addons/godot_ai/clients/cline.gd | 43 - addons/godot_ai/clients/cline.gd.uid | 1 - addons/godot_ai/clients/codex.gd | 45 - addons/godot_ai/clients/codex.gd.uid | 1 - addons/godot_ai/clients/cursor.gd | 21 - addons/godot_ai/clients/cursor.gd.uid | 1 - addons/godot_ai/clients/deepseek_harness.gd | 45 - .../godot_ai/clients/deepseek_harness.gd.uid | 1 - addons/godot_ai/clients/gemini_cli.gd | 25 - addons/godot_ai/clients/gemini_cli.gd.uid | 1 - addons/godot_ai/clients/grok.gd | 35 - addons/godot_ai/clients/grok.gd.uid | 1 - addons/godot_ai/clients/hermes.gd | 46 - addons/godot_ai/clients/hermes.gd.uid | 1 - addons/godot_ai/clients/kilo_code.gd | 38 - addons/godot_ai/clients/kilo_code.gd.uid | 1 - addons/godot_ai/clients/kimi_code.gd | 34 - addons/godot_ai/clients/kimi_code.gd.uid | 1 - addons/godot_ai/clients/kiro.gd | 23 - addons/godot_ai/clients/kiro.gd.uid | 1 - addons/godot_ai/clients/opencode.gd | 39 - addons/godot_ai/clients/opencode.gd.uid | 1 - addons/godot_ai/clients/pi.gd | 63 - addons/godot_ai/clients/pi.gd.uid | 1 - addons/godot_ai/clients/qwen_code.gd | 26 - addons/godot_ai/clients/qwen_code.gd.uid | 1 - addons/godot_ai/clients/roo_code.gd | 42 - addons/godot_ai/clients/roo_code.gd.uid | 1 - addons/godot_ai/clients/trae.gd | 23 - addons/godot_ai/clients/trae.gd.uid | 1 - addons/godot_ai/clients/vscode.gd | 30 - addons/godot_ai/clients/vscode.gd.uid | 1 - addons/godot_ai/clients/vscode_insiders.gd | 23 - .../godot_ai/clients/vscode_insiders.gd.uid | 1 - addons/godot_ai/clients/windsurf.gd | 29 - addons/godot_ai/clients/windsurf.gd.uid | 1 - addons/godot_ai/clients/zed.gd | 29 - addons/godot_ai/clients/zed.gd.uid | 1 - addons/godot_ai/clients/zoo_code.gd | 36 - addons/godot_ai/clients/zoo_code.gd.uid | 1 - addons/godot_ai/connection.gd | 1048 ----- addons/godot_ai/connection.gd.uid | 1 - .../custom_tools/custom_tool_wrapper.gd | 66 - .../custom_tools/custom_tool_wrapper.gd.uid | 1 - .../godot_ai/custom_tools/mcp_call_context.gd | 44 - .../custom_tools/mcp_call_context.gd.uid | 1 - .../custom_tools/mcp_custom_tool_spec.gd | 66 - .../custom_tools/mcp_custom_tool_spec.gd.uid | 1 - .../custom_tools/mcp_service_locator.gd | 16 - .../custom_tools/mcp_service_locator.gd.uid | 1 - .../custom_tools/mcp_tool_registry.gd | 277 -- .../custom_tools/mcp_tool_registry.gd.uid | 1 - .../godot_ai/debugger/mcp_debugger_plugin.gd | 1579 -------- .../debugger/mcp_debugger_plugin.gd.uid | 1 - addons/godot_ai/dispatcher.gd | 476 --- addons/godot_ai/dispatcher.gd.uid | 1 - addons/godot_ai/dock_panels/log_viewer.gd | 100 - addons/godot_ai/dock_panels/log_viewer.gd.uid | 1 - .../godot_ai/dock_panels/port_picker_panel.gd | 78 - .../dock_panels/port_picker_panel.gd.uid | 1 - addons/godot_ai/export/mcp_export_plugin.gd | 69 - .../godot_ai/export/mcp_export_plugin.gd.uid | 1 - addons/godot_ai/handlers/_node_validator.gd | 71 - .../godot_ai/handlers/_node_validator.gd.uid | 1 - addons/godot_ai/handlers/_param_validators.gd | 30 - .../handlers/_param_validators.gd.uid | 1 - addons/godot_ai/handlers/_property_errors.gd | 82 - .../godot_ai/handlers/_property_errors.gd.uid | 1 - addons/godot_ai/handlers/animation_handler.gd | 825 ---- .../handlers/animation_handler.gd.uid | 1 - addons/godot_ai/handlers/animation_presets.gd | 536 --- .../handlers/animation_presets.gd.uid | 1 - addons/godot_ai/handlers/animation_values.gd | 442 -- .../godot_ai/handlers/animation_values.gd.uid | 1 - addons/godot_ai/handlers/api_handler.gd | 89 - addons/godot_ai/handlers/api_handler.gd.uid | 1 - addons/godot_ai/handlers/audio_handler.gd | 361 -- addons/godot_ai/handlers/audio_handler.gd.uid | 1 - addons/godot_ai/handlers/autoload_handler.gd | 91 - .../godot_ai/handlers/autoload_handler.gd.uid | 1 - addons/godot_ai/handlers/batch_handler.gd | 190 - addons/godot_ai/handlers/batch_handler.gd.uid | 1 - addons/godot_ai/handlers/camera_handler.gd | 1145 ------ .../godot_ai/handlers/camera_handler.gd.uid | 1 - addons/godot_ai/handlers/camera_presets.gd | 81 - .../godot_ai/handlers/camera_presets.gd.uid | 1 - addons/godot_ai/handlers/camera_values.gd | 132 - addons/godot_ai/handlers/camera_values.gd.uid | 1 - addons/godot_ai/handlers/client_handler.gd | 127 - .../godot_ai/handlers/client_handler.gd.uid | 1 - .../handlers/control_draw_recipe_handler.gd | 318 -- .../control_draw_recipe_handler.gd.uid | 1 - addons/godot_ai/handlers/csg_handler.gd | 118 - addons/godot_ai/handlers/csg_handler.gd.uid | 1 - addons/godot_ai/handlers/curve_handler.gd | 243 -- addons/godot_ai/handlers/curve_handler.gd.uid | 1 - addons/godot_ai/handlers/editor_handler.gd | 1060 ----- .../godot_ai/handlers/editor_handler.gd.uid | 1 - .../godot_ai/handlers/environment_handler.gd | 181 - .../handlers/environment_handler.gd.uid | 1 - .../godot_ai/handlers/filesystem_handler.gd | 312 -- .../handlers/filesystem_handler.gd.uid | 1 - addons/godot_ai/handlers/gridmap_handler.gd | 192 - .../godot_ai/handlers/gridmap_handler.gd.uid | 1 - addons/godot_ai/handlers/input_handler.gd | 462 --- addons/godot_ai/handlers/input_handler.gd.uid | 1 - addons/godot_ai/handlers/material_handler.gd | 809 ---- .../godot_ai/handlers/material_handler.gd.uid | 1 - addons/godot_ai/handlers/material_presets.gd | 92 - .../godot_ai/handlers/material_presets.gd.uid | 1 - addons/godot_ai/handlers/material_values.gd | 194 - .../godot_ai/handlers/material_values.gd.uid | 1 - addons/godot_ai/handlers/node_handler.gd | 1390 ------- addons/godot_ai/handlers/node_handler.gd.uid | 1 - addons/godot_ai/handlers/particle_handler.gd | 860 ---- .../godot_ai/handlers/particle_handler.gd.uid | 1 - addons/godot_ai/handlers/particle_presets.gd | 293 -- .../godot_ai/handlers/particle_presets.gd.uid | 1 - addons/godot_ai/handlers/particle_values.gd | 246 -- .../godot_ai/handlers/particle_values.gd.uid | 1 - .../handlers/physics_shape_handler.gd | 338 -- .../handlers/physics_shape_handler.gd.uid | 1 - addons/godot_ai/handlers/project_handler.gd | 572 --- .../godot_ai/handlers/project_handler.gd.uid | 1 - addons/godot_ai/handlers/resource_handler.gd | 591 --- .../godot_ai/handlers/resource_handler.gd.uid | 1 - addons/godot_ai/handlers/scene_handler.gd | 420 -- addons/godot_ai/handlers/scene_handler.gd.uid | 1 - addons/godot_ai/handlers/script_handler.gd | 501 --- .../godot_ai/handlers/script_handler.gd.uid | 1 - addons/godot_ai/handlers/signal_handler.gd | 274 -- .../godot_ai/handlers/signal_handler.gd.uid | 1 - addons/godot_ai/handlers/test_handler.gd | 349 -- addons/godot_ai/handlers/test_handler.gd.uid | 1 - addons/godot_ai/handlers/texture_handler.gd | 199 - .../godot_ai/handlers/texture_handler.gd.uid | 1 - addons/godot_ai/handlers/theme_handler.gd | 476 --- addons/godot_ai/handlers/theme_handler.gd.uid | 1 - addons/godot_ai/handlers/tilemap_handler.gd | 163 - .../godot_ai/handlers/tilemap_handler.gd.uid | 1 - addons/godot_ai/handlers/tileset_handler.gd | 178 - .../godot_ai/handlers/tileset_handler.gd.uid | 1 - addons/godot_ai/handlers/ui_handler.gd | 525 --- addons/godot_ai/handlers/ui_handler.gd.uid | 1 - addons/godot_ai/mcp_dock.gd | 3559 ----------------- addons/godot_ai/mcp_dock.gd.uid | 1 - addons/godot_ai/migration_bridge.gd | 212 + addons/godot_ai/migration_bridge.gd.uid | 1 + addons/godot_ai/migration_coordinator.gd | 213 + addons/godot_ai/migration_coordinator.gd.uid | 1 + addons/godot_ai/migration_fallback.gd | 217 + addons/godot_ai/migration_fallback.gd.uid | 1 + .../migration_payload/godot-ai-v3-plugin.zip | Bin 0 -> 1890142 bytes .../godot-ai-v4-plugin.manifest.json | 1 + .../godot-ai-v4-plugin.manifest.sig | Bin 0 -> 512 bytes .../migration_payload/godot-ai-v4-plugin.zip | Bin 0 -> 1971643 bytes addons/godot_ai/plugin.cfg | 6 +- addons/godot_ai/plugin.gd | 2240 +---------- addons/godot_ai/plugin.gd.uid | 2 +- addons/godot_ai/runtime/draw_recipe.gd | 86 - addons/godot_ai/runtime/draw_recipe.gd.uid | 1 - addons/godot_ai/runtime/editor_logger.gd | 139 - addons/godot_ai/runtime/editor_logger.gd.uid | 1 - addons/godot_ai/runtime/game_helper.gd | 1317 ------ addons/godot_ai/runtime/game_helper.gd.uid | 1 - addons/godot_ai/runtime/game_logger.gd | 142 - addons/godot_ai/runtime/game_logger.gd.uid | 1 - addons/godot_ai/runtime/validation_logger.gd | 43 - .../godot_ai/runtime/validation_logger.gd.uid | 1 - addons/godot_ai/telemetry.gd | 198 - addons/godot_ai/telemetry.gd.uid | 1 - .../godot_ai/testing/script_error_capture.gd | 49 - .../testing/script_error_capture.gd.uid | 1 - addons/godot_ai/testing/test_runner.gd | 441 -- addons/godot_ai/testing/test_runner.gd.uid | 1 - addons/godot_ai/testing/test_suite.gd | 330 -- addons/godot_ai/testing/test_suite.gd.uid | 1 - addons/godot_ai/tool_catalog.gd | 110 - addons/godot_ai/tool_catalog.gd.uid | 1 - addons/godot_ai/update_reload_runner.gd | 590 --- addons/godot_ai/update_reload_runner.gd.uid | 1 - addons/godot_ai/utils/allow_hosts.gd | 170 - addons/godot_ai/utils/allow_hosts.gd.uid | 1 - addons/godot_ai/utils/class_introspection.gd | 259 -- .../godot_ai/utils/class_introspection.gd.uid | 1 - addons/godot_ai/utils/diagnostics_capture.gd | 66 - .../godot_ai/utils/diagnostics_capture.gd.uid | 1 - addons/godot_ai/utils/editor_log_buffer.gd | 127 - .../godot_ai/utils/editor_log_buffer.gd.uid | 1 - addons/godot_ai/utils/error_codes.gd | 163 - addons/godot_ai/utils/error_codes.gd.uid | 1 - addons/godot_ai/utils/fuzzy_suggestions.gd | 39 - .../godot_ai/utils/fuzzy_suggestions.gd.uid | 1 - addons/godot_ai/utils/game_log_buffer.gd | 105 - addons/godot_ai/utils/game_log_buffer.gd.uid | 1 - addons/godot_ai/utils/json_values.gd | 92 - addons/godot_ai/utils/json_values.gd.uid | 1 - addons/godot_ai/utils/log_backtrace.gd | 113 - addons/godot_ai/utils/log_backtrace.gd.uid | 1 - addons/godot_ai/utils/log_buffer.gd | 67 - addons/godot_ai/utils/log_buffer.gd.uid | 1 - addons/godot_ai/utils/mcp_adoption_label.gd | 23 - .../godot_ai/utils/mcp_adoption_label.gd.uid | 1 - .../utils/mcp_client_refresh_state.gd | 107 - .../utils/mcp_client_refresh_state.gd.uid | 1 - addons/godot_ai/utils/mcp_server_state.gd | 182 - addons/godot_ai/utils/mcp_server_state.gd.uid | 1 - addons/godot_ai/utils/mcp_startup_path.gd | 34 - addons/godot_ai/utils/mcp_startup_path.gd.uid | 1 - addons/godot_ai/utils/path_validator.gd | 178 - addons/godot_ai/utils/path_validator.gd.uid | 1 - addons/godot_ai/utils/port_resolver.gd | 474 ++- addons/godot_ai/utils/release_verifier.gd | 876 ++++ addons/godot_ai/utils/release_verifier.gd.uid | 1 + addons/godot_ai/utils/resource_io.gd | 277 -- addons/godot_ai/utils/resource_io.gd.uid | 1 - addons/godot_ai/utils/scene_path.gd | 155 - addons/godot_ai/utils/scene_path.gd.uid | 1 - addons/godot_ai/utils/screenshot_encode.gd | 38 - .../godot_ai/utils/screenshot_encode.gd.uid | 1 - addons/godot_ai/utils/server_lifecycle.gd | 2061 ---------- addons/godot_ai/utils/server_lifecycle.gd.uid | 1 - addons/godot_ai/utils/server_version_check.gd | 126 - .../utils/server_version_check.gd.uid | 1 - addons/godot_ai/utils/settings.gd | 135 - addons/godot_ai/utils/settings.gd.uid | 1 - addons/godot_ai/utils/structured_log_ring.gd | 156 - .../godot_ai/utils/structured_log_ring.gd.uid | 1 - .../godot_ai/utils/surfaced_error_tracker.gd | 642 --- .../utils/surfaced_error_tracker.gd.uid | 1 - .../utils/update_activation_runner.gd | 391 ++ .../utils/update_activation_runner.gd.uid | 1 + addons/godot_ai/utils/update_installer.gd | 548 +++ addons/godot_ai/utils/update_installer.gd.uid | 1 + addons/godot_ai/utils/update_manager.gd | 775 ---- addons/godot_ai/utils/update_manager.gd.uid | 1 - addons/godot_ai/utils/update_mixed_state.gd | 140 - .../godot_ai/utils/update_mixed_state.gd.uid | 1 - addons/godot_ai/utils/uv_cache_cleanup.gd | 161 - addons/godot_ai/utils/uv_cache_cleanup.gd.uid | 1 - addons/godot_ai/utils/variant_serializer.gd | 102 - .../godot_ai/utils/variant_serializer.gd.uid | 1 - addons/godot_ai/vision_routing.gd | 1043 ----- addons/godot_ai/vision_routing.gd.uid | 1 - 280 files changed, 3061 insertions(+), 44042 deletions(-) delete mode 100644 addons/godot_ai/LICENSE delete mode 100644 addons/godot_ai/README.md delete mode 100644 addons/godot_ai/client_configurator.gd delete mode 100644 addons/godot_ai/client_configurator.gd.uid delete mode 100644 addons/godot_ai/clients/_atomic_write.gd delete mode 100644 addons/godot_ai/clients/_atomic_write.gd.uid delete mode 100644 addons/godot_ai/clients/_base.gd delete mode 100644 addons/godot_ai/clients/_base.gd.uid delete mode 100644 addons/godot_ai/clients/_cli_exec.gd delete mode 100644 addons/godot_ai/clients/_cli_exec.gd.uid delete mode 100644 addons/godot_ai/clients/_cli_finder.gd delete mode 100644 addons/godot_ai/clients/_cli_finder.gd.uid delete mode 100644 addons/godot_ai/clients/_cli_strategy.gd delete mode 100644 addons/godot_ai/clients/_cli_strategy.gd.uid delete mode 100644 addons/godot_ai/clients/_dsh_strategy.gd delete mode 100644 addons/godot_ai/clients/_dsh_strategy.gd.uid delete mode 100644 addons/godot_ai/clients/_json_strategy.gd delete mode 100644 addons/godot_ai/clients/_json_strategy.gd.uid delete mode 100644 addons/godot_ai/clients/_manual_command.gd delete mode 100644 addons/godot_ai/clients/_manual_command.gd.uid delete mode 100644 addons/godot_ai/clients/_path_template.gd delete mode 100644 addons/godot_ai/clients/_path_template.gd.uid delete mode 100644 addons/godot_ai/clients/_registry.gd delete mode 100644 addons/godot_ai/clients/_registry.gd.uid delete mode 100644 addons/godot_ai/clients/_toml_strategy.gd delete mode 100644 addons/godot_ai/clients/_toml_strategy.gd.uid delete mode 100644 addons/godot_ai/clients/_yaml_strategy.gd delete mode 100644 addons/godot_ai/clients/_yaml_strategy.gd.uid delete mode 100644 addons/godot_ai/clients/antigravity.gd delete mode 100644 addons/godot_ai/clients/antigravity.gd.uid delete mode 100644 addons/godot_ai/clients/cherry_studio.gd delete mode 100644 addons/godot_ai/clients/cherry_studio.gd.uid delete mode 100644 addons/godot_ai/clients/claude_code.gd delete mode 100644 addons/godot_ai/clients/claude_code.gd.uid delete mode 100644 addons/godot_ai/clients/claude_desktop.gd delete mode 100644 addons/godot_ai/clients/claude_desktop.gd.uid delete mode 100644 addons/godot_ai/clients/cline.gd delete mode 100644 addons/godot_ai/clients/cline.gd.uid delete mode 100644 addons/godot_ai/clients/codex.gd delete mode 100644 addons/godot_ai/clients/codex.gd.uid delete mode 100644 addons/godot_ai/clients/cursor.gd delete mode 100644 addons/godot_ai/clients/cursor.gd.uid delete mode 100644 addons/godot_ai/clients/deepseek_harness.gd delete mode 100644 addons/godot_ai/clients/deepseek_harness.gd.uid delete mode 100644 addons/godot_ai/clients/gemini_cli.gd delete mode 100644 addons/godot_ai/clients/gemini_cli.gd.uid delete mode 100644 addons/godot_ai/clients/grok.gd delete mode 100644 addons/godot_ai/clients/grok.gd.uid delete mode 100644 addons/godot_ai/clients/hermes.gd delete mode 100644 addons/godot_ai/clients/hermes.gd.uid delete mode 100644 addons/godot_ai/clients/kilo_code.gd delete mode 100644 addons/godot_ai/clients/kilo_code.gd.uid delete mode 100644 addons/godot_ai/clients/kimi_code.gd delete mode 100644 addons/godot_ai/clients/kimi_code.gd.uid delete mode 100644 addons/godot_ai/clients/kiro.gd delete mode 100644 addons/godot_ai/clients/kiro.gd.uid delete mode 100644 addons/godot_ai/clients/opencode.gd delete mode 100644 addons/godot_ai/clients/opencode.gd.uid delete mode 100644 addons/godot_ai/clients/pi.gd delete mode 100644 addons/godot_ai/clients/pi.gd.uid delete mode 100644 addons/godot_ai/clients/qwen_code.gd delete mode 100644 addons/godot_ai/clients/qwen_code.gd.uid delete mode 100644 addons/godot_ai/clients/roo_code.gd delete mode 100644 addons/godot_ai/clients/roo_code.gd.uid delete mode 100644 addons/godot_ai/clients/trae.gd delete mode 100644 addons/godot_ai/clients/trae.gd.uid delete mode 100644 addons/godot_ai/clients/vscode.gd delete mode 100644 addons/godot_ai/clients/vscode.gd.uid delete mode 100644 addons/godot_ai/clients/vscode_insiders.gd delete mode 100644 addons/godot_ai/clients/vscode_insiders.gd.uid delete mode 100644 addons/godot_ai/clients/windsurf.gd delete mode 100644 addons/godot_ai/clients/windsurf.gd.uid delete mode 100644 addons/godot_ai/clients/zed.gd delete mode 100644 addons/godot_ai/clients/zed.gd.uid delete mode 100644 addons/godot_ai/clients/zoo_code.gd delete mode 100644 addons/godot_ai/clients/zoo_code.gd.uid delete mode 100644 addons/godot_ai/connection.gd delete mode 100644 addons/godot_ai/connection.gd.uid delete mode 100644 addons/godot_ai/custom_tools/custom_tool_wrapper.gd delete mode 100644 addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid delete mode 100644 addons/godot_ai/custom_tools/mcp_call_context.gd delete mode 100644 addons/godot_ai/custom_tools/mcp_call_context.gd.uid delete mode 100644 addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd delete mode 100644 addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid delete mode 100644 addons/godot_ai/custom_tools/mcp_service_locator.gd delete mode 100644 addons/godot_ai/custom_tools/mcp_service_locator.gd.uid delete mode 100644 addons/godot_ai/custom_tools/mcp_tool_registry.gd delete mode 100644 addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid delete mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd delete mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid delete mode 100644 addons/godot_ai/dispatcher.gd delete mode 100644 addons/godot_ai/dispatcher.gd.uid delete mode 100644 addons/godot_ai/dock_panels/log_viewer.gd delete mode 100644 addons/godot_ai/dock_panels/log_viewer.gd.uid delete mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd delete mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd.uid delete mode 100644 addons/godot_ai/export/mcp_export_plugin.gd delete mode 100644 addons/godot_ai/export/mcp_export_plugin.gd.uid delete mode 100644 addons/godot_ai/handlers/_node_validator.gd delete mode 100644 addons/godot_ai/handlers/_node_validator.gd.uid delete mode 100644 addons/godot_ai/handlers/_param_validators.gd delete mode 100644 addons/godot_ai/handlers/_param_validators.gd.uid delete mode 100644 addons/godot_ai/handlers/_property_errors.gd delete mode 100644 addons/godot_ai/handlers/_property_errors.gd.uid delete mode 100644 addons/godot_ai/handlers/animation_handler.gd delete mode 100644 addons/godot_ai/handlers/animation_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/animation_presets.gd delete mode 100644 addons/godot_ai/handlers/animation_presets.gd.uid delete mode 100644 addons/godot_ai/handlers/animation_values.gd delete mode 100644 addons/godot_ai/handlers/animation_values.gd.uid delete mode 100644 addons/godot_ai/handlers/api_handler.gd delete mode 100644 addons/godot_ai/handlers/api_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/audio_handler.gd delete mode 100644 addons/godot_ai/handlers/audio_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/autoload_handler.gd delete mode 100644 addons/godot_ai/handlers/autoload_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/batch_handler.gd delete mode 100644 addons/godot_ai/handlers/batch_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/camera_handler.gd delete mode 100644 addons/godot_ai/handlers/camera_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/camera_presets.gd delete mode 100644 addons/godot_ai/handlers/camera_presets.gd.uid delete mode 100644 addons/godot_ai/handlers/camera_values.gd delete mode 100644 addons/godot_ai/handlers/camera_values.gd.uid delete mode 100644 addons/godot_ai/handlers/client_handler.gd delete mode 100644 addons/godot_ai/handlers/client_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd delete mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/csg_handler.gd delete mode 100644 addons/godot_ai/handlers/csg_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/curve_handler.gd delete mode 100644 addons/godot_ai/handlers/curve_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/editor_handler.gd delete mode 100644 addons/godot_ai/handlers/editor_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/environment_handler.gd delete mode 100644 addons/godot_ai/handlers/environment_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/filesystem_handler.gd delete mode 100644 addons/godot_ai/handlers/filesystem_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/gridmap_handler.gd delete mode 100644 addons/godot_ai/handlers/gridmap_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/input_handler.gd delete mode 100644 addons/godot_ai/handlers/input_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/material_handler.gd delete mode 100644 addons/godot_ai/handlers/material_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/material_presets.gd delete mode 100644 addons/godot_ai/handlers/material_presets.gd.uid delete mode 100644 addons/godot_ai/handlers/material_values.gd delete mode 100644 addons/godot_ai/handlers/material_values.gd.uid delete mode 100644 addons/godot_ai/handlers/node_handler.gd delete mode 100644 addons/godot_ai/handlers/node_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/particle_handler.gd delete mode 100644 addons/godot_ai/handlers/particle_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/particle_presets.gd delete mode 100644 addons/godot_ai/handlers/particle_presets.gd.uid delete mode 100644 addons/godot_ai/handlers/particle_values.gd delete mode 100644 addons/godot_ai/handlers/particle_values.gd.uid delete mode 100644 addons/godot_ai/handlers/physics_shape_handler.gd delete mode 100644 addons/godot_ai/handlers/physics_shape_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/project_handler.gd delete mode 100644 addons/godot_ai/handlers/project_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/resource_handler.gd delete mode 100644 addons/godot_ai/handlers/resource_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/scene_handler.gd delete mode 100644 addons/godot_ai/handlers/scene_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/script_handler.gd delete mode 100644 addons/godot_ai/handlers/script_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/signal_handler.gd delete mode 100644 addons/godot_ai/handlers/signal_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/test_handler.gd delete mode 100644 addons/godot_ai/handlers/test_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/texture_handler.gd delete mode 100644 addons/godot_ai/handlers/texture_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/theme_handler.gd delete mode 100644 addons/godot_ai/handlers/theme_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/tilemap_handler.gd delete mode 100644 addons/godot_ai/handlers/tilemap_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/tileset_handler.gd delete mode 100644 addons/godot_ai/handlers/tileset_handler.gd.uid delete mode 100644 addons/godot_ai/handlers/ui_handler.gd delete mode 100644 addons/godot_ai/handlers/ui_handler.gd.uid delete mode 100644 addons/godot_ai/mcp_dock.gd delete mode 100644 addons/godot_ai/mcp_dock.gd.uid create mode 100644 addons/godot_ai/migration_bridge.gd create mode 100644 addons/godot_ai/migration_bridge.gd.uid create mode 100644 addons/godot_ai/migration_coordinator.gd create mode 100644 addons/godot_ai/migration_coordinator.gd.uid create mode 100644 addons/godot_ai/migration_fallback.gd create mode 100644 addons/godot_ai/migration_fallback.gd.uid create mode 100644 addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip create mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json create mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig create mode 100644 addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip delete mode 100644 addons/godot_ai/runtime/draw_recipe.gd delete mode 100644 addons/godot_ai/runtime/draw_recipe.gd.uid delete mode 100644 addons/godot_ai/runtime/editor_logger.gd delete mode 100644 addons/godot_ai/runtime/editor_logger.gd.uid delete mode 100644 addons/godot_ai/runtime/game_helper.gd delete mode 100644 addons/godot_ai/runtime/game_helper.gd.uid delete mode 100644 addons/godot_ai/runtime/game_logger.gd delete mode 100644 addons/godot_ai/runtime/game_logger.gd.uid delete mode 100644 addons/godot_ai/runtime/validation_logger.gd delete mode 100644 addons/godot_ai/runtime/validation_logger.gd.uid delete mode 100644 addons/godot_ai/telemetry.gd delete mode 100644 addons/godot_ai/telemetry.gd.uid delete mode 100644 addons/godot_ai/testing/script_error_capture.gd delete mode 100644 addons/godot_ai/testing/script_error_capture.gd.uid delete mode 100644 addons/godot_ai/testing/test_runner.gd delete mode 100644 addons/godot_ai/testing/test_runner.gd.uid delete mode 100644 addons/godot_ai/testing/test_suite.gd delete mode 100644 addons/godot_ai/testing/test_suite.gd.uid delete mode 100644 addons/godot_ai/tool_catalog.gd delete mode 100644 addons/godot_ai/tool_catalog.gd.uid delete mode 100644 addons/godot_ai/update_reload_runner.gd delete mode 100644 addons/godot_ai/update_reload_runner.gd.uid delete mode 100644 addons/godot_ai/utils/allow_hosts.gd delete mode 100644 addons/godot_ai/utils/allow_hosts.gd.uid delete mode 100644 addons/godot_ai/utils/class_introspection.gd delete mode 100644 addons/godot_ai/utils/class_introspection.gd.uid delete mode 100644 addons/godot_ai/utils/diagnostics_capture.gd delete mode 100644 addons/godot_ai/utils/diagnostics_capture.gd.uid delete mode 100644 addons/godot_ai/utils/editor_log_buffer.gd delete mode 100644 addons/godot_ai/utils/editor_log_buffer.gd.uid delete mode 100644 addons/godot_ai/utils/error_codes.gd delete mode 100644 addons/godot_ai/utils/error_codes.gd.uid delete mode 100644 addons/godot_ai/utils/fuzzy_suggestions.gd delete mode 100644 addons/godot_ai/utils/fuzzy_suggestions.gd.uid delete mode 100644 addons/godot_ai/utils/game_log_buffer.gd delete mode 100644 addons/godot_ai/utils/game_log_buffer.gd.uid delete mode 100644 addons/godot_ai/utils/json_values.gd delete mode 100644 addons/godot_ai/utils/json_values.gd.uid delete mode 100644 addons/godot_ai/utils/log_backtrace.gd delete mode 100644 addons/godot_ai/utils/log_backtrace.gd.uid delete mode 100644 addons/godot_ai/utils/log_buffer.gd delete mode 100644 addons/godot_ai/utils/log_buffer.gd.uid delete mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd delete mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd.uid delete mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd delete mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd.uid delete mode 100644 addons/godot_ai/utils/mcp_server_state.gd delete mode 100644 addons/godot_ai/utils/mcp_server_state.gd.uid delete mode 100644 addons/godot_ai/utils/mcp_startup_path.gd delete mode 100644 addons/godot_ai/utils/mcp_startup_path.gd.uid delete mode 100644 addons/godot_ai/utils/path_validator.gd delete mode 100644 addons/godot_ai/utils/path_validator.gd.uid create mode 100644 addons/godot_ai/utils/release_verifier.gd create mode 100644 addons/godot_ai/utils/release_verifier.gd.uid delete mode 100644 addons/godot_ai/utils/resource_io.gd delete mode 100644 addons/godot_ai/utils/resource_io.gd.uid delete mode 100644 addons/godot_ai/utils/scene_path.gd delete mode 100644 addons/godot_ai/utils/scene_path.gd.uid delete mode 100644 addons/godot_ai/utils/screenshot_encode.gd delete mode 100644 addons/godot_ai/utils/screenshot_encode.gd.uid delete mode 100644 addons/godot_ai/utils/server_lifecycle.gd delete mode 100644 addons/godot_ai/utils/server_lifecycle.gd.uid delete mode 100644 addons/godot_ai/utils/server_version_check.gd delete mode 100644 addons/godot_ai/utils/server_version_check.gd.uid delete mode 100644 addons/godot_ai/utils/settings.gd delete mode 100644 addons/godot_ai/utils/settings.gd.uid delete mode 100644 addons/godot_ai/utils/structured_log_ring.gd delete mode 100644 addons/godot_ai/utils/structured_log_ring.gd.uid delete mode 100644 addons/godot_ai/utils/surfaced_error_tracker.gd delete mode 100644 addons/godot_ai/utils/surfaced_error_tracker.gd.uid create mode 100644 addons/godot_ai/utils/update_activation_runner.gd create mode 100644 addons/godot_ai/utils/update_activation_runner.gd.uid create mode 100644 addons/godot_ai/utils/update_installer.gd create mode 100644 addons/godot_ai/utils/update_installer.gd.uid delete mode 100644 addons/godot_ai/utils/update_manager.gd delete mode 100644 addons/godot_ai/utils/update_manager.gd.uid delete mode 100644 addons/godot_ai/utils/update_mixed_state.gd delete mode 100644 addons/godot_ai/utils/update_mixed_state.gd.uid delete mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd delete mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd.uid delete mode 100644 addons/godot_ai/utils/variant_serializer.gd delete mode 100644 addons/godot_ai/utils/variant_serializer.gd.uid delete mode 100644 addons/godot_ai/vision_routing.gd delete mode 100644 addons/godot_ai/vision_routing.gd.uid diff --git a/addons/godot_ai/LICENSE b/addons/godot_ai/LICENSE deleted file mode 100644 index 7806d22..0000000 --- a/addons/godot_ai/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Godot AI contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/addons/godot_ai/README.md b/addons/godot_ai/README.md deleted file mode 100644 index f8a730d..0000000 --- a/addons/godot_ai/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Godot AI - -Connect AI assistants to a live Godot editor via the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP). - -Godot AI bridges Claude Code, Codex, Antigravity, DeepSeek Harness, and other MCP clients with your editor — inspect scenes, create nodes, modify properties, run tests, search project files, and more, all from a prompt. - -## Quick Start - -1. Copy `addons/godot_ai/` into your project's `addons/` folder -2. Enable the plugin: **Project > Project Settings > Plugins > Godot AI** -3. Pick your MCP client in the **Godot AI** dock and press **Configure** - -The plugin auto-starts the MCP server and connects over WebSocket. No manual configuration required. - -## Requirements - -- Godot 4.5+ (4.7+ recommended) -- [uv](https://docs.astral.sh/uv/) (used to install the Python server) -
- Install uv - - **macOS / Linux:** - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - ``` - - **Windows (PowerShell):** - ```powershell - powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - ``` - - **Homebrew (macOS / Linux):** - ```bash - brew install uv - ``` - - **pipx:** - ```bash - pipx install uv - ``` - - See the [uv install docs](https://docs.astral.sh/uv/getting-started/installation/) for more options. - -
-- An MCP client ([Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [Codex](https://openai.com/index/codex/) | [Antigravity](https://www.antigravity.dev/)) - -## Documentation - -Full documentation, contributing guide, and source code: [github.com/hi-godot/godot-ai](https://github.com/hi-godot/godot-ai) - -## License - -[MIT](LICENSE) diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd deleted file mode 100644 index ec3806c..0000000 --- a/addons/godot_ai/client_configurator.gd +++ /dev/null @@ -1,1784 +0,0 @@ -@tool -class_name McpClientConfigurator -extends RefCounted - -## Public facade for the MCP client configuration system. -## -## Per-client logic lives in clients/*.gd (one descriptor per client) and is -## dispatched through clients/_registry.gd. This file: -## - owns server-side identifiers (SERVER_NAME, HTTP/WS port helpers) -## - registers the EditorSettings port overrides and resolves the live -## port/URL via `http_port()` / `ws_port()` / `http_url()` -## - keeps server-launch discovery (.venv → uvx → system godot-ai) -## - exposes string-id wrappers around configure / check_status / remove / -## manual_command so callers don't need to touch the registry directly -## -## To add a new client: drop a file in clients/, then preload it in -## clients/_registry.gd. No edits required here. - -const Client := preload("res://addons/godot_ai/clients/_base.gd") -const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") -const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") -const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") -const YamlStrategy := preload("res://addons/godot_ai/clients/_yaml_strategy.gd") -const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd") -const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") -const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd") -const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd") -const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") -const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") - -const SERVER_NAME := "godot-ai" - -## Fallback ports. Live port selection goes through `http_port()` / `ws_port()`, -## which read overrides from EditorSettings first. Users on Windows whose 8000 -## is grabbed by Hyper-V / WSL2 / Docker can pick a different port in -## Editor Settings > Plugins > godot_ai without touching code. See #146 for -## the Windows-reservation diagnostics this is the escape hatch for. -const DEFAULT_HTTP_PORT := 8000 -const DEFAULT_WS_PORT := 9500 -const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE" -const MIN_PORT := 1024 -const MAX_PORT := 65535 -## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a -## pathological run of occupied ports can't stall the (cold-path) caller. -## 64 localhost binds are sub-millisecond; finding a free port realistically -## takes one or two probes, so this only bounds the worst case. -const SUGGEST_PORT_MAX_PROBES := 64 -const SETTING_WS_PORT := "godot_ai/ws_port" -const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing" -const SETTING_KEEP_SERVER_ON_EXIT := "godot_ai/keep_server_on_exit" -## External cwd the user can set when an MCP client (Pi, code-server, …) reads -## project-tier config files from a directory this editor can't see. The -## merge-tier strategies include it as an extra `_project_candidate_paths` root -## so project overrides there are detected instead of silently shadowed by a -## global-tier write. Codex-review finding F1. -const SETTING_EXTERNAL_CLIENT_CWD := "godot_ai/external_client_cwd" -const _DISCOVERY_TIMEOUT_MS := 3000 -## Codex launches Windows console-subsystem MCP commands in a visible terminal. -## A GUI-subsystem Python keeps the bridge attached to Codex's redirected MCP -## pipes without allocating a console, then starts console launchers such as -## uvx with CREATE_NO_WINDOW. Keep stdin/stdout/stderr explicit: pythonw can use -## its own inherited pipes, but subprocess defaults do not reliably forward -## them to a child when no console exists. -## This string is a wire format written verbatim into user config `args`. -## Whitespace or formatting changes make every existing Windows entry report -## CONFIGURED_MISMATCH, so changing it is a deliberate migration decision, not -## a refactor. The inline `-c` script is required because the uvx and system -## tiers resolve a system interpreter where `godot_ai` is not importable. -const _WINDOWS_STDIO_BOOTSTRAP := ( - "import subprocess,sys; " - + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=sys.stdin, stdout=sys.stdout, " - + "stderr=sys.stderr, creationflags=0x08000000))" -) - - -## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`. -static func http_port() -> int: - return _read_port_setting(McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) - - -## Active WebSocket port: user override (if in range) or `DEFAULT_WS_PORT`. -static func ws_port() -> int: - return _read_port_setting(SETTING_WS_PORT, DEFAULT_WS_PORT) - - -static func http_url() -> String: - return "http://127.0.0.1:%d/mcp" % http_port() - - -## Read a URL already captured on the main thread without evaluating the -## EditorSettings-backed fallback unless the snapshot is genuinely incomplete. -static func server_url_from(launch_context: Dictionary) -> String: - if launch_context.has("server_url"): - return str(launch_context["server_url"]) - return http_url() - - -static func _read_port_setting(key: String, default_port: int) -> int: - var es := EditorInterface.get_editor_settings() - if es == null or not es.has_setting(key): - return default_port - var value: int = int(es.get_setting(key)) - if value < MIN_PORT or value > MAX_PORT: - return default_port - return value - - -## Register the port overrides in EditorSettings so they show up in the -## editor's Settings > Plugins section with a range hint. Called once from -## `plugin.gd._enter_tree` before `_start_server` so spawn args see the -## configured values. Safe to call repeatedly — `add_property_info` is -## idempotent and `set_initial_value` only seeds the default. -static func ensure_settings_registered() -> void: - var es := EditorInterface.get_editor_settings() - if es == null: - return - _register_port_setting(es, McpSettings.SETTING_HTTP_PORT, DEFAULT_HTTP_PORT) - _register_port_setting(es, SETTING_WS_PORT, DEFAULT_WS_PORT) - _register_bool_setting(es, SETTING_STARTUP_TRACE, false) - _register_bool_setting(es, SETTING_KEEP_SERVER_ON_EXIT, false) - _register_client_scope_setting(es) - _register_string_setting(es, SETTING_EXTERNAL_CLIENT_CWD, "") - - -static func _register_port_setting(es: EditorSettings, key: String, default_port: int) -> void: - if not es.has_setting(key): - es.set_setting(key, default_port) - es.set_initial_value(key, default_port, false) - es.add_property_info({ - "name": key, - "type": TYPE_INT, - "hint": PROPERTY_HINT_RANGE, - "hint_string": "%d,%d,1" % [MIN_PORT, MAX_PORT], - }) - - -## Surface the CLI registration scope as an enum in Settings > Plugins so it is -## discoverable without hand-editing editor_settings-4.tres. Kept at `user` by -## default; `project` writes the entry into /.mcp.json instead. -static func _register_client_scope_setting(es: EditorSettings) -> void: - var key := McpSettings.SETTING_CLIENT_SCOPE - if not es.has_setting(key): - es.set_setting(key, McpSettings.DEFAULT_CLIENT_SCOPE) - es.set_initial_value(key, McpSettings.DEFAULT_CLIENT_SCOPE, false) - es.add_property_info({ - "name": key, - "type": TYPE_STRING, - "hint": PROPERTY_HINT_ENUM, - "hint_string": ",".join(PackedStringArray(McpSettings.CLIENT_SCOPES)), - }) - - -static func _register_bool_setting(es: EditorSettings, key: String, default_value: bool) -> void: - if not es.has_setting(key): - es.set_setting(key, default_value) - es.set_initial_value(key, default_value, false) - es.add_property_info({ - "name": key, - "type": TYPE_BOOL, - }) - - -static func _register_string_setting(es: EditorSettings, key: String, default_value: String) -> void: - ## Same idempotent set-then-hint dance as the bool helper; `PROPERTY_HINT_NONE` - ## leaves the editor's plain text-input widget in place (the cwd string is - ## freeform). The hint-string slot is required by the API even when empty. - if not es.has_setting(key): - es.set_setting(key, default_value) - es.set_initial_value(key, default_value, false) - es.add_property_info({ - "name": key, - "type": TYPE_STRING, - "hint": PROPERTY_HINT_NONE, - "hint_string": "", - }) - - -static func startup_trace_enabled() -> bool: - ## env_lookup + _editor_setting_lookup for the same worker-thread - ## reason as mode_override (#691). - var raw := McpPathTemplate.env_lookup(STARTUP_TRACE_ENV).strip_edges().to_lower() - if raw == "1" or raw == "true" or raw == "yes" or raw == "on": - return true - var setting: Variant = _editor_setting_lookup(SETTING_STARTUP_TRACE) - if setting != null: - return bool(setting) - return false - - -## keep_server_on_exit (#800): when enabled, editor teardown detaches from -## the managed server instead of killing it, and the spawn env opts the -## server out of both self-reap paths (owner-PID watchdog, session-idle -## backstop) — so MCP clients connected over HTTP stay served across editor -## sessions, and the next editor start adopts the survivor. Off by default: -## "server dies with the editor" stays the shipped behavior. Read via -## _editor_setting_lookup for the same worker-thread reason as -## startup_trace_enabled (#691). -static func keep_server_on_exit() -> bool: - var setting: Variant = _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) - if setting != null: - return bool(setting) - return false - - -## #691: EditorSettings counterpart of McpPathTemplate.env_lookup. The #678 -## startup walk's discovery worker reaches mode_override() (via -## get_server_command) and EditorInterface / EditorSettings are not -## thread-safe objects. Main thread: live read + mutex-guarded snapshot -## refresh. Worker thread: snapshot only — a never-warmed key reads as -## null (unset), never a live EditorInterface call. Warmed alongside the -## env snapshot in warm_env_snapshot(), which runs on the main thread -## before any worker dispatch. -static var _setting_snapshot := {} -static var _setting_snapshot_mutex := Mutex.new() -## The aggregate MCP status command runs on a worker thread. Keep the same -## main-thread-only LaunchContext contract used by dock workers by publishing a -## deep snapshot whenever capture_launch_context() runs on the main thread. -static var _launch_context_snapshot := {} -static var _launch_context_snapshot_mutex := Mutex.new() - - -static func _editor_setting_lookup(key: String) -> Variant: - if OS.get_thread_caller_id() == OS.get_main_thread_id(): - var live: Variant = null - if Engine.is_editor_hint(): - var es := EditorInterface.get_editor_settings() - if es != null and es.has_setting(key): - live = es.get_setting(key) - _setting_snapshot_mutex.lock() - _setting_snapshot[key] = live - _setting_snapshot_mutex.unlock() - return live - _setting_snapshot_mutex.lock() - var cached: Variant = _setting_snapshot.get(key, null) - _setting_snapshot_mutex.unlock() - return cached - - -## Read the `godot_ai/excluded_domains` EditorSetting as a canonicalized -## comma-separated list (sorted, deduplicated, whitespace-stripped). Returns -## "" when the setting is missing or resolves to an empty set — callers can -## skip appending the flag in that case so older servers that don't know -## `--exclude-domains` don't see an empty argument. -## -## Unknown domain names (e.g. a domain removed since the setting was last -## written) are dropped here, at the single chokepoint both the startup -## flag builder (plugin.gd) and the dock display read — the server's -## `parse_exclude_list` hard-fails on unknown names, so a stale setting -## would otherwise block server startup. -static func excluded_domains() -> String: - var es := EditorInterface.get_editor_settings() - if es == null or not es.has_setting(McpSettings.SETTING_EXCLUDED_DOMAINS): - return "" - return _canonicalize_excluded_domains(str(es.get_setting(McpSettings.SETTING_EXCLUDED_DOMAINS))) - - -## Pure canonicalizer shared by the main-thread LaunchContext capture and -## tests. Unknown domains are dropped for the same startup-safety reason as -## `excluded_domains()` above. -static func _canonicalize_excluded_domains(raw: String) -> String: - var parts := PackedStringArray() - for p in raw.split(","): - var t := p.strip_edges() - if t.is_empty() or parts.find(t) != -1: - continue - if not McpToolCatalog.is_excludable_domain(t): - continue - parts.append(t) - parts.sort() - return ",".join(parts) - - -## Snapshot every EditorSettings-backed value needed to render or verify an -## attach launch command. Main-thread calls refresh the snapshot; worker calls -## return that snapshot without touching EditorInterface (#691). Warm it on the -## main thread before dispatching a worker. -static func capture_launch_context() -> Dictionary: - if OS.get_thread_caller_id() != OS.get_main_thread_id(): - _launch_context_snapshot_mutex.lock() - var cached := _launch_context_snapshot.duplicate(true) - _launch_context_snapshot_mutex.unlock() - return cached - var captured_http_port := http_port() - var context := { - "http_port": captured_http_port, - "ws_port": ws_port(), - "excluded_domains": excluded_domains(), - "plugin_version": get_plugin_version(), - "allow_dev_venv": mode_override() != "user", - "platform": OS.get_name(), - "server_url": "http://127.0.0.1:%d/mcp" % captured_http_port, - "project_roots": capture_project_roots(), - ## The opt-out must ride the attach argv: the client spawns the bridge - ## (and the bridge its backend) with no editor in the loop, so the - ## env-injection path in server_lifecycle.gd never runs for them. - "telemetry_enabled": McpSettings.telemetry_enabled(), - } - _launch_context_snapshot_mutex.lock() - _launch_context_snapshot = context.duplicate(true) - _launch_context_snapshot_mutex.unlock() - return context - - -## Resolve roots used by cwd-relative client project config tiers while the -## engine singletons are safe to access. Workers receive this immutable snapshot. -static func capture_project_roots() -> PackedStringArray: - var current_access := DirAccess.open(".") - var current_root := "" if current_access == null else current_access.get_current_dir().simplify_path() - var project_root := ProjectSettings.globalize_path("res://").simplify_path() - # Codex F1: when the user sets `godot_ai/external_client_cwd` we probe that - # directory in addition to the two guessed roots. The setter / setter-snapshot - # pathway is the same as the existing EditorSettings (warm_env_snapshot - # pre-warms this key so worker-thread callers see the snapshot, not the live - # EditorInterface). Empty string is filtered out by `_canonicalize_roots_for_test`. - var external_cwd := str(_editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD)) - return _canonicalize_roots_for_test(PackedStringArray([current_root, project_root, external_cwd])) - - -## Canonicalize a list of root paths to one filesystem representation per -## directory and deduplicate. `ProjectSettings.globalize_path` maps any -## `res://` / `user://` form to the absolute path and is idempotent on an -## already-absolute path, so a `res://`-form candidate and its absolute twin -## collapse to the same entry. Without this pass, `_project_candidate_paths` -## would probe `/.pi/mcp.json` against both representations of the same -## directory and `manual_target_details` would mis-report a single project -## override as multiple tiers (codex-review finding F4). Public-ish seam -## (named `_..._for_test`) so the clients suite can pin the behaviour without -## having to mock `DirAccess.open(".")`. -static func _canonicalize_roots_for_test(raws: PackedStringArray) -> PackedStringArray: - var seen := {} - var roots := PackedStringArray() - for raw in raws: - var s := str(raw).strip_edges() - if s.is_empty(): - continue - var canon := ProjectSettings.globalize_path(s).simplify_path() - if canon.is_empty() or seen.has(canon): - continue - seen[canon] = true - roots.append(canon) - return roots - - -static func _project_roots_from_context(context: Dictionary) -> PackedStringArray: - var roots = context.get("project_roots", PackedStringArray()) - return roots if roots is PackedStringArray else PackedStringArray() - - -## Read the `godot_ai/allow_remote_hosts` EditorSetting as a canonicalized -## comma-separated list of CIDRs / bare IPs (#507). Returns "" when the -## setting is missing or empty — callers skip appending `--allow-host` in -## that case so spawns stay byte-for-byte identical to the loopback-only -## default (and compatible with pre-#421 servers). Mirrors -## `excluded_domains()` above. -static func allow_hosts() -> String: - var es := EditorInterface.get_editor_settings() - if es == null or not es.has_setting(McpSettings.SETTING_ALLOW_HOSTS): - return "" - return McpAllowHosts.normalize(str(es.get_setting(McpSettings.SETTING_ALLOW_HOSTS))) - - -## Suggest a port the caller can actually switch to. Walks -## `candidate`..`candidate+span-1` and returns the first port that is both -## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker -## grab these; bind fails with WinError 10013 and netstat shows nothing) and -## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes -## "free" honest on macOS/Linux, where the reservation table is empty but the -## next port up may still be occupied — the same suggestion feeds the dock -## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE -## log line. Falls back to the clamped candidate if nothing in the window -## clears both checks (caller surfaces it as a best-effort hint; the user can -## retry or pick another). Best-effort by nature: a TOCTOU window remains -## between the probe and the caller actually binding the port. The bind probe -## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't -## stall on a pathological run of occupied ports. -static func suggest_free_port(start: int, span: int = 2048) -> int: - var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) - var limit := mini(candidate + span - 1, MAX_PORT) - var p := candidate - var probes := 0 - while p <= limit and probes < SUGGEST_PORT_MAX_PROBES: - ## Jump past a whole Windows-reserved range in one step (no-op on - ## POSIX: returns `p` unchanged), so we don't probe port-by-port - ## through the large adjacent ranges those services reserve. The - ## jump itself runs no bind probes, so it doesn't count against the cap. - var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT) - if not_reserved < p or not_reserved > limit: - break - p = not_reserved - probes += 1 - if PortResolver.can_bind_local_port(p): - return p - p += 1 - return candidate - - -# --- Client operations (string id) --------------------------------------- - -static func client_ids() -> PackedStringArray: - return ClientRegistry.ids() - - -static func has_client(id: String) -> bool: - return ClientRegistry.has_id(id) - - -static func client_display_name(id: String) -> String: - var c := ClientRegistry.get_by_id(id) - return c.display_name if c != null else id - - -## Pass an explicit `url` when calling from a worker thread: `http_url()` -## reads `EditorInterface.get_editor_settings()`, which is main-thread-only. -## Empty defaults to the live server URL — appropriate for MCP-tool callers -## that always run on main. -static func configure(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary: - if ClientRegistry.stale_session_detected(): - return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE} - var client := ClientRegistry.get_by_id(id) - if client == null: - return {"status": "error", "message": "Unknown client: %s" % id} - var path_error := _config_path_resolution_error(client) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - ## Capture `url` once so a port flip in EditorSettings between write and - ## verify can't trigger a spurious CONFIGURED_MISMATCH against an entry - ## that just landed correctly. - if url.is_empty(): - url = http_url() - var context := launch_context - if client.command_shape != Client.CommandShape.NONE and context.is_empty(): - if OS.get_thread_caller_id() != OS.get_main_thread_id(): - return { - "status": "error", - "message": "Cannot configure %s without a main-thread launch snapshot; retry from the dock." % client.display_name, - } - context = capture_launch_context() - var launch := ( - resolve_attach_launch(context) - if client.command_shape != Client.CommandShape.NONE - else {} - ) - var result := _dispatch_configure(client, url, launch, context) - ## Trust-but-verify: a strategy may report ok and have actually written the - ## file, yet the entry is missing/stale on the read-back path — most often - ## because the user's installed client is reading a different file than - ## `path_template` resolves to (issue #201). Re-read the live state and - ## surface a clear error before the dock reports a bogus green dot. - return _verify_post_state(client, result, Client.Status.CONFIGURED, url, "configure", launch, context) - - -static func check_status(id: String) -> Client.Status: - if ClientRegistry.stale_session_detected(): - return Client.Status.ERROR - var client := ClientRegistry.get_by_id(id) - if client == null: - return Client.Status.NOT_CONFIGURED - var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} - return _dispatch_check_status(client, http_url(), context) - - -static func check_status_for_url_with_cli_path( - id: String, url: String, cli_path: String, launch_context: Dictionary = {} -) -> Client.Status: - return check_status_details_for_url_with_cli_path(id, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) - - -## True when 's stored entry verifies EXACTLY against the launch this -## editor would have rendered at `from_version` — same ports, exclusions, -## telemetry flag, command shape; nothing differs but the version pin. This -## is the post-update auto-repin gate: only such entries are provably "what -## Configure wrote before the update", so rewriting them to the current -## version restores the user's own prior intent. Anything else — entries -## pointing at another editor's ports (the smoke-fixture blast radius that -## motivated this gate), hand-edits, changed settings — stays untouched for -## the drift banner's human click. An empty `from_version` (marker written -## by a pre-gate runner) fails closed. -## -## The version-substituted context is safe to resolve and cache: -## `plugin_version` is part of both the attach-launch resolution -## (`_resolve_attach_launch_uncached`) and its cache key. -static func entry_drift_is_version_pin_only( - id: String, from_version: String, launch_context: Dictionary = {} -) -> bool: - var pinned_from := from_version.strip_edges() - if pinned_from.is_empty(): - return false - ## Never shell out from this gate — it runs on the MAIN thread from the - ## dock's sweep-completion callback (#890 review P2). The cli-descriptor - ## probe paths run a subprocess with a multi-second timeout (`claude mcp - ## get` is 6s), which would freeze the editor; those clients fail closed - ## to the drift banner's click flow, whose fan-out already runs on - ## worker threads. Every path left after these guards is a bounded - ## config-file read plus cached launch discovery (worst case one CLI - ## lookup on a cold CliFinder cache — the same bounded one-shot the - ## dock's `_reprobe_uv_if_negative` accepts on this thread). - var client := ClientRegistry.get_by_id(id) - if client == null: - return false - if client.config_type == "cli": - if client.command_shape == Client.CommandShape.NONE: - return false - if not client.has_json_fallback(): - return false - if _scope_diverges_from_json_fallback(client): - return false - var context := launch_context if not launch_context.is_empty() else capture_launch_context() - var old_context := context.duplicate(true) - old_context["plugin_version"] = pinned_from - ## Same URL under both versions: the URL carries the port, not the - ## version, so URL-mode entries can never be version-pin-only drift and - ## correctly fail this check. - var url := str(context.get("server_url", http_url())) - var status := check_status_for_url_with_cli_path(id, url, "", old_context) - return status == Client.Status.CONFIGURED - - -## Detailed variant used by the dock refresh worker. Returns -## `{"status": Status, "error_msg": String}` so the worker can surface -## "probe timed out" on the row instead of silently flipping it to -## NOT_CONFIGURED. Callers that only need the status can use the simpler -## helper above. -static func check_status_details_for_url_with_cli_path( - id: String, - url: String, - cli_path: String, - launch_context: Dictionary = {}, - resolved_launch: Dictionary = {}, -) -> Dictionary: - ## One comprehensible line per row beats a wall of per-field type errors — - ## the dock keeps painting, every row names the same repair (restart). - if ClientRegistry.stale_session_detected(): - return {"status": Client.Status.ERROR, "error_msg": ClientRegistry.RESTART_TO_FINISH_UPDATE} - var client := ClientRegistry.get_by_id(id) - if client == null: - return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} - # A cli client with no resolved binary normally reads as NOT_CONFIGURED. - # Skip that shortcut when the client has a JSON fallback (#463): the - # dispatch below reads its config file directly so the status dot reflects - # a fallback-configured entry instead of always showing red. - if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback(): - return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} - var path_error := _config_path_resolution_error(client) - if not path_error.is_empty(): - return {"status": Client.Status.ERROR, "error_msg": path_error} - if client.command_shape != Client.CommandShape.NONE and launch_context.is_empty(): - return { - "status": Client.Status.ERROR, - "error_msg": "Missing launch-context snapshot; retry the status refresh.", - } - return _dispatch_check_status_with_cli_path_details( - client, url, cli_path, launch_context, resolved_launch - ) - - -## #691: main-thread pre-warm of McpPathTemplate's env snapshot, covering -## the base vars plus every descriptor-declared config-file/config-home env -## (OPENCODE_CONFIG, CLAUDE_CONFIG_DIR, CODEX_HOME, …), so worker-thread config-path -## resolution never calls OS.get_environment concurrently with the spawn -## window's setenv/unsetenv. Also warms the EditorSettings snapshot for -## the mode/trace overrides so worker-thread mode_override() / -## startup_trace_enabled() never touch EditorInterface. Idempotent; -## called from plugin _enter_tree and before each dock worker dispatch. -static func warm_env_snapshot() -> void: - var extras := PackedStringArray() - for id in client_ids(): - var client := ClientRegistry.get_by_id(String(id)) - if client == null: - continue - ## Reflected get(): after an in-session self-update these fields can - ## read as Nil on stale instances, and String(Nil) is a hard error (#850). Skipping just degrades env-override - ## resolution until the restart the registry is already asking for. - for env_name in [client.get("config_file_env"), client.get("config_home_env")]: - if env_name is String and not env_name.is_empty() and not extras.has(env_name): - extras.append(env_name) - McpPathTemplate.warm_env_snapshot(extras) - _editor_setting_lookup(MODE_OVERRIDE_SETTING) - _editor_setting_lookup(SETTING_STARTUP_TRACE) - _editor_setting_lookup(SETTING_KEEP_SERVER_ON_EXIT) - _editor_setting_lookup(SETTING_EXTERNAL_CLIENT_CWD) - McpSettings.warm_client_scope() - # Publish the complete launch context while EditorInterface access is safe; - # worker callers of capture_launch_context() read this snapshot only. - capture_launch_context() - - -static func client_status_probe_snapshot(id: String) -> Dictionary: - var client := ClientRegistry.get_by_id(id) - if client == null: - return {} - var cli_path := "" - var installed := false - if client.config_type == "cli": - cli_path = CliStrategy.resolve_cli_path(client) - # #463: a JSON-fallback cli client (Claude Code as a VS Code extension) - # is "installed" when its fallback config exists, even with no binary. - installed = not cli_path.is_empty() or client.is_installed() - else: - installed = client.is_installed() - return {"id": id, "cli_path": cli_path, "installed": installed} - - -## Force lazy GDScript bytecode swaps to complete before a client-status -## worker reaches the registry and strategies. Pure-memory only: callers can -## run this on the handler thread without performing CLI or config probes. -static func warm_status_worker_bytecode() -> void: - var ids := client_ids() - if ids.is_empty(): - return - var any_client := ClientRegistry.get_by_id(String(ids[0])) - if any_client != null: - JsonStrategy.verify_entry(any_client, {}, "") - DshStrategy.command_launch_error(any_client, {}) - TomlStrategy.format_body(PackedStringArray(), "") - CliStrategy.format_args(PackedStringArray(), "", "") - # Compile the aggregate worker entry point on main as well. After a plugin - # reload, first-dereferencing this function from Thread can hang in Godot's - # lazy bytecode swap even when every strategy it calls was already warmed. - run_client_status_sweep({}, true) - - -## Worker entry point for the MCP aggregate status command. Every filesystem, -## CLI, and launch-discovery probe stays inside this function; the WebSocket -## handler only schedules it and returns the deferred sentinel. -static func run_client_status_sweep( - fallback_launch_context: Dictionary = {}, warm_only: bool = false -) -> Dictionary: - if warm_only: - return {} - var clients := [] - var launch_context := capture_launch_context() - if launch_context.is_empty(): - launch_context = fallback_launch_context.duplicate(true) - if launch_context.is_empty(): - return {"worker_error": "Client status launch context was not warmed on the main thread."} - var server_url := server_url_from(launch_context) - var resolved_launch := resolve_attach_launch(launch_context) - for client_id in client_ids(): - var probe := client_status_probe_snapshot(client_id) - var details := check_status_details_for_url_with_cli_path( - client_id, - server_url, - str(probe.get("cli_path", "")), - launch_context, - resolved_launch, - ) - clients.append(_client_status_sweep_entry( - client_id, details, bool(probe.get("installed", false)) - )) - return {"data": {"clients": clients}} - - -static func _client_status_sweep_entry( - client_id: String, details: Dictionary, installed: bool -) -> Dictionary: - var status = details.get("status", Client.Status.NOT_CONFIGURED) - var entry := { - "id": client_id, - "display_name": client_display_name(client_id), - "status": Client.status_label(status), - "installed": installed, - } - var error_msg := str(details.get("error_msg", "")) - if not error_msg.is_empty(): - entry["error"] = error_msg - return entry - - -## Pass an explicit `url` when calling from a worker thread — see -## `configure()` above for why. The url is only used to format the -## verify-after-write diagnostic message; the remove itself doesn't need it. -static func remove(id: String, url: String = "", launch_context: Dictionary = {}) -> Dictionary: - if ClientRegistry.stale_session_detected(): - return {"status": "error", "message": ClientRegistry.RESTART_TO_FINISH_UPDATE} - var client := ClientRegistry.get_by_id(id) - if client == null: - return {"status": "error", "message": "Unknown client: %s" % id} - var path_error := _config_path_resolution_error(client) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if url.is_empty(): - url = http_url() - var context := launch_context - if client.command_shape != Client.CommandShape.NONE and context.is_empty(): - if OS.get_thread_caller_id() != OS.get_main_thread_id(): - return { - "status": "error", - "message": "Cannot remove %s without a main-thread launch snapshot; retry from the dock." % client.display_name, - } - context = capture_launch_context() - var launch := ( - resolve_attach_launch(context) - if client.command_shape != Client.CommandShape.NONE - else {} - ) - var result := _dispatch_remove(client, context) - return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch, context) - - -## Resolve config-backed path errors before attach-launch discovery. This both -## gives ambiguity precedence over unrelated launcher failures and avoids -## spending the status worker's command budget on a Configure action that must -## fail closed regardless. CLI clients keep their existing CLI/fallback dispatch. -static func _config_path_resolution_error(client: Client) -> String: - if client.config_type == "cli": - return "" - return str(client.resolved_config_path_details().get("error", "")) - - -# --- Strategy dispatch + verify (testable seam) -------------------------- - -static func _dispatch_configure( - client: Client, url: String, launch: Dictionary = {}, launch_context: Dictionary = {} -) -> Dictionary: - launch = launch_for_client(client, launch) - match client.config_type: - "json": - return JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) - "toml": - return TomlStrategy.configure(client, SERVER_NAME, url, launch) - "yaml": - return YamlStrategy.configure(client, SERVER_NAME, url, launch) - "dsh": - return DshStrategy.configure(client, SERVER_NAME, url, launch) - "cli": - # #463: fall back to writing the config file directly when the CLI - # binary isn't on PATH (Claude Code as a VS Code/Cursor extension). - if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): - return _note_unhonoured_scope( - client, JsonStrategy.configure(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) - ) - return CliStrategy.configure(client, SERVER_NAME, url, launch) - return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} - - -static func _dispatch_remove(client: Client, launch_context: Dictionary = {}) -> Dictionary: - match client.config_type: - "json": - return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) - "toml": - return TomlStrategy.remove(client, SERVER_NAME) - "yaml": - return YamlStrategy.remove(client, SERVER_NAME) - "dsh": - return DshStrategy.remove(client, SERVER_NAME) - "cli": - # #463: mirror the configure fallback so Remove also works without - # the CLI binary — otherwise a fallback-written entry is unremovable. - if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty(): - return JsonStrategy.remove(client, SERVER_NAME, _project_roots_from_context(launch_context)) - return CliStrategy.remove(client, SERVER_NAME) - return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]} - - -static func _dispatch_check_status( - client: Client, url: String, launch_context: Dictionary = {} -) -> Client.Status: - return _dispatch_check_status_with_cli_path(client, url, "", launch_context) - - -static func _dispatch_check_status_with_cli_path( - client: Client, url: String, cli_path: String, launch_context: Dictionary = {} -) -> Client.Status: - return _dispatch_check_status_with_cli_path_details(client, url, cli_path, launch_context).get("status", Client.Status.NOT_CONFIGURED) - - -static func _dispatch_check_status_with_cli_path_details( - client: Client, - url: String, - cli_path: String, - launch_context: Dictionary = {}, - resolved_launch: Dictionary = {}, -) -> Dictionary: - match client.config_type: - "json": - var launch := {} - if client.command_shape != Client.CommandShape.NONE: - launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return JsonStrategy.check_status_details(client, SERVER_NAME, url, launch, _project_roots_from_context(launch_context)) - "toml": - var launch := {} - if client.command_shape != Client.CommandShape.NONE: - launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return TomlStrategy.check_status_details(client, SERVER_NAME, url, launch) - "yaml": - var yaml_launch := {} - if client.command_shape != Client.CommandShape.NONE: - yaml_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return YamlStrategy.check_status_details(client, SERVER_NAME, url, yaml_launch) - "dsh": - var dsh_launch := {} - if client.command_shape != Client.CommandShape.NONE: - dsh_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return DshStrategy.check_status_details(client, SERVER_NAME, url, dsh_launch) - "cli": - # Command-shape CLI clients register through their CLI, but the entry - # lands in the same file the JSON fallback reads (`claude mcp add - # --scope user` writes mcpServers in ~/.claude.json). Reading that - # file gives exact launch-drift detection — a changed port, version - # pin, or exclusion list — which scanning `mcp list` stdout cannot, - # so it is preferred even when the CLI binary resolves. - # #872: ...but only while the selected scope is still the one - # `path_template` points at — see _scope_diverges_from_json_fallback. - if ( - client.command_shape != Client.CommandShape.NONE - and client.has_json_fallback() - and not _scope_diverges_from_json_fallback(client) - ): - var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch, _project_roots_from_context(launch_context)) - var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client) - # #463: with no CLI binary, read the JSON fallback config so a - # fallback-configured entry reports CONFIGURED instead of red. - if resolved_cli.is_empty() and client.has_json_fallback(): - var fallback_launch := {} - if client.command_shape != Client.CommandShape.NONE: - fallback_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - return JsonStrategy.check_status_details(client, SERVER_NAME, url, fallback_launch, _project_roots_from_context(launch_context)) - var cli_launch := {} - if client.command_shape != Client.CommandShape.NONE: - cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) - # #872: at a scope `path_template` can't see, a plain listing probe - # cannot tell our entry from a leftover one in another scope. When - # the descriptor offers a scope-aware probe, use it — same single - # subprocess, but it reports which scope actually resolved. - if ( - not client.cli_scope_status_template.is_empty() - and CliStrategy.uses_scope_token(client) - ): - return CliStrategy.check_scope_status_details( - client, SERVER_NAME, url, resolved_cli, cli_launch, McpSettings.client_scope() - ) - return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch) - return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} - - -## #872: the #463 JSON fallback writes the user-scope file whatever -## `godot_ai/mcp_client_scope` says — that file IS `path_template`, and -## `clients/_path_template.gd` has no project-relative token to aim it -## elsewhere. Returning a bare "configured" would hide that the requested -## scope was not honoured, so say so in the message. -## -## Deliberately a message and not a status. `_verify_post_state` compares the -## post-write status against CONFIGURED and replaces the ok with an error on -## any mismatch (pinned by test_verify_post_state_treats_drift_as_failure_ -## after_configure), so reporting this write as NOT_CONFIGURED or MISMATCH -## would turn a *successful* configure into a user-facing failure — the same -## false-error class #872's blocker was about, just moved onto the fallback -## path. The entry is genuinely written and genuinely works; only its scope -## differs from the request, and that is a caveat, not a failure. -static func _note_unhonoured_scope(client: Client, result: Dictionary) -> Dictionary: - if result.get("status") != "ok": - return result - if not CliStrategy.uses_scope_token(client): - return result - var scope := McpSettings.client_scope() - if scope == McpSettings.DEFAULT_CLIENT_SCOPE: - return result - var noted := result.duplicate(true) - noted["message"] = ( - "%s — wrote %s scope, not %s: the %s CLI wasn't found, and the file fallback can only write %s" - % [ - str(result.get("message", "")), - McpSettings.DEFAULT_CLIENT_SCOPE, - scope, - client.display_name, - client.resolved_config_path(), - ] - ) - return noted - - -## True when this client's CLI writes its entry somewhere `path_template` -## cannot see, so reading that file would describe the wrong thing (#872). -## -## `claude mcp add --scope project` writes /.mcp.json and `--scope local` -## writes a per-project block inside ~/.claude.json — neither is the top-level -## `mcpServers` map `path_template` + `server_key_path` resolve to. Reading it -## anyway makes `_verify_post_state` turn every successful project-scope -## Configure into "configure ok but verification still reads Not configured", -## and pins the dock row red (or green, describing a stale user-scope entry). -## -## Diverging sends status down the CLI probe instead: `mcp list` is -## scope-agnostic and inherits the same cwd the register ran in, so read-back -## and write agree by construction. The cost is losing exact launch-drift -## detection — a stdout scan sees the command, not the full argv — which is the -## documented trade for a status that is merely coarse instead of wrong. -## -## Deliberately says nothing about whether the CLI resolves (#879). An earlier -## version walked PATH here and returned false for an unresolvable binary, on -## the reasoning that configure would then have taken the #463 JSON fallback — -## which writes user scope whatever the setting says — so the file was the -## right thing to read again. That extra clause could not change any outcome: -## this is only consulted from the one call site below, already guarded on -## `has_json_fallback()`, and when the CLI does not resolve the very next -## branch (`resolved_cli.is_empty() and client.has_json_fallback()`) takes the -## fallback read anyway. All it bought was a second full `McpCliFinder.find` -## sweep per status check, on top of the one that immediately follows. -static func _scope_diverges_from_json_fallback(client: Client) -> bool: - if not CliStrategy.uses_scope_token(client): - return false - return McpSettings.client_scope() != McpSettings.DEFAULT_CLIENT_SCOPE - - -static func _resolved_or_discovered_launch( - client: Client, resolved_launch: Dictionary, launch_context: Dictionary -) -> Dictionary: - var launch := ( - resolved_launch - if not resolved_launch.is_empty() - else resolve_attach_launch(launch_context) - ) - return launch_for_client(client, launch) - - -## After a configure/remove returns ok, re-read the live status. If it doesn't -## match `expected`, replace the result with an error that names the actual -## status and the resolved config path so the user can self-diagnose. The -## strategy's own error path is left untouched — already actionable. -static func _verify_post_state( - client: Client, - result: Dictionary, - expected: Client.Status, - url: String, - action: String, - resolved_launch: Dictionary = {}, - launch_context: Dictionary = {}, -) -> Dictionary: - if result.get("status") != "ok": - return result - var details := _dispatch_check_status_with_cli_path_details( - client, url, "", launch_context, resolved_launch - ) - var actual := details.get("status", Client.Status.NOT_CONFIGURED) - if actual == expected: - return result - var path_hint := _post_state_path_hint(client, str(details.get("resolved_scope", ""))) - return { - "status": "error", - "message": "%s reported %s ok but verification still reads %s (expected %s).%s" % [ - client.display_name, action, - Client.status_label(actual), Client.status_label(expected), - path_hint, - ], - } - - -## Where to send the user when verification finds an entry that should not be -## there. `resolved_config_path()` is `path_template` — right only for the -## default user scope. Naming ~/.claude.json for a project-scope survivor sends -## them to a file with nothing in it, which is worse than naming no file at all -## (#879). The scope probe supplies `resolved_scope`; `path_template` has no -## project-relative token (see `_note_unhonoured_scope`), so the project case is -## described rather than resolved to a path. -static func _post_state_path_hint(client: Client, resolved_scope: String) -> String: - match resolved_scope: - "project": - return ( - " The surviving entry is project-scoped: inspect the .mcp.json in the" - + " directory the editor was launched from and remove the godot-ai entry" - + " by hand if needed." - ) - "local": - var local_path := client.resolved_config_path() - if local_path.is_empty(): - return "" - return ( - " The surviving entry is local-scoped: inspect the block for the" - + " directory the editor was launched from in %s and remove the" - + " godot-ai entry by hand if needed." - ) % local_path - _: - var path := client.resolved_config_path() - if path.is_empty(): - return "" - return " Inspect %s and remove the godot-ai entry by hand if needed." % path - - -## #877: Configure's first act is the all-scope pre-cleanup — it deletes the -## `godot-ai` entry from every scope the descriptor can write to, including a -## `.mcp.json` resolved against the CLI's working directory, which need not be -## this project's folder. The manual-command panel that spells those removes -## out is only shown when Configure FAILS (`mcp_dock.gd`), so on the success -## path — the one where the sweep actually ran — this note is its only -## disclosure. Empty for descriptors without a scope token: their single -## implicit pass removes exactly the entry the register is about to rewrite, -## which needs no warning (the same rule `_sweep_caveat` applies). -## -## "attempted", not "cleared": `CliStrategy.configure` discards every -## pre-cleanup result, and `mcp remove` exits non-zero for an absent entry as -## well as for a real failure, so a timed-out or failed remove leaves the scope -## untouched while the register still returns ok. The note names what ran, -## not what it can prove. -static func configure_sweep_note(id: String) -> String: - var client := ClientRegistry.get_by_id(id) - if client == null or client.config_type != "cli": - return "" - if client.cli_unregister_template.is_empty() or not CliStrategy.uses_scope_token(client): - return "" - return "attempted to clear %s from %s" % [ - SERVER_NAME, ", ".join(CliStrategy.cleanup_scopes(client)), - ] - - -static func manual_command(id: String) -> String: - var client := ClientRegistry.get_by_id(id) - if client == null: - return "" - var path_resolution := client.resolved_config_path_details() - var path_error := str(path_resolution.get("error", "")) - if not path_error.is_empty(): - return "Config path unavailable: %s" % path_error - var context := capture_launch_context() if client.command_shape != Client.CommandShape.NONE else {} - var launch := ( - launch_for_client(client, resolve_attach_launch(context)) - if client.command_shape != Client.CommandShape.NONE - else {} - ) - var cmd := ManualCommand.build( - client, - SERVER_NAME, - server_url_from(context), - str(path_resolution.get("path", "")), - launch, - _project_roots_from_context(context), - ) - if cmd.is_empty(): - return cmd - ## #507: when the allow-host opt-in names a non-loopback range, also - ## surface the LAN URL so the user can copy-paste the right address into - ## a remote agent. Informational only — configure/remove still WRITE the - ## loopback URL above; nothing about the config-file contract changes. - var note := McpAllowHosts.lan_url_note(allow_hosts(), IP.get_local_addresses(), http_port()) - if not note.is_empty(): - cmd += "\n\n" + note - return cmd - - -static func config_path(id: String) -> String: - var client := ClientRegistry.get_by_id(id) - return client.resolved_config_path() if client != null else "" - - -## Resolve the highest-precedence config tier that actually contains the server -## entry. For Pi-style merge clients, returns the tier path the entry lives in -## (e.g. `~/.pi/agent/.mcp.json`) instead of the lowest-tier `path_template` -## `config_path()` returns. Falls back to `path_template` when no entry is -## found anywhere. Empty `launch_context` is allowed; command-shape clients -## synthesize one via `capture_launch_context()` so dock row construction can -## call this without first capturing launch state. -static func effective_config_path(id: String, launch_context: Dictionary = {}) -> String: - var client := ClientRegistry.get_by_id(id) - if client == null: - return "" - var resolution := client.resolved_config_path_details() - var fallback := str(resolution.get("path", "")) - var context := launch_context - if context.is_empty() and client.command_shape != Client.CommandShape.NONE: - context = capture_launch_context() - var roots := _project_roots_from_context(context) - var target := JsonStrategy.manual_target_details(client, SERVER_NAME, fallback, roots) - if bool(target.get("ok", false)): - return str(target.get("path", fallback)) - return fallback - - -## Path that `_check_status_merged` would consider authoritative for the -## server entry, or `path_template` when no entry is found anywhere. -## For Pi-style merge clients this is the latest project tier when one -## exists (matching the F2 last-wins status logic), falling back to the -## latest global tier, then to `path_template`. -## -## The dock uses this for Open/Reveal so the file the user inspects is -## the same file that drives the status check — `effective_config_path` -## fails closed (returns `path_template`) when there are multiple project -## tiers, which historically sent users to the wrong file (codex round 3, -## F-3-4). Splitting this from `effective_config_path` keeps the manual -## instructions fail-closed while letting the dock follow the -## authoritative tier. -static func effective_authoritative_path(id: String, launch_context: Dictionary = {}) -> String: - var client := ClientRegistry.get_by_id(id) - if client == null: - return "" - var resolution := client.resolved_config_path_details() - var fallback := str(resolution.get("path", "")) - var context := launch_context - if context.is_empty() and client.command_shape != Client.CommandShape.NONE: - context = capture_launch_context() - var roots := _project_roots_from_context(context) - var authoritative: String = JsonStrategy.authoritative_tier_path(client, SERVER_NAME, roots) - if not authoritative.is_empty(): - return authoritative - return fallback - - -static func is_installed(id: String) -> bool: - var client := ClientRegistry.get_by_id(id) - return client != null and client.is_installed() - - -# --- Server command discovery -------------------------------------------- -# -# Three-tier resolution: -# 1. .venv python — dev checkout, source code -# 2. uvx — user install, published package from PyPI -# 3. godot-ai CLI — system-wide pip/pipx/uv install - -static func get_plugin_version() -> String: - var cfg := ConfigFile.new() - if cfg.load("res://addons/godot_ai/plugin.cfg") == OK: - return cfg.get_value("plugin", "version", "0.0.1") - return "0.0.1" - - -## Strip PEP 440 local build metadata for PyPI pins: `3.0.2+local.1` → `3.0.2`. -## Pre-release segments (`3.1.0-rc1`) are preserved — only `+…` is removed. -static func _pypi_pin_version(version: String) -> String: - var v := version.strip_edges() - var plus := v.find("+") - if plus >= 0: - v = v.substr(0, plus) - return v - - -## Resolve the client-owned `godot-ai attach` command from a main-thread -## LaunchContext. Discovery itself is worker-safe: path/environment lookup is -## snapshot-backed and subprocess probes are wall-clock bounded. -## -## `discovery_override` is a data-only test seam. Supplying a key (including -## an empty value) bypasses that tier's live lookup; `system_version_result` -## bypasses the real `godot-ai --version` subprocess. -static func resolve_attach_launch( - launch_context: Dictionary, discovery_override: Dictionary = {} -) -> Dictionary: - ## Test overrides always bypass the session cache so fixture-controlled - ## discovery remains deterministic. Production results are keyed by every - ## setting that affects the rendered command; a port/domain/version change - ## therefore cannot reuse stale arguments. - if not discovery_override.is_empty(): - return _resolve_attach_launch_uncached(launch_context, discovery_override) - var cache_key := _attach_launch_cache_key(launch_context) - _attach_launch_cache_mutex.lock() - if _attach_launch_cache.has(cache_key): - var cached: Dictionary = _attach_launch_cache[cache_key].duplicate(true) - _attach_launch_cache_mutex.unlock() - return cached - ## Keep the cache lock through the bounded discovery probes. This cold path - ## runs at most once per distinct context and prevents simultaneous status - ## workers from repeating the same subprocess probes. Invalidation waits for - ## the in-flight result, then clears it, so stale work cannot repopulate a - ## freshly invalidated cache. - var resolved := _resolve_attach_launch_uncached(launch_context) - _attach_launch_cache[cache_key] = resolved.duplicate(true) - _attach_launch_cache_mutex.unlock() - return resolved - - -static func _resolve_attach_launch_uncached( - launch_context: Dictionary, discovery_override: Dictionary = {} -) -> Dictionary: - for key in ["http_port", "ws_port", "excluded_domains", "plugin_version", "allow_dev_venv", "platform"]: - if not launch_context.has(key): - return _attach_discovery_error("Launch context is missing `%s`; retry Configure." % key) - - var plugin_version := str(launch_context.get("plugin_version", "")).strip_edges() - if plugin_version.is_empty(): - return _attach_discovery_error("The bundled godot-ai version is unavailable; reinstall the plugin and retry Configure.") - - var common_args: Array[String] = [ - "attach", - "--port", str(int(launch_context.get("http_port", DEFAULT_HTTP_PORT))), - "--ws-port", str(int(launch_context.get("ws_port", DEFAULT_WS_PORT))), - ] - var exclusions := str(launch_context.get("excluded_domains", "")).strip_edges() - if not exclusions.is_empty(): - common_args.append_array(["--exclude-domains", exclusions]) - ## Default true when the key is absent (hand-built contexts in tests, stale - ## pre-upgrade snapshots) — matching the server's send-by-default posture. - ## Toggling the setting changes the rendered argv, so existing entries read - ## CONFIGURED_MISMATCH and the dock offers Reconfigure, like any other - ## launch-affecting value. - if not bool(launch_context.get("telemetry_enabled", true)): - common_args.append("--disable-telemetry") - - var venv_python := "" - if discovery_override.has("venv_python"): - venv_python = str(discovery_override["venv_python"]) - elif bool(launch_context.get("allow_dev_venv", true)): - venv_python = _cached_venv_python() - if bool(launch_context.get("allow_dev_venv", true)) and not venv_python.is_empty(): - var venv_args: Array[String] = ["-m", "godot_ai"] - venv_args.append_array(common_args) - return _finalize_attach_launch( - "dev_venv", venv_python, venv_args, launch_context, discovery_override - ) - - var uvx := "" - if discovery_override.has("uvx_path"): - uvx = str(discovery_override["uvx_path"]) - else: - ## Strict lookup for attach entries: never write a bare `uvx` command - ## that a GUI-launched client may be unable to resolve from its PATH. - uvx = find_uvx() - if not uvx.is_empty(): - var uvx_args: Array[String] = [ - "--link-mode", "copy", - "--from", "godot-ai==%s" % _pypi_pin_version(plugin_version), - "godot-ai", - ] - uvx_args.append_array(common_args) - return _finalize_attach_launch( - "uvx", uvx, uvx_args, launch_context, discovery_override - ) - - var system_cmd := "" - if discovery_override.has("system_path"): - system_cmd = str(discovery_override["system_path"]) - else: - system_cmd = _find_system_install() - if not system_cmd.is_empty(): - var probe: Dictionary - if discovery_override.has("system_version_result"): - probe = discovery_override["system_version_result"] as Dictionary - else: - probe = McpCliExec.run(system_cmd, ["--version"], _DISCOVERY_TIMEOUT_MS, false) - var version_check := _system_version_from_probe(probe) - if bool(version_check.get("ok", false)): - var found_version := str(version_check.get("version", "")) - if found_version == plugin_version: - return _finalize_attach_launch( - "system", system_cmd, common_args, launch_context, discovery_override - ) - return _attach_discovery_error( - "System godot-ai is version %s, but this plugin requires %s. Install uv or update the system package, then retry Configure." - % [found_version, plugin_version] - ) - if bool(probe.get("timed_out", false)): - return _attach_discovery_error( - "Timed out checking the system godot-ai version. Install uv or repair the system command, then retry Configure." - ) - return _attach_discovery_error( - "Could not verify the system godot-ai version. Install uv or repair the system command, then retry Configure." - ) - - return _attach_discovery_error( - "No compatible godot-ai launcher was found. Install uv (provides uvx), then retry Configure." - ) - - -## Return a launch shape that cannot allocate a visible console on Windows. -## The development tier can execute its sibling pythonw directly. uvx and the -## system entry point still need their own environments, so pythonw acts only -## as a stdio-preserving, CREATE_NO_WINDOW process bootstrap for those tiers. -static func _finalize_attach_launch( - tier: String, - command: String, - args: Array[String], - launch_context: Dictionary, - discovery_override: Dictionary, -) -> Dictionary: - if str(launch_context.get("platform", "")) != "Windows": - return {"ok": true, "tier": tier, "command": command, "args": args} - - var pythonw := _resolve_consoleless_python(command, tier, discovery_override) - if pythonw.is_empty(): - return _attach_discovery_error( - "Windows requires pythonw.exe to launch the MCP bridge without opening a terminal window. Repair this Python or uv installation, then retry Configure." - ) - - ## `console_command`/`console_args` carry the unwrapped console-subsystem - ## launch for clients that opt out of pythonw via - ## `needs_consoleless_launcher = false` (#863). Strategies only consume - ## `command`/`args`/`ok`; `launch_for_client` swaps the shapes per client. - if tier == "dev_venv": - return { - "ok": true, "tier": tier, "command": pythonw, "args": args, - "console_command": command, "console_args": args, - } - - var wrapped_args: Array[String] = ["-c", _WINDOWS_STDIO_BOOTSTRAP, command] - wrapped_args.append_array(args) - return { - "ok": true, "tier": tier, "command": pythonw, "args": wrapped_args, - "console_command": command, "console_args": args, - } - - -## Select the launch shape a specific client should see. Clients with -## `needs_consoleless_launcher = false` (Antigravity, #863) get the plain -## console command captured by `_finalize_attach_launch`; everyone else keeps -## the pythonw shape unchanged. Idempotent: the returned dict carries no -## console keys, so a second application is a no-op. -static func launch_for_client(client: Client, launch: Dictionary) -> Dictionary: - if client == null or client.needs_consoleless_launcher: - return launch - if not launch.has("console_command"): - return launch - var selected := launch.duplicate(true) - selected["command"] = selected["console_command"] - selected["args"] = selected["console_args"] - selected.erase("console_command") - selected.erase("console_args") - return selected - - -static func _resolve_consoleless_python( - command: String, tier: String, discovery_override: Dictionary -) -> String: - ## Data-only override keeps resolver tests independent of the host's Python. - if discovery_override.has("consoleless_python"): - return str(discovery_override["consoleless_python"]) - - ## Venv/system console-script launchers normally keep pythonw beside their - ## python.exe. The dev tier must use that exact interpreter so godot_ai is - ## imported from the selected checkout rather than some unrelated install. - var sibling := command.get_base_dir().path_join("pythonw.exe") - if FileAccess.file_exists(sibling): - return sibling - if tier == "dev_venv": - return "" - - ## uvx may be installed without a PATH-visible CPython. Ask its sibling uv - ## for the already-managed system interpreter; Godot AI's existing uvx - ## server launch ensures one normally exists before client configuration. - if tier == "uvx": - var uv := command.get_base_dir().path_join("uv.exe") - if not FileAccess.file_exists(uv): - uv = CliFinder.find(["uv.exe"]) - if not uv.is_empty(): - var probe := McpCliExec.run( - uv, ["python", "find", "--system"], _DISCOVERY_TIMEOUT_MS, false - ) - if int(probe.get("exit_code", -1)) == 0: - var python := str(probe.get("stdout", "")).strip_edges() - if not python.is_empty(): - var managed_pythonw := python.get_base_dir().path_join("pythonw.exe") - if FileAccess.file_exists(managed_pythonw): - return managed_pythonw - - ## A system Python GUI launcher is sufficient for the non-dev bootstrap; - ## it does not import godot_ai itself. - return CliFinder.find(["pythonw.exe"]) - - -static func _system_version_from_probe(probe: Dictionary) -> Dictionary: - if int(probe.get("exit_code", -1)) != 0: - return {"ok": false} - var output := str(probe.get("stdout", "")).strip_edges() - var pattern := RegEx.new() - if pattern.compile("^godot-ai\\s+([^\\s]+)(?:\\s|$)") != OK: - return {"ok": false} - var matched := pattern.search(output) - if matched == null: - return {"ok": false} - return {"ok": true, "version": matched.get_string(1)} - - -static func _attach_discovery_error(message: String) -> Dictionary: - return {"ok": false, "error": message} - - -## Override for the dev-vs-user heuristic. Accepted values: -## "dev" — force dev-checkout mode (skip update check + self-install) -## "user" — force user-install mode (run update check, allow self-install) -## as long as the data-safety guard (addons_dir_is_symlink) passes -## other / unset — "auto": fall back to the .venv-proximity heuristic -## -## Use `user` to test the AssetLib self-update flow from inside a dev -## checkout (there's a .venv nearby but `addons/godot_ai` is a plain copy — -## e.g. after unpacking a release zip into `test_project/`). -## -## Two ways to set it, resolved in priority order: -## 1. EditorSettings → `godot_ai/mode_override` — set manually via -## Editor Settings (no dock UI writes it today); persists -## per-editor-install and wins over the env var so an editor-side -## choice always takes effect without relaunching. -## 2. Env var `GODOT_AI_MODE` — useful for CLI launches and CI. -const MODE_OVERRIDE_ENV := "GODOT_AI_MODE" -const MODE_OVERRIDE_SETTING := "godot_ai/mode_override" - - -static func mode_override() -> String: - # 1. EditorSetting wins — the user explicitly set it via Editor Settings. - # _editor_setting_lookup handles the `Engine.is_editor_hint()` gate - # (no-op in the game subprocess; see CLAUDE.md "Game-side code") and - # serves worker threads from a main-thread-warmed snapshot — this - # runs on the #678 startup walk's discovery worker, and - # EditorInterface/EditorSettings are not thread-safe (#691). - var setting: Variant = _editor_setting_lookup(MODE_OVERRIDE_SETTING) - if setting != null: - var setting_val := str(setting).strip_edges().to_lower() - if setting_val == "dev" or setting_val == "user": - return setting_val - # 2. Env var fallback. env_lookup, not OS.get_environment: same - # worker-thread reason (#691). - var raw := McpPathTemplate.env_lookup(MODE_OVERRIDE_ENV).strip_edges().to_lower() - if raw == "dev" or raw == "user": - return raw - return "" - - -static func is_dev_checkout() -> bool: - match mode_override(): - "dev": - return true - "user": - return false - return not _find_venv_python().is_empty() - - -## Data-safety check for self-install: is `res://addons/godot_ai` a symbolic -## link? In a dev checkout this points at the canonical `plugin/` source -## tree, and writing files into it would clobber tracked source. This check -## is independent of `is_dev_checkout()` so a forced-user mode override -## still cannot extract a release zip over the symlink. -static func addons_dir_is_symlink() -> bool: - return _is_symlink(ProjectSettings.globalize_path("res://addons/godot_ai")) - - -## Mirrors the idiom used in `mcp_dock.gd::_resolve_plugin_symlink_target` — -## open the parent dir and ask Godot via `DirAccess.is_link()`, which -## handles symlinks on POSIX and reparse points on Windows natively. -static func _is_symlink(path: String) -> bool: - if path.is_empty(): - return false - var dir := DirAccess.open(path.get_base_dir()) - if dir == null: - ## This is a data-safety guard (a symlinked addons dir is a dev - ## checkout self-update must never write through). When the path - ## exists but its parent can't be opened, we can't PROVE it isn't - ## a link — fail closed and treat it as one (#711). - return DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path) - return dir.is_link(path) - - -## `refresh` forces uvx to re-fetch PyPI index metadata on spawn — used by -## `_start_server`'s one-shot retry when the first attempt exited fast with -## no pid-file on the uvx tier (stale-index-cache failure mode). No-op on -## other tiers: dev_venv and system resolve locally, so the flag has nowhere -## to go. See plugin.gd::_should_retry_with_refresh. -static func get_server_command(refresh: bool = false) -> Array[String]: - ## `mode_override() == "user"` skips the dev_venv tier even when a nearby - ## .venv exists — the override then becomes an actual workaround for - ## the "user venv misidentified as dev checkout" bug, not just a - ## cosmetic relabel. - if mode_override() != "user": - var venv_python := _cached_venv_python() - if not venv_python.is_empty(): - print("MCP | using dev venv: %s" % venv_python) - return [venv_python, "-m", "godot_ai"] - - var uvx := find_uvx() - if not uvx.is_empty(): - var version := get_plugin_version() - ## PEP 440 local build tags (e.g. 3.0.2+local.1) are not on PyPI. - ## Pin uvx to the public base version so the server still boots; - ## checkout-local extras need the dev_venv tier above - ## (symlink/junction → repo .venv). - var pypi_version := _pypi_pin_version(version) - ## Pin to the EXACT plugin version rather than `~=`. Under the - ## tilde form, uvx was happy to reuse a cached tool env that matched - ## the minor constraint — so an install that first spawned 1.2.0 kept - ## using 1.2.0 even after 1.2.1/1.2.2 landed. Exact pinning makes the - ## cache key version-specific: if the cached env matches, fast hit; - ## otherwise uvx installs the exact version fresh. Keeps plugin and - ## server version in lockstep without needing `--refresh-package` on - ## every spawn. See issue #133. - if pypi_version != version: - print( - "MCP | using uvx (godot-ai==%s; local plugin %s not on PyPI)%s" - % [pypi_version, version, " [refresh]" if refresh else ""] - ) - else: - print("MCP | using uvx (godot-ai==%s)%s" % [pypi_version, " [refresh]" if refresh else ""]) - var cmd: Array[String] = [uvx] - if refresh: - cmd.append("--refresh") - cmd.append_array(["--from", "godot-ai==%s" % pypi_version, "godot-ai"]) - return cmd - - var system_cmd := _find_system_install() - if not system_cmd.is_empty(): - print("MCP | using system install: %s" % system_cmd) - return [system_cmd] - - push_warning("MCP | no server found — install uv or run: pip install godot-ai") - return [] - - -## Which tier `get_server_command` would resolve to, without side-effects. -## Returned as a stable string so handshakes and session_list can expose it -## to MCP callers. Values track the `Literal` on the Python side. -static func get_server_launch_mode() -> String: - if mode_override() != "user" and not _cached_venv_python().is_empty(): - return "dev_venv" - if not find_uvx().is_empty(): - return "uvx" - if not _find_system_install().is_empty(): - return "system" - return "unknown" - - -static func find_uvx() -> String: - return CliFinder.find(_uvx_cli_names()) - - -## Pre-build the uvx tool environment for `godot-ai==` in the -## background, so the FIRST spawn of that version is a warm cache hit. -## Fired when a self-update install starts: post-update the old backend is -## killed and the new one spawned, and a cold `uvx --from godot-ai==` -## resolve+install takes seconds — while an attach bridge pinned to the OLD -## version respawns its already-cached backend near-instantly, winning the -## port bind race every time (the INCOMPATIBLE dead end after every -## self-update with a live AI client). Warming the new env while the plugin -## zip downloads flips that race. -## -## `--version` makes the spawned process exit immediately after uv resolves -## and installs the env. Detached fire-and-forget: a failure only means the -## post-update spawn pays the cold cost it always used to. Returns the -## spawned PID, or -1 when skipped (no uvx on this machine — the dev-venv -## and system tiers have no per-version cache to warm — or no version). -static func prewarm_server_package(version: String) -> int: - var args := prewarm_server_package_argv(version) - if args.is_empty(): - return -1 - var uvx := find_uvx() - if uvx.is_empty(): - return -1 - var pid := OS.create_process(uvx, args) - if pid > 0: - print("MCP | pre-warming godot-ai==%s server package for the post-update restart" % version.strip_edges()) - return pid - - -## Pure argv builder for the pre-warm spawn, split out so tests can pin the -## exact command without spawning a process. Empty array when there is no -## version to pin, or when the version carries characters outside the PEP -## 440 alphabet — the value can originate from a GitHub release tag, and -## while OS.create_process argv can't be shell-injected, constraining the -## pin keeps a hostile tag from smuggling anything into the requirement -## spec uv parses (#890 review). -static func prewarm_server_package_argv(version: String) -> Array[String]: - var pinned := version.strip_edges() - if pinned.is_empty(): - return [] - ## No `*`: PEP 440 reserves it for prefix-match SPECIFIERS (`==3.2.*`), - ## not version identifiers — letting it through would turn the exact pin - ## into a prefix match (#890 CodeRabbit). - var re := RegEx.new() - if re.compile("^[A-Za-z0-9.+!-]+$") != OK or re.search(pinned) == null: - return [] - return ["--from", "godot-ai==%s" % pinned, "godot-ai", "--version"] - - -static func _uvx_cli_names() -> Array[String]: - var names: Array[String] = [] - names.append("uvx.exe" if OS.get_name() == "Windows" else "uvx") - return names - - -## Drop the `CliFinder` cache for the platform-specific uvx binary -## name. Pairs with `invalidate_uv_version_cache()` so the dock's -## `_on_install_uv` can refresh both caches with one call each. The -## OS-specific name matters: Windows caches under `uvx.exe`, every -## other platform under `uvx`; hard-coding `"uvx"` here would leave -## the CLI-path cache stale on Windows after a fresh install and the -## dock would keep showing "uv: not found" for the rest of the session. -static func invalidate_uvx_cli_cache() -> void: - for name in _uvx_cli_names(): - CliFinder.invalidate(name) - - -## Drop the entire `CliFinder` cache. Called from any explicit-user-action -## refresh path (`force=true` in `_request_client_status_refresh` — manual -## Refresh button, popup-open, compat wrapper, future external API) so a -## freshly-installed CLI (claude, codex, gemini, …) gets detected without -## an editor restart. Per-CLI invalidation (`invalidate_uvx_cli_cache`) is -## preferred when the dock knows which binary changed; this catch-all -## handles the "any CLI may have been installed since the last sweep" case. -## -## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched` -## with a mutex so it can race safely against worker threads calling -## `find()` from `_run_client_action_worker`. The mutex is held only -## across the dictionary clear, never across the bounded subprocess lookup, -## so this call can never block the main thread on a subprocess. -static func invalidate_cli_cache() -> void: - CliFinder.invalidate() - - -static var _uv_version_cache: String = "" -static var _uv_version_searched: bool = false - - -## Cached for the editor session. The dock's `_refresh_setup_status` -## (called via `call_deferred` from `_build_ui`) calls this on the -## main thread in user mode, so the cold `uvx --version` probe is -## wall-clock bounded and cached. Subsequent calls (focus-in refresh, -## manual Refresh clicks) reuse the cached string. -## -## Invalidate via `invalidate_uv_version_cache()` when the user -## installs / reinstalls uv via the dock so the next refresh reflects -## the new install. The dock's `_on_install_uv` calls this alongside -## `CliFinder.invalidate("uvx")` to clear both the path cache and -## the version cache in one place. -static func check_uv_version() -> String: - if _uv_version_searched: - return _uv_version_cache - var uvx := find_uvx() - if uvx.is_empty(): - _uv_version_searched = true - _uv_version_cache = "" - return "" - var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false) - if int(result.get("exit_code", -1)) == 0: - var lines := PackedStringArray(str(result.get("stdout", "")).split("\n")) - _uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else "" - else: - _uv_version_cache = "" - _uv_version_searched = true - return _uv_version_cache - - -static func invalidate_uv_version_cache() -> void: - _uv_version_searched = false - _uv_version_cache = "" - - -## True when a probe has run this session and came back empty — i.e. the -## dock is currently rendering "uv: not found". Lets callers decide when -## a re-probe is worth paying for (server-connect transition, manual -## Refresh) without ever re-probing once uv has been found. -static func uv_probe_negative() -> bool: - return _uv_version_searched and _uv_version_cache.is_empty() - - -## Drop both uv caches — the resolved uvx path AND the cached -## `uvx --version` output — so the next check_uv_version() re-runs the -## full detection. #739: a probe that fails once at editor startup -## (contended spawn, cold Defender scan, stale PATH under a -## Steam-launched editor) used to pin "uv: not found" for the whole -## session; the Install-uv click was the only invalidation path. Callers -## invoke this on events that suggest the failure was transient. -static func invalidate_uv_detection() -> void: - invalidate_uvx_cli_cache() - invalidate_uv_version_cache() - _attach_launch_cache_mutex.lock() - _attach_launch_cache.clear() - _attach_launch_cache_mutex.unlock() - - -static var _venv_python_cache: String = "" -static var _venv_python_searched: bool = false -## #678 worker threads write this cache while main-thread callers read -## it; same lock discipline as McpCliFinder (clients/_cli_finder.gd). -static var _venv_mutex: Mutex = Mutex.new() -static var _attach_launch_cache := {} -static var _attach_launch_cache_mutex := Mutex.new() - - -static func _attach_launch_cache_key(launch_context: Dictionary) -> String: - return JSON.stringify([ - launch_context.get("http_port", null), - launch_context.get("ws_port", null), - launch_context.get("excluded_domains", null), - launch_context.get("plugin_version", null), - launch_context.get("allow_dev_venv", null), - launch_context.get("platform", null), - launch_context.get("telemetry_enabled", null), - ]) - - -static func _cached_venv_python() -> String: - _venv_mutex.lock() - if not _venv_python_searched: - _venv_python_cache = _find_venv_python() - _venv_python_searched = true - var cached := _venv_python_cache - _venv_mutex.unlock() - return cached - - -## Absolute path to `res://addons/godot_ai`, resolving Windows junctions / -## POSIX symlinks via `DirAccess.read_link`. Unresolved globalize_path only -## walks the *logical* project path (e.g. MyGame/addons/godot_ai → MyGame) -## and never reaches a fork checkout's `.venv` (…/godot-ai/.venv). -static func resolve_addons_realpath() -> String: - var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\") - if addons_path.is_empty(): - return "" - var parent := addons_path.get_base_dir() - var dir := DirAccess.open(parent) - if dir != null and dir.is_link(addons_path): - var target := dir.read_link(addons_path) - if not target.is_empty(): - if target.is_relative_path(): - target = parent.path_join(target).simplify_path() - return target.rstrip("/").rstrip("\\") - return addons_path - - -static func _find_venv_python() -> String: - ## Optional hard override (junction edge cases / CI). - var env_py := McpPathTemplate.env_lookup("GODOT_AI_VENV_PYTHON").strip_edges() - if not env_py.is_empty(): - if FileAccess.file_exists(env_py): - return env_py - ## An explicit override pointing nowhere is a misconfiguration the - ## user needs to see — falling through silently would make the dev - ## venv appear randomly ignored. - push_warning( - "godot-ai: GODOT_AI_VENV_PYTHON is set but no file exists at '%s'; ignoring override." - % env_py - ) - ## 1) Walk up from the open project (classic monorepo / test_project layout). - var from_project := _find_venv_python_in( - ProjectSettings.globalize_path("res://").rstrip("/").rstrip("\\") - ) - if not from_project.is_empty(): - return from_project - ## 2) Junctioned plugin: resolve reparse target, then walk up to fork root. - var addons_real := resolve_addons_realpath() - if not addons_real.is_empty(): - var from_addons := _find_venv_python_in(addons_real) - if not from_addons.is_empty(): - return from_addons - return "" - - -## Pure path-based lookup so tests can drive it with a scratch dir instead of -## monkey-patching `res://`. Only treats a `.venv/bin/python` as a godot-ai dev -## venv if a sibling `src/godot_ai/` exists in the same parent dir — otherwise -## an unrelated user venv (e.g. `~/.venv` from a data-science side project) -## gets picked up and `python -m godot_ai` fails with ModuleNotFoundError about -## 5s into startup, cascading into an infinite reconnect loop. The retry-with- -## refresh recovery in `plugin.gd::_should_retry_with_refresh` only fires on -## the uvx tier, so the dev_venv misidentification has no escape hatch — the -## detection has to be right the first time. -static func _find_venv_python_in(start_dir: String) -> String: - var dir := start_dir.rstrip("/").rstrip("\\") - var python_name := "python" if OS.get_name() != "Windows" else "python.exe" - var venv_dir := ".venv/bin/" if OS.get_name() != "Windows" else ".venv/Scripts/" - ## 8 hops: game project roots are shallow; junctioned plugins sit at - ## /plugin/addons/godot_ai (4) and nested worktrees may be deeper. - for i in 8: - var venv_path := dir.path_join(venv_dir + python_name) - if FileAccess.file_exists(venv_path) and DirAccess.dir_exists_absolute(dir.path_join("src/godot_ai")): - return venv_path - var parent := dir.get_base_dir() - if parent == dir or parent.is_empty(): - break - dir = parent - return "" - - -## Walk up from `start_dir` looking for a sibling `src/godot_ai/` — returns -## the absolute path of the enclosing `src/` dir, or "". Used by the dev -## server launcher to prepend the caller's own source to PYTHONPATH so a -## worktree-launched editor serves the worktree's Python, not the root -## repo's editable install. See #84. -static func find_worktree_src_dir(start_dir: String) -> String: - var dir := start_dir.rstrip("/") - for i in 5: - var candidate := dir.path_join("src/godot_ai") - if DirAccess.dir_exists_absolute(candidate): - return dir.path_join("src") - var parent := dir.get_base_dir() - if parent == dir: - break - dir = parent - return "" - - -## Delegates to McpCliFinder rather than shelling out to which/where -## directly: the finder adds the well-known-install-dirs and login-shell -## PATH tiers plus its per-exe cache, and this drops the private -## `_pick_best_path` cross-class reach (#711). -static func _find_system_install() -> String: - ## Built with append, not a ternary of untyped literals — assigning a - ## ternary's Array to Array[String] is a runtime error on newer Godot - ## builds (same idiom as _uvx_cli_names above). - var names: Array[String] = ["godot-ai"] - if OS.get_name() == "Windows": - names.push_front("godot-ai.exe") - return CliFinder.find(names) diff --git a/addons/godot_ai/client_configurator.gd.uid b/addons/godot_ai/client_configurator.gd.uid deleted file mode 100644 index 9182096..0000000 --- a/addons/godot_ai/client_configurator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1kiy8hqyymyj diff --git a/addons/godot_ai/clients/_atomic_write.gd b/addons/godot_ai/clients/_atomic_write.gd deleted file mode 100644 index f772c10..0000000 --- a/addons/godot_ai/clients/_atomic_write.gd +++ /dev/null @@ -1,206 +0,0 @@ -@tool -class_name McpAtomicWrite -extends RefCounted - -## Write text to a file via temp + rename so a crash mid-write never leaves -## the user's MCP config truncated. Creates the parent dir if needed and -## keeps a one-shot `.backup` of the prior file. -## -## On filesystems where rename-over-existing fails (Windows under AV / lock -## pressure, some SMB shares), falls back to overwrite-copy plus a -## backup-restore on failure. The original file is never removed before the -## new bytes are verified on disk — if both the rename and the copy fail, -## the user's prior config is restored from the `.backup` snapshot. See -## issue #297 finding #10 for the data-loss scenario this guards against. - - -static func write(path: String, content: String) -> bool: - # If the target is a symlink (stow/chezmoi-managed dotfiles), rename-over - # would replace the LINK with a regular file, silently detaching the - # config from the user's dotfile repo (#534). Resolve the link chain and - # write to the real target so the symlink survives. - path = _resolve_symlink_target(path) - var dir_path := path.get_base_dir() - if not DirAccess.dir_exists_absolute(dir_path): - if DirAccess.make_dir_recursive_absolute(dir_path) != OK: - return false - - # Decide the permission mode the final file (and its backup) must carry - # BEFORE we replace anything. A rewrite must preserve the prior file's - # mode: the Claude CLI creates ~/.claude.json as 0600 (it holds OAuth - # creds + history), and a naive FileAccess write + DirAccess copy would - # silently relax that to the umask default (0644) and leak it on shared - # machines. A brand-new config defaults to owner-only 0600 since these - # files routinely carry tokens. On platforms without POSIX permissions - # (Windows) the get/set calls no-op and this logic is inert. See #297 - # finding TC-1. - var had_original := FileAccess.file_exists(path) - var target_mode := _resolve_target_mode(path, had_original) - - # Suffix the temp name with this process's PID so two editors writing the - # same config concurrently (both clicking Configure) can't interleave - # bytes on a shared fixed ".tmp" path (#534). Each process stages its own - # temp file; the final rename remains the atomic commit point. - var tmp_path := "%s.tmp.%d" % [path, OS.get_process_id()] - var file := FileAccess.open(tmp_path, FileAccess.WRITE) - if file == null: - return false - # Lock the temp inode down BEFORE writing any bytes. FileAccess.open creates - # it at the umask default (often 0644); chmod'ing the still-empty file first - # means the config contents are never on disk under a world-readable mode in - # the create->chmod gap. rename preserves the inode mode, so the swapped-in - # file lands correct and is never briefly world-readable under the target name. - _apply_mode(tmp_path, target_mode) - file.store_string(content) - # Push Godot's internal buffer out to the OS before the rename. Godot - # exposes no fsync, so the bytes aren't guaranteed durable on the physical - # disk until the OS flushes its own cache — a power loss in that window can - # still lose the data. But flush() ensures the rename can't be ordered ahead - # of the write at the application layer, which is the failure this guards. - file.flush() - file.close() - # Re-assert the mode on the closed inode. The pre-write chmod above closes - # the world-readable window; this second apply is the authoritative one - # (a chmod issued while the FileAccess handle is still open doesn't reliably - # stick inside the editor) and guarantees the final mode before the rename, - # which preserves it. - _apply_mode(tmp_path, target_mode) - - # Verify the staged temp landed intact before committing it anywhere. The - # copy-fallback path below already guards this (`_written_size_matches` at - # the rename-fallback check); the rename path was the one gap — under - # disk-full/quota the temp can be silently truncated, and an unverified - # rename would swap a truncated file over the live target while the - # caller is told the write succeeded (#687). - if not _written_size_matches(tmp_path, content): - DirAccess.remove_absolute(tmp_path) - return false - - # Best-effort: snapshot the prior file before we touch the target so we - # can restore on a failed swap. The backup is also kept on success as a - # one-shot rollback aid for the user — give it the same (preserved) mode - # so a 0600 config's backup isn't itself a world-readable copy. - # - # copy_absolute creates the backup at the umask default and we can only - # chmod it afterward, so there's a sub-millisecond window where the backup - # carries default perms. Accepted: it duplicates bytes already sitting at - # `path` (which the caller created 0600) inside the user's own config dir, - # and Godot exposes no API to create the copy pre-chmod'd. Not worth - # reimplementing copy by hand to shave that window. - var backup_path := path + ".backup" - var backup_made := false - if had_original: - DirAccess.remove_absolute(backup_path) - if DirAccess.copy_absolute(path, backup_path) == OK: - backup_made = true - _apply_mode(backup_path, target_mode) - - if DirAccess.rename_absolute(tmp_path, path) == OK: - return true - - # Rename-over-existing rejected (Windows + AV / lock timing, some SMB - # shares). Use overwrite-copy as the recovery path: copy_absolute never - # removes the original before writing the new bytes, so a failure here - # leaves the user's prior config in place rather than nuking it. - if DirAccess.copy_absolute(tmp_path, path) == OK and _written_size_matches(path, content): - # copy_absolute creates the destination with the default mode, so - # re-apply the preserved/owner-only mode after the copy lands. - _apply_mode(path, target_mode) - DirAccess.remove_absolute(tmp_path) - return true - - # Copy didn't land cleanly. Restore the destination to its pre-call state. - if backup_made: - # Restore the snapshot we took before the swap. `copy_absolute` - # overwrites the destination, so we don't pre-remove `path` — the - # pre-remove created a window where `path` was gone if the - # subsequent copy itself failed. If the restore copy fails now the - # user's prior bytes are still in `.backup` for manual recovery - # and the false return value tells the caller the swap didn't - # complete. - DirAccess.copy_absolute(backup_path, path) - _apply_mode(path, target_mode) - elif not had_original and FileAccess.file_exists(path): - # No prior file existed but copy_absolute landed partial bytes at - # `path`. Remove them so the failure leaves nothing on disk rather - # than a truncated/invalid new file. The `file_exists` guard keeps - # us off non-file destinations (a path that points at a directory - # yields `had_original=false` too, but we must not try to delete - # the directory). Issue #297 PR review. - DirAccess.remove_absolute(path) - # (If `had_original` is true but the snapshot couldn't be taken, the - # original on disk is whatever copy_absolute managed to write before - # failing. This is a best-effort path — the false return value tells the - # caller the swap didn't complete; recovery beyond that requires a - # backup we couldn't take.) - DirAccess.remove_absolute(tmp_path) - return false - - -## Follow a symlink chain at `path` and return the final real target, so the -## temp+rename lands on the linked-to file instead of replacing the link. -## -## Best-effort by design: DirAccess.is_link()/read_link() are only implemented -## on platforms with POSIX symlinks (Linux/macOS; on Windows and other -## platforms is_link() returns false), and opening the parent dir can fail for -## exotic paths. In every "can't tell" case we return `path` unchanged, which -## is exactly the pre-#534 behavior — never worse, symlink-preserving where -## the engine lets us detect one. -static func _resolve_symlink_target(path: String) -> String: - var resolved := path - # Bounded hops so a symlink cycle can't loop us forever. - for _hop in 8: - var base_dir := resolved.get_base_dir() - var da := DirAccess.open(base_dir) - if da == null or not da.is_link(resolved): - return resolved - var target := da.read_link(resolved) - if target.is_empty(): - return resolved - if target.is_relative_path(): - target = base_dir.path_join(target) - resolved = target.simplify_path() - return resolved - - -static func _resolve_target_mode(path: String, had_original: bool) -> int: - # Preserve the prior file's POSIX mode on a rewrite; default a brand-new - # config (or any case we can't read a mode for) to owner read+write (0600). - # - # get_unix_permissions returns 0 both on Windows (no POSIX perms) and for a - # genuine 0000 file. Treating 0 as "use the 0600 floor" is deliberate, not a - # missed case: these are config files the plugin must read and write, 0000 is - # unusable, and re-applying 0000 would lock the owner out next run. 0600 is - # still owner-only so this never widens access. (A genuinely-0000 file can't - # reach a rewrite through the config strategies anyway — their read-first - # guard fails to open it and refuses the write before we get here.) - if had_original: - var existing := FileAccess.get_unix_permissions(path) - if existing > 0: - return existing - return FileAccess.UNIX_READ_OWNER | FileAccess.UNIX_WRITE_OWNER - - -static func _apply_mode(path: String, mode: int) -> void: - # Best-effort. set_unix_permissions returns ERR_UNAVAILABLE on platforms - # without POSIX permissions (Windows); that's expected and ignored so the - # write still works there. mode <= 0 should never happen (resolve always - # returns >0) but is guarded so a future caller can't chmod a file to nothing. - if mode <= 0: - return - var err := FileAccess.set_unix_permissions(path, mode) - # Surface a real chmod failure (not the Windows no-op) so permission - # hardening on a sensitive config doesn't fail completely silently. - if err != OK and err != ERR_UNAVAILABLE: - push_warning("MCP | could not set permissions on %s (error %d)" % [path, err]) - - -static func _written_size_matches(path: String, content: String) -> bool: - # `store_string` writes UTF-8 bytes with no BOM and no newline translation, - # so the byte length on disk must match `to_utf8_buffer().size()` exactly. - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - return false - var on_disk := f.get_length() - f.close() - return on_disk == content.to_utf8_buffer().size() diff --git a/addons/godot_ai/clients/_atomic_write.gd.uid b/addons/godot_ai/clients/_atomic_write.gd.uid deleted file mode 100644 index add9f7f..0000000 --- a/addons/godot_ai/clients/_atomic_write.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://6fkb5uau0r4h diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd deleted file mode 100644 index 4a5981a..0000000 --- a/addons/godot_ai/clients/_base.gd +++ /dev/null @@ -1,451 +0,0 @@ -@tool -class_name McpClient -extends RefCounted - -## Descriptor for one MCP client (Cursor, Claude Desktop, Codex, ...). -## -## Subclasses set fields in `_init()` and MUST NOT carry Callables — strategies -## (json/toml/cli) interpret the data. Enforced by -## `test_clients.gd::test_descriptors_are_data_only`. -## -## Why no Callables: per-client `.gd` files get hot-reloaded on disk-mtime -## change. A worker thread mid-call into a descriptor lambda races the -## bytecode swap and SEGVs (issue #229). Bonus: also obsoletes the stale- -## Callable workaround from #192. - -## CONFIGURED_MISMATCH = an entry with our `SERVER_NAME` exists in the user's -## client config, but its URL or launch command doesn't match the current -## ports/version/exclusions — typical after a setting change or update. -## Distinguishing this from `NOT_CONFIGURED` lets the dock surface a "your -## saved client configuration is stale" banner instead of conflating it with -## "you never configured this client". -enum Status { NOT_CONFIGURED, CONFIGURED, CONFIGURED_MISMATCH, ERROR } - - -## Lowercase string label for a `Status` value. Single source of truth so the -## MCP `client_status` tool, the dock, and the verify-after-write diagnostic -## in `McpClientConfigurator` all emit the same names — agents pattern-match -## against this set, so a fifth value being silently introduced would break -## them. -static func status_label(status: McpClient.Status) -> String: - match status: - Status.CONFIGURED: - return "configured" - Status.NOT_CONFIGURED: - return "not_configured" - Status.CONFIGURED_MISMATCH: - return "configured_mismatch" - return "error" - - -## One-line configure success message, shared by every strategy so the dock -## and the `client_manage` tool describe the transport that was actually -## written. Command-shape clients register the stdio `godot-ai attach` -## bridge — the URL-era "(HTTP: )" suffix would name a transport the -## write never touched (found live in the #838 Windows smoke). -static func configured_message(client: McpClient, server_url: String) -> String: - if client.command_shape != CommandShape.NONE: - return "%s configured (stdio attach)" % client.display_name - return "%s configured (HTTP: %s)" % [client.display_name, server_url] - -var id: String = "" ## stable key, e.g. "cursor" -var display_name: String = "" ## "Cursor" -var config_type: String = "" ## "json" | "toml" | "yaml" | "cli" | "dsh" - -# JSON / TOML clients ------------------------------------------------------ -## {"darwin": "~/...", "windows": "$APPDATA/...", "linux": "$XDG_CONFIG_HOME/..."} -## Keys may also use "unix" as a shorthand for darwin+linux. -var path_template: Dictionary = {} - -## Optional ordered path candidates by platform. Each value is an Array of -## templates; one `*` may appear in a directory segment so packaged-app roots -## can be discovered without hardcoding publisher hashes. -## -## Resolution contract: -## 1. Existing files win in descriptor order, except that a unique wildcard -## match is authoritative even before its config leaf exists. A matching -## package root therefore creates inside that package rather than writing -## a fallback path that may become invisible after copy-on-write. When -## that private leaf is new, Configure seeds it from the first later -## existing candidate so read-through content is not shadowed. -## 2. If no file or wildcard package match exists, the first non-wildcard -## template is the deterministic create target. -## 3. Multiple matches within any wildcard group are ambiguous and fail -## closed instead of choosing an arbitrary package. -## -## Exact-file and config-home environment overrides still have higher -## priority. When this map has no entry for the current platform, -## `path_template` remains the fallback. -var config_path_candidates: Dictionary = {} - -## Optional global JSON config files merged by the client from lowest to highest -## precedence. Unlike `config_path_candidates`, every existing file participates; -## Configure updates the effective last definition, status verifies it, and -## Remove clears every global definition transactionally. -var config_merge_path_templates: Dictionary = {} -## Project-relative tiers that may override the global files. Their root is the -## external client's working directory, which the Godot process cannot know. -## The strategy checks plausible roots and fails closed instead of mutating them. -var config_merge_project_paths: PackedStringArray = PackedStringArray() - -## De-duplicate persistent path-ambiguity warnings across recurring status -## refreshes. The actionable message still returns on every resolution; only -## the editor-console echo is single-shot until the ambiguity clears/changes. -var _last_config_path_warning := "" -var _config_path_warning_mutex := Mutex.new() - -## Path inside the config object where the per-server map lives. -## Cursor / Claude Desktop / most others: ["mcpServers"] -## VS Code: ["servers"] -## OpenCode: ["mcp"] -var server_key_path: PackedStringArray = PackedStringArray() -## Alternative server-map paths accepted by the client, in precedence order -## after `server_key_path`. JSON strategy operations select the first non-null -## existing path so Configure, status, and Remove agree with the client parser. -var server_key_path_aliases: Array[PackedStringArray] = [] - -## Field inside the entry dict that holds our server URL. -## "url" by default; some clients use "serverUrl" or "httpUrl". -var entry_url_field: String = "url" - -## Required entry fields — written on every Configure AND verified by the -## default verifier. Use this for transport pins (e.g. `type: -## "streamable-http"`) where a missing/wrong value breaks negotiation: a -## legacy entry without the pin fails verification and surfaces as drift. -## -## DO NOT put user-mutable state here (auto-approval lists, `disabled` -## flags, opt-in toggles). Verifying those treats every user customisation -## as drift, and Configure-All-Mismatched then silently overwrites them -## back to defaults — see the `entry_initial_fields` doc below. -var entry_extra_fields: Dictionary = {} - -## Default fields written ONLY when the entry doesn't yet exist. Reconfigure -## preserves whatever the user (or the client itself) has set; the verifier -## ignores these keys entirely. Use for opt-in flags and user-state arrays — -## e.g. Roo / Cline / Kilo `alwaysAllow` / `autoApprove` lists, `disabled: -## false`, `isActive: true`. The pre-#229 behaviour was equivalent: per- -## client `entry_builder` lambdas seeded these as defaults but the -## per-client `verify_entry` lambdas only checked transport pins, so a -## user-customised array was `CONFIGURED`, not drift. Splitting the field -## restores that contract under the data-only descriptor model. -var entry_initial_fields: Dictionary = {} - -## Client-owned stdio launch shape. Each strategy renders the shape in its -## config language: -## -## - FLAT — `command` string + `args` array as sibling keys. JSON and YAML -## strategies. A client whose docs require a type discriminator next to the -## flat keys (VS Code's `type: "stdio"`, Claude Code's fallback file) stays -## FLAT and declares it via `command_transport_key` / `command_transport_value`, -## so TYPED_FLAT remains reserved vocabulary. -## - COMMAND_ARRAY — the launch argv carried as one array. In the JSON -## strategy the entry's `command` field IS that array (OpenCode's -## `"command": ["uvx", …]`). In the TOML strategy the launcher renders as a -## `command = "…"` line plus an `args = […]` array (Codex, Grok) — the name -## refers to the argv-as-TOML-array body it emits. -## - NESTED_COMMAND — command/args nested inside a sub-object. Reserved; no -## current client needs it and strategies reject it with an actionable error. -## -## CLI-registered clients (`config_type == "cli"`) express the launch through -## `cli_register_template` tokens instead; their `command_shape` governs the -## JSON-fallback file rendering (Claude Code, #463). -## -## Values are data-only shared vocabulary; keeping them data-only avoids -## reintroducing the descriptor Callable race from #229. -enum CommandShape { NONE, FLAT, TYPED_FLAT, COMMAND_ARRAY, NESTED_COMMAND } -var command_shape: CommandShape = CommandShape.NONE - -## Whether manual instructions may offer the client's native URL transport as -## an alternative to its command shape. This is capability metadata, not a -## consequence of `command_shape`: Codex supports a URL block, while Claude -## Desktop's local `claude_desktop_config.json` entries are stdio-only. -var command_supports_url_fallback: bool = false - -## Optional discriminator required by a client's command transport shape -## (for example `type = "stdio"`). Empty means command+args are sufficient. -var command_transport_key: String = "" -var command_transport_value: Variant = null - -## Whether this client's Windows stdio entry must launch through the -## GUI-subsystem pythonw bootstrap (#827). The bootstrap exists for clients -## that run console-subsystem MCP commands in a visible terminal (Codex); -## Electron-family spawners hide child consoles themselves, and at least one -## (Antigravity) hangs tool calls when handed a GUI-subsystem executable -## (#863). Set false to write the plain console launcher on Windows. -var needs_consoleless_launcher: bool = true - -## Keys from the legacy transport that Configure must delete. Codex removes -## `url`, because Codex rejects a server entry containing both URL and stdio -## launch fields. -var command_legacy_keys: PackedStringArray = PackedStringArray() - -## Keys inside a preserved JSON `env` object that belonged to a legacy launch -## shape and must be removed during migration. Other environment values remain -## user-owned and survive Configure. Currently consumed by the JSON strategy. -var command_env_legacy_keys: PackedStringArray = PackedStringArray() - -## Defaults seeded only for a new entry. Reconfigure preserves user values. -## Codex uses this for enabled/startup/tool timeout defaults. -var command_initial_fields: Dictionary = {} - -## Declarative documentation of fields owned by the user and timeout fields -## supported by this client. Strategies preserve these values and tests pin -## the descriptor contract; no control flow lives on the descriptor. -var command_user_fields: PackedStringArray = PackedStringArray() -var command_timeout_fields: PackedStringArray = PackedStringArray() - -## Paths whose existence implies the user has this client installed. -## Used purely for the dock's "installed" badge. `is_installed()` additionally -## checks `resolved_config_path()`, so a config relocated via an environment -## override is detected without listing it here. -var detect_paths: PackedStringArray = PackedStringArray() - -# Config-path env overrides -------------------------------------------------- -## Some clients name the exact config file in an environment variable -## (OpenCode: `$OPENCODE_CONFIG`). When the variable is set and non-empty, it -## wins over directory-valued `config_home_env` and `path_template`. Relative -## values fail closed because the editor and client may have different working -## directories; auto-configuration cannot safely assume they resolve alike. -var config_file_env: String = "" - -## Some clients honor an env var that relocates their entire config home -## (Codex: `$CODEX_HOME/config.toml`; Claude Code: `$CLAUDE_CONFIG_DIR/.claude.json`). -## When `config_home_env` names an env var that is set and non-empty, -## `resolved_config_path()` returns `/` -## instead of resolving `path_template`. Both fields must be non-empty for the -## override to apply. Only declare a mapping when the client's docs guarantee -## the env var relocates the exact file we write — a wrong mapping writes the -## MCP entry somewhere the client never reads and Configure false-succeeds. -var config_home_env: String = "" -## Path of the config file relative to the env var's directory, e.g. -## "config.toml". Joined verbatim — no per-OS variants needed because the env -## value itself is already an absolute (or ~-prefixed) directory. -var config_home_env_subpath: String = "" - -# CLI clients -------------------------------------------------------------- -var cli_names: PackedStringArray = PackedStringArray() -## Argument templates with `{name}` and `{url}` tokens; the strategy -## substitutes them at call time. Tokens are matched verbatim — no escaping -## semantics, no shell expansion. Command-shape templates additionally use the -## whole-element tokens `{command}` / `{args...}` (see `McpCliStrategy.format_args`). -## Populated by CLI descriptors (currently `claude_code`; `kimi_code` moved to -## mcp.json in #813). -var cli_register_template: PackedStringArray = PackedStringArray() -var cli_unregister_template: PackedStringArray = PackedStringArray() -## Args run to read current state; stdout is scanned for the server name and -## URL. Presence of `name` AND `url` → CONFIGURED, name only → MISMATCH, -## neither → NOT_CONFIGURED. -var cli_status_args: PackedStringArray = PackedStringArray() -## Optional scope-aware status template with a `{name}` token, for descriptors -## that also take `{scope}` in their register template. `cli_status_args` lists -## whatever the CLI resolves for the current directory and cannot say WHICH -## scope an entry came from, so a leftover user-scope entry reads as CONFIGURED -## while the selected project scope is empty — the dock then shows green over -## exactly the "loaded in every workspace" state the scope setting exists to -## end (#872). This template must print a `Scope: …` line -## (`claude mcp get ` does) and exit non-zero when the server is absent. -## Only consulted when the selected scope isn't the one `path_template` reads. -var cli_scope_status_template: PackedStringArray = PackedStringArray() - -# Codex / TOML clients ----------------------------------------------------- -## Dotted TOML path under which our entry lives, e.g. ["mcp_servers", "godot-ai"]. -## Strategies build the [section."name"] header from this. -var toml_section_path: PackedStringArray = PackedStringArray() -var toml_legacy_section_aliases: PackedStringArray = PackedStringArray() -## Lines (without the [header]) emitted under the section, with `{url}` -## tokens. Substituted at call time. -var toml_body_template: PackedStringArray = PackedStringArray() - - -## Resolved absolute config path for this client on the current OS. Exact-file -## overrides win first, followed by directory-valued `config_home_env`, then -## ordered candidates / `path_template`. Ignoring either override can write a -## file the client never reads and false-succeed. -func resolved_config_path() -> String: - return str(resolved_config_path_details().get("path", "")) - - -## Detailed sibling used by status/configure/remove so safe resolution -## failures reach the dock instead of collapsing into NOT_CONFIGURED. `error` -## is empty for ordinary unsupported/missing path mappings to preserve the -## long-standing status behavior for clients not installed on this platform. -func resolved_config_path_details() -> Dictionary: - ## Reflected reads: after an in-session self-update, an instance created - ## before the update can answer Nil for vars the update added, and the - ## typed calls below would each hard-error (Nil -> Dictionary, #850's - ## per-row error wall). Fail with the - ## one repair message instead; the registry's coherence probe drives the - ## same text on the status path. - var candidates: Variant = get("config_path_candidates") - var template: Variant = get("path_template") - var file_env: Variant = get("config_file_env") - if not (candidates is Dictionary) or not (template is Dictionary) or not (file_env is String): - return {"path": "", "error": McpClientRegistry.RESTART_TO_FINISH_UPDATE} - var file_override := config_file_override_details() - if not str(file_override.get("path", "")).is_empty() or not str(file_override.get("error", "")).is_empty(): - _clear_config_path_warning() - return file_override - var override := config_home_override() - if not override.is_empty(): - _clear_config_path_warning() - return {"path": override, "error": ""} - var candidate_key := McpPathTemplate.platform_key(candidates) - if not candidate_key.is_empty(): - return _resolve_ordered_config_path_candidates(candidates[candidate_key]) - _clear_config_path_warning() - return {"path": McpPathTemplate.resolve(template), "error": ""} - - -## The exact-file env override plus any fail-closed diagnostic. Empty path and -## error means no override applies (no mapping, unset, or blank env var). -func config_file_override_details() -> Dictionary: - if config_file_env.is_empty(): - return {"path": "", "error": ""} - ## env_lookup, not OS.get_environment: this can run on dock workers (#691). - var raw_path := McpPathTemplate.env_lookup(config_file_env).strip_edges() - if raw_path.is_empty(): - return {"path": "", "error": ""} - var expanded := McpPathTemplate.expand(raw_path) - if not expanded.is_absolute_path(): - return { - "path": "", - "error": "%s's $%s override must be an absolute config-file path; got %s" % [ - display_name, config_file_env, raw_path, - ], - } - if DirAccess.dir_exists_absolute(expanded): - return { - "path": "", - "error": "%s's $%s override must point to a config file, not a directory: %s" % [ - display_name, config_file_env, expanded, - ], - } - return {"path": expanded, "error": ""} - - -func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary: - if not (templates is Array or templates is PackedStringArray): - _clear_config_path_warning() - return {"path": "", "error": ""} - var ordered_templates: Array = [] - for template_variant in templates: - ordered_templates.append(str(template_variant)) - var fallback_create_path := "" - for index in range(ordered_templates.size()): - var template := str(ordered_templates[index]) - var group := McpPathTemplate.expand_path_candidates(template) - if group.size() > 1: - var message := ( - "%s has multiple matching config package paths for %s: %s. " - + "Remove the stale package installation or edit the intended config manually." - ) % [display_name, template, ", ".join(group)] - _warn_config_path_once(message) - return {"path": "", "error": message} - if group.is_empty(): - continue - var path := String(group[0]) - if FileAccess.file_exists(path): - _clear_config_path_warning() - return {"path": path, "error": ""} - # A wildcard only resolves when its package directory exists. Treat that - # installation evidence as authoritative and create its private config - # directly instead of relying on copy-on-write read-through. Preserve - # anything currently visible through read-through by naming the first - # later existing candidate as a one-time seed source. - if template.contains("*"): - var seed_path := _first_existing_later_candidate(ordered_templates, index + 1) - _clear_config_path_warning() - return {"path": path, "error": "", "seed_path": seed_path} - if fallback_create_path.is_empty(): - fallback_create_path = path - _clear_config_path_warning() - return {"path": fallback_create_path, "error": ""} - - -func _first_existing_later_candidate(templates: Array, start_index: int) -> String: - for index in range(start_index, templates.size()): - var group := McpPathTemplate.expand_path_candidates(str(templates[index])) - # A seed is optional. Never choose among an ambiguous later wildcard; - # the authoritative target was already resolved by the earlier group. - if group.size() != 1: - continue - var path := String(group[0]) - if FileAccess.file_exists(path): - return path - return "" - - -func _warn_config_path_once(message: String) -> void: - _config_path_warning_mutex.lock() - var should_warn := message != _last_config_path_warning - _last_config_path_warning = message - _config_path_warning_mutex.unlock() - if should_warn: - push_warning(message) - - -func _clear_config_path_warning() -> void: - _config_path_warning_mutex.lock() - _last_config_path_warning = "" - _config_path_warning_mutex.unlock() - - -## The env-var-relocated config path, or "" when no override applies -## (no mapping declared, env var unset, or env var empty/whitespace). -func config_home_override() -> String: - if config_home_env.is_empty() or config_home_env_subpath.is_empty(): - return "" - ## env_lookup, not OS.get_environment: this runs on dock worker threads, - ## which must not race the spawn window's setenv/unsetenv (#691). - var home := McpPathTemplate.env_lookup(config_home_env).strip_edges() - if home.is_empty(): - return "" - # Expand a leading ~ so `CODEX_HOME=~/codex-alt` behaves like the shell. - return McpPathTemplate.expand(home).path_join(config_home_env_subpath) - - -## True when a CLI client also declares where its config file lives, so it can -## fall back to writing that file directly when the CLI binary isn't on PATH. -## #463: Claude Code installed only as a VS Code / Cursor extension exposes no -## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers` -## into ~/.claude.json — so we can produce the same entry ourselves. -func has_json_fallback() -> bool: - return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty() - - -## True if the user appears to have this client installed locally. -func is_installed() -> bool: - if config_type == "cli": - if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty(): - return true - # CLI not on PATH. A cli client with a JSON fallback (Claude Code as a - # VS Code/Cursor extension, #463) still counts as installed if its - # fallback config file already exists. - if has_json_fallback(): - var cfg := resolved_config_path() - return not cfg.is_empty() and FileAccess.file_exists(cfg) - return false - for p in detect_paths: - for resolved in McpPathTemplate.expand_path_candidates(p): - if FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved): - return true - # Fall back to "config file already exists" — usually means installed at some point. - var cfg := resolved_config_path() - return not cfg.is_empty() and FileAccess.file_exists(cfg) - - -static func _array_from_packed(packed: PackedStringArray) -> Array[String]: - var out: Array[String] = [] - for s in packed: - out.append(s) - return out - - -## Slice a PackedStringArray into a new PackedStringArray over [from, to). -## Used by `_toml_strategy` and `_manual_command` to peel the section path -## apart for `[a.b."c"]` header rendering. -static func _packed_slice(packed: PackedStringArray, from: int, to: int) -> PackedStringArray: - var out := PackedStringArray() - for i in range(from, to): - out.append(packed[i]) - return out diff --git a/addons/godot_ai/clients/_base.gd.uid b/addons/godot_ai/clients/_base.gd.uid deleted file mode 100644 index 5ea77d3..0000000 --- a/addons/godot_ai/clients/_base.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cyowqr1x12ilg diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd deleted file mode 100644 index 1e09811..0000000 --- a/addons/godot_ai/clients/_cli_exec.gd +++ /dev/null @@ -1,169 +0,0 @@ -@tool -class_name McpCliExec -extends RefCounted - -## Wall-clock-bounded CLI invocation. Every dock shell-out to a per-client -## CLI (`claude mcp list`, `claude mcp add ...`, etc.) goes through here so -## a hung subprocess can't trap the calling thread forever. -## -## Without the timeout, a contended `claude mcp list` has been observed to -## hang for 6+ minutes (issues #238, #239) — wedging the dock's status -## refresh worker, and on the Configure / Remove paths the editor main -## thread itself. -## -## Why poll/kill instead of `OS.execute(..., true)`: GDScript can't -## interrupt a blocking `OS.execute`, so a hung CLI takes its caller's -## thread with it. `OS.execute_with_pipe` returns immediately with a PID; -## we drive the wait ourselves and `OS.kill` the orphan if budget -## expires. CLI registry commands have bounded output (a few hundred -## bytes), so we don't bother draining the pipe during the poll loop — -## the kernel buffer absorbs it. -## -## Returns a Dictionary with: -## exit_code: process exit code (0 = success). -1 on timeout / spawn failure. -## stdout: captured stdout text. May be partial on timeout. -## stderr: captured stderr text. May be partial on timeout. Empty when -## `capture_stderr` is false. -## output: stdout + (newline + stderr if non-empty). Convenience for -## the common case of "show whatever the CLI said when it -## failed" — `claude mcp add` writes its real diagnostics to -## stderr, so callers that only read `stdout` would surface -## a generic "exit code 1" instead. -## timed_out: true if we killed the process at the wall-clock budget. -## spawn_failed: true if `OS.execute_with_pipe` didn't return a usable PID. - -const DEFAULT_TIMEOUT_MS := 8000 -const _POLL_INTERVAL_MS := 50 -const _KILL_GRACE_MS := 500 - - -static func run( - exe: String, - args: Array, - timeout_ms: int = DEFAULT_TIMEOUT_MS, - capture_stderr: bool = true -) -> Dictionary: - if exe.is_empty(): - return _spawn_failed_result() - return _run_piped(exe, args, timeout_ms, capture_stderr) - - -static func _run_piped( - exe: String, - args: Array, - timeout_ms: int, - capture_stderr: bool, -) -> Dictionary: - - var spawn_exe := exe - var spawn_args := args - if OS.get_name() == "Windows": - var lower := exe.to_lower() - if lower.ends_with(".cmd") or lower.ends_with(".bat"): - ## CreateProcessW can't launch `.cmd` / `.bat` scripts on its - ## own — they're cmd.exe input, not PE binaries. Without this - ## wrap, the moment `McpCliFinder` resolves a Node-style shim - ## (npm's `claude.cmd`, pnpm's wrappers, …) the next - ## `OS.execute_with_pipe` surfaces "Could not create child - ## process: ..." in Godot's output log (#251). Passing - ## `exe` as a separate argv element keeps spaces in the path - ## quoted by Godot's standard quoter — no manual escaping. - spawn_exe = "cmd.exe" - spawn_args = ["/c", exe] - spawn_args.append_array(args) - - var info := OS.execute_with_pipe(spawn_exe, spawn_args) - if info.is_empty(): - return _spawn_failed_result() - - var pid: int = int(info.get("pid", -1)) - var stdio: Variant = info.get("stdio", null) - var stderr_pipe: Variant = info.get("stderr", null) - if pid <= 0: - _close_pipes(stdio, stderr_pipe) - return _spawn_failed_result() - - var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS) - while OS.is_process_running(pid): - if Time.get_ticks_msec() >= deadline: - ## Kill before draining: a pipe read can block while the child is - ## still alive. Once it exits, drain any buffered partial output. - OS.kill(pid) - var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS - while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline: - OS.delay_msec(_POLL_INTERVAL_MS) - - var partial_stdout := "" - var partial_stderr := "" - if not OS.is_process_running(pid): - partial_stdout = _drain_pipe(stdio) - partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else "" - _close_pipes(stdio, stderr_pipe) - return { - "exit_code": -1, - "stdout": partial_stdout, - "stderr": partial_stderr, - "output": _join_streams(partial_stdout, partial_stderr), - "timed_out": true, - "spawn_failed": false, - } - OS.delay_msec(_POLL_INTERVAL_MS) - - var stdout := _drain_pipe(stdio) - var stderr_text := _drain_pipe(stderr_pipe) if capture_stderr else "" - _close_pipes(stdio, stderr_pipe) - - return { - "exit_code": OS.get_process_exit_code(pid), - "stdout": stdout, - "stderr": stderr_text, - "output": _join_streams(stdout, stderr_text), - "timed_out": false, - "spawn_failed": false, - } - - -static func _spawn_failed_result() -> Dictionary: - return { - "exit_code": -1, - "stdout": "", - "stderr": "", - "output": "", - "timed_out": false, - "spawn_failed": true, - } - - -static func _drain_pipe(pipe: Variant) -> String: - if not (pipe is FileAccess): - return "" - var f := pipe as FileAccess - var bytes := PackedByteArray() - var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output. - while bytes.size() < max_bytes: - var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size())) - if chunk.is_empty(): - break - bytes.append_array(chunk) - if f.eof_reached(): - break - return bytes.get_string_from_utf8() - - -static func _join_streams(stdout: String, stderr_text: String) -> String: - ## Most CLIs write their actionable diagnostics to one stream or the - ## other, never both — so concatenation gives "the message" without - ## the caller having to guess which key to read. Newline-separate so - ## callers that grep don't see two lines run together. - if stderr_text.is_empty(): - return stdout - if stdout.is_empty(): - return stderr_text - return "%s\n%s" % [stdout, stderr_text] - - -static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void: - if stdio is FileAccess: - (stdio as FileAccess).close() - if stderr_pipe is FileAccess: - (stderr_pipe as FileAccess).close() diff --git a/addons/godot_ai/clients/_cli_exec.gd.uid b/addons/godot_ai/clients/_cli_exec.gd.uid deleted file mode 100644 index 4a97a2c..0000000 --- a/addons/godot_ai/clients/_cli_exec.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhoe3ypkhm12v diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd deleted file mode 100644 index 394e76c..0000000 --- a/addons/godot_ai/clients/_cli_finder.gd +++ /dev/null @@ -1,181 +0,0 @@ -@tool -class_name McpCliFinder -extends RefCounted - -## Generic three-tier CLI resolution for clients whose binary lives somewhere -## a GUI-launched Godot's minimal PATH won't see: -## 1. Well-known install locations (~/.local/bin, /opt/homebrew/bin, ...) -## 2. Login shell lookup (`bash -lc 'command -v '`) — picks up .zshrc / .bashrc -## 3. Plain `which` / `where` against the inherited PATH -## Caches per-exe so repeated dock refreshes don't fork a shell every frame. -## -## Thread safety: `find()` runs on action-worker threads -## (`_run_client_action_worker` in `mcp_dock.gd`), and `invalidate()` runs on -## the main thread (manual Refresh path). Godot `Dictionary` is not safe for -## concurrent mutation, so `_cache` / `_searched` access is guarded by -## `_mutex`. The mutex is held only across dictionary read/write — the slow -## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a -## main-thread `invalidate()` can never block on a worker's subprocess. -## Two workers racing the same exe both call `_resolve()` and both write -## back the same answer; that's wasted work, not corruption. - - -static var _mutex: Mutex = Mutex.new() -static var _cache: Dictionary = {} # exe_name -> resolved path (or "") -static var _searched: Dictionary = {} - -const _LOOKUP_TIMEOUT_MS := 3000 - - -## Find any of the supplied exe names; returns the first hit. -## On Windows pass the .exe variant in `exe_names` if relevant. -static func find(exe_names: Array[String]) -> String: - for name in exe_names: - var hit := _find_one(name) - if not hit.is_empty(): - return hit - return "" - - -## Drop cache for one exe (call after the user installs / reinstalls). -static func invalidate(exe_name: String = "") -> void: - _mutex.lock() - if exe_name.is_empty(): - _cache.clear() - _searched.clear() - else: - _cache.erase(exe_name) - _searched.erase(exe_name) - _mutex.unlock() - - -static func _find_one(exe_name: String) -> String: - _mutex.lock() - var already_searched: bool = _searched.get(exe_name, false) - var cached: String = _cache.get(exe_name, "") - _mutex.unlock() - if already_searched: - return cached - # `_resolve()` does FileAccess + bounded subprocess lookup (forks - # `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that - # would let a concurrent `invalidate()` on the main thread freeze the - # editor for the duration of the subprocess — which defeats the whole - # point of running CLI lookup off the main thread. - var hit := _resolve(exe_name) - _mutex.lock() - _cache[exe_name] = hit - _searched[exe_name] = true - _mutex.unlock() - return hit - - -static func _resolve(exe_name: String) -> String: - var is_windows := OS.get_name() == "Windows" - - # 1. Well-known locations - for dir in _well_known_dirs(): - var full := dir.path_join(exe_name) - if FileAccess.file_exists(full): - return full - - # 2. Login shell lookup (Unix only) - if not is_windows: - ## env_lookup, not OS.get_environment: CLI resolution runs on dock - ## worker threads (configure/remove actions) and must not race the - ## spawn window's setenv/unsetenv (#691). - var shell := McpPathTemplate.env_lookup("SHELL") - if shell.is_empty(): - shell = "/bin/bash" - var stripped := exe_name.trim_suffix(".exe") - var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false) - if int(login_result.get("exit_code", -1)) == 0: - var login_found: String = str(login_result.get("stdout", "")).strip_edges() - if not login_found.is_empty() and FileAccess.file_exists(login_found): - return login_found - - # 3. which / where with inherited PATH - var lookup := "where" if is_windows else "which" - var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false) - if int(result.get("exit_code", -1)) == 0: - var output := str(result.get("stdout", "")) - var lines := PackedStringArray(output.split("\n")) - var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges() - if not found.is_empty(): - return found - return "" - - -## Executable extensions Windows' CreateProcessW can launch from a path -## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe` -## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is -## the legacy COM-format executable that some shims still ship. -const _WINDOWS_EXEC_EXTS := [".exe", ".cmd", ".bat", ".com"] - - -## Pick the best path from `where` output on Windows. -## -## npm-installed Node CLIs ship as BOTH `/` (a POSIX bash shim -## for WSL / Git Bash users) AND `/.cmd` (the actual Windows -## wrapper). `where ` lists both. CreateProcessW — the underlying -## syscall behind `OS.execute_with_pipe` — refuses to launch the -## extensionless POSIX shim, surfacing as -## `ERROR: Could not create child process: "...\claude" mcp list` -## in Godot's output log (#251). Picking a path with a real executable -## extension dodges that entirely. -## -## Extension scan is the OUTER loop so the order in `_WINDOWS_EXEC_EXTS` -## drives preference — `.exe` wins over `.cmd` even when the `.cmd` shows -## up first in `where` output (one fewer process per shell-out). Falls -## back to the first non-empty line when no entry has a recognised -## extension, so we never come up empty when `where` returned *something*. -static func _pick_best_path(lines: PackedStringArray) -> String: - var stripped := PackedStringArray() - for raw in lines: - var line := raw.strip_edges() - if not line.is_empty(): - stripped.append(line) - if stripped.is_empty(): - return "" - for ext in _WINDOWS_EXEC_EXTS: - for candidate in stripped: - if candidate.to_lower().ends_with(ext): - return candidate - return stripped[0] - - -static func _well_known_dirs() -> Array[String]: - ## env_lookup, not OS.get_environment — see _resolve()'s worker-thread - ## note (#691). - var home := McpPathTemplate.env_lookup("HOME") - if home.is_empty(): - home = McpPathTemplate.env_lookup("USERPROFILE") - match OS.get_name(): - "macOS": - return [ - home.path_join(".local/bin"), - home.path_join(".claude/local"), - home.path_join(".cargo/bin"), - "/opt/homebrew/bin", - "/usr/local/bin", - ] - "Windows": - var local := McpPathTemplate.env_lookup("LOCALAPPDATA") - var prog := McpPathTemplate.env_lookup("ProgramFiles") - var paths: Array[String] = [] - if not home.is_empty(): - paths.append(home.path_join(".claude/local")) - paths.append(home.path_join(".local/bin")) - paths.append(home.path_join(".cargo/bin")) - paths.append(home.path_join("AppData/Local/Programs/uv")) - if not local.is_empty(): - paths.append(local.path_join("Programs/uv")) - if not prog.is_empty(): - paths.append(prog.path_join("uv")) - return paths - _: - return [ - home.path_join(".local/bin"), - home.path_join(".claude/local"), - home.path_join(".cargo/bin"), - "/usr/local/bin", - ] diff --git a/addons/godot_ai/clients/_cli_finder.gd.uid b/addons/godot_ai/clients/_cli_finder.gd.uid deleted file mode 100644 index 9985270..0000000 --- a/addons/godot_ai/clients/_cli_finder.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cnp5b6fcwou2y diff --git a/addons/godot_ai/clients/_cli_strategy.gd b/addons/godot_ai/clients/_cli_strategy.gd deleted file mode 100644 index 0714115..0000000 --- a/addons/godot_ai/clients/_cli_strategy.gd +++ /dev/null @@ -1,372 +0,0 @@ -@tool -class_name McpCliStrategy -extends RefCounted - -## Strategy for MCP clients that own their own state via a CLI (e.g. -## `claude mcp add`). Reads `cli_register_template` / `cli_unregister_template` -## / `cli_status_args` from the descriptor and substitutes `{name}` / `{url}` -## tokens. Command-shape descriptors additionally use the whole-element launch -## tokens `{command}` / `{args...}` (see `format_args`). No descriptor-supplied -## Callables — see `_base.gd` for why. -## -## Every shell-out goes through `McpCliExec.run`, which wraps the call in a -## wall-clock timeout. A hung CLI (e.g. `claude mcp list` under -## inter-Claude-Code contention) gets killed at the budget instead of -## locking up the caller forever — see issues #238 / #239. - -## The descriptor token resolved from `godot_ai/mcp_client_scope` (#872). -## Only Claude Code takes it today; any `config_type = "cli"` descriptor can. -const SCOPE_TOKEN := "{scope}" - -const _CONFIGURE_TIMEOUT_MS := 10000 -const _REMOVE_TIMEOUT_MS := 10000 -const _STATUS_TIMEOUT_MS := 6000 - - -static func configure( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> Dictionary: - ## Fail closed before any subprocess runs: a command-shape client without a - ## verified attach launcher must not register anything (see - ## docs/client-configuration.md — an ERROR beats an entry known to be broken). - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": "error", "message": launch_error} - var cli := _resolve_cli(client) - if cli.is_empty(): - return {"status": "error", "message": "%s not found" % client.display_name} - - # Best-effort prior cleanup so re-configure is idempotent. Bounded to - # the same budget — a hung unregister shouldn't block the configure - # that follows. - if not client.cli_unregister_template.is_empty(): - ## #872: a `{scope}` descriptor sweeps EVERY scope, not just the - ## selected one. Flipping the setting user -> project and pressing - ## Configure would otherwise leave the old user-scope entry alive — - ## exactly the "loaded in every unrelated workspace" problem the - ## setting exists to fix. `mcp remove` on an absent entry is a no-op - ## and the result is discarded either way, so the extra passes are - ## safe; they cost one bounded subprocess per additional scope. - for pre_scope in _cleanup_scopes(client): - var pre_args := _format_args( - client.cli_unregister_template, server_name, server_url, {}, pre_scope - ) - McpCliExec.run(cli, pre_args, _REMOVE_TIMEOUT_MS) - - if client.cli_register_template.is_empty(): - return {"status": "error", "message": "%s descriptor missing cli_register_template" % client.display_name} - var args := _format_args(client.cli_register_template, server_name, server_url, launch) - var result := McpCliExec.run(cli, args, _CONFIGURE_TIMEOUT_MS) - if result.get("timed_out", false): - return { - "status": "error", - "message": "Configure %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ - client.display_name, _CONFIGURE_TIMEOUT_MS / 1000, - ], - } - if result.get("spawn_failed", false): - return {"status": "error", "message": "Failed to spawn %s" % client.display_name} - if int(result.get("exit_code", -1)) == 0: - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - ## `claude mcp add` writes its real failure diagnostics to stderr, so - ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise - ## the user sees "exit code 1" instead of the actual error. - var combined := str(result.get("output", "")).strip_edges() - var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1)) - return {"status": "error", "message": "Failed to configure %s: %s" % [client.display_name, err]} - - -## Run the descriptor's `cli_status_args`, scan stdout for `server_name` and -## the expected target. The matching rule is the only sensible one for "list -## MCP entries" output across CLI clients we currently support: name AND -## target present → CONFIGURED; name only → MISMATCH; neither → -## NOT_CONFIGURED. For URL descriptors the target is `server_url`; for -## command-shape descriptors it is the resolved attach launcher path (the -## listing prints the registered command line, not a URL). Command-shape CLI -## clients with a JSON fallback file get exact drift detection via the JSON -## strategy instead — the configurator prefers that path and only lands here -## for CLI clients whose state isn't file-readable. -static func check_status( - client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} -) -> McpClient.Status: - return check_status_with_cli_path(client, server_name, server_url, _resolve_cli(client), launch) - - -static func check_status_with_cli_path( - client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {} -) -> McpClient.Status: - return check_status_details(client, server_name, server_url, cli, launch).get("status", McpClient.Status.NOT_CONFIGURED) - - -## Detailed variant used by the dock's refresh worker so it can surface a -## "probe timed out" badge on the affected row instead of silently -## conflating the timeout with NOT_CONFIGURED. Returns -## `{"status": Status, "error_msg": String}`. The caller plumbs -## `error_msg` straight into `_apply_row_status`. -static func check_status_details( - client: McpClient, server_name: String, server_url: String, cli: String, launch: Dictionary = {} -) -> Dictionary: - if cli.is_empty(): - return _status_details(McpClient.Status.NOT_CONFIGURED) - if client.cli_status_args.is_empty(): - return _status_details(McpClient.Status.NOT_CONFIGURED) - var expected_target := server_url - if client.command_shape != McpClient.CommandShape.NONE: - ## Same fail-closed contract as configure: without a verified launcher - ## there is no target to compare against, and guessing would report a - ## broken entry as green. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return _status_details(McpClient.Status.ERROR, launch_error) - expected_target = str(launch.get("command", "")) - var result := McpCliExec.run( - cli, - McpClient._array_from_packed(client.cli_status_args), - _STATUS_TIMEOUT_MS, - false - ) - if result.get("timed_out", false): - return _status_details(McpClient.Status.ERROR, "probe timed out") - if result.get("spawn_failed", false): - return _status_details(McpClient.Status.NOT_CONFIGURED) - if int(result.get("exit_code", -1)) != 0: - return _status_details(McpClient.Status.NOT_CONFIGURED) - var text := str(result.get("stdout", "")) - if text.find(server_name) < 0: - return _status_details(McpClient.Status.NOT_CONFIGURED) - ## Server registered, but pointing somewhere else — drift after a - ## port change. Surface as mismatch so the dock offers Reconfigure. - if text.find(expected_target) < 0: - return _status_details(McpClient.Status.CONFIGURED_MISMATCH) - return _status_details(McpClient.Status.CONFIGURED) - - -## Empty string when this client's launch requirements are satisfied. A -## command-shape descriptor requires a successfully resolved attach launch; -## URL descriptors (`CommandShape.NONE`) never require one. Mirrors -## `McpJsonStrategy.command_launch_error` / the TOML equivalent. -static func command_launch_error(client: McpClient, launch: Dictionary) -> String: - if client.command_shape == McpClient.CommandShape.NONE: - return "" - if not bool(launch.get("ok", false)): - return str(launch.get("error", "No compatible attach launcher was found.")) - return "" - - -static func _status_details(status: McpClient.Status, error_msg: String = "") -> Dictionary: - return {"status": status, "error_msg": error_msg} - - -static func remove(client: McpClient, server_name: String) -> Dictionary: - var cli := _resolve_cli(client) - if cli.is_empty(): - return {"status": "error", "message": "%s not found" % client.display_name} - if client.cli_unregister_template.is_empty(): - return {"status": "error", "message": "%s descriptor missing cli_unregister_template" % client.display_name} - var args := _format_args(client.cli_unregister_template, server_name, "") - var result := McpCliExec.run(cli, args, _REMOVE_TIMEOUT_MS) - if result.get("timed_out", false): - return { - "status": "error", - "message": "Remove %s timed out after %ds — see 'Run this manually' below to retry by hand" % [ - client.display_name, _REMOVE_TIMEOUT_MS / 1000, - ], - } - if result.get("spawn_failed", false): - return {"status": "error", "message": "Failed to spawn %s" % client.display_name} - if int(result.get("exit_code", -1)) == 0: - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - ## `claude mcp add` writes its real failure diagnostics to stderr, so - ## prefer `output` (stdout + stderr) over `stdout` alone — otherwise - ## the user sees "exit code 1" instead of the actual error. - var combined := str(result.get("output", "")).strip_edges() - var err := combined if not combined.is_empty() else "exit code %d" % int(result.get("exit_code", -1)) - return {"status": "error", "message": "Failed to remove %s: %s" % [client.display_name, err]} - - -## Substitute `{name}` and `{url}` tokens in every template entry. -## Tokens match verbatim — `{name_suffix}` is NOT touched, so callers don't -## have to worry about partial-token collisions in their argv. -## -## Launch tokens are whole-element only: an element that is exactly -## `{command}` becomes the resolved attach launcher path, and an element that -## is exactly `{args...}` is spliced into the argv as one element per launch -## arg. Whole-element matching keeps a literal brace inside a path or flag -## from ever triggering an expansion. -## -## `scope_override` forces a specific `{scope}` value instead of the live -## setting; the configure pre-cleanup uses it to sweep the scopes the user is -## NOT currently on. Empty means "resolve from the setting". -static func format_args( - template: PackedStringArray, - server_name: String, - server_url: String, - launch: Dictionary = {}, - scope_override: String = "", -) -> Array[String]: - return _format_args(template, server_name, server_url, launch, scope_override) - - -static func _format_args( - template: PackedStringArray, - server_name: String, - server_url: String, - launch: Dictionary = {}, - scope_override: String = "", -) -> Array[String]: - var scope := scope_override if not scope_override.is_empty() else McpSettings.client_scope() - var out: Array[String] = [] - for arg in template: - var s := String(arg) - if s == "{command}": - out.append(str(launch.get("command", ""))) - continue - if s == "{args...}": - for launch_arg in launch.get("args", []): - out.append(str(launch_arg)) - continue - s = s.replace("{name}", server_name) - s = s.replace("{url}", server_url) - ## Resolved per-call rather than baked into the descriptor so the - ## setting can change without an editor restart, and so the manual - ## command shown in the dock always matches what Configure would run. - s = s.replace(SCOPE_TOKEN, scope) - out.append(s) - return out - - -## Scope-aware status for descriptors that declare `cli_scope_status_template`. -## Returns the same `{"status", "error_msg"}` shape as check_status_details. -## -## Split from the subprocess call so the verdict is a pure function of what the -## CLI printed — `_scope_probe_verdict` is unit-tested against real recorded -## `claude mcp get` output rather than needing a `claude` binary on the runner. -static func check_scope_status_details( - client: McpClient, - server_name: String, - server_url: String, - cli: String, - launch: Dictionary, - expected_scope: String, -) -> Dictionary: - if cli.is_empty() or client.cli_scope_status_template.is_empty(): - return _status_details(McpClient.Status.NOT_CONFIGURED) - var expected_target := server_url - if client.command_shape != McpClient.CommandShape.NONE: - ## Same fail-closed contract as configure and the plain status probe. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return _status_details(McpClient.Status.ERROR, launch_error) - expected_target = str(launch.get("command", "")) - var args := _format_args(client.cli_scope_status_template, server_name, server_url) - var result := McpCliExec.run(cli, args, _STATUS_TIMEOUT_MS, false) - if result.get("timed_out", false): - return _status_details(McpClient.Status.ERROR, "probe timed out") - if result.get("spawn_failed", false): - return _status_details(McpClient.Status.NOT_CONFIGURED) - return _scope_probe_verdict( - int(result.get("exit_code", -1)), - str(result.get("stdout", "")), - expected_scope, - expected_target, - ) - - -## The `Scope:` label the probe printed, normalised to a CLIENT_SCOPES value, -## or "" when no line was recognisable. Matched on the first word after the -## label so the parenthetical blurb ("(shared via .mcp.json)") can change -## wording between CLI releases without breaking detection. -static func _scope_from_probe_output(text: String) -> String: - for raw_line in text.split("\n"): - var line := String(raw_line).strip_edges() - var marker := line.find("Scope:") - if marker < 0: - continue - var rest := line.substr(marker + 6).strip_edges().to_lower() - for scope in McpSettings.CLIENT_SCOPES: - var name := String(scope) - if rest.begins_with(name): - return name - return "" - - -## Pure verdict for a scope probe. A non-zero exit is the CLI's "no such -## server" signal. An entry resolved from a scope other than the selected one -## is MISMATCH, not CONFIGURED: the dock offers Reconfigure, and Configure's -## all-scope pre-cleanup is what actually moves it. -## -## A missing or unrecognised `Scope:` line degrades to the target check alone, -## deliberately rather than failing closed. `_verify_post_state` turns any -## non-CONFIGURED status after a successful write into an error, so treating an -## unparsed label as MISMATCH would mean a future CLI release that reworded or -## dropped that line breaks Configure outright — a false error plus a -## permanently amber row whose Reconfigure button cannot clear it. The entry -## has already been matched by name and exact launcher path at that point; the -## only thing in doubt is which scope it came from, and a slightly optimistic -## dot is a much cheaper failure than a configure flow that reports success as -## failure. Today's claude (2.1.241) always prints the line, so this is a -## forward-compatibility hedge, not a live code path. -static func _scope_probe_verdict( - exit_code: int, text: String, expected_scope: String, expected_target: String -) -> Dictionary: - if exit_code != 0: - return _status_details(McpClient.Status.NOT_CONFIGURED) - ## `resolved_scope` rides along as structured data so callers can act on it - ## — `_verify_post_state`'s path hint needs to know WHICH scope survived to - ## name the right file, and parsing it back out of the human-facing message - ## would couple that hint to this wording (#879). Parsed BEFORE the target - ## check so both mismatch paths carry it: an entry whose launcher drifted - ## sends the user to the wrong file just as readily as one whose scope did. - var resolved := _scope_from_probe_output(text) - if not expected_target.is_empty() and text.find(expected_target) < 0: - var drifted := _status_details(McpClient.Status.CONFIGURED_MISMATCH) - if not resolved.is_empty(): - drifted["resolved_scope"] = resolved - return drifted - if resolved.is_empty(): - return _status_details(McpClient.Status.CONFIGURED) - if resolved != expected_scope: - var details := _status_details( - McpClient.Status.CONFIGURED_MISMATCH, - "registered at %s scope, not %s" % [resolved, expected_scope], - ) - details["resolved_scope"] = resolved - return details - return _status_details(McpClient.Status.CONFIGURED) - - -## True when the descriptor's register template takes `{scope}`, i.e. where -## its entry lands is decided by the `godot_ai/mcp_client_scope` setting rather -## than fixed by the descriptor. The configurator uses this to decide whether -## the JSON-fallback file is still a valid place to read status back from. -static func uses_scope_token(client: McpClient) -> bool: - return client.cli_register_template.has(SCOPE_TOKEN) - - -## Public view of the pre-cleanup sweep, for the manual-command text: what the -## dock tells the user to run has to match what Configure actually runs (#877). -static func cleanup_scopes(client: McpClient) -> Array[String]: - return _cleanup_scopes(client) - - -## Scopes the configure pre-cleanup removes from. A descriptor without the -## `{scope}` token has exactly one place its entry can live, so it keeps the -## single pass it always had — "" means "resolve the scope normally". -static func _cleanup_scopes(client: McpClient) -> Array[String]: - if not uses_scope_token(client): - return [""] - var scopes: Array[String] = [] - for scope in McpSettings.CLIENT_SCOPES: - scopes.append(String(scope)) - return scopes - - -static func _resolve_cli(client: McpClient) -> String: - return McpCliFinder.find(McpClient._array_from_packed(client.cli_names)) - - -static func resolve_cli_path(client: McpClient) -> String: - return _resolve_cli(client) diff --git a/addons/godot_ai/clients/_cli_strategy.gd.uid b/addons/godot_ai/clients/_cli_strategy.gd.uid deleted file mode 100644 index f84a92b..0000000 --- a/addons/godot_ai/clients/_cli_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bvib7d8eabbcm diff --git a/addons/godot_ai/clients/_dsh_strategy.gd b/addons/godot_ai/clients/_dsh_strategy.gd deleted file mode 100644 index e18385c..0000000 --- a/addons/godot_ai/clients/_dsh_strategy.gd +++ /dev/null @@ -1,759 +0,0 @@ -@tool -class_name McpDshStrategy -extends RefCounted - -## Configure / status / remove for DeepSeek Harness (dsh) MCP servers. -## -## DeepSeek Harness has no `mcp` CLI verb (verified against dsh -## 0.1.0-rc.6 — `dsh --help` exposes only `web` and `plugin`). MCP servers -## register as `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch -## layer `$DSH_HOME/cordis.patch.yml` — the machine-local layer dsh applies -## over EVERY profile's own patch layer (the web GUI included), so one entry -## covers every profile the user boots. -## -## The patch file is a top-level YAML array of loader patch entries -## (`@deepseek-ai/cordis-plugin-include`'s `applyEntryPatches`). A non-insert -## row only OVERRIDES an existing bundle row (a new id is skipped with a -## warning), so a brand-new server must be added with an `insert` row: -## -## - insert: -## - id: mcp-godot-ai -## name: '@deepseek-ai/dsh-mcp-client' -## config: -## serverName: godot-ai -## transport: stdio -## command: "..." -## args: [...] -## -## Both shapes verified live against the dsh loader -## (`dsh --profile web --dump-config` composes the inserted entry; a plain -## row for a new id warns "patch: entry not found" and is skipped). We write -## one dedicated insert row per server, preserve every other row byte-for-byte -## (including other users' insert rows, overrides, comments, and `!!js` -## expressions), and status-verify the entry we wrote — the same file-read -## drift contract the JSON/TOML/YAML strategies use. -## -## The entry's `config` nests the launch under `serverName`/`transport`/ -## `command`/`args`; `serverName` is the model-facing tool namespace -## (`mcp__godot-ai__`), so it carries the server name like the -## `server_key_path` map key does for the other strategies. - -## Loader entry id prefix inside the patch list. The full id is -## `` — "mcp-godot-ai" for the godot-ai server. -const ENTRY_ID_PREFIX := "mcp-" -## The loader plugin that bridges MCP servers into the harness. Must be -## installed in the profile's node_modules (dsh ships it as a dependency of -## the CLI package). Quoted in the file: `@` is a reserved YAML indicator and -## cannot start a plain scalar. -const PLUGIN_NAME := "@deepseek-ai/dsh-mcp-client" -## Indent of a nested entry header under `- insert:` (children step 2 spaces -## deeper), matching the shipped profile patch files. -const ENTRY_INDENT := 4 - - -static func entry_id(server_name: String) -> String: - return ENTRY_ID_PREFIX + server_name - - -static func configure( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty(): - return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} - ## Fail closed before touching the file — same contract as JSON/TOML/YAML. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": "error", "message": launch_error} - - var read := _read(path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [path, read["error"]]} - ## Fail closed on content that is not a loader patch list: dsh's patch - ## parser rejects a non-array file at boot, so appending our row to a - ## top-level mapping would hand the user a broken file (#867 review). - ## `[]` is a valid empty sequence meaning "no rows"; the literal must not - ## survive an append (block rows after `[]` are malformed YAML), so only - ## that line is dropped — comments around it belong to the user and stay. - var text := String(read["data"]) - text = _strip_empty_flow_sequence(text) - if not _is_patch_sequence(text): - return { - "status": "error", - "message": "Refusing to overwrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Configure." % path, - } - - var existing_config: Variant = null - var lines := _split_lines(text) - var found := _find_entry_block(lines, entry_id(server_name), true) - if found.is_empty(): - var plain := _find_entry_block(lines, entry_id(server_name), false) - if not plain.is_empty(): - ## A plain `- id: mcp-godot-ai` row is inert for a new id — the - ## loader skips it with a warning and never registers the server. - ## Replacing the whole row with the insert form migrates it. - found = plain - if not found.is_empty(): - ## Pass the parsed config through so user-mutable fields (env, - ## toolCallTimeoutMs, reconnect) survive a reconfigure. - existing_config = _extract_config(lines, found) - var new_entry := build_entry(client, server_name, server_url, existing_config, launch) - ## Nested replacement must keep the sequence's existing header indent — - ## items of one block sequence share indentation, so a hand-written - ## 2-space file re-emitted at 4 spaces would no longer parse (#867 - ## review). Fresh rows and plain-row migrations keep the 4-space style. - var base_indent := ENTRY_INDENT - if not found.is_empty(): - var header_indent := int(found.get("indent", 0)) - if header_indent > 0: - base_indent = header_indent - var rendered := render_nested_entry(entry_id(server_name), new_entry, base_indent) - var out := "" - if found.is_empty(): - out = _append_row(lines, rendered) - else: - out = _replace_lines(lines, found, rendered) - if not McpAtomicWrite.write(path, out): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - - -static func check_status( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> McpClient.Status: - return check_status_details(client, server_name, server_url, launch)["status"] - - -## Same contract as the other strategies (#711): {status, error_msg}. An -## existing-but-unreadable config is ERROR with the diagnostic — not -## NOT_CONFIGURED — so the dock row can tell "no config" from "config the -## editor can't read" instead of offering a Configure that would fail. -static func check_status_details( - client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var read := _read(path) - if not read["ok"]: - return { - "status": McpClient.Status.ERROR, - "error_msg": "Cannot read %s: %s" % [path, read["error"]], - } - var text := String(read["data"]) - if not _is_patch_sequence(text): - ## A top-level mapping/scalar is not a dsh patch list: nothing in it - ## can be "configured", and a nested `- id:` inside a mapping must - ## not read as ours (#867 review). - return { - "status": McpClient.Status.ERROR, - "error_msg": "%s is not a top-level YAML list of loader entries; fix or remove the file." % path, - } - var lines := _split_lines(text) - var found := _find_entry_block(lines, entry_id(server_name), true) - if found.is_empty(): - ## A plain row for our id is inert (the loader skips unknown ids), so - ## the server is NOT registered no matter what the file says. It only - ## becomes registered once Configure rewrites it as an insert row. - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var config := _extract_config(lines, found) - if config.is_empty(): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - ## An entry exists but no verified launcher does — mirror the other - ## strategies: this is an environment ERROR, not entry drift. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": launch_error} - if verify_entry(client, config, server_name, server_url, launch): - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - - -static func remove(client: McpClient, server_name: String) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": "ok", "message": "Not configured"} - var read := _read(path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} - var text := String(read["data"]) - if not _is_patch_sequence(text): - ## Same fail-closed contract as configure (#867 review): never modify - ## a file that dsh cannot load as a patch list. - return { - "status": "error", - "message": "Refusing to rewrite %s: it is not a top-level YAML list of loader entries. Fix or remove the file, then re-run Remove." % path, - } - var lines := _split_lines(text) - var found := _find_entry_block(lines, entry_id(server_name), true) - if found.is_empty(): - found = _find_entry_block(lines, entry_id(server_name), false) - if found.is_empty(): - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - var out := _remove_lines(lines, found) - if _is_blank_or_comment_only(out): - ## dsh's patch parser requires a top-level YAML array: an empty or - ## comment-only patch file FAILS BOOT (`loadOptionalPatches` throws on - ## a non-array file), while an absent file means "no layer". After our - ## row is gone, an all-blank file must therefore be deleted, not - ## written back. - if FileAccess.file_exists(path): - var remove_err := DirAccess.remove_absolute(path) - if remove_err != OK: - return {"status": "error", "message": "Cannot remove %s (error %d)" % [path, remove_err]} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - if not McpAtomicWrite.write(path, out): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - - -## Build the `config` dict written inside the loader entry. Command mode -## (FLAT, #838) nests `serverName`/`transport`/`command`/`args`; URL mode nests -## `serverName`/`transport`/`url`. Existing user state (env, timeouts, -## reconnect) survives a reconfigure via the deep copy. -static func build_entry( - client: McpClient, - server_name: String, - server_url: String, - existing: Variant = null, - launch: Dictionary = {}, -) -> Dictionary: - var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} - entry["serverName"] = server_name - if client.command_shape == McpClient.CommandShape.FLAT: - entry["command"] = str(launch.get("command", "")) - entry["args"] = _array_copy(launch.get("args", [])) - if not client.command_transport_key.is_empty(): - entry[client.command_transport_key] = client.command_transport_value - for key in client.command_initial_fields: - if not entry.has(key): - entry[key] = client.command_initial_fields[key] - for key in client.command_legacy_keys: - entry.erase(String(key)) - return entry - if client.command_shape == McpClient.CommandShape.NONE: - ## Single URL-mode source of truth (#867 review): build_entry's URL - ## branch delegates to build_url_entry so the two can never drift. - return build_url_entry(client, server_name, server_url, existing) - return {} - - -## URL-mode sibling for the manual-instruction fallback text and the -## build_entry URL branch. DeepSeek Harness' mcp-client bridge accepts -## `transport: streamable-http` with a `url`, so the entry keeps the same -## `serverName` namespace with an HTTP transport instead of the stdio attach -## bridge. Existing user state survives via the deep copy. -static func build_url_entry( - client: McpClient, server_name: String, server_url: String, existing: Variant = null -) -> Dictionary: - var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} - entry["serverName"] = server_name - entry["transport"] = "streamable-http" - if not client.entry_url_field.is_empty(): - entry[client.entry_url_field] = server_url - return entry - - -## Verify a stored `config` dict matches the current launch. Command mode -## checks every launch-affecting value exactly and requires legacy URL keys -## gone; serverName must match the pinned namespace. URL mode checks the url -## and the streamable-http pin. -static func verify_entry( - client: McpClient, - config: Dictionary, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> bool: - if config.get("serverName") != server_name: - return false - if client.command_shape != McpClient.CommandShape.NONE: - if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): - return false - for key in client.command_legacy_keys: - if config.has(String(key)): - return false - if config.get("command") != launch.get("command"): - return false - if not _arrays_equal(config.get("args", null), launch.get("args", null)): - return false - if not client.command_transport_key.is_empty(): - if config.get(client.command_transport_key, null) != client.command_transport_value: - return false - return true - if config.get(client.entry_url_field, "") != server_url: - return false - return config.get("transport", "") == "streamable-http" - - -## Empty string when this client's launch requirements are satisfied. -## Mirrors `McpYamlStrategy.command_launch_error`; the patch-list dialect -## supports FLAT (and the URL fallback) only. -static func command_launch_error(client: McpClient, launch: Dictionary) -> String: - if client.command_shape == McpClient.CommandShape.NONE: - return "" - if client.command_shape != McpClient.CommandShape.FLAT: - return "%s uses a command shape not supported by DeepSeek Harness config yet" % client.display_name - if not bool(launch.get("ok", false)): - return str(launch.get("error", "No compatible attach launcher was found.")) - return "" - - -# --- Rendering -------------------------------------------------------------- - -## Render a full `- insert:` row containing one nested loader entry. Public -## seam for the dock's manual-instruction text so the pasted YAML matches -## what Configure would write byte-for-byte. -static func render_insert_row(entry_id_value: String, config: Dictionary) -> PackedStringArray: - var lines: PackedStringArray = ["- insert:"] - lines.append_array(render_nested_entry(entry_id_value, config)) - return lines - - -## Render one nested loader entry (the `- id:` block inside an `insert` -## list). `base_indent` defaults to the 4-space style used by the shipped -## patch files; a nested replacement reuses the indent the existing entry was -## found at so the block sequence keeps a single indentation (#867 review). -static func render_nested_entry( - entry_id_value: String, config: Dictionary, base_indent: int = ENTRY_INDENT -) -> PackedStringArray: - var lines: PackedStringArray = [] - lines.append("%s- id: %s" % [_indent_str(base_indent), entry_id_value]) - lines.append("%sname: '%s'" % [_indent_str(base_indent + 2), PLUGIN_NAME]) - lines.append("%sconfig:" % _indent_str(base_indent + 2)) - for key in config: - lines.append_array(_emit_field(String(key), config[key], base_indent + 4)) - return lines - - -## Emit one `key: value` line (or a nested block for Dictionary values) at -## the given indent. Arrays render in flow style; scalars through the shared -## YAML emitter so quoting rules stay identical across the two YAML dialects. -static func _emit_field(key: String, value: Variant, indent: int) -> PackedStringArray: - var lines: PackedStringArray = [] - var prefix := _indent_str(indent) - if value is Dictionary: - lines.append("%s%s:" % [prefix, key]) - for sub_key in value: - lines.append_array(_emit_field(String(sub_key), value[sub_key], indent + 2)) - elif value is Array or value is PackedStringArray: - lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_flow_array(_array_copy(value))]) - else: - lines.append("%s%s: %s" % [prefix, key, McpYamlStrategy.emit_scalar(value)]) - return lines - - -# --- Line-level parsing (no YAML parser in Godot) --------------------------- - -## Split file text into lines, dropping only a single trailing newline so the -## remainder round-trips byte-for-byte. `text` is never split on the empty -## string, so an empty file yields an empty array. -static func _split_lines(text: String) -> PackedStringArray: - if text.is_empty(): - return PackedStringArray() - var lines := text.split("\n") - if lines.size() > 0 and String(lines[lines.size() - 1]).is_empty(): - lines.remove_at(lines.size() - 1) - return lines - - -## Locate the block owned by our loader entry: -## require_nested=true — the `- id: ` line inside an `insert` -## list (indent > 0), plus its indented children. -## require_nested=false — a plain top-level `- id: ` row (indent -## 0), plus its indented children. -## Returns {"start": int, "end": int, "indent": int} (end exclusive) or {} -## when absent. The header indent is detected dynamically so a hand-written -## file using non-2-space indentation is still found; children are lines -## deeper than the header. `end` stops right AFTER the last content line: -## trailing blank and comment lines between our entry and the next sibling -## belong to the file, not to our block, so `_replace_lines` / `_remove_lines` -## (which splice [start, end)) never delete them (#867 review). -static func _find_entry_block(lines: PackedStringArray, entry_id_value: String, require_nested: bool) -> Dictionary: - for i in range(lines.size()): - var indent := _indent_of(lines[i]) - if require_nested and indent <= 0: - continue - if not require_nested and indent != 0: - continue - var parsed := _parse_entry_header(lines[i]) - if parsed.is_empty() or parsed.get("id") != entry_id_value: - continue - ## A nested match must actually live inside an `- insert:` row: a - ## `- id:` list under some other top-level row (an override row's own - ## sequence, say) is not a loader entry and is not ours to touch. - if require_nested and not _under_insert_row(lines, i): - continue - var j := i + 1 - var last_content := i - while j < lines.size(): - var l := lines[j] - if _is_blank_or_comment(l): - j += 1 - continue - if _indent_of(l) <= indent: - break - last_content = j - j += 1 - return {"start": i, "end": last_content + 1, "indent": indent} - return {} - - -## True when the nearest preceding 0-indent content line above `index` is an -## `- insert:` row header. Blank and comment lines are skipped; hitting the -## top of the file without a header means the nested line is orphaned and -## cannot be a loader entry. -static func _under_insert_row(lines: PackedStringArray, index: int) -> bool: - for i in range(index - 1, -1, -1): - if _is_blank_or_comment(lines[i]): - continue - if _indent_of(lines[i]) == 0: - return lines[i].strip_edges().begins_with("- insert:") - return false - - -## Parse a `- id: ` header line into {"id": String}. Returns {} for -## any other line. The value may be plain or single/double-quoted. -static func _parse_entry_header(line: String) -> Dictionary: - var stripped := line.strip_edges() - if not stripped.begins_with("- "): - return {} - var body := stripped.substr(2).strip_edges() - if not body.begins_with("id:"): - return {} - var value := body.substr(3).strip_edges() - if value.begins_with("\"") and value.ends_with("\"") and value.length() >= 2: - var parsed: Variant = JSON.parse_string(value) - if parsed is String: - value = parsed - elif value.begins_with("'") and value.ends_with("'") and value.length() >= 2: - value = value.substr(1, value.length() - 2) - return {"id": value} - - -## Extract the `config:` sub-dict from a found entry block. Fields may nest -## (env, reconnect); deeper values are parsed with the same scalar coercion -## the YAML strategy uses. Returns {} when the block has no config block. -static func _extract_config(lines: PackedStringArray, block: Dictionary) -> Dictionary: - var start := int(block.get("start", -1)) - var end := int(block.get("end", -1)) - if start < 0: - return {} - var config_indent := -1 - var config_start := -1 - for i in range(start, end): - var stripped := lines[i].strip_edges() - if _is_blank_or_comment(lines[i]): - continue - if stripped == "config:": - config_indent = _indent_of(lines[i]) - config_start = i - break - if config_start < 0: - return {} - return _parse_block_lines(lines, config_start + 1, end, config_indent) - - -## Parse sibling `key: value` lines at a fixed minimum indent into a -## Dictionary. Nested blocks (a key with no inline value) recurse and their -## child lines are consumed by the recursion — the parent index advances past -## them so they are never re-parsed as top-level config fields (#867 review). -## Flow arrays parse back into Arrays via JSON. Quoted and plain scalars -## strip via the YAML strategy's shared `coerce_scalar`. -static func _parse_block_lines(lines: PackedStringArray, start: int, end: int, min_indent: int) -> Dictionary: - var out: Dictionary = {} - var i := start - while i < end: - var l := lines[i] - if _is_blank_or_comment(l): - i += 1 - continue - var indent := _indent_of(l) - if indent <= min_indent: - break - var stripped := l.strip_edges() - var colon := stripped.find(":") - if colon < 0: - i += 1 - continue - var key := stripped.substr(0, colon).strip_edges() - var value := stripped.substr(colon + 1).strip_edges() - if value.is_empty(): - ## Nested block: find where its children end (a line at or above - ## the block key's indent), parse only that span, and jump past it. - var child_end := i + 1 - while child_end < end: - if not _is_blank_or_comment(lines[child_end]) and _indent_of(lines[child_end]) <= indent: - break - child_end += 1 - out[key] = _parse_block_lines(lines, i + 1, child_end, indent) - i = child_end - else: - ## Shared with the YAML strategy so the two dialects can never - ## drift on quoting/typing rules (#867 review). - out[key] = McpYamlStrategy.coerce_scalar(value) - i += 1 - return out - - -# --- Rewrites --------------------------------------------------------------- - -## Append a new `- insert:` row at the end of the file. A blank separator -## line keeps rows visually distinct; the file always ends with a newline. -static func _append_row(lines: PackedStringArray, nested_lines: PackedStringArray) -> String: - var parts: Array[String] = [] - for l in lines: - parts.append(l) - ## Collapse trailing blank lines so the appended row is not orphaned - ## below stray empties; one separator blank keeps rows readable. - while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): - parts.remove_at(parts.size() - 1) - if parts.size() > 0: - parts.append("") - parts.append("- insert:") - for l in nested_lines: - parts.append(l) - return _join_lines(parts) - - -## Replace the lines owned by our entry. When the found block is a plain -## top-level row (indent 0 header), the whole row — including its children — -## is replaced by the insert form. When it is a nested block inside an -## insert row, only that block's lines are replaced, leaving sibling entries -## and comments in the same row byte-for-byte intact. -static func _replace_lines(lines: PackedStringArray, block: Dictionary, nested_lines: PackedStringArray) -> String: - var start := int(block.get("start", -1)) - var end := int(block.get("end", -1)) - var parts: Array[String] = [] - for i in range(lines.size()): - if i == start: - ## A nested block keeps the surrounding `- insert:` row, so only - ## the nested entry lines (indent > 0) are spliced in. - if _indent_of(lines[start]) > 0: - for l in nested_lines: - parts.append(l) - else: - parts.append("- insert:") - for l in nested_lines: - parts.append(l) - if i >= start and i < end: - continue - parts.append(lines[i]) - return _join_lines(parts) - - -## Remove our entry. A nested block is spliced out; if its enclosing insert -## row is left without any nested entries (only the bare `- insert:` header, -## blanks, and comments), the row header goes too so the file stays a valid -## patch list. A plain row for our id is removed wholesale. -static func _remove_lines(lines: PackedStringArray, block: Dictionary) -> String: - var start := int(block.get("start", -1)) - var end := int(block.get("end", -1)) - var nested := _indent_of(lines[start]) > 0 - var parts: Array[String] = [] - if nested: - ## Find the enclosing `- insert:` row header: the nearest preceding - ## 0-indent `- ` line. - var row_start := -1 - for i in range(start - 1, -1, -1): - if _indent_of(lines[i]) == 0 and lines[i].strip_edges().begins_with("- "): - row_start = i - break - ## Row end: first 0-indent non-blank/comment line after the block. - var row_end := lines.size() - for i in range(end, lines.size()): - if _indent_of(lines[i]) == 0 and not _is_blank_or_comment(lines[i]): - row_end = i - break - var has_other_entries := false - for i in range(row_start + 1, row_end): - if i >= start and i < end: - continue - if _is_blank_or_comment(lines[i]): - continue - if _indent_of(lines[i]) > 0 and lines[i].strip_edges().begins_with("- "): - has_other_entries = true - break - if has_other_entries or row_start < 0: - ## Only our block goes; sibling entries and any trailing - ## comments in the same row survive. - for i in range(lines.size()): - if i >= start and i < end: - continue - parts.append(lines[i]) - else: - ## The row held only our entry: drop the `- insert:` header and - ## our block, but keep trailing blank/comment lines — a comment - ## the user wrote above the next row must survive (#867 review). - for i in range(lines.size()): - if i >= row_start and i < end: - continue - parts.append(lines[i]) - parts = _trim_trailing_blanks(parts) - else: - ## Plain top-level row for our id: drop the whole block. - for i in range(lines.size()): - if i >= start and i < end: - continue - parts.append(lines[i]) - parts = _trim_trailing_blanks(parts) - return _join_lines(parts) - - -## Remove trailing blank lines left behind by a removal. -static func _trim_trailing_blanks(parts: Array[String]) -> Array[String]: - while parts.size() > 0 and String(parts[parts.size() - 1]).strip_edges().is_empty(): - parts.remove_at(parts.size() - 1) - return parts - - -## True when the text holds no patch rows at all — blank lines and comments -## only. Such a file cannot be written back (see `remove`), because dsh's -## patch parser rejects a non-array file at boot. -static func _is_blank_or_comment_only(text: String) -> bool: - for line in text.split("\n"): - var stripped := line.strip_edges() - if not stripped.is_empty() and not stripped.begins_with("#"): - return false - return true - - -## When the document's only content line is the empty flow sequence `[]`, -## return the text with that one line removed — blanks and comments stay, -## because they belong to the user. Any other document comes back unchanged; -## a `[]` mixed with real rows is left for `_is_patch_sequence` to refuse. -static func _strip_empty_flow_sequence(text: String) -> String: - var lines := _split_lines(text) - var bracket_line := -1 - for i in range(lines.size()): - if _is_blank_or_comment(lines[i]): - continue - if bracket_line < 0 and lines[i].strip_edges() == "[]": - bracket_line = i - continue - return text - if bracket_line < 0: - return text - var parts: Array[String] = [] - for i in range(lines.size()): - if i != bracket_line: - parts.append(lines[i]) - if parts.is_empty(): - return "" - return _join_lines(parts) - - -## True when the text is a valid loader patch list (or empty/comment-only / -## the empty flow sequence `[]`, which all mean "no layer"). Anything else — -## a top-level mapping or scalar — would make dsh fail boot, so Configure -## refuses to write into it instead of producing a broken file (#867 review). -## Only 0-indent content lines define the top-level structure: they must all -## be `- ` rows. Deeper lines are row children and are not inspected. `[]` is -## valid only as the COMPLETE document — appending rows after the empty flow -## sequence would produce malformed YAML, so a mixed `[]` + rows file fails -## the check too. -static func _is_patch_sequence(text: String) -> bool: - if text.strip_edges().is_empty(): - return true - var saw_brackets := false - var content_count := 0 - for line in text.split("\n"): - var stripped := line.strip_edges() - if stripped.is_empty() or stripped.begins_with("#"): - continue - content_count += 1 - if stripped == "[]": - saw_brackets = true - continue - if _indent_of(line) == 0 and not stripped.begins_with("- "): - return false - if saw_brackets and content_count > 1: - return false - return true - - -static func _join_lines(parts: Array) -> String: - if parts.is_empty(): - return "" - var lines: Array[String] = [] - for p in parts: - lines.append(String(p)) - return "\n".join(lines) + "\n" - - -# --- Small helpers ---------------------------------------------------------- - -static func _indent_str(n: int) -> String: - var out := "" - for i in range(n): - out += " " - return out - - -static func _indent_of(line: String) -> int: - var n := 0 - while n < line.length() and (line[n] == " " or line[n] == "\t"): - n += 1 - return n - - -static func _is_blank_or_comment(line: String) -> bool: - var stripped := line.strip_edges() - return stripped.is_empty() or stripped.begins_with("#") - - -static func _array_copy(value: Variant) -> Array: - if value is Array: - return (value as Array).duplicate(true) - if value is PackedStringArray: - return McpClient._array_from_packed(value) - return [] - - -static func _arrays_equal(left: Variant, right: Variant) -> bool: - if not (left is Array or left is PackedStringArray): - return false - if not (right is Array or right is PackedStringArray): - return false - var left_array := _array_copy(left) - var right_array := _array_copy(right) - if left_array.size() != right_array.size(): - return false - for i in range(left_array.size()): - if left_array[i] != right_array[i]: - return false - return true - - -## Returns {"ok": true, "data": String} when the file is absent or readable, -## and {"ok": false, "error": String} when unreadable. Callers must NOT fall -## back to an empty string on the error path — doing so blows away the user's -## other patch rows on the next write. -static func _read(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - return {"ok": true, "data": ""} - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - var err := FileAccess.get_open_error() - return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} - var t := f.get_as_text() - f.close() - return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_dsh_strategy.gd.uid b/addons/godot_ai/clients/_dsh_strategy.gd.uid deleted file mode 100644 index 36703f4..0000000 --- a/addons/godot_ai/clients/_dsh_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ch0brn7swb6qx diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd deleted file mode 100644 index 4e883a8..0000000 --- a/addons/godot_ai/clients/_json_strategy.gd +++ /dev/null @@ -1,977 +0,0 @@ -@tool -class_name McpJsonStrategy -extends RefCounted - -## Read–merge–write strategy for JSON-backed MCP clients. -## All knobs come from the McpClient descriptor as plain data — no Callables. -## See `_base.gd` for why descriptors are data-only. - - -static func configure( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> Dictionary: - if _uses_merge_tiers(client): - return _configure_merged(client, server_name, server_url, launch, project_roots) - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty(): - return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} - - var seed_path := str(resolution.get("seed_path", "")) - var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path - var read := _read_or_init(read_path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": "error", "message": launch_error} - var config: Dictionary = read["data"] - var holder := _ensure_path(config, select_server_key_path(config, client)) - ## Pass the existing entry through so `build_entry` can preserve user-mutable - ## state (auto-approval lists, `disabled` toggles) instead of resetting it - ## to descriptor defaults on every Configure click. See `entry_initial_fields` - ## docs in `_base.gd`. - var existing: Variant = holder.get(server_name, null) - holder[server_name] = build_entry(client, server_url, existing, launch) - - # F5: refuse to re-serialize a file whose parsed integers above 2^53 would - # lose precision. Editing by hand keeps those values intact. - if _has_lossy_numbers(config): - return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} - if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - -## Pi-style clients merge several global config files. Update the effective -## highest-precedence definition; fail closed when a project override exists -## because Pi's external working directory cannot be inferred safely here. -static func _configure_merged( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary, - project_roots: PackedStringArray, -) -> Dictionary: - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": "error", "message": launch_error} - var project := _load_project_definitions(client, server_name, project_roots) - if not project.get("ok", false): - return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} - var project_tiers: Array = project.get("tiers", []) - if not project_tiers.is_empty(): - return {"status": "error", "message": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} - var loaded := _load_merge_tiers(client) - if not loaded.get("ok", false): - return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} - var tiers: Array = loaded.get("tiers", []) - if tiers.is_empty(): - return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} - var target_index := 0 - for index in range(tiers.size()): - var config: Dictionary = tiers[index]["data"] - var holder := _walk_path(config, select_server_key_path(config, client)) - if holder is Dictionary and holder.has(server_name): - target_index = index - var tier: Dictionary = tiers[target_index] - var config: Dictionary = tier["data"] - var holder := _ensure_path(config, select_server_key_path(config, client)) - var existing: Variant = holder.get(server_name, null) - holder[server_name] = build_entry(client, server_url, existing, launch) - var path := str(tier["path"]) - # F5: refuse to re-serialize a tier whose parsed integers above 2^53 would - # lose precision. The same check guards the simple `configure` path. - if _has_lossy_numbers(config): - return {"status": "error", "message": "Refusing to rewrite %s: contains integers above 2^53 that Godot's JSON parser would re-emit imprecise. Edit the file by hand." % path} - if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)): - return {"status": "error", "message": "Cannot write to %s" % path} - # Codex F1 caveat: when we wrote a global tier but couldn't find any project - # override in the roots we probed AND the user hasn't told us where Pi (or any - # external cwd client) actually runs from, we can't prove this write is the - # effective config. Surface the actionable hint instead of silently passing. - # The early `not project_tiers.is_empty()` return above already covers the - # "project override exists in probed roots" case (we fail closed there), so - # reaching this point with `project_tiers.is_empty()` means: no probed - # override was found. - var message := McpClient.configured_message(client, server_url) - var external_cwd := str(McpClientConfigurator._editor_setting_lookup(McpClientConfigurator.SETTING_EXTERNAL_CLIENT_CWD)) - if external_cwd.is_empty() and project_tiers.is_empty(): - message += " If %s is launched from a cwd this editor cannot see, the effective entry may still be in a project-tier file — set godot_ai/external_client_cwd to that cwd to verify." % client.display_name - return {"status": "ok", "message": message} - - -static func check_status( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> McpClient.Status: - return check_status_details(client, server_name, server_url, launch, project_roots).get("status", McpClient.Status.NOT_CONFIGURED) - - -## Detailed variant feeding the dock's error_msg plumbing (#711): a config -## file that EXISTS but can't be read or parsed is Status.ERROR carrying the -## read/parse error, not NOT_CONFIGURED — the write path refuses to touch -## such a file (see `_read_or_init`), so the status dot must say "broken -## file", not "click Configure". -static func check_status_details( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> Dictionary: - if _uses_merge_tiers(client): - return _check_status_merged(client, server_name, server_url, launch, project_roots) - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var read := _read_or_init(path) - if not read["ok"]: - return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])} - var config: Dictionary = read["data"] - var holder := _walk_path(config, select_server_key_path(config, client)) - if not (holder is Dictionary) or not holder.has(server_name): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - return _entry_status_details(client, holder[server_name], server_url, launch) - - -## Verify the effective last definition after applying the client's merge order. -static func _check_status_merged( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary, - project_roots: PackedStringArray, -) -> Dictionary: - var loaded := _load_merge_tiers(client) - if not loaded.get("ok", false): - return {"status": McpClient.Status.ERROR, "error_msg": str(loaded.get("error", "Cannot read merged config tiers"))} - var effective: Variant = null - for tier in loaded.get("tiers", []): - var config: Dictionary = tier["data"] - var holder := _walk_path(config, select_server_key_path(config, client)) - if holder is Dictionary and holder.has(server_name): - effective = holder[server_name] - var project := _load_project_definitions(client, server_name, project_roots) - if not project.get("ok", false): - return {"status": McpClient.Status.ERROR, "error_msg": str(project.get("error", "Cannot inspect project config tiers"))} - var project_tiers: Array = project.get("tiers", []) - if not project_tiers.is_empty(): - # Last-definition-wins mirrors the global-tier fold above and how - # pi-codemode-mcp merges project tiers on disk. Earlier tiers are dead - # once a later one defines the same server, so an early-stale entry - # doesn't make Pi's effective config drift (codex-review finding F2). - # Pass `[latest]` to `_project_override_message` so the error names only - # the file the user actually has to edit. - var latest: Dictionary = project_tiers[project_tiers.size() - 1] - var details := _entry_status_details(client, latest["entry"], server_url, launch) - if details.get("status") != McpClient.Status.CONFIGURED: - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": _project_override_message([latest], "update or remove", client.display_name, server_name)} - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - if effective == null: - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - return _entry_status_details(client, effective, server_url, launch) - - -static func _entry_status_details( - client: McpClient, - entry: Variant, - server_url: String, - launch: Dictionary, -) -> Dictionary: - if not (entry is Dictionary): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": launch_error} - if verify_entry(client, entry, server_url, launch): - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - - -static func remove( - client: McpClient, - server_name: String, - project_roots: PackedStringArray = PackedStringArray(), -) -> Dictionary: - if _uses_merge_tiers(client): - return _remove_merged(client, server_name, project_roots) - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": "ok", "message": "Not configured"} - # Token-preserving removal (F5): pull the original file text via - # `_read_file_text` and edit only the entry's bytes — keeps every other - # byte, including integers above 2^53, byte-for-byte identical to what - # the user wrote. The parsed-dict round-trip path is gone. - var file_read := _read_file_text(path) - if not file_read.get("ok", false): - return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, file_read.get("error", "")]} - var config: Dictionary = file_read.get("data", {}) - var key_path := select_server_key_path(config, client) - var original_text: String = str(file_read.get("original_text", "")) - var updated: String = _text_remove_server_entry(original_text, key_path, server_name) - if updated == original_text: - return {"status": "ok", "message": "Not configured"} - if not McpAtomicWrite.write(path, updated): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - - -static func _remove_merged( - client: McpClient, server_name: String, project_roots: PackedStringArray -) -> Dictionary: - var project := _load_project_definitions(client, server_name, project_roots) - if not project.get("ok", false): - return {"status": "error", "message": str(project.get("error", "Cannot inspect project config tiers"))} - var project_tiers: Array = project.get("tiers", []) - if not project_tiers.is_empty(): - return {"status": "error", "message": _project_override_message(project_tiers, "remove", client.display_name, server_name)} - var loaded := _load_merge_tiers(client) - if not loaded.get("ok", false): - return {"status": "error", "message": str(loaded.get("error", "Cannot read merged config tiers"))} - var writes: Array[Dictionary] = [] - for tier in loaded.get("tiers", []): - if not tier.get("exists", false): - continue - var key_path := select_server_key_path(tier["data"], client) - var original: String = str(tier.get("original_text", "")) - # Token-preserving removal (F5): edit `original` textually instead of - # re-serialising the parsed config. This preserves every byte outside - # the entry block, so unrelated integers (including those above the - # IEEE-754 exact-int range that `_narrow_integral_numbers` cannot - # round-trip) keep their original literals. - var updated: String = _text_remove_server_entry(original, key_path, server_name) - if updated == original: - continue # entry not in this tier; skip - writes.append({ - "path": tier["path"], - "text": updated, - "original_text": original, - }) - if writes.is_empty(): - return {"status": "ok", "message": "Not configured"} - var written := _write_transaction(writes) - if not written.get("ok", false): - return {"status": "error", "message": written.get("error", "Cannot remove merged configuration")} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - - -## Synthesize the entry dict the strategy writes under -## `server_key_path[server_name]`. Both URL and command entries deep-copy the -## existing dict before overwriting strategy-owned fields, preserving unknown -## client additions as well as descriptor-documented user fields. -static func build_entry( - client: McpClient, - server_url: String, - existing: Variant = null, - launch: Dictionary = {}, -) -> Dictionary: - if _is_supported_command_shape(client.command_shape): - var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} - if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: - ## OpenCode-style: the entry's `command` field IS the argv array. - ## A stale sibling `args` from a FLAT-style hand edit would be - ## ambiguous next to it, so it is strategy-owned and removed. - command_entry["command"] = _launch_argv(launch) - command_entry.erase("args") - else: - command_entry["command"] = str(launch.get("command", "")) - command_entry["args"] = _array_copy(launch.get("args", [])) - if not client.command_transport_key.is_empty(): - command_entry[client.command_transport_key] = client.command_transport_value - for key in client.command_initial_fields: - if not command_entry.has(key): - command_entry[key] = client.command_initial_fields[key] - for key in client.command_legacy_keys: - command_entry.erase(String(key)) - _remove_legacy_env_keys(command_entry, client.command_env_legacy_keys) - return command_entry - if client.command_shape != McpClient.CommandShape.NONE: - return {} - return build_url_entry(client, server_url, existing) - - -static func build_url_entry(client: McpClient, server_url: String, existing: Variant = null) -> Dictionary: - var entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} - entry[client.entry_url_field] = server_url - for k in client.entry_extra_fields: - entry[k] = client.entry_extra_fields[k] - for k in client.entry_initial_fields: - if not entry.has(k): - entry[k] = client.entry_initial_fields[k] - return entry - - -## Default verifier for a stored entry. Command entries must match every -## launch-affecting value exactly; legacy URL or env keys are migration drift. -## For URL clients, assert `entry[entry_url_field] == url` AND every -## key in `entry_extra_fields` matches verbatim. Type-pinning for Cline / -## Roo / Kilo (`type: "streamable-http"` etc.) falls out of this — pre-fix -## entries that lack the type field fail verification and surface as drift. -static func verify_entry( - client: McpClient, - entry: Dictionary, - server_url: String, - launch: Dictionary = {}, -) -> bool: - if client.command_shape != McpClient.CommandShape.NONE: - if not _is_supported_command_shape(client.command_shape) or not bool(launch.get("ok", false)): - return false - for key in client.command_legacy_keys: - if entry.has(String(key)): - return false - var env = entry.get("env", null) - if env is Dictionary: - for key in client.command_env_legacy_keys: - if env.has(String(key)): - return false - if client.command_shape == McpClient.CommandShape.COMMAND_ARRAY: - if not _arrays_equal(entry.get("command", null), _launch_argv(launch)): - return false - if entry.has("args"): - return false - else: - if entry.get("command") != launch.get("command"): - return false - if not _arrays_equal(entry.get("args", null), launch.get("args", null)): - return false - if not client.command_transport_key.is_empty(): - if not entry.has(client.command_transport_key): - return false - if entry.get(client.command_transport_key) != client.command_transport_value: - return false - return true - if entry.get(client.entry_url_field, "") != server_url: - return false - for k in client.entry_extra_fields: - if entry.get(k) != client.entry_extra_fields[k]: - return false - return true - - -static func command_launch_error(client: McpClient, launch: Dictionary) -> String: - if client.command_shape == McpClient.CommandShape.NONE: - return "" - if not _is_supported_command_shape(client.command_shape): - return "%s uses a command shape not supported by JSON yet" % client.display_name - if not bool(launch.get("ok", false)): - return str(launch.get("error", "No compatible attach launcher was found.")) - return "" - - -static func _is_supported_command_shape(shape: McpClient.CommandShape) -> bool: - return shape == McpClient.CommandShape.FLAT or shape == McpClient.CommandShape.COMMAND_ARRAY - - -## The full launch argv as one array: launcher path followed by every arg. -static func _launch_argv(launch: Dictionary) -> Array: - var argv: Array = [str(launch.get("command", ""))] - argv.append_array(_array_copy(launch.get("args", []))) - return argv - - -static func _remove_legacy_env_keys(entry: Dictionary, legacy_keys: PackedStringArray) -> void: - if legacy_keys.is_empty(): - return - var existing_env = entry.get("env", null) - if not (existing_env is Dictionary): - return - var env: Dictionary = (existing_env as Dictionary).duplicate(true) - for key in legacy_keys: - env.erase(String(key)) - if env.is_empty(): - entry.erase("env") - else: - entry["env"] = env - - -static func _array_copy(value: Variant) -> Array: - if value is Array: - return (value as Array).duplicate(true) - if value is PackedStringArray: - return McpClient._array_from_packed(value) - return [] - - -static func _arrays_equal(left: Variant, right: Variant) -> bool: - if not (left is Array or left is PackedStringArray): - return false - if not (right is Array or right is PackedStringArray): - return false - var left_array := _array_copy(left) - var right_array := _array_copy(right) - if left_array.size() != right_array.size(): - return false - for i in range(left_array.size()): - if left_array[i] != right_array[i]: - return false - return true - - -## Read a config file once and parse the captured text. Returns -## {"exists": bool, "ok": bool, "data": Dictionary, "original_text": String} -## when the file is absent or parses cleanly, and -## {"exists": true, "ok": false, "error": String, "original_text": String} -## when the file exists with non-empty content we cannot safely round-trip. -## Callers must NOT treat the error path as an empty config — doing so blows -## away the user's other MCP entries on the next write. The `original_text` -## is the exact captured source so transactional rollback can restore -## byte-for-byte; the UTF-8 BOM is stripped only from the parsing copy. -static func _read_file_text(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - return {"exists": false, "ok": true, "data": {}, "original_text": ""} - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - var open_err := FileAccess.get_open_error() - return {"exists": true, "ok": false, "error": "could not open for reading (error %d)" % open_err, "original_text": ""} - var content := file.get_as_text() - file.close() - if content.strip_edges().is_empty(): - return {"exists": true, "ok": true, "data": {}, "original_text": content} - var parse_copy := content - # Strip a UTF-8 BOM if present — some editors (notably on Windows) save - # JSON with a leading , which Godot's JSON.parse rejects outright. - # Previously this landed on the "unparseable → wipe" path. - if parse_copy.begins_with(""): - parse_copy = parse_copy.substr(1) - var json := JSON.new() - if json.parse(parse_copy) != OK: - var msg := "JSON parse error on line %d: %s" % [json.get_error_line(), json.get_error_message()] - push_warning("MCP | %s in %s" % [msg, path]) - return {"exists": true, "ok": false, "error": msg, "original_text": content} - if not (json.data is Dictionary): - return {"exists": true, "ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data)), "original_text": content} - return {"exists": true, "ok": true, "data": json.data, "original_text": content} - - -## Returns {"ok": true, "data": Dictionary} when the file is absent or parses -## cleanly, and {"ok": false, "error": String} when the file exists with -## non-empty content we cannot safely round-trip. Callers must NOT fall back -## to an empty dict on the error path — doing so blows away the user's other -## MCP entries on the next write. -static func _read_or_init(path: String) -> Dictionary: - var read := _read_file_text(path) - var result: Dictionary = {"ok": read.get("ok", false), "data": read.get("data", {})} - if not result.get("ok", false): - result["error"] = read.get("error", "") - return result - - -## Walk a key path, creating intermediate Dicts as needed. Returns the leaf Dict. -static func _ensure_path(root: Dictionary, key_path: PackedStringArray) -> Dictionary: - var cur := root - for key in key_path: - var next = cur.get(key) - if not (next is Dictionary): - next = {} - cur[key] = next - cur = next - return cur - - -## Walk a key path, returning the leaf Dict if all hops exist; else null. -static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant: - var cur: Variant = root - for key in key_path: - if not (cur is Dictionary) or not cur.has(key): - return null - cur = cur[key] - return cur - - -## Match clients that accept multiple server-map keys without creating a -## higher-precedence canonical map that shadows an existing legacy map. A -## non-null scalar still wins, matching nullish-coalescing parsers; Configure's -## `_ensure_path` then repairs it using the strategy's existing behavior. -static func select_server_key_path(root: Dictionary, client: McpClient) -> PackedStringArray: - var candidates: Array[PackedStringArray] = [client.server_key_path] - # Dynamic access keeps mixed-snapshot self-updates parse-safe when a new - # strategy is briefly loaded with an older McpClient base (#398/#736). - var aliases = client.get("server_key_path_aliases") - if aliases is Array: - for alias in aliases: - if alias is PackedStringArray: - candidates.append(alias) - for key_path in candidates: - if _walk_path(root, key_path) != null: - return key_path - return client.server_key_path - - -static func _uses_merge_tiers(client: McpClient) -> bool: - var templates = client.get("config_merge_path_templates") - return templates is Dictionary and not templates.is_empty() - - -## Resolve the global config tiers in the client's documented merge order. -static func _merge_paths(client: McpClient) -> PackedStringArray: - var result := PackedStringArray() - var templates = client.get("config_merge_path_templates") - if templates is Dictionary: - var platform_key := McpPathTemplate.platform_key(templates) - if not platform_key.is_empty(): - var raw_paths: Variant = templates.get(platform_key, []) - if raw_paths is Array or raw_paths is PackedStringArray: - for template in raw_paths: - var path := McpPathTemplate.expand(str(template)).simplify_path() - if not path.is_empty() and not result.has(path): - result.append(path) - return result - - -## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect -## plausible roots only to detect overrides; callers fail closed rather than -## mutating both guesses. -static func _project_candidate_paths( - client: McpClient, roots: PackedStringArray -) -> PackedStringArray: - var result := PackedStringArray() - var project_paths = client.get("config_merge_project_paths") - if not (project_paths is PackedStringArray): - return result - for project_path in project_paths: - var absolute_path := String(project_path).simplify_path() - if absolute_path.is_absolute_path() and FileAccess.file_exists(absolute_path) and not result.has(absolute_path): - result.append(absolute_path) - for root in roots: - if String(root).is_empty(): - continue - for relative_path in project_paths: - if String(relative_path).is_absolute_path(): - continue - var path := String(root).path_join(String(relative_path)).simplify_path() - if FileAccess.file_exists(path) and not result.has(path): - result.append(path) - return result - - -static func _load_project_definitions( - client: McpClient, server_name: String, project_roots: PackedStringArray -) -> Dictionary: - var tiers: Array[Dictionary] = [] - for path in _project_candidate_paths(client, project_roots): - var read := _read_or_init(path) - if not read.get("ok", false): - return {"ok": false, "error": "Cannot inspect project config %s: %s" % [path, read.get("error", "invalid JSON")]} - var config: Dictionary = read["data"] - var key_path := select_server_key_path(config, client) - var holder := _walk_path(config, key_path) - if holder is Dictionary and holder.has(server_name): - tiers.append({ - "path": path, - "data": config, - "key_path": key_path, - "entry": holder[server_name], - }) - return {"ok": true, "tiers": tiers} - - -static func _project_override_message( - tiers: Array, action: String, client_name: String, server_name: String -) -> String: - var paths := PackedStringArray() - for tier in tiers: - paths.append(str(tier["path"])) - return "%s project config overrides %s at %s. %s resolves project files from its own working directory, so the dock cannot safely choose one; %s the entry manually." % [client_name, server_name, ", ".join(paths), client_name, action] - - -static func _load_merge_tiers(client: McpClient) -> Dictionary: - var tiers: Array[Dictionary] = [] - for path in _merge_paths(client): - var read := _read_file_text(path) - if not read.get("ok", false): - return { - "ok": false, - "error": "Refusing to rewrite %s: %s. Fix or move the file, then re-run Configure." % [path, read.get("error", "invalid JSON")], - } - tiers.append({ - "path": path, - "exists": read.get("exists", false), - "original_text": read.get("original_text", ""), - "data": read.get("data", {}), - }) - return {"ok": true, "tiers": tiers} - - -## Commit a multi-file removal with best-effort rollback. Each individual write -## is atomic; this layer restores earlier tiers if a later commit fails. -static func _write_transaction(writes: Array[Dictionary]) -> Dictionary: - var completed: Array[Dictionary] = [] - for write in writes: - var path := str(write["path"]) - if McpAtomicWrite.write(path, str(write["text"])): - completed.append(write) - continue - var rollback_failed := PackedStringArray() - completed.reverse() - for previous in completed: - if not McpAtomicWrite.write(str(previous["path"]), str(previous["original_text"])): - rollback_failed.append(str(previous["path"])) - var suffix := "" - if not rollback_failed.is_empty(): - suffix = " Rollback also failed for: %s" % ", ".join(rollback_failed) - return {"ok": false, "error": "Cannot write to %s; earlier tier changes were rolled back.%s" % [path, suffix]} - return {"ok": true} -## Path that `_check_status_merged`'s last-wins logic would consider -## authoritative for the server entry, or "" when no entry is found in -## any tier. Mirrors the same project-tier / global-tier resolution the -## status check uses so callers (notably the dock Open/Reveal buttons) -## land on the file that actually drives Pi's effective config. -## -## Resolution order (codex round 3, F-3-4): -## 1. Latest project tier containing the entry — F2 last-wins means the -## latest of `.pi/mcp.json` and `.mcp.json` wins (matches `_check_status_merged`). -## 2. Latest global tier containing the entry — already iterated in -## merge order, last-iterated-wins (same loop pattern as -## `manual_target_details` lines 660-665). -## 3. "" when no tier has the entry — caller decides what fallback -## (`path_template`) to use. -## -## Returns "" (not `path_template`) on the "nothing anywhere" branch so -## the dock can distinguish "no entry yet" from "entry is at the -## template path" if it wants to. -static func authoritative_tier_path( - client: McpClient, - server_name: String, - project_roots: PackedStringArray, -) -> String: - if not _uses_merge_tiers(client): - return "" - var project := _load_project_definitions(client, server_name, project_roots) - if not bool(project.get("ok", false)): - return "" - var project_tiers: Array = project.get("tiers", []) - if not project_tiers.is_empty(): - # F2 last-wins: latest project tier is authoritative. When there are - # multiple project tiers, the user-visible status is driven by the - # latest, so the Open/Reveal buttons should send them there too. - var latest: Dictionary = project_tiers[project_tiers.size() - 1] - return str(latest.get("path", "")) - var loaded := _load_merge_tiers(client) - if not bool(loaded.get("ok", false)): - return "" - var tiers: Array = loaded.get("tiers", []) - # Iterate in priority order, overwrite `selected_path` each time we - # find a tier containing the entry. With Pi's merge path order - # [mcp.json, .mcp.json] this leaves the higher-precedence `.mcp.json`. - var selected_path: String = "" - for tier in tiers: - var data: Dictionary = tier.get("data", {}) - var holder: Variant = _walk_path(data, select_server_key_path(data, client)) - if holder is Dictionary and holder.has(server_name): - selected_path = str(tier.get("path", "")) - return selected_path - - -## Pick the file and top-level map shown by the manual JSON instructions using -## the same merge and alias precedence as automatic Configure. -static func manual_target_details( - client: McpClient, - server_name: String, - fallback_path: String, - project_roots: PackedStringArray = PackedStringArray(), -) -> Dictionary: - if _uses_merge_tiers(client): - var project := _load_project_definitions(client, server_name, project_roots) - if not project.get("ok", false): - return {"ok": false, "error": project.get("error", "Cannot inspect project config tiers")} - var project_tiers: Array = project.get("tiers", []) - if project_tiers.size() > 1: - return {"ok": false, "error": _project_override_message(project_tiers, "update or remove", client.display_name, server_name)} - if project_tiers.size() == 1: - var selected_project: Dictionary = project_tiers[0] - return { - "ok": true, - "path": selected_project["path"], - "key_path": selected_project["key_path"], - } - var loaded := _load_merge_tiers(client) - if not loaded.get("ok", false): - return {"ok": false, "error": loaded.get("error", "Cannot read merged config tiers")} - var tiers: Array = loaded.get("tiers", []) - var selected: Variant = tiers[0] if not tiers.is_empty() else null - for tier in tiers: - var config: Dictionary = tier["data"] - var holder := _walk_path(config, select_server_key_path(config, client)) - if holder is Dictionary and holder.has(server_name): - selected = tier - if selected != null: - var config: Dictionary = selected["data"] - return { - "ok": true, - "path": selected["path"], - "key_path": select_server_key_path(config, client), - } - var read := _read_or_init(fallback_path) - if not read.get("ok", false): - return {"ok": false, "error": "Cannot inspect %s: %s" % [fallback_path, read.get("error", "invalid JSON")]} - return { - "ok": true, - "path": fallback_path, - "key_path": select_server_key_path(read["data"], client), - } - - -## Godot's JSON.parse turns every JSON number into a float, so a later -## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict -## consumers (Go's encoding/json into an int field, etc.) reject, and which -## needlessly rewrites every number across the user's *other* entries. Re-narrow -## exactly-representable integral floats back to int so they serialize without -## the ".0". Walks dicts/arrays in place and returns the (same) value. -## -## Integers above 2^53 already lost precision when Godot parsed them to double, -## so they're left as the float Godot produced rather than faking exactness — -## byte-perfect preservation would require not parsing the file at all, and such -## magnitudes don't occur in MCP client configs. -static func _narrow_integral_numbers(value: Variant) -> Variant: - match typeof(value): - TYPE_FLOAT: - if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0: - return int(value) - TYPE_DICTIONARY: - for k in value: - value[k] = _narrow_integral_numbers(value[k]) - TYPE_ARRAY: - for i in value.size(): - value[i] = _narrow_integral_numbers(value[i]) - return value - - -## True when `value` contains any float outside the IEEE-754 exact-int range -## (`abs(f) > 2^53`) that would lose precision when JSON.stringify re-emits it. -## The configure path refuses such files instead of silently mutating unrelated -## integers; the remove path never needs this check because it does -## token-preserving surgery (F5). -static func _has_lossy_numbers(value: Variant) -> bool: - match typeof(value): - TYPE_FLOAT: - # `>=` not `>`: `2^53 + 1` rounds down to `2^53.0` when Godot parses - # the integer to double, so even a "clean" `2^53.0` after parse may - # have lost a least-significant bit we cannot recover. Refusing at - # the boundary is conservative but correct — and 2^53+ magnitudes - # don't appear in real MCP client configs anyway. - if is_finite(value) and absf(value) >= 9007199254740992.0: - return true - TYPE_DICTIONARY: - for k in value: - if _has_lossy_numbers(value[k]): - return true - TYPE_ARRAY: - for i in value.size(): - if _has_lossy_numbers(value[i]): - return true - return false - - -## Token-preserving entry removal. Walks the JSON text with a brace-balanced -## scanner to locate the entry at `key_path[0]/.../[server_name]` and returns -## `text` with that entry's bytes deleted (along with the surrounding -## whitespace + trailing comma). Everything else — including integers above -## the IEEE-754 exact-int range that `_narrow_integral_numbers` would silently -## mutate — is preserved byte-for-byte. Returns `text` unchanged when the -## entry isn't found in `text` (callers treat this as a no-op write). -static func _text_remove_server_entry(text: String, key_path: PackedStringArray, server_name: String) -> String: - # Descend through `key_path`. Each step finds `"":` directly inside the - # current container and moves the cursor past the value's opening `{` or `[`. - # After the loop, `cursor` is positioned just past the opening bracket of the - # container that holds our entry, and `container_depth` tracks how deeply - # nested that container is from the root (0 = root object/array). - var cursor := 0 - # Skip past the root's opening `{` or `[` (and any leading whitespace or - # UTF-8 BOM) so `_find_key_at_container_depth` starts scanning from - # inside the root container with `inner_depth == 0`. Without this step, - # the first character encountered is the root's `{`, which bumps - # `inner_depth` to 1 and makes the top-level key check fail. The BOM - # skip mirrors `_read_file_text`'s parse_copy BOM-strip above so the - # scanner sees the same view of the file the parser does; the BOM - # itself stays in `text` so the byte-survival F5 contract still holds - # (codex round 3, F-3-6 — without it, files saved with a Windows BOM - # left the entry in place after Remove). - while cursor < text.length() and _is_json_ws(text[cursor]): - cursor += 1 - if cursor < text.length() and text[cursor] == "": - cursor += 1 - if cursor < text.length() and (text[cursor] == "{" or text[cursor] == "["): - cursor += 1 - var container_depth := 0 - for key in key_path: - var key_bytes := JSON.stringify(String(key)) - var match := _find_key_at_container_depth(text, cursor, key_bytes) - if match.is_empty(): - return text - var open_char := text[match["value_start"]] - if open_char != "{" and open_char != "[": - return text # leaf isn't an object/array — path can't reach our entry - container_depth += 1 - cursor = match["value_start"] + 1 - # Now find `"":` at the top level of the container we descended - # into (the helper gates on `inner_depth == 0`). - var entry_bytes := JSON.stringify(server_name) - var entry_match := _find_key_at_container_depth(text, cursor, entry_bytes) - if entry_match.is_empty(): - return text - var key_start: int = entry_match["key_start"] - var value_end: int = entry_match["value_end"] - # Trim the surrounding comma in a mutually exclusive way: the trailing comma - # (after the entry's value) carries the burden when the entry is NOT the last - # in its container, and the leading comma (before the entry's key) carries it - # only when the entry IS the last. Trimming BOTH unconditionally corrupts a - # middle-position entry into `"x"}"y"` with no separator — codex-review - # finding F5 (regression after the initial round). - var remove_end := value_end - # Skip JSON whitespace before checking for the trailing comma — valid - # (if unusual) formats like `"godot-ai":{} ,"other":{}` put whitespace - # between the value and its separator. codex round 4 finding. - var trailing_scan := remove_end - while trailing_scan < text.length() and _is_json_ws(text[trailing_scan]): - trailing_scan += 1 - var had_trailing_comma := trailing_scan < text.length() and text[trailing_scan] == "," - if had_trailing_comma: - # Jump past the comma (and any whitespace before it we just skipped). - remove_end = trailing_scan + 1 - while remove_end < text.length() and _is_json_ws(text[remove_end]): - remove_end += 1 - var remove_start := key_start - if not had_trailing_comma: - while remove_start > 0 and _is_json_ws(text[remove_start - 1]): - remove_start -= 1 - if remove_start > 0 and text[remove_start - 1] == ",": - remove_start -= 1 - while remove_start > 0 and _is_json_ws(text[remove_start - 1]): - remove_start -= 1 - return text.substr(0, remove_start) + text.substr(remove_end) - - -## Look for `key_bytes` (a JSON-encoded string literal including the surrounding -## quotes) at the TOP LEVEL of the container we're inside, scanning `text` from -## `start` onwards. The caller is expected to advance `start` just past the -## container's opening `{` or `[`, so we begin with `inner_depth == 0` and a -## key matches when we hit it while `inner_depth` is still 0. The scan exits -## immediately if `inner_depth` drops below 0 (the target container closed) -## to prevent same-named keys in later sibling containers from being picked -## up. Returns `{key_start, value_start, value_end}` on success or `{}` if -## the key isn't present in that container. -static func _find_key_at_container_depth(text: String, start: int, key_bytes: String) -> Dictionary: - var i := start - var in_string := false - var escape := false - var inner_depth := 0 # depth *inside* the current container - while i < text.length(): - var c := text[i] - if in_string: - if escape: - escape = false - elif c == "\\": - escape = true - elif c == '"': - in_string = false - i += 1 - continue - if c == '"': - if text.substr(i, key_bytes.length()) == key_bytes: - var after_key := i + key_bytes.length() - while after_key < text.length() and _is_json_ws(text[after_key]): - after_key += 1 - if after_key < text.length() and text[after_key] == ":": - var value_start := after_key + 1 - while value_start < text.length() and _is_json_ws(text[value_start]): - value_start += 1 - var value_end := _json_value_span_end(text, value_start) - if value_end >= 0 and inner_depth == 0: - return {"key_start": i, "value_start": value_start, "value_end": value_end} - in_string = true - i += 1 - continue - if c == "{" or c == "[": - inner_depth += 1 - elif c == "}" or c == "]": - inner_depth -= 1 - # Bail out the moment we leave the container we're scanning. Without - # this guard, a later sibling container at the same level (e.g. a - # top-level `extensions` block after `mcpServers` closes) re-balances - # `inner_depth` back to 0 and a same-named key in that sibling would - # match — silently deleting data we never intended to touch - # (codex-review finding F5, regression after the initial round). - if inner_depth < 0: - return {} - i += 1 - return {} - - -## Given `value_start` pointing at the first char of a JSON value, return the -## position AFTER the value's last char. Handles objects, arrays, strings, -## and bare literals (numbers, true/false/null). Returns -1 if the value is -## unterminated. -static func _json_value_span_end(text: String, value_start: int) -> int: - if value_start >= text.length(): - return -1 - var c := text[value_start] - if c == "{" or c == "[": - var open_char := c - var close_char := "}" if c == "{" else "]" - var depth := 1 - var i := value_start + 1 - var in_string := false - var escape := false - while i < text.length() and depth > 0: - var ch := text[i] - if in_string: - if escape: - escape = false - elif ch == "\\": - escape = true - elif ch == '"': - in_string = false - i += 1 - continue - if ch == '"': - in_string = true - elif ch == open_char: - depth += 1 - elif ch == close_char: - depth -= 1 - i += 1 - return i if depth == 0 else -1 - if c == '"': - var i := value_start + 1 - var escape := false - while i < text.length(): - var ch := text[i] - if escape: - escape = false - elif ch == "\\": - escape = true - elif ch == '"': - return i + 1 - i += 1 - return -1 - # Bare literal: number / true / false / null. Walk until a JSON-significant - # delimiter (`,`, `}`, `]`, or whitespace). - var i := value_start - while i < text.length(): - var ch := text[i] - if ch == "," or ch == "}" or ch == "]" or _is_json_ws(ch): - break - i += 1 - return i - - -static func _is_json_ws(c: String) -> bool: - return c == " " or c == "\t" or c == "\n" or c == "\r" diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid deleted file mode 100644 index 5e41fbb..0000000 --- a/addons/godot_ai/clients/_json_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://g8a4iijpk22w diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd deleted file mode 100644 index 3e178ca..0000000 --- a/addons/godot_ai/clients/_manual_command.gd +++ /dev/null @@ -1,383 +0,0 @@ -@tool -class_name McpManualCommand -extends RefCounted - -const SHELL_POSIX := "posix" -const SHELL_POWERSHELL := "powershell" -## Keep this intersection deliberately small. PowerShell treats a leading `@` -## as splatting syntax and commas as list separators, while POSIX shells accept -## both literally; quoting either is safer than trying to infer token position. -const _SHELL_BARE_SAFE_CHARS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+=:./-" - -## Synthesize the "Run this manually" string the dock surfaces when -## auto-configure can't find a CLI / write a file. Generated from the -## descriptor's declarative fields — there is no per-client builder -## Callable. See `_base.gd` for why descriptors are data-only. - - -static func build( - client: McpClient, - server_name: String, - server_url: String, - resolved_path: String, - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> String: - match client.config_type: - "cli": - return _build_cli(client, server_name, server_url, resolved_path, launch, project_roots) - "json": - return _build_json(client, server_name, server_url, resolved_path, launch, project_roots) - "toml": - return _build_toml(client, server_name, server_url, resolved_path, launch) - "yaml": - return _build_yaml(client, server_name, server_url, resolved_path, launch) - "dsh": - return _build_dsh(client, server_name, server_url, resolved_path, launch) - return "" - - -## CLI clients: format the register template against the *short* CLI name so -## the user can paste it into a terminal regardless of where their binary -## lives. (The auto-configure path resolves to an absolute uvx-style path; -## that's noise for a paste-into-terminal hint. The attach launcher path -## inside a command-shape line stays absolute — status verification compares -## the registered command against the resolved launcher verbatim.) -static func _build_cli( - client: McpClient, - server_name: String, - server_url: String, - resolved_path: String = "", - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> String: - if client.cli_register_template.is_empty() or client.cli_names.is_empty(): - return "" - var shell_kind := _shell_kind_for_platform() - var short_name: String = String(client.cli_names[0]) - # Prefer the non-.exe form for a cross-platform-looking command line. - for n in client.cli_names: - if not String(n).ends_with(".exe"): - short_name = String(n) - break - var cmd := "" - if client.command_shape != McpClient.CommandShape.NONE: - var launch_error := McpCliStrategy.command_launch_error(client, launch) - if not launch_error.is_empty(): - cmd = "Attach launch command unavailable: %s" % launch_error - else: - var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url, launch) - var parts: Array[String] = [short_name] - for arg in args: - parts.append(String(arg)) - var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) - lines.append(_render_command_line(parts, shell_kind)) - cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) - else: - var args := McpCliStrategy.format_args(client.cli_register_template, server_name, server_url) - var parts: Array[String] = [short_name] - for arg in args: - parts.append(String(arg)) - var lines := _sweep_lines(client, server_name, server_url, short_name, shell_kind) - lines.append(_render_command_line(parts, shell_kind)) - cmd = _format_shell_block(lines, shell_kind) + _sweep_caveat(client) - # #463: a CLI client with a JSON fallback (Claude Code) may have no `claude` - # binary at all — e.g. installed only as a VS Code/Cursor extension. The CLI - # line above is useless to that user, so also show the config-file edit that - # auto-configure falls back to writing. - if client.has_json_fallback() and not resolved_path.is_empty(): - return "%s\n\nNo `%s` CLI (e.g. installed as a VS Code/Cursor extension)? %s" % [ - cmd, short_name, _build_json(client, server_name, server_url, resolved_path, launch, project_roots), - ] - return cmd - - -## #877: Configure's first act is the pre-cleanup — it runs the unregister -## template once per scope the descriptor can write to (`_cli_strategy.gd` -## `configure`) before registering. Rendering only the register line made this -## hint disagree with what the button does, which is the worst property a -## "run this manually" string can have. The removes go in the SAME shell block -## as the register, in the order Configure runs them, so the whole thing stays -## one copy-paste rather than a labelled command plus loose prose. -static func _sweep_lines( - client: McpClient, - server_name: String, - server_url: String, - short_name: String, - shell_kind: String, -) -> Array[String]: - var lines: Array[String] = [] - if client.cli_unregister_template.is_empty(): - return lines - for pre_scope in McpCliStrategy.cleanup_scopes(client): - var args := McpCliStrategy.format_args( - client.cli_unregister_template, server_name, server_url, {}, pre_scope - ) - var parts: Array[String] = [short_name] - for arg in args: - parts.append(String(arg)) - lines.append(_render_command_line(parts, shell_kind)) - return lines - - -## The one thing the commands themselves cannot show: that the project pass -## resolves `.mcp.json` against the CLI's cwd, so it can delete an entry from a -## repository that has nothing to do with this project. Only for `{scope}` -## descriptors — a single implicit-scope pass removes exactly the entry the -## register is about to rewrite, which needs no warning. Kept outside the shell -## block so pasting the block never picks up prose. -static func _sweep_caveat(client: McpClient) -> String: - if client.cli_unregister_template.is_empty() or not McpCliStrategy.uses_scope_token(client): - return "" - return ( - "\n\nThe project-scope remove rewrites the .mcp.json in the directory the" - + " editor was launched from — not necessarily this project — so it drops a" - + " hand-maintained godot-ai entry there. Other servers in that file are left" - + " alone." - ) - - -static func _shell_kind_for_platform() -> String: - return SHELL_POWERSHELL if OS.get_name() == "Windows" else SHELL_POSIX - - -## Render a command for one explicitly named shell. The label is load-bearing: -## POSIX and PowerShell use different escaping for embedded single quotes, so -## presenting the command without its target shell invites a bad copy/paste. -static func _format_shell_command(parts: Array[String], shell_kind: String) -> String: - return _format_shell_block([_render_command_line(parts, shell_kind)], shell_kind) - - -## One label over N command lines. The label is per-block, not per-line: -## repeating "Run in PowerShell:" four times would bury the single line that -## actually registers. -static func _format_shell_block(lines: Array[String], shell_kind: String) -> String: - var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:" - return "%s\n%s" % [label, "\n".join(lines)] - - -static func _render_command_line(parts: Array[String], shell_kind: String) -> String: - var rendered: Array[String] = [] - for part in parts: - rendered.append(_shell_display_arg(part, shell_kind)) - return " ".join(rendered) - - -## Quote one argv element for the paste-into-terminal hint. Single-quoted -## strings are literal in both supported shells, but embedded single quotes -## have shell-specific spellings. Backslashes, double quotes, dollar signs, -## and PowerShell backticks remain byte-for-byte unchanged inside the quotes. -static func _shell_display_arg(arg: String, shell_kind: String) -> String: - if arg.is_empty(): - return "''" - var stays_bare := true - for index in range(arg.length()): - if _SHELL_BARE_SAFE_CHARS.find(arg.substr(index, 1)) < 0: - stays_bare = false - break - if stays_bare: - return arg - if shell_kind == SHELL_POWERSHELL: - return "'%s'" % arg.replace("'", "''") - return "'%s'" % arg.replace("'", "'\"'\"'") - - -static func _build_json( - client: McpClient, - server_name: String, - server_url: String, - resolved_path: String, - launch: Dictionary = {}, - project_roots: PackedStringArray = PackedStringArray(), -) -> String: - var target := McpJsonStrategy.manual_target_details(client, server_name, resolved_path, project_roots) - var target_note := "" - if not target.get("ok", false): - target_note = "Target inspection failed: %s" % str(target.get("error", "cannot inspect config")) - target = {"path": resolved_path, "key_path": client.server_key_path} - var target_path := str(target.get("path", resolved_path)) - var key_path: PackedStringArray = target.get("key_path", client.server_key_path) - var key := key_path[0] if key_path.size() > 0 else "mcpServers" - if client.command_shape != McpClient.CommandShape.NONE: - var lines: Array[String] = [] - var launch_error := McpJsonStrategy.command_launch_error(client, launch) - if launch_error.is_empty(): - var command_entry := McpJsonStrategy.build_entry(client, server_url, null, launch) - lines.append("Edit %s and add under \"%s\":" % [target_path, key]) - lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(command_entry)]) - else: - lines.append("Attach launch command unavailable: %s" % launch_error) - if client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add under \"%s\":" % [target_path, key]) - var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url) - lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)]) - if not target_note.is_empty(): - lines.append("") - lines.append(target_note) - return "\n".join(lines) - var entry := McpJsonStrategy.build_entry(client, server_url) - var instructions := "Edit %s and add under \"%s\":\n \"%s\": %s" % [target_path, key, server_name, _format_entry_inline(entry)] - if not target_note.is_empty(): - instructions += "\n\n" + target_note - return instructions - - -static func _build_toml( - client: McpClient, - _server_name: String, - server_url: String, - resolved_path: String, - launch: Dictionary = {}, -) -> String: - var header := _toml_header(client) - if client.command_shape != McpClient.CommandShape.NONE: - var lines: Array[String] = [] - var rendered := McpTomlStrategy.render_body(client, server_url, launch) - if bool(rendered.get("ok", false)): - lines.append("Edit %s and add:" % resolved_path) - lines.append(" %s" % header) - for body_line in rendered.get("lines", []): - lines.append(" %s" % str(body_line)) - else: - lines.append("Attach launch command unavailable: %s" % str(rendered.get("error", "no compatible launcher found"))) - if client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — replace the command/args block above with this URL-mode block; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add:" % resolved_path) - lines.append(" %s" % header) - lines.append(" url = %s" % McpTomlStrategy.encode_basic_string(server_url)) - return "\n".join(lines) - var body := McpTomlStrategy.format_body(client.toml_body_template, server_url) - var lines: Array[String] = ["Edit %s and add:" % resolved_path, " %s" % header] - for b in body: - lines.append(" %s" % String(b)) - return "\n".join(lines) - - -static func _build_yaml( - client: McpClient, - server_name: String, - server_url: String, - resolved_path: String, - launch: Dictionary = {}, -) -> String: - var key := client.server_key_path[0] if client.server_key_path.size() > 0 else "mcp_servers" - if client.command_shape != McpClient.CommandShape.NONE: - var lines: Array[String] = [] - var launch_error := McpYamlStrategy.command_launch_error(client, launch) - if launch_error.is_empty(): - var command_entry := McpYamlStrategy.build_entry(client, server_url, null, launch) - lines.append("Edit %s and add under '%s':" % [resolved_path, key]) - for entry_line in McpYamlStrategy.render_entry_lines(server_name, command_entry): - lines.append(String(entry_line)) - else: - lines.append("Attach launch command unavailable: %s" % launch_error) - if client.command_supports_url_fallback: - lines.append("") - lines.append("Advanced fallback — use this URL-mode entry instead; never configure both shapes together. URL mode depends on your client's own reconnect behavior. If the server is down when the client starts, restarting the client may be required.") - lines.append("Edit %s and add under '%s':" % [resolved_path, key]) - var fallback_entry := {client.entry_url_field: server_url} - for entry_line in McpYamlStrategy.render_entry_lines(server_name, fallback_entry): - lines.append(String(entry_line)) - return "\n".join(lines) - var entry := McpYamlStrategy.build_entry(client, server_url) - var lines: Array[String] = [ - "Edit %s and add under '%s':" % [resolved_path, key], - " %s:" % server_name, - ] - for k in entry: - lines.append(" %s: %s" % [k, str(entry[k])]) - return "\n".join(lines) - - -## DeepSeek Harness: the user pastes a loader `insert` row into the home -## patch layer (`$DSH_HOME/cordis.patch.yml`). Auto-configure writes exactly -## these lines, so the manual text matches the file Configure would produce -## byte-for-byte. -static func _build_dsh( - client: McpClient, - server_name: String, - server_url: String, - resolved_path: String, - launch: Dictionary = {}, -) -> String: - var lines: Array[String] = ["Edit %s and add this loader entry (an `insert` row; a plain `- id:` row only overrides an existing bundle id and would be skipped):" % resolved_path] - var entry_id_value := McpDshStrategy.entry_id(server_name) - if client.command_shape != McpClient.CommandShape.NONE: - var launch_error := McpDshStrategy.command_launch_error(client, launch) - if launch_error.is_empty(): - var command_entry := McpDshStrategy.build_entry(client, server_name, server_url, null, launch) - for row_line in McpDshStrategy.render_insert_row(entry_id_value, command_entry): - lines.append(String(row_line)) - else: - lines.append("Attach launch command unavailable: %s" % launch_error) - if client.command_supports_url_fallback: - lines.append("") - ## No command/args block was rendered on the launch-error path, so - ## "replace the block above" would point at nothing. - var fallback_lead := ( - "replace the command/args block above with this URL-mode entry" - if launch_error.is_empty() - else "use this URL-mode entry instead" - ) - lines.append("Advanced fallback — %s; never configure both shapes together. URL mode depends on the harness' own reconnect behavior. If the server is down when the harness starts, restarting the harness may be required." % fallback_lead) - var fallback_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) - for row_line in McpDshStrategy.render_insert_row(entry_id_value, fallback_entry): - lines.append(String(row_line)) - return "\n".join(lines) - var url_entry := McpDshStrategy.build_url_entry(client, server_name, server_url) - for row_line in McpDshStrategy.render_insert_row(entry_id_value, url_entry): - lines.append(String(row_line)) - return "\n".join(lines) - - -## Mirrors the [section."name"] header `_toml_strategy._primary_header` -## emits, kept here so the manual-command text matches the file we'd write. -static func _toml_header(client: McpClient) -> String: - var parts := client.toml_section_path - if parts.size() < 2: - return "[%s]" % ".".join(parts) - var section := ".".join(McpClient._array_from_packed(McpClient._packed_slice(parts, 0, parts.size() - 1))) - var name := parts[parts.size() - 1] - return "[%s.\"%s\"]" % [section, name] - - -## Format an entry dict as a single inline JSON-ish string, matching the -## pre-refactor manual-command style: `{ "k": v, "k": v }` with spaces. -## Pre-existing manual-command tests assert the exact substring shape; this -## keeps them stable. -## -## Uses `JSON.stringify` for every leaf String (key OR value) so paths -## containing backslashes / quotes / newlines render as syntactically valid -## JSON. A Windows uvx path like `C:\Users\foo\uvx.exe` would otherwise be -## emitted as `"C:\Users\foo\uvx.exe"` — invalid JSON, unsafe to paste. -static func _format_entry_inline(entry: Dictionary) -> String: - var parts: Array[String] = [] - for k in entry: - parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(entry[k])]) - if parts.is_empty(): - return "{}" - return "{ %s }" % ", ".join(parts) - - -static func _format_value(value: Variant) -> String: - # Strings, bools, numbers, null all round-trip correctly through JSON.stringify - # without spurious quoting of non-string scalars (true → `true`, 5 → `5`). - # Arrays and Dictionaries are formatted manually so the inline ` { k: v } ` - # spacing matches the pre-refactor manual-command output shape that tests - # pin with assert_contains. - if value is Array: - var arr_parts: Array[String] = [] - for v in value: - arr_parts.append(_format_value(v)) - return "[%s]" % ", ".join(arr_parts) - if value is Dictionary: - var d_parts: Array[String] = [] - for k in value: - d_parts.append("%s: %s" % [JSON.stringify(String(k)), _format_value(value[k])]) - if d_parts.is_empty(): - return "{}" - return "{ %s }" % ", ".join(d_parts) - return JSON.stringify(value) diff --git a/addons/godot_ai/clients/_manual_command.gd.uid b/addons/godot_ai/clients/_manual_command.gd.uid deleted file mode 100644 index 07a96f4..0000000 --- a/addons/godot_ai/clients/_manual_command.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ct1wmgfk408x0 diff --git a/addons/godot_ai/clients/_path_template.gd b/addons/godot_ai/clients/_path_template.gd deleted file mode 100644 index f7493d1..0000000 --- a/addons/godot_ai/clients/_path_template.gd +++ /dev/null @@ -1,206 +0,0 @@ -@tool -class_name McpPathTemplate -extends RefCounted - -## Expands ~ / $HOME / $APPDATA / $XDG_CONFIG_HOME / $LOCALAPPDATA / $USERPROFILE -## inside path templates so per-client descriptors can declare paths declaratively -## without hand-rolling per-OS lookups. - -## #691: dock worker threads (client-status refresh, configure/remove -## actions) and the #678 startup walk's discovery worker expand these -## templates off the main thread, while the spawn step mutates the -## process-global environment around `OS.create_process` -## (`GODOT_AI_OWNER_PID`, `GODOT_AI_PLUGIN_SPAWNED`, `PYTHONPATH`, -## `GODOT_AI_DISABLE_TELEMETRY`). A glibc `getenv` racing a concurrent -## `setenv` can return a freed pointer — rare but process-fatal. All env -## reads in this layer therefore go through `env_lookup`: on the MAIN -## thread it reads live and refreshes a mutex-guarded snapshot; off the -## main thread it serves from the snapshot, so no `OS.get_environment` -## runs concurrently with the spawn window's mutations. Callers pre-warm -## every var their workers can touch via `warm_env_snapshot` (plugin -## `_enter_tree` and the dock's phase-1 refresh prep, both main-thread, -## both before any worker starts). -static var _env_snapshot := {} -static var _env_snapshot_mutex := Mutex.new() - -## Every var this layer and its sibling consumers (`_base.gd` -## `config_file_override_details`, `config_home_override`, `_cli_finder.gd` lookups, -## `client_configurator.gd` mode/trace reads) can touch off-main. -## Descriptor-declared config-file/config-home env names are passed as extras -## by the warm callers. -const _BASE_ENV_VARS: Array[String] = [ - "HOME", - "USERPROFILE", - "XDG_CONFIG_HOME", - "APPDATA", - "LOCALAPPDATA", - "SHELL", - "ProgramFiles", - "GODOT_AI_MODE", - "GODOT_AI_STARTUP_TRACE", - ## #804 (#752 adoption): _find_venv_python reads this via env_lookup on - ## the dock's worker path; without pre-warming, a set override reads as - ## empty there and is silently ignored — the exact misconfiguration the - ## push_warning in client_configurator.gd exists to surface. - "GODOT_AI_VENV_PYTHON", -] - - -## Thread-safe env read (#691). Main thread: live read + snapshot refresh. -## Worker thread: snapshot only, so it can never race a main-thread -## setenv/unsetenv. A worker read of a never-warmed var returns "" — the -## same value an unset var reads as — never a live OS.get_environment, -## which would reintroduce the race for exactly the vars nobody thought -## to warm. Missing warm-up degrades resolution; it must not touch the -## process-global environment off-main. -static func env_lookup(name: String) -> String: - if OS.get_thread_caller_id() == OS.get_main_thread_id(): - var live := OS.get_environment(name) - _env_snapshot_mutex.lock() - _env_snapshot[name] = live - _env_snapshot_mutex.unlock() - return live - _env_snapshot_mutex.lock() - var cached: Variant = _env_snapshot.get(name, null) - _env_snapshot_mutex.unlock() - if cached != null: - return str(cached) - return "" - - -## Main-thread pre-warm so subsequent worker reads never touch the real -## environment. Idempotent; safe to call before every worker dispatch. -static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray()) -> void: - for var_name in _BASE_ENV_VARS: - env_lookup(var_name) - for var_name in extra_vars: - if not String(var_name).is_empty(): - env_lookup(String(var_name)) - - -## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map. -static func resolve(template_map: Dictionary) -> String: - var key := platform_key(template_map) - if key.is_empty(): - return "" - var template: String = template_map[key] - return expand(template) - - -## Return the platform-specific key present in a descriptor map. `unix` is a -## shorthand for macOS and Linux. Public so descriptors can use the same -## platform selection for ordered path-candidate arrays as for one path. -static func platform_key(template_map: Dictionary) -> String: - var key := _os_key() - if template_map.has(key): - return key - if (key == "darwin" or key == "linux") and template_map.has("unix"): - return "unix" - return "" - - -## Expand one path template into zero or more concrete paths. A single `*` is -## allowed inside one DIRECTORY segment (for example `Packages/Claude_*`). The -## wildcard is resolved by enumerating that segment's parent; the remaining -## suffix may name a file that does not exist yet, which lets callers derive a -## deterministic create target for a fresh packaged-app install. -## -## Multiple wildcards fail closed and return no candidates. A wildcard final -## segment may identify an installation directory for `detect_paths`. Returned -## paths are sorted for deterministic tests and diagnostics; callers still -## reject ambiguous config groups rather than picking one. -static func expand_path_candidates(template: String) -> PackedStringArray: - var expanded := expand(template) - if expanded.is_empty(): - return PackedStringArray() - var star := expanded.find("*") - if star < 0: - return PackedStringArray([expanded]) - if expanded.find("*", star + 1) >= 0: - return PackedStringArray() - - var slash_before := maxi(expanded.rfind("/", star), expanded.rfind("\\", star)) - var forward_after := expanded.find("/", star) - var backward_after := expanded.find("\\", star) - var slash_after := forward_after - if slash_after < 0 or (backward_after >= 0 and backward_after < slash_after): - slash_after = backward_after - if slash_before < 0: - return PackedStringArray() - - var parent := expanded.substr(0, slash_before) - var pattern := ( - expanded.substr(slash_before + 1) - if slash_after < 0 - else expanded.substr(slash_before + 1, slash_after - slash_before - 1) - ) - var suffix := "" if slash_after < 0 else expanded.substr(slash_after + 1) - var pattern_star := pattern.find("*") - if pattern_star < 0: - return PackedStringArray() - var prefix := pattern.substr(0, pattern_star) - var ending := pattern.substr(pattern_star + 1) - var dir := DirAccess.open(parent) - if dir == null: - return PackedStringArray() - - var matches := PackedStringArray() - for child in dir.get_directories(): - if _wildcard_segment_matches(String(child), prefix, ending): - var matched_path := parent.path_join(String(child)) - matches.append(matched_path if suffix.is_empty() else matched_path.path_join(suffix)) - matches.sort() - return matches - - -## Substitute env vars and ~ in a single template string. -static func expand(template: String) -> String: - if template.is_empty(): - return "" - var out := template - if out.begins_with("~/") or out == "~": - var home := _home() - out = home if out == "~" else home.path_join(out.substr(2)) - # $HOME, $APPDATA, $LOCALAPPDATA, $USERPROFILE, $XDG_CONFIG_HOME - for var_name in ["XDG_CONFIG_HOME", "LOCALAPPDATA", "USERPROFILE", "APPDATA", "HOME"]: - var token := "$%s" % var_name - if out.find(token) >= 0: - var value := env_lookup(var_name) - if value.is_empty() and var_name == "XDG_CONFIG_HOME": - value = _home().path_join(".config") - if value.is_empty() and var_name == "APPDATA": - value = _home().path_join("AppData/Roaming") - if value.is_empty() and var_name == "LOCALAPPDATA": - value = _home().path_join("AppData/Local") - if value.is_empty() and var_name == "HOME": - value = _home() - out = out.replace(token, value) - return out - - -static func _os_key() -> String: - match OS.get_name(): - "macOS": - return "darwin" - "Windows": - return "windows" - _: - return "linux" - - -static func _wildcard_segment_matches(value: String, prefix: String, ending: String) -> bool: - # Prefix/suffix tests alone allow the two fixed portions to overlap inside a - # too-short value. Glob semantics require room for both portions even when - # `*` matches an empty string. - if value.length() < prefix.length() + ending.length(): - return false - if OS.get_name() == "Windows": - return value.to_lower().begins_with(prefix.to_lower()) and value.to_lower().ends_with(ending.to_lower()) - return value.begins_with(prefix) and value.ends_with(ending) - - -static func _home() -> String: - var h := env_lookup("HOME") - if h.is_empty(): - h = env_lookup("USERPROFILE") - return h diff --git a/addons/godot_ai/clients/_path_template.gd.uid b/addons/godot_ai/clients/_path_template.gd.uid deleted file mode 100644 index f2403d7..0000000 --- a/addons/godot_ai/clients/_path_template.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://5pd418va35ms diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd deleted file mode 100644 index f61f667..0000000 --- a/addons/godot_ai/clients/_registry.gd +++ /dev/null @@ -1,156 +0,0 @@ -@tool -class_name McpClientRegistry -extends RefCounted - -## Central enumeration of every supported MCP client. Adding a new client -## means: drop a file in clients/, then append one path below. -## -## Paths, not preloads (#736): a preload array pulled all client descriptor -## scripts into the boot-time compile closure of everything that preloads -## this registry (plugin.gd via client_configurator.gd and mcp_dock.gd), -## stalling "Initializing plugins" on every editor boot. Descriptors are -## only needed when the dock refreshes client statuses or a client_* -## command runs, so they load lazily on first registry access. - -const _CLIENT_SCRIPT_PATHS := [ - "res://addons/godot_ai/clients/claude_code.gd", - "res://addons/godot_ai/clients/claude_desktop.gd", - "res://addons/godot_ai/clients/codex.gd", - "res://addons/godot_ai/clients/grok.gd", - "res://addons/godot_ai/clients/antigravity.gd", - "res://addons/godot_ai/clients/cursor.gd", - "res://addons/godot_ai/clients/windsurf.gd", - "res://addons/godot_ai/clients/vscode.gd", - "res://addons/godot_ai/clients/vscode_insiders.gd", - "res://addons/godot_ai/clients/zed.gd", - "res://addons/godot_ai/clients/gemini_cli.gd", - "res://addons/godot_ai/clients/cline.gd", - "res://addons/godot_ai/clients/kilo_code.gd", - "res://addons/godot_ai/clients/roo_code.gd", - "res://addons/godot_ai/clients/zoo_code.gd", - "res://addons/godot_ai/clients/kiro.gd", - "res://addons/godot_ai/clients/trae.gd", - "res://addons/godot_ai/clients/cherry_studio.gd", - "res://addons/godot_ai/clients/opencode.gd", - "res://addons/godot_ai/clients/qwen_code.gd", - "res://addons/godot_ai/clients/kimi_code.gd", - "res://addons/godot_ai/clients/hermes.gd", - "res://addons/godot_ai/clients/pi.gd", - "res://addons/godot_ai/clients/deepseek_harness.gd", -] - -static var _instances: Array[McpClient] = [] -static var _by_id: Dictionary = {} -## First registry access can come from the dock's client-status refresh -## worker thread while the main thread hits it via a client_* command — -## serialize the one-time load so a racing thread can never observe a -## half-built registry. load() itself is thread-safe via ResourceLoader. -static var _load_mutex := Mutex.new() -## True when even a fresh rebuild yields instances missing base-schema -## fields — the deep stale-script state after an in-session self-update -## (#850; docs/releasing.md release-shape rules). Only an editor restart -## heals it; callers surface RESTART_TO_FINISH_UPDATE instead of erroring -## per client. Never reset within a session: rebuilding again cannot help, -## it would only repeat the load work and warning on every dock sweep. -static var _stale_session := false - -const RESTART_TO_FINISH_UPDATE := ( - "Godot AI was updated in this editor session. Restart the editor to finish the update." -) - - -static func all() -> Array[McpClient]: - _ensure_loaded() - return _instances - - -static func get_by_id(id: String) -> McpClient: - _ensure_loaded() - return _by_id.get(id, null) - - -static func ids() -> PackedStringArray: - var out := PackedStringArray() - for c in all(): - out.append(c.id) - return out - - -static func has_id(id: String) -> bool: - _ensure_loaded() - return _by_id.has(id) - - -## True when this editor session is running a self-update whose script -## reloads left descriptor state unusable. Client operations short-circuit -## with RESTART_TO_FINISH_UPDATE rather than spamming per-field errors. -static func stale_session_detected() -> bool: - _ensure_loaded() - return _stale_session - - -## An instance is coherent when fields added to the CURRENT McpClient schema -## read back with their declared types. After an in-session self-update, -## hot-patched or pre-update instances answer Nil for vars the update added -## (#850: `config_path_candidates` and -## `config_file_env` read as Nil, crashing platform_key / String()). The -## reflected `get()` avoids typed-access errors on such instances. -static func _instance_is_coherent(inst: Object) -> bool: - if inst == null: - return false - return ( - inst.get("config_path_candidates") is Dictionary - and inst.get("config_file_env") is String - and inst.get("path_template") is Dictionary - and inst.get("server_key_path_aliases") is Array - and inst.get("config_merge_path_templates") is Dictionary - and inst.get("config_merge_project_paths") is PackedStringArray - ) - - -static func _cache_is_coherent() -> bool: - return not _instances.is_empty() and _instance_is_coherent(_instances[0]) - - -static func _ensure_loaded() -> void: - if _stale_session: - return - if _cache_is_coherent(): - return - _load_mutex.lock() - ## Re-check under the lock: another thread may have rebuilt (or concluded - ## staleness) while this one waited. - if not _stale_session and not _cache_is_coherent(): - ## Covers both the first load and the post-self-update rebuild: this - ## registry file can survive an update unchanged, so its statics keep - ## serving pre-update instances to freshly reloaded callers. A rebuild - ## instantiates from the reloaded descriptor scripts, which repairs - ## every case except a stale base Script object itself. - _load() - if not _instances.is_empty() and not _cache_is_coherent(): - _stale_session = true - push_warning("MCP | %s" % RESTART_TO_FINISH_UPDATE) - _load_mutex.unlock() - - -static func _load() -> void: - ## Build into locals and publish whole containers last, so the lock-free - ## fast path in _ensure_loaded can never see a partially-filled registry. - var instances: Array[McpClient] = [] - var by_id: Dictionary = {} - for path in _CLIENT_SCRIPT_PATHS: - var script := load(path) as GDScript - if script == null: - push_warning("MCP | failed to load client descriptor %s" % path) - continue - var inst: McpClient = script.new() - if inst.id.is_empty(): - push_warning("MCP | client descriptor %s has empty id" % path) - continue - if by_id.has(inst.id): - push_warning("MCP | duplicate client id: %s" % inst.id) - continue - instances.append(inst) - by_id[inst.id] = inst - _by_id = by_id - _instances = instances diff --git a/addons/godot_ai/clients/_registry.gd.uid b/addons/godot_ai/clients/_registry.gd.uid deleted file mode 100644 index 57edcf7..0000000 --- a/addons/godot_ai/clients/_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxougoq8xwg1 diff --git a/addons/godot_ai/clients/_toml_strategy.gd b/addons/godot_ai/clients/_toml_strategy.gd deleted file mode 100644 index a929dbd..0000000 --- a/addons/godot_ai/clients/_toml_strategy.gd +++ /dev/null @@ -1,730 +0,0 @@ -@tool -class_name McpTomlStrategy -extends RefCounted - -## TOML upsert for URL entries and client-owned command entries. -## -## This remains deliberately smaller than a general TOML parser, but the -## parts that affect migration are semantic: assignments retain their whole -## value span (including multiline arrays/strings), and command/args status -## verification decodes TOML strings and arrays rather than comparing text. - - -static func configure( - client: McpClient, - _server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty(): - return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name} - - var seed_path := str(resolution.get("seed_path", "")) - var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path - var read := _read_or_init(read_path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} - - var rendered := render_body(client, server_url, launch) - if not bool(rendered.get("ok", false)): - return {"status": "error", "message": str(rendered.get("error", "Could not build the TOML entry."))} - - var lines: Array[String] = _split_lines(String(read["data"])) - var body: Array[String] = rendered["lines"] - var pinned_keys: Dictionary = rendered["pinned_keys"] - var initial_keys: Dictionary = rendered["initial_keys"] - var removed_keys: Dictionary = rendered["removed_keys"] - - var section := _find_section(lines, _all_headers(client)) - var header := _primary_header(client) - var new_lines: Array[String] = [header] - - if section.is_empty(): - new_lines.append_array(body) - var output_fresh: Array[String] = [] - output_fresh.append_array(lines) - if not output_fresh.is_empty() and not output_fresh[-1].strip_edges().is_empty(): - output_fresh.append("") - output_fresh.append_array(new_lines) - if not McpAtomicWrite.write(path, "\n".join(output_fresh)): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - - var old_items := _value_items(lines, int(section["start"]) + 1, int(section["end"])) - var old_by_key := {} - for item in old_items: - var old_key := str(item.get("key", "")) - if not old_key.is_empty() and not old_by_key.has(old_key): - old_by_key[old_key] = item - - var body_items := _value_items(body, 0, body.size()) - var emitted_keys := {} - for item in body_items: - var key := str(item.get("key", "")) - if key.is_empty(): - new_lines.append_array(item["lines"]) - continue - emitted_keys[key] = true - if initial_keys.has(key) and old_by_key.has(key): - new_lines.append_array(old_by_key[key]["lines"]) - else: - ## Pinned keys always use the freshly rendered span. A generated key - ## that is neither pinned nor initial is also rendered deterministically. - new_lines.append_array(item["lines"]) - - ## Carry unknown/user-owned assignments and standalone comments verbatim. - ## Whole item spans prevent multiline arrays/strings from being truncated. - for item in old_items: - var key := str(item.get("key", "")) - if not key.is_empty(): - if emitted_keys.has(key) or pinned_keys.has(key) or removed_keys.has(key): - continue - new_lines.append_array(item["lines"]) - continue - var item_lines: Array = item.get("lines", []) - for line in item_lines: - if not str(line).strip_edges().is_empty(): - new_lines.append(str(line)) - - var output: Array[String] = [] - output.append_array(_slice(lines, 0, int(section["start"]))) - output.append_array(new_lines) - output.append_array(_slice(lines, int(section["end"]), lines.size())) - output = _rewrite_legacy_descendant_headers(output, client) - - if not McpAtomicWrite.write(path, "\n".join(output)): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - - -static func check_status( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> McpClient.Status: - return check_status_details(client, server_name, server_url, launch).get("status", McpClient.Status.NOT_CONFIGURED) - - -static func check_status_details( - client: McpClient, - _server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var read := _read_or_init(path) - if not read["ok"]: - return {"status": McpClient.Status.ERROR, "error_msg": String(read["error"])} - var lines: Array[String] = _split_lines(String(read["data"])) - var section := _find_section(lines, _all_headers(client)) - if section.is_empty(): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - - var items := _value_items(lines, int(section["start"]) + 1, int(section["end"])) - var by_key := {} - for item in items: - var key := str(item.get("key", "")) - if not key.is_empty() and not by_key.has(key): - by_key[key] = item - - if client.command_shape != McpClient.CommandShape.NONE: - if not bool(launch.get("ok", false)): - return { - "status": McpClient.Status.ERROR, - "error_msg": str(launch.get("error", "No compatible attach launcher was found.")), - } - for legacy_key in client.command_legacy_keys: - if by_key.has(String(legacy_key)): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not by_key.has("command") or not by_key.has("args"): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - var command_value := _decode_toml_string(_item_value(by_key["command"])) - var args_value := _decode_toml_string_array(_item_value(by_key["args"])) - if not bool(command_value.get("ok", false)) or not bool(args_value.get("ok", false)): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if str(command_value.get("value", "")) != str(launch.get("command", "")): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not _string_arrays_equal(args_value.get("value", []), launch.get("args", [])): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - if not client.command_transport_key.is_empty(): - var transport_key := client.command_transport_key - if not by_key.has(transport_key): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - var decoded_transport := _decode_toml_scalar(_item_value(by_key[transport_key])) - if not bool(decoded_transport.get("ok", false)) or decoded_transport.get("value") != client.command_transport_value: - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - - if not by_key.has("url"): - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - var url_value := _decode_toml_string(_item_value(by_key["url"])) - if not bool(url_value.get("ok", false)) or str(url_value.get("value", "")) != server_url: - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - - -static func remove(client: McpClient, _server_name: String) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": "ok", "message": "Not configured"} - var read := _read_or_init(path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} - var lines: Array[String] = _split_lines(String(read["data"])) - var headers := _all_headers(client) - var subtable_prefixes := _subtable_prefixes(headers) - - var output: Array[String] = [] - var i := 0 - while i < lines.size(): - if _matches_any_header(lines[i], headers) or _matches_subtable_prefix(lines[i], subtable_prefixes): - i += 1 - while i < lines.size() and not _is_any_section_header(lines[i]): - i += 1 - continue - output.append(lines[i]) - i += 1 - - if not McpAtomicWrite.write(path, "\n".join(output)): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - - -## Substitute `{url}` in every legacy URL body-template line. -static func format_body(template: PackedStringArray, server_url: String) -> PackedStringArray: - var out := PackedStringArray() - for line in template: - out.append(String(line).replace("{url}", server_url)) - return out - - -## Encode a TOML basic string. This is intentionally public for the -## cross-language fixture test that parses the rendered sample with tomllib. -static func encode_basic_string(value: String) -> String: - return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"').replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n").replace("\f", "\\f").replace("\r", "\\r") - - -## Multi-line string-array encoding used by command-shape entries. -static func encode_string_array(values: Variant) -> Array[String]: - var out: Array[String] = ["["] - for value in values: - out.append(" %s," % encode_basic_string(str(value))) - out.append("]") - return out - - -static func render_body(client: McpClient, server_url: String, launch: Dictionary) -> Dictionary: - if client.command_shape == McpClient.CommandShape.NONE: - if client.toml_body_template.is_empty(): - return {"ok": false, "error": "%s descriptor missing toml_body_template" % client.display_name} - var legacy_body := format_body(client.toml_body_template, server_url) - var legacy_lines: Array[String] = [] - var pinned := {} - var initial := {} - for idx in range(legacy_body.size()): - legacy_lines.append(String(legacy_body[idx])) - var key := _line_key(String(client.toml_body_template[idx])) - if key.is_empty(): - continue - if String(client.toml_body_template[idx]).contains("{url}"): - pinned[key] = true - else: - initial[key] = true - return { - "ok": true, - "lines": legacy_lines, - "pinned_keys": pinned, - "initial_keys": initial, - "removed_keys": {}, - } - - if client.command_shape != McpClient.CommandShape.COMMAND_ARRAY: - return {"ok": false, "error": "%s uses a command shape not supported by TOML yet" % client.display_name} - if not bool(launch.get("ok", false)): - return {"ok": false, "error": str(launch.get("error", "No compatible attach launcher was found."))} - - var command_lines: Array[String] = [ - "command = %s" % encode_basic_string(str(launch.get("command", ""))), - ] - command_lines.append_array(_encode_assignment("args", launch.get("args", []))) - - var pinned_keys := {"command": true, "args": true} - if not client.command_transport_key.is_empty(): - var encoded_transport := _encode_scalar(client.command_transport_value) - if encoded_transport.is_empty(): - return { - "ok": false, - "error": "Unsupported TOML transport `%s` for %s" % [ - client.command_transport_key, client.display_name, - ], - } - command_lines.append("%s = %s" % [client.command_transport_key, encoded_transport]) - pinned_keys[client.command_transport_key] = true - - var initial_keys := {} - for key in client.command_initial_fields: - var encoded := _encode_assignment(str(key), client.command_initial_fields[key]) - if encoded.is_empty(): - return {"ok": false, "error": "Unsupported TOML default `%s` for %s" % [key, client.display_name]} - command_lines.append_array(encoded) - initial_keys[str(key)] = true - - var removed_keys := {} - for key in client.command_legacy_keys: - removed_keys[String(key)] = true - return { - "ok": true, - "lines": command_lines, - "pinned_keys": pinned_keys, - "initial_keys": initial_keys, - "removed_keys": removed_keys, - } - - -static func _encode_assignment(key: String, value: Variant) -> Array[String]: - if value is Array or value is PackedStringArray: - var lines := encode_string_array(value) - lines[0] = "%s = %s" % [key, lines[0]] - return lines - var encoded := _encode_scalar(value) - var lines: Array[String] = [] - if not encoded.is_empty(): - lines.append("%s = %s" % [key, encoded]) - return lines - - -static func _encode_scalar(value: Variant) -> String: - if value is String: - return encode_basic_string(value) - if value is bool: - return "true" if value else "false" - if value is int or value is float: - return str(value) - return "" - - -# --- span-aware merge helpers ------------------------------------------- - -static func _value_items(lines: Array[String], from: int, to: int) -> Array[Dictionary]: - var out: Array[Dictionary] = [] - var i := from - while i < to: - var key := _line_key(lines[i]) - if key.is_empty(): - out.append({"key": "", "lines": [lines[i]]}) - i += 1 - continue - var end := _value_span_end(lines, i, to) - out.append({"key": key, "lines": _slice(lines, i, end)}) - i = end - return out - - -static func _value_span_end(lines: Array[String], start: int, limit: int) -> int: - var state := {"quote": "", "square": 0, "curly": 0, "escaped": false} - for i in range(start, limit): - var begin := 0 - if i == start: - var eq := _assignment_equal(lines[i]) - begin = eq + 1 if eq >= 0 else 0 - _scan_toml_value_line(lines[i], begin, state) - if str(state["quote"]).is_empty() and int(state["square"]) == 0 and int(state["curly"]) == 0: - return i + 1 - return limit - - -static func _scan_toml_value_line(line: String, begin: int, state: Dictionary) -> void: - var i := begin - while i < line.length(): - var quote := str(state["quote"]) - if quote == '"""' or quote == "'''": - if line.substr(i).begins_with(quote): - state["quote"] = "" - i += 3 - continue - if quote == '"""' and line.unicode_at(i) == 92 and not bool(state["escaped"]): - state["escaped"] = true - i += 1 - continue - state["escaped"] = false - i += 1 - continue - if quote == '"' or quote == "'": - var c := line.unicode_at(i) - if quote == '"' and c == 92 and not bool(state["escaped"]): - state["escaped"] = true - i += 1 - continue - if c == quote.unicode_at(0) and not bool(state["escaped"]): - state["quote"] = "" - state["escaped"] = false - i += 1 - continue - - if line.substr(i).begins_with('"""'): - state["quote"] = '"""' - i += 3 - continue - if line.substr(i).begins_with("'''"): - state["quote"] = "'''" - i += 3 - continue - var c := line.unicode_at(i) - if c == 34: - state["quote"] = '"' - elif c == 39: - state["quote"] = "'" - elif c == 35: - break - elif c == 91: - state["square"] = int(state["square"]) + 1 - elif c == 93: - state["square"] = maxi(0, int(state["square"]) - 1) - elif c == 123: - state["curly"] = int(state["curly"]) + 1 - elif c == 125: - state["curly"] = maxi(0, int(state["curly"]) - 1) - i += 1 - - -static func _line_key(line: String) -> String: - var eq := _assignment_equal(line) - if eq <= 0: - return "" - return line.substr(0, eq).strip_edges() - - -static func _assignment_equal(line: String) -> int: - var quote := 0 - var escaped := false - for i in range(line.length()): - var c := line.unicode_at(i) - if quote != 0: - if quote == 34 and c == 92 and not escaped: - escaped = true - continue - if c == quote and not escaped: - quote = 0 - escaped = false - continue - if c == 34 or c == 39: - quote = c - elif c == 35: - return -1 - elif c == 61: - return i - return -1 - - -# --- semantic value decoding -------------------------------------------- - -static func _item_value(item: Dictionary) -> String: - var item_lines: Array = item.get("lines", []) - if item_lines.is_empty(): - return "" - var first := str(item_lines[0]) - var eq := _assignment_equal(first) - if eq < 0: - return "" - var parts: Array[String] = [first.substr(eq + 1)] - for i in range(1, item_lines.size()): - parts.append(str(item_lines[i])) - return "\n".join(parts) - - -static func _decode_toml_scalar(raw: String) -> Dictionary: - var cleaned := _without_comments(raw).strip_edges() - if cleaned == "true": - return {"ok": true, "value": true} - if cleaned == "false": - return {"ok": true, "value": false} - var string_value := _decode_toml_string(cleaned) - if bool(string_value.get("ok", false)): - return string_value - if cleaned.is_valid_int(): - return {"ok": true, "value": cleaned.to_int()} - if cleaned.is_valid_float(): - return {"ok": true, "value": cleaned.to_float()} - return {"ok": false} - - -static func _decode_toml_string(raw: String) -> Dictionary: - var cleaned := _without_comments(raw).strip_edges() - if cleaned.length() >= 2 and cleaned.begins_with("'") and cleaned.ends_with("'"): - return {"ok": true, "value": cleaned.substr(1, cleaned.length() - 2)} - if cleaned.length() < 2 or not cleaned.begins_with('"') or not cleaned.ends_with('"'): - return {"ok": false} - var parsed: Variant = JSON.parse_string(cleaned) - if parsed is String: - return {"ok": true, "value": parsed} - return {"ok": false} - - -static func _decode_toml_string_array(raw: String) -> Dictionary: - var cleaned := _without_comments(raw).strip_edges() - if not cleaned.begins_with("["): - return {"ok": false} - var i := 1 - var values: Array[String] = [] - while true: - i = _skip_space(cleaned, i) - if i >= cleaned.length(): - return {"ok": false} - if cleaned.unicode_at(i) == 93: - i = _skip_space(cleaned, i + 1) - return {"ok": i == cleaned.length(), "value": values} - var parsed := _parse_string_at(cleaned, i) - if not bool(parsed.get("ok", false)): - return {"ok": false} - values.append(str(parsed.get("value", ""))) - i = _skip_space(cleaned, int(parsed.get("next", i))) - if i >= cleaned.length(): - return {"ok": false} - var c := cleaned.unicode_at(i) - if c == 44: - i += 1 - continue - if c == 93: - continue - return {"ok": false} - return {"ok": false} # Unreachable; keeps GDScript's return analysis explicit. - - -static func _parse_string_at(text: String, start: int) -> Dictionary: - if start >= text.length(): - return {"ok": false} - var quote := text.unicode_at(start) - if quote != 34 and quote != 39: - return {"ok": false} - var i := start + 1 - var escaped := false - while i < text.length(): - var c := text.unicode_at(i) - if quote == 34 and c == 92 and not escaped: - escaped = true - i += 1 - continue - if c == quote and not escaped: - var raw := text.substr(start, i - start + 1) - if quote == 39: - return {"ok": true, "value": raw.substr(1, raw.length() - 2), "next": i + 1} - var parsed: Variant = JSON.parse_string(raw) - if parsed is String: - return {"ok": true, "value": parsed, "next": i + 1} - return {"ok": false} - escaped = false - i += 1 - return {"ok": false} - - -static func _without_comments(raw: String) -> String: - var out: Array[String] = [] - for line in raw.split("\n"): - var quote := 0 - var escaped := false - var kept := "" - for i in range(line.length()): - var c := line.unicode_at(i) - if quote != 0: - kept += line.substr(i, 1) - if quote == 34 and c == 92 and not escaped: - escaped = true - continue - if c == quote and not escaped: - quote = 0 - escaped = false - continue - if c == 34 or c == 39: - quote = c - kept += line.substr(i, 1) - elif c == 35: - break - else: - kept += line.substr(i, 1) - out.append(kept) - return "\n".join(out) - - -static func _skip_space(text: String, start: int) -> int: - var i := start - while i < text.length() and text.substr(i, 1) in [" ", "\t", "\r", "\n"]: - i += 1 - return i - - -static func _string_arrays_equal(left: Variant, right: Variant) -> bool: - if not (left is Array or left is PackedStringArray): - return false - if not (right is Array or right is PackedStringArray): - return false - if left.size() != right.size(): - return false - for i in range(left.size()): - if str(left[i]) != str(right[i]): - return false - return true - - -# --- file / section helpers --------------------------------------------- - -static func _read_or_init(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - return {"ok": true, "data": ""} - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - var err := FileAccess.get_open_error() - return {"ok": false, "error": "could not open for reading (error %d)" % err} - var text := f.get_as_text() - f.close() - return {"ok": true, "data": text} - - -static func _split_lines(content: String) -> Array[String]: - var out: Array[String] = [] - for line in content.split("\n"): - out.append(line) - return out - - -static func _slice(lines: Array[String], from: int, to: int) -> Array[String]: - var out: Array[String] = [] - for i in range(from, to): - out.append(lines[i]) - return out - - -static func _primary_header(client: McpClient) -> String: - var parts := client.toml_section_path - if parts.size() < 2: - return "[%s]" % ".".join(parts) - var section := ".".join(McpClient._packed_slice(parts, 0, parts.size() - 1)) - var name := parts[parts.size() - 1] - return "[%s.\"%s\"]" % [section, name] - - -static func _all_headers(client: McpClient) -> Array[String]: - var primary := _primary_header(client) - var out: Array[String] = [primary] - var bare := _bare_key_header(client) - if not bare.is_empty() and bare != primary: - out.append(bare) - for legacy in client.toml_legacy_section_aliases: - out.append("[%s]" % legacy) - return out - - -static func _bare_key_header(client: McpClient) -> String: - var parts := client.toml_section_path - if parts.is_empty(): - return "" - for part in parts: - if not _is_bare_key(String(part)): - return "" - return "[%s]" % ".".join(parts) - - -static func _is_bare_key(value: String) -> bool: - if value.is_empty(): - return false - for i in range(value.length()): - var c := value.unicode_at(i) - var alpha := (c >= 65 and c <= 90) or (c >= 97 and c <= 122) - var digit := c >= 48 and c <= 57 - if not (alpha or digit or c == 45 or c == 95): - return false - return true - - -static func _subtable_prefixes(headers: Array[String]) -> Array[String]: - var out: Array[String] = [] - for header in headers: - if header.length() > 2 and header.ends_with("]"): - out.append(header.substr(0, header.length() - 1) + ".") - return out - - -static func _matches_subtable_prefix(line: String, prefixes: Array[String]) -> bool: - var trimmed := line.strip_edges() - for prefix in prefixes: - if not trimmed.begins_with(prefix): - continue - var rest := trimmed.substr(prefix.length()) - var bracket := rest.find("]") - if bracket < 0: - continue - var remainder := rest.substr(bracket + 1).strip_edges() - if remainder.is_empty() or remainder.begins_with("#"): - return true - return false - - -static func _matches_any_header(line: String, headers: Array[String]) -> bool: - var trimmed := line.strip_edges() - for header in headers: - if not trimmed.begins_with(header): - continue - var remainder := trimmed.substr(header.length()).strip_edges() - if remainder.is_empty() or remainder.begins_with("#"): - return true - return false - - -static func _find_section(lines: Array[String], headers: Array[String]) -> Dictionary: - for i in range(lines.size()): - if _matches_any_header(lines[i], headers): - var end := lines.size() - for j in range(i + 1, lines.size()): - if _is_any_section_header(lines[j]): - end = j - break - return {"start": i, "end": end} - return {} - - -static func _is_any_section_header(line: String) -> bool: - var trimmed := line.strip_edges() - if not trimmed.begins_with("["): - return false - var bracket := trimmed.find("]") - if bracket < 0: - return false - var remainder := trimmed.substr(bracket + 1).strip_edges() - return remainder.is_empty() or remainder.begins_with("#") - - -static func _rewrite_legacy_descendant_headers( - lines: Array[String], client: McpClient -) -> Array[String]: - if client.toml_legacy_section_aliases.is_empty(): - return lines - var primary := _primary_header(client) - var primary_prefix := primary.substr(0, primary.length() - 1) + "." - var out: Array[String] = [] - for line in lines: - var rewritten := line - var trimmed := line.strip_edges() - var indent_length := line.find("[") - var indent := line.substr(0, indent_length) if indent_length >= 0 else "" - for alias in client.toml_legacy_section_aliases: - var legacy_prefix := "[%s." % String(alias) - if trimmed.begins_with(legacy_prefix): - rewritten = indent + primary_prefix + trimmed.substr(legacy_prefix.length()) - break - out.append(rewritten) - return out diff --git a/addons/godot_ai/clients/_toml_strategy.gd.uid b/addons/godot_ai/clients/_toml_strategy.gd.uid deleted file mode 100644 index 723cb79..0000000 --- a/addons/godot_ai/clients/_toml_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwdvxgn0aurqv diff --git a/addons/godot_ai/clients/_yaml_strategy.gd b/addons/godot_ai/clients/_yaml_strategy.gd deleted file mode 100644 index 0efd515..0000000 --- a/addons/godot_ai/clients/_yaml_strategy.gd +++ /dev/null @@ -1,549 +0,0 @@ -@tool -class_name McpYamlStrategy -extends RefCounted - -## Minimal YAML upsert for Hermes Agent MCP config. -## -## Hermes reads MCP servers from ~/.hermes/config.yaml under the -## `mcp_servers` key (snake_case, YAML). HTTP entries are transport-inferred: -## just `url` (plus optional `headers`), no `type` field. We only parse the -## `mcp_servers` block and re-emit it; other top-level keys in the user's -## config.yaml are preserved verbatim by round-tripping the raw lines around -## that block. No general YAML parser — Godot has none in stdlib, and Hermes -## only needs this one shape. See issue #640. - -const INDENT := " " # YAML forbids tab indentation; match the 2-space style of ~/.hermes/config.yaml - - -static func configure( - client: McpClient, - server_name: String, - server_url: String, - launch: Dictionary = {}, -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty(): - return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name} - ## Fail closed before touching the file — same contract as JSON/TOML. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": "error", "message": launch_error} - - var seed_path := str(resolution.get("seed_path", "")) - var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path - var read := _read(read_path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to overwrite %s: %s. Fix or move the file, then re-run Configure." % [read_path, read["error"]]} - - var text: String = read["data"] - var block := _extract_block(text) - var entries: Dictionary = block["entries"] - - # Preserve existing entry's user-mutable keys; force the transport keys. - var existing: Dictionary = entries.get(server_name, {}) - var new_entry := build_entry(client, server_url, existing, launch) - entries[server_name] = new_entry - - var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"]) - if not McpAtomicWrite.write(path, out): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": McpClient.configured_message(client, server_url)} - - -static func check_status( - client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} -) -> McpClient.Status: - return check_status_details(client, server_name, server_url, launch)["status"] - - -## Same contract as the JSON/TOML strategies (#711): {status, error_msg}. -## An existing-but-unreadable config is ERROR with the diagnostic — not -## NOT_CONFIGURED — so the dock row can tell "no config" from "config the -## editor can't read" instead of offering a Configure that would fail. -static func check_status_details( - client: McpClient, server_name: String, server_url: String, launch: Dictionary = {} -) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var read := _read(path) - if not read["ok"]: - return { - "status": McpClient.Status.ERROR, - "error_msg": "Cannot read %s: %s" % [path, read["error"]], - } - var block := _extract_block(String(read["data"])) - var entries: Dictionary = block["entries"] - if not entries.has(server_name): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - var entry: Variant = entries[server_name] - if not (entry is Dictionary): - return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} - ## An entry exists but no verified launcher does — mirror JSON/TOML: this - ## is an environment ERROR, not entry drift. - var launch_error := command_launch_error(client, launch) - if not launch_error.is_empty(): - return {"status": McpClient.Status.ERROR, "error_msg": launch_error} - if verify_entry(client, entry, server_url, launch): - return {"status": McpClient.Status.CONFIGURED, "error_msg": ""} - return {"status": McpClient.Status.CONFIGURED_MISMATCH, "error_msg": ""} - - -static func remove(client: McpClient, server_name: String) -> Dictionary: - var resolution := client.resolved_config_path_details() - var path := str(resolution.get("path", "")) - var path_error := str(resolution.get("error", "")) - if not path_error.is_empty(): - return {"status": "error", "message": path_error} - if path.is_empty() or not FileAccess.file_exists(path): - return {"status": "ok", "message": "Not configured"} - var read := _read(path) - if not read["ok"]: - return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} - var text: String = read["data"] - var block := _extract_block(text) - var entries: Dictionary = block["entries"] - if not entries.has(server_name): - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - entries.erase(server_name) - var out := _assemble(text, block["prefix_lines"], entries, block["suffix_lines"]) - if not McpAtomicWrite.write(path, out): - return {"status": "error", "message": "Cannot write to %s" % path} - return {"status": "ok", "message": "%s configuration removed" % client.display_name} - - -## Build the entry dict written under mcp_servers[server_name]. -## -## URL mode (`CommandShape.NONE`): Hermes HTTP entries are transport-inferred -## — { url: } plus whatever user-mutable keys (headers, enabled, tools, -## ...) the existing entry carries. Stdio-bridge keys are the one exception -## (see _STDIO_BRIDGE_KEYS). No `type` field. -## -## Command mode (`CommandShape.FLAT`, #838): flat `command` + `args` keys, -## transport inferred the same way — which is exactly why the descriptor's -## `command_legacy_keys` (url, headers) must be scrubbed: a Hermes entry with -## both a url and a command picks the wrong transport. User keys survive. -static func build_entry( - client: McpClient, - server_url: String, - existing: Variant = null, - launch: Dictionary = {}, -) -> Dictionary: - if client.command_shape == McpClient.CommandShape.FLAT: - var command_entry: Dictionary = (existing as Dictionary).duplicate(true) if existing is Dictionary else {} - command_entry["command"] = str(launch.get("command", "")) - command_entry["args"] = _array_copy(launch.get("args", [])) - if not client.command_transport_key.is_empty(): - command_entry[client.command_transport_key] = client.command_transport_value - for key in client.command_initial_fields: - if not command_entry.has(key): - command_entry[key] = client.command_initial_fields[key] - for key in client.command_legacy_keys: - command_entry.erase(String(key)) - return command_entry - if client.command_shape != McpClient.CommandShape.NONE: - ## Every production caller checks command_launch_error first. Keep this - ## builder defensive too so a future unsupported shape cannot silently - ## degrade into a flat YAML entry. - return {} - var entry: Dictionary = {} - if existing is Dictionary: - ## User-mutable keys (headers, enabled, tools, ...) survive a - ## reconfigure — the same preservation contract the JSON strategy's - ## entry_initial_fields split implements. Only the stdio-bridge keys - ## are scrubbed (see _STDIO_BRIDGE_KEYS), then the url is repointed. - entry = (existing as Dictionary).duplicate(true) - for stale_key in _STDIO_BRIDGE_KEYS: - entry.erase(stale_key) - entry[client.entry_url_field] = server_url - return entry - - -## Keys a prior stdio-bridge entry (e.g. `command: uvx mcp-proxy`) may carry. -## These must NOT survive a URL reconfigure: a Hermes entry with both a url -## and a command picks the wrong transport. -const _STDIO_BRIDGE_KEYS := ["command", "args", "env"] - - -## Empty string when this client's launch requirements are satisfied. -## Mirrors `McpJsonStrategy.command_launch_error`; YAML supports FLAT only. -static func command_launch_error(client: McpClient, launch: Dictionary) -> String: - if client.command_shape == McpClient.CommandShape.NONE: - return "" - if client.command_shape != McpClient.CommandShape.FLAT: - return "%s uses a command shape not supported by YAML yet" % client.display_name - if not bool(launch.get("ok", false)): - return str(launch.get("error", "No compatible attach launcher was found.")) - return "" - - -## Verify a stored entry matches. -## -## URL mode: Hermes entries have no transport type pin, so verification is: -## url matches. Extra keys (headers, enabled, tools) are user-mutable and -## intentionally NOT checked (mirrors json entry_initial_fields). -## -## Command mode: every launch-affecting value must match exactly and legacy -## URL-transport keys must be gone — their presence is migration drift. -static func verify_entry( - client: McpClient, - entry: Dictionary, - server_url: String, - launch: Dictionary = {}, -) -> bool: - if client.command_shape != McpClient.CommandShape.NONE: - if client.command_shape != McpClient.CommandShape.FLAT or not bool(launch.get("ok", false)): - return false - for key in client.command_legacy_keys: - if entry.has(String(key)): - return false - if entry.get("command") != launch.get("command"): - return false - if not _arrays_equal(entry.get("args", null), launch.get("args", null)): - return false - if not client.command_transport_key.is_empty(): - if entry.get(client.command_transport_key, null) != client.command_transport_value: - return false - return true - return entry.get(client.entry_url_field, "") == server_url - - -static func _array_copy(value: Variant) -> Array: - if value is Array: - return (value as Array).duplicate(true) - if value is PackedStringArray: - return McpClient._array_from_packed(value) - return [] - - -static func _arrays_equal(left: Variant, right: Variant) -> bool: - if not (left is Array or left is PackedStringArray): - return false - if not (right is Array or right is PackedStringArray): - return false - var left_array := _array_copy(left) - var right_array := _array_copy(right) - if left_array.size() != right_array.size(): - return false - for i in range(left_array.size()): - if left_array[i] != right_array[i]: - return false - return true - - -# --- YAML block handling (scoped to mcp_servers) ------------------------- - -## Parse the file into three regions: -## prefix_lines — everything before `mcp_servers:` (may be empty) -## entries — the map of server_name -> {url, ...} under mcp_servers -## suffix_lines — everything after the mcp_servers block (may be empty) -## This lets us rewrite only the mcp_servers block and keep the rest of the -## user's config.yaml byte-for-byte intact. -static func _extract_block(text: String) -> Dictionary: - ## allow_empty must stay true: dropping empty splits would silently strip - ## the user's blank lines from the preserved prefix/suffix regions on - ## every rewrite. The parse loops below already skip blank lines. - var lines := text.split("\n") - var prefix: PackedStringArray = [] - var entries: Dictionary = {} - var suffix: PackedStringArray = [] - var header_idx := -1 - for i in range(lines.size()): - if lines[i].strip_edges().begins_with("mcp_servers:"): - header_idx = i - break - if header_idx < 0: - # No mcp_servers yet — whole file is prefix; block will be appended. - prefix = lines.duplicate() - return {"prefix_lines": prefix, "entries": entries, "suffix_lines": [], "header_idx": -1} - - for i in range(0, header_idx): - prefix.append(lines[i]) - - # Determine the indent of the first entry so we can tell sibling - # entries (same indent) apart from nested keys (deeper indent) and - # parent-level keys (less indent). All server entries under - # `mcp_servers:` share one indent level; breaking on 0-indent alone - # mis-nests 2-space-indented siblings under the first entry. - var entry_indent := -1 - var probe := header_idx + 1 - while probe < lines.size() and _is_blank_or_comment(lines[probe]): - probe += 1 - if probe < lines.size(): - entry_indent = _indent_of(lines[probe]) - - # Empty block guard: the first nonblank line after the header must sit - # DEEPER than the header itself to be an entry. At or above the header's - # indent it is a sibling/parent key — parsing it as an entry would - # swallow the user's next top-level key and re-emit it nested under - # mcp_servers, corrupting the file. - if probe < lines.size() and entry_indent <= _indent_of(lines[header_idx]): - for j in range(header_idx + 1, lines.size()): - suffix.append(lines[j]) - return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} - - var i := header_idx + 1 - while i < lines.size(): - var raw := lines[i] - ## Comment-only lines inside the block are skipped like blanks — - ## treating one as an entry header would re-emit it as a bogus - ## `# comment:` server on rewrite. (Comments INSIDE the rewritten - ## block are consequently dropped; comments outside the block live - ## in prefix/suffix and survive verbatim.) - if _is_blank_or_comment(raw): - i += 1 - continue - # Stop at any line indented less than a sibling entry (parent key - # or a new top-level section), or at the header's own level. - if _indent_of(raw) < entry_indent: - break - var entry := _parse_entry(raw, lines, i, entry_indent) - if not entry["name"].is_empty(): - entries[entry["name"]] = entry["data"] - i = entry["next_idx"] - - for j in range(i, lines.size()): - suffix.append(lines[j]) - - return {"prefix_lines": prefix, "entries": entries, "suffix_lines": suffix, "header_idx": header_idx} - - -## Parse one ` name:` entry starting at `lines[start]`. Consumes all deeper- -## indented sublines (url, headers, etc.) and returns the next sibling index. -static func _parse_entry(raw: String, lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: - var name := raw.strip_edges().trim_suffix(":").strip_edges() - var data: Dictionary = {} - var i := start + 1 - while i < lines.size(): - var l := lines[i] - ## Comments inside an entry (e.g. ` # auth for CI`) would parse - ## as a `# auth for CI` key — skip them like blanks. - if _is_blank_or_comment(l): - i += 1 - continue - # A line at or above the entry's indent is a sibling/parent key. - if _indent_of(l) <= entry_indent: - break - var stripped := l.strip_edges() - var colon := stripped.find(":") - if colon < 0: - i += 1 - continue - var key := stripped.substr(0, colon).strip_edges() - var val := stripped.substr(colon + 1).strip_edges() - if val.is_empty(): - # Nested block (e.g. headers:). Parse as raw sub-dict lines for - # preservation; we don't introspect deeper than url at the top. - var sub := _parse_subblock(lines, i + 1, entry_indent) - data[key] = sub["value"] - i = sub["next_idx"] - else: - data[key] = coerce_scalar(val) - i += 1 - return {"name": name, "data": data, "next_idx": i} - - -## Parse a nested block (e.g. headers:) as a preserved sub-dictionary of -## scalar key/values. Deeper nesting is flattened into scalar strings — fine -## for Hermes' known shape (headers are flat key: value). -static func _parse_subblock(lines: PackedStringArray, start: int, entry_indent: int) -> Dictionary: - var sub: Dictionary = {} - var i := start - while i < lines.size(): - var l := lines[i] - if _is_blank_or_comment(l): - i += 1 - continue - # A line at or above the parent entry's indent ends the nested block. - if _indent_of(l) <= entry_indent: - break - var stripped := l.strip_edges() - var colon := stripped.find(":") - if colon < 0: - i += 1 - continue - var key := stripped.substr(0, colon).strip_edges() - var val := stripped.substr(colon + 1).strip_edges() - if val.is_empty(): - i += 1 - continue - sub[key] = coerce_scalar(val) - i += 1 - return {"value": sub, "next_idx": i} - - -## Reassemble the full file text from prefix + a freshly built mcp_servers -## block + suffix. If the block didn't exist before, it is appended. -static func _assemble(_text: String, prefix: PackedStringArray, entries: Dictionary, suffix: PackedStringArray) -> String: - var out: PackedStringArray = [] - for l in prefix: - out.append(l) - # Trim trailing blank lines from prefix so we don't stack double blanks. - while out.size() > 0 and out[out.size() - 1].strip_edges().is_empty(): - out.remove_at(out.size() - 1) - - if not _text.contains("mcp_servers:"): - # File existed but had no mcp_servers block — append it. - if out.size() > 0: - out.append("") - out.append("mcp_servers:") - for name in entries: - out.append_array(_emit_entry(name, entries[name])) - else: - out.append("mcp_servers:") - for name in entries: - out.append_array(_emit_entry(name, entries[name])) - - # Suffix: keep as-is. - for l in suffix: - out.append(l) - return "\n".join(out) - - -## Public rendering seam for the dock's manual-instruction text, so the -## pasted YAML matches what Configure would write byte-for-byte. -static func render_entry_lines(name: String, data: Dictionary) -> PackedStringArray: - return _emit_entry(name, data) - - -## Emit one ` name:` entry with its scalar keys (top level only; headers -## sub-dict is re-emitted as nested scalars; arrays — the command entry's -## `args` — are emitted in flow style on one line). -static func _emit_entry(name: String, data: Dictionary) -> PackedStringArray: - var lines: PackedStringArray = [] - lines.append(INDENT + "%s:" % name) - for key in data: - var val = data[key] - if val is Dictionary: - lines.append(INDENT + INDENT + "%s:" % key) - for sk in val: - lines.append(INDENT + INDENT + INDENT + "%s: %s" % [sk, emit_scalar(val[sk])]) - elif val is Array or val is PackedStringArray: - lines.append(INDENT + INDENT + "%s: %s" % [key, emit_flow_array(_array_copy(val))]) - else: - lines.append(INDENT + INDENT + "%s: %s" % [key, emit_scalar(val)]) - return lines - - -## Flow-style sequence with every item double-quoted. JSON string quoting is -## valid YAML double-quote style (shared escape set), so the same encoding -## both writes the file and — via JSON.parse_string in `coerce_scalar` — -## reads it back for verification. Shared with `McpDshStrategy` for the -## DeepSeek Harness patch-list `args` lines. -static func emit_flow_array(values: Array) -> String: - var parts: Array[String] = [] - for v in values: - parts.append(JSON.stringify(str(v))) - return "[%s]" % ", ".join(parts) - - -static func emit_scalar(v: Variant) -> String: - match typeof(v): - TYPE_BOOL: - return "true" if bool(v) else "false" - TYPE_INT: - return str(int(v)) - TYPE_FLOAT: - return str(float(v)) - _: - return emit_string_scalar(str(v)) - - -## Plain YAML scalars cannot safely carry ": ", " #", quotes, flow -## indicators, or leading indicator characters — a Windows launcher path with -## spaces would silently corrupt the entry. Quote exactly when needed so -## existing plain values (urls, bools-as-strings) keep their current -## byte-shape on rewrite. Backslashes are quoted too: a Windows path parses -## either way, but quoting makes the written shape unambiguous for any -## consumer. Shared with `McpDshStrategy` for DeepSeek Harness patch entries. -static func emit_string_scalar(s: String) -> String: - if s.is_empty(): - return "\"\"" - var needs_quote := s.begins_with(" ") or s.ends_with(" ") - if not needs_quote: - for needle in [": ", " #", "\"", "'", "\n", "\t", "\\", "{", "}", "[", "]", ","]: - if s.contains(needle): - needs_quote = true - break - if not needs_quote: - for prefix in ["#", "-", "?", "&", "*", "!", "|", ">", "%", "@", "`"]: - if s.begins_with(prefix): - needs_quote = true - break - return JSON.stringify(s) if needs_quote else s - - -## Blank and comment-only lines carry no structure — every scan loop skips -## them the same way so a `# comment` can never be mistaken for an entry -## header or a key/value line. -static func _is_blank_or_comment(line: String) -> bool: - var stripped := line.strip_edges() - return stripped.is_empty() or stripped.begins_with("#") - - -## Returns the leading-whitespace indent width of a line (spaces + tabs -## counted as 1 each). Used to distinguish sibling entries (same indent) -## from nested keys (deeper indent) and parent-level keys (less indent). -static func _indent_of(line: String) -> int: - var n := 0 - while n < line.length() and (line[n] == " " or line[n] == "\t"): - n += 1 - return n - - -## Minimal scalar coercion for parsed YAML values. Quotes are stripped; -## bare true/false/numbers are typed; double-quoted flow sequences (the -## command entry's `args`) parse back into an Array. Good enough for Hermes' -## url/headers/command/args. A hand-edited args in block style or with -## unquoted items doesn't JSON-parse — it stays a raw string, compares -## unequal, and surfaces as CONFIGURED_MISMATCH, which Reconfigure -## normalizes back to the flow form. Public: the dsh strategy parses its -## patch-list scalars with the same rules, so both YAML dialects share one -## coercion (#867 review). -static func coerce_scalar(s: String) -> Variant: - var t := s.strip_edges() - if t.begins_with("[") and t.ends_with("]"): - var parsed_array: Variant = JSON.parse_string(t) - if parsed_array is Array: - return parsed_array - return t - if t.begins_with("\"") and t.ends_with("\""): - var parsed_string: Variant = JSON.parse_string(t) - if parsed_string is String: - return parsed_string - return t.substr(1, t.length() - 2) - if t.begins_with("'") and t.ends_with("'"): - return t.substr(1, t.length() - 2) - if t == "true": - return true - if t == "false": - return false - if t.is_valid_int(): - return t.to_int() - if t.is_valid_float(): - return t.to_float() - return t - - -## Returns {"ok": true, "data": String} when the file is absent or readable, -## and {"ok": false, "error": String} when unreadable. Callers must NOT fall -## back to an empty string on the error path — doing so blows away the user's -## other config.yaml entries on the next write. -static func _read(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - return {"ok": true, "data": ""} - var f := FileAccess.open(path, FileAccess.READ) - if f == null: - var err := FileAccess.get_open_error() - return {"ok": false, "error": "could not open for reading (%s)" % error_string(err)} - var t := f.get_as_text() - f.close() - return {"ok": true, "data": t} diff --git a/addons/godot_ai/clients/_yaml_strategy.gd.uid b/addons/godot_ai/clients/_yaml_strategy.gd.uid deleted file mode 100644 index d8adce8..0000000 --- a/addons/godot_ai/clients/_yaml_strategy.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cfnw4oe71ra1l diff --git a/addons/godot_ai/clients/antigravity.gd b/addons/godot_ai/clients/antigravity.gd deleted file mode 100644 index a707735..0000000 --- a/addons/godot_ai/clients/antigravity.gd +++ /dev/null @@ -1,39 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "antigravity" - display_name = "Antigravity" - config_type = "json" - ## Antigravity moved its shared MCP config from `~/.gemini/antigravity/` - ## to `~/.gemini/config/` (IDE + CLI now read the same file there); the - ## old path is left in `detect_paths` below so an existing install is - ## still recognized, but new/updated entries write to the current path. - path_template = { - "unix": "~/.gemini/config/mcp_config.json", - "windows": "$USERPROFILE/.gemini/config/mcp_config.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_url_field = "serverUrl" - ## `disabled` is user-state (they may have flipped the entry off in the - ## UI); seeded on first Configure but preserved across reconfigure. - entry_initial_fields = {"disabled": false} - ## Attach migration (#838). Antigravity stdio entries are flat - ## command/args/env with no type discriminator — transport is inferred - ## from `command` vs `serverUrl` presence (antigravity.google/docs/mcp), - ## so the legacy `serverUrl` must not survive next to a command. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["serverUrl"]) - command_initial_fields = {"disabled": false} - command_user_fields = PackedStringArray(["disabled", "disabledTools", "authProviderType", "env"]) - command_supports_url_fallback = true - ## Antigravity's spawner hangs stdio tool calls when the entry launches a - ## GUI-subsystem pythonw.exe (#863), and it hides child console windows - ## itself, so the visible-terminal problem the bootstrap solves (#827) - ## never applies. Write the plain console launcher on Windows. - needs_consoleless_launcher = false - detect_paths = PackedStringArray(path_template.values() + [ - "~/.gemini/antigravity/mcp_config.json", - "$USERPROFILE/.gemini/antigravity/mcp_config.json", - ]) diff --git a/addons/godot_ai/clients/antigravity.gd.uid b/addons/godot_ai/clients/antigravity.gd.uid deleted file mode 100644 index 0721a2f..0000000 --- a/addons/godot_ai/clients/antigravity.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4l1g0apa2hch diff --git a/addons/godot_ai/clients/cherry_studio.gd b/addons/godot_ai/clients/cherry_studio.gd deleted file mode 100644 index 873b9fe..0000000 --- a/addons/godot_ai/clients/cherry_studio.gd +++ /dev/null @@ -1,18 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "cherry_studio" - display_name = "Cherry Studio" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/CherryStudio/mcp_servers.json", - "windows": "$APPDATA/CherryStudio/mcp_servers.json", - "linux": "$XDG_CONFIG_HOME/CherryStudio/mcp_servers.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_extra_fields = {"type": "streamableHttp"} - ## `isActive` is user-state (they may have toggled the server off in the UI). - ## Seed on first Configure but preserve across reconfigure. - entry_initial_fields = {"isActive": true} diff --git a/addons/godot_ai/clients/cherry_studio.gd.uid b/addons/godot_ai/clients/cherry_studio.gd.uid deleted file mode 100644 index 7ada8cd..0000000 --- a/addons/godot_ai/clients/cherry_studio.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwbuykxvbv5f7 diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd deleted file mode 100644 index d0378b6..0000000 --- a/addons/godot_ai/clients/claude_code.gd +++ /dev/null @@ -1,84 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "claude_code" - display_name = "Claude Code" - config_type = "cli" - cli_names = PackedStringArray(["claude", "claude.exe"] if OS.get_name() == "Windows" else ["claude"]) - ## Stdio registration through the client-owned `godot-ai attach` bridge - ## (#838). `--` stops claude's own flag parsing so the attach argv passes - ## through verbatim; stdio is the CLI's default transport. Scope comes from - ## the `godot_ai/mcp_client_scope` EditorSetting via the `{scope}` token; it - ## defaults to `user` — the same ~/.claude.json the pre-attach HTTP entry - ## lived in — and can be set to `project` to write /.mcp.json so the - ## server is not loaded in every unrelated workspace. - cli_register_template = PackedStringArray( - ["mcp", "add", "--scope", "{scope}", "{name}", "--", "{command}", "{args...}"] - ) - ## Explicit scope: an unscoped `mcp remove` deletes from whichever scope - ## matches first, so the flag keeps each removal aimed at one known place. - ## Note what that does and does not protect (#877): the dock's **Remove** - ## button runs this once, at the selected scope only. **Configure** runs it - ## once per scope in `CLIENT_SCOPES` before registering, so a Configure at - ## any setting does delete a `godot-ai` entry from /.mcp.json if one is - ## there — see the cwd caveat below for which directory that actually is. - cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "{scope}", "{name}"]) - ## Scope caveat: `--scope project` resolves `.mcp.json` against the - ## *spawned CLI's* working directory, which is whatever the editor process - ## inherited at launch — not necessarily `res://`. Godot has no API to set - ## a child's cwd (`OS.execute_with_pipe` takes none), so this is an - ## accepted limitation rather than something the descriptor can pin. It is - ## self-consistent: `cli_status_args` runs from the same cwd, so the - ## read-back finds whatever the register wrote (see - ## `_scope_diverges_from_json_fallback` in client_configurator.gd). - cli_status_args = PackedStringArray(["mcp", "list"]) - ## `mcp list` prints the resolved entry for the cwd without saying which - ## scope won, so it cannot tell "registered at project scope" from "an old - ## user-scope entry is shadowing an empty project scope". `mcp get` prints - ## both the command and a `Scope: …` line, in the same single subprocess, - ## so scope-token clients probe with it instead (#872). Verified against - ## claude 2.1.241, which prints one of: - ## Scope: User config (available in all your projects) - ## Scope: Project config (shared via .mcp.json) - ## Scope: Local config (private to you in this project) - ## and exits 1 with "No MCP server named …" when the entry is absent. - cli_scope_status_template = PackedStringArray(["mcp", "get", "{name}"]) - ## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g. - ## Claude Code installed only as a VS Code / Cursor extension. The CLI is - ## still preferred for Configure whenever it resolves; this is what gets - ## written otherwise. `claude mcp add --scope user -- ` - ## produces exactly this shape under `mcpServers` in ~/.claude.json - ## (verified live against claude CLI in an isolated CLAUDE_CONFIG_DIR): - ## "godot-ai": { "type": "stdio", "command": "", "args": [...], "env": {} } - ## The fallback writer omits the empty `env`; the verifier accepts both. - ## Status reads this file only while the selected scope is still `user` — - ## it is the CLI's own store for that scope, and file reads give exact - ## launch-drift detection that `mcp list` stdout scanning cannot. At - ## `project`/`local` the entry is somewhere `path_template` cannot see, so - ## `_scope_diverges_from_json_fallback` (client_configurator.gd) routes - ## status to `cli_scope_status_template` instead and this file is not read. - path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"} - server_key_path = PackedStringArray(["mcpServers"]) - ## URL-mode shape, used only for the manual-instruction fallback text — - ## `claude mcp add --scope --transport http` writes {type: http, url}, - ## where is whatever `godot_ai/mcp_client_scope` resolves to. - entry_extra_fields = {"type": "http"} - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - ## Legacy HTTP entries carried a `url`; Claude Code rejects an entry mixing - ## url with command fields, and the stale `type: "http"` is repinned to - ## "stdio" by the transport key above. - command_legacy_keys = PackedStringArray(["url"]) - command_user_fields = PackedStringArray(["env"]) - command_supports_url_fallback = true - ## Documented: $CLAUDE_CONFIG_DIR relocates Claude Code's config home, - ## including .claude.json ($CLAUDE_CONFIG_DIR/.claude.json). The preferred - ## CLI path needs no help — the spawned `claude` binary inherits the - ## editor's environment and resolves the dir itself — but the JSON - ## fallback above would otherwise write ~/.claude.json that a relocated - ## install never reads (#617). - config_home_env = "CLAUDE_CONFIG_DIR" - config_home_env_subpath = ".claude.json" diff --git a/addons/godot_ai/clients/claude_code.gd.uid b/addons/godot_ai/clients/claude_code.gd.uid deleted file mode 100644 index 3d3335f..0000000 --- a/addons/godot_ai/clients/claude_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cp1u1hdpa6f8d diff --git a/addons/godot_ai/clients/claude_desktop.gd b/addons/godot_ai/clients/claude_desktop.gd deleted file mode 100644 index c1aa9bb..0000000 --- a/addons/godot_ai/clients/claude_desktop.gd +++ /dev/null @@ -1,38 +0,0 @@ -@tool -extends McpClient - -## Claude Desktop's mcpServers entries launch a local stdio process. The -## client-owned `godot-ai attach` bridge keeps that stdio session stable while -## adopting or starting the shared HTTP backend as Godot editors come and go. - - -func _init() -> void: - id = "claude_desktop" - display_name = "Claude Desktop" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Claude/claude_desktop_config.json", - "windows": "$APPDATA/Claude/claude_desktop_config.json", - "linux": "$XDG_CONFIG_HOME/Claude/claude_desktop_config.json", - } - ## Store-installed Claude runs inside MSIX AppData virtualization. Godot is - ## outside that container, so `%APPDATA%` names a different physical file - ## once Claude has created its private copy. A unique Store package root is - ## authoritative even before the config leaf exists: create the private file - ## directly so a later copy-on-write cannot hide an entry written to roaming. - ## With no Store package, use the conventional roaming path. The wildcard - ## avoids coupling to the publisher-hash suffix. - config_path_candidates = { - "windows": [ - "$LOCALAPPDATA/Packages/Claude_*/LocalCache/Roaming/Claude/claude_desktop_config.json", - "$APPDATA/Claude/claude_desktop_config.json", - ], - } - detect_paths = PackedStringArray([ - "$LOCALAPPDATA/Packages/Claude_*", - ]) - server_key_path = PackedStringArray(["mcpServers"]) - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url"]) - command_env_legacy_keys = PackedStringArray(["UV_LINK_MODE"]) - command_user_fields = PackedStringArray(["env", "disabled"]) diff --git a/addons/godot_ai/clients/claude_desktop.gd.uid b/addons/godot_ai/clients/claude_desktop.gd.uid deleted file mode 100644 index 9759a1a..0000000 --- a/addons/godot_ai/clients/claude_desktop.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bilntn5n8oqe3 diff --git a/addons/godot_ai/clients/cline.gd b/addons/godot_ai/clients/cline.gd deleted file mode 100644 index 2a1f025..0000000 --- a/addons/godot_ai/clients/cline.gd +++ /dev/null @@ -1,43 +0,0 @@ -@tool -extends McpClient - -## Cline is a VS Code extension. Its MCP settings live in VS Code's -## globalStorage under the extension id `saoudrizwan.claude-dev`. - - -func _init() -> void: - id = "cline" - display_name = "Cline" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", - "windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", - "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## Cline (like Roo) defaults a typeless entry to SSE transport, which - ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin - ## the type explicitly. Cline's schema uses "streamableHttp" (camelCase, - ## see src/services/mcp/schemas.ts in the cline repo) — distinct from - ## Roo's "streamable-http" string. Parallel to the Roo fix in #190. - entry_extra_fields = {"type": "streamableHttp"} - ## `disabled` and `autoApprove` are user-state (they may have flipped the - ## entry off, or auto-approved specific tools). Seed on first Configure - ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`. - entry_initial_fields = {"disabled": false, "autoApprove": []} - ## Attach migration (#838). Cline stdio entries are flat command/args/env; - ## its schema accepts `type: "stdio"` and normalizes typeless command - ## entries to it (apps/vscode/src/services/mcp/schemas.ts), so pin the - ## type — that also repins the legacy "streamableHttp" value instead of - ## letting it survive the deep-copy and misroute the transport. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_initial_fields = {"disabled": false, "autoApprove": []} - command_user_fields = PackedStringArray([ - "disabled", "autoApprove", "timeout", "oauth", "metadata", - "remoteConfigured", "env", "cwd", - ]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cline.gd.uid b/addons/godot_ai/clients/cline.gd.uid deleted file mode 100644 index 95e20f1..0000000 --- a/addons/godot_ai/clients/cline.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d36nywn2nkgts diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd deleted file mode 100644 index b8231bc..0000000 --- a/addons/godot_ai/clients/codex.gd +++ /dev/null @@ -1,45 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "codex" - display_name = "Codex" - config_type = "toml" - path_template = {"unix": "~/.codex/config.toml", "windows": "$USERPROFILE/.codex/config.toml"} - ## Documented: when $CODEX_HOME is set, Codex reads config.toml directly - ## from it instead of ~/.codex (#617). - config_home_env = "CODEX_HOME" - config_home_env_subpath = "config.toml" - toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"]) - # Older Codex builds used the unquoted form with underscore-substituted ids. - toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"]) - command_shape = McpClient.CommandShape.COMMAND_ARRAY - command_supports_url_fallback = true - command_legacy_keys = PackedStringArray(["url"]) - ## Initial-only: users may disable the entry or tune either timeout and - ## Configure preserves that choice. Codex currently defaults to 10s for - ## startup and 60s per tool; test_run legitimately has a 300s server - ## budget, so the generated config leaves transport margin at the client. - command_initial_fields = { - "enabled": true, - "startup_timeout_sec": 60, - "tool_timeout_sec": 360, - } - command_timeout_fields = PackedStringArray([ - "startup_timeout_sec", - "tool_timeout_sec", - ]) - command_user_fields = PackedStringArray([ - "enabled", - "required", - "startup_timeout_sec", - "tool_timeout_sec", - "enabled_tools", - "disabled_tools", - "default_tools_approval_mode", - "env", - "env_vars", - "cwd", - ]) - detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/codex.gd.uid b/addons/godot_ai/clients/codex.gd.uid deleted file mode 100644 index 1e1c3ae..0000000 --- a/addons/godot_ai/clients/codex.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hdlwcfdr8mdk diff --git a/addons/godot_ai/clients/cursor.gd b/addons/godot_ai/clients/cursor.gd deleted file mode 100644 index e4fe98d..0000000 --- a/addons/godot_ai/clients/cursor.gd +++ /dev/null @@ -1,21 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "cursor" - display_name = "Cursor" - config_type = "json" - path_template = {"unix": "~/.cursor/mcp.json", "windows": "$USERPROFILE/.cursor/mcp.json"} - server_key_path = PackedStringArray(["mcpServers"]) - ## Attach migration (#838). Cursor's stdio entries are flat command/args/env - ## (cursor.com/docs/context/mcp). The docs' reference table documents - ## `type: "stdio"`; pinning it also repins any hand-added `type: "http"` - ## left on the legacy URL entry, which would otherwise survive the - ## deep-copy migration and misroute the transport. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url"]) - command_user_fields = PackedStringArray(["env", "envFile"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/cursor.gd.uid b/addons/godot_ai/clients/cursor.gd.uid deleted file mode 100644 index e0c7ddf..0000000 --- a/addons/godot_ai/clients/cursor.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bvpbssfanukef diff --git a/addons/godot_ai/clients/deepseek_harness.gd b/addons/godot_ai/clients/deepseek_harness.gd deleted file mode 100644 index b9db2a5..0000000 --- a/addons/godot_ai/clients/deepseek_harness.gd +++ /dev/null @@ -1,45 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "deepseek_harness" - display_name = "DeepSeek Harness" - ## DeepSeek Harness (dsh) has no `mcp` CLI verb — MCP servers register as - ## `@deepseek-ai/dsh-mcp-client` plugin entries in the HOME patch layer - ## `$DSH_HOME/cordis.patch.yml`, which dsh applies over every profile's - ## own patch layer (the web GUI included). config_type "dsh" routes to - ## `McpDshStrategy`. Verified live against dsh 0.1.0-rc.6: new entries - ## must be `insert` rows; plain rows only override existing bundle ids. - config_type = "dsh" - ## Home patch layer, not a per-profile file: one entry covers the web - ## GUI, headless, tui, and any custom profile. - path_template = { - "unix": "~/.dsh/cordis.patch.yml", - "windows": "~/.dsh/cordis.patch.yml", - } - ## Documented: `$DSH_HOME` relocates the whole harness home, and the - ## loader reads `$DSH_HOME/cordis.patch.yml` (homePatchPath) — same - ## false-success-write class as CODEX_HOME (#617), so the override must - ## be declared. - config_home_env = "DSH_HOME" - config_home_env_subpath = "cordis.patch.yml" - ## Installed detection: the harness home directory itself (dsh GUI and - ## CLI both live there), or the config file once one exists. - detect_paths = PackedStringArray(["~/.dsh"]) - ## Attach migration (#838). The loader entry nests the launch under - ## `config`; the mcp-client plugin requires `transport` next to - ## command/args, and a `url` must never coexist with command fields. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "transport" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url"]) - ## Fields the mcp-client plugin documents as user-owned; preserved on - ## reconfigure and never drift-checked. - command_user_fields = PackedStringArray([ - "env", "cwd", "toolCallTimeoutMs", "failOnStartupError", "reconnect", - ]) - command_timeout_fields = PackedStringArray(["toolCallTimeoutMs"]) - ## The bridge accepts `transport: streamable-http` with a `url`, so the - ## manual instructions may offer the HTTP alternative. - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/deepseek_harness.gd.uid b/addons/godot_ai/clients/deepseek_harness.gd.uid deleted file mode 100644 index 8cc2ed3..0000000 --- a/addons/godot_ai/clients/deepseek_harness.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cw3pghlevtql8 diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd deleted file mode 100644 index fda58a3..0000000 --- a/addons/godot_ai/clients/gemini_cli.gd +++ /dev/null @@ -1,25 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "gemini_cli" - display_name = "Gemini CLI" - config_type = "json" - path_template = { - "unix": "~/.gemini/settings.json", - "windows": "$USERPROFILE/.gemini/settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_url_field = "httpUrl" - ## Attach migration (#838). Gemini CLI stdio entries are flat - ## command/args/env(+cwd); the config is one-of `command` | `url` (SSE) | - ## `httpUrl` (docs/tools/mcp-server.md), so BOTH URL keys are legacy next - ## to a command. `trust` bypasses tool confirmations — never seed it. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["httpUrl", "url"]) - command_user_fields = PackedStringArray([ - "timeout", "trust", "includeTools", "excludeTools", "env", "cwd", - ]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/gemini_cli.gd.uid b/addons/godot_ai/clients/gemini_cli.gd.uid deleted file mode 100644 index 2d4e85a..0000000 --- a/addons/godot_ai/clients/gemini_cli.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8288pxninajy diff --git a/addons/godot_ai/clients/grok.gd b/addons/godot_ai/clients/grok.gd deleted file mode 100644 index 7b6710e..0000000 --- a/addons/godot_ai/clients/grok.gd +++ /dev/null @@ -1,35 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "grok" - display_name = "Grok Build" - config_type = "toml" - # Grok Build reads MCP servers from ~/.grok/config.toml - # (https://x.ai / Grok user guide: MCP servers section). - path_template = { - "unix": "~/.grok/config.toml", - "windows": "$USERPROFILE/.grok/config.toml", - } - toml_section_path = PackedStringArray(["mcp_servers", "godot-ai"]) - # Some docs / older notes used an underscore form. - toml_legacy_section_aliases = PackedStringArray(["mcp_servers.godot_ai"]) - ## Attach migration (#838). Grok's stdio sections are flat command/args/env - ## with no type discriminator (docs.x.ai/build/features/mcp-servers); - ## url/headers are the HTTP form and must not survive next to a command. - ## Docs default startup_timeout_sec to 30 — a cold `uvx` install of the - ## pinned package can exceed that, so new entries seed 60 (preserved once - ## the user tunes it). tool_timeout_sec's documented 6000s default already - ## clears test_run's 300s budget, so it is left alone. The old body - ## template's `enabled = true` line was never documented for Grok — it is - ## no longer seeded, and an existing value survives as a user key. - command_shape = McpClient.CommandShape.COMMAND_ARRAY - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_initial_fields = {"startup_timeout_sec": 60} - command_user_fields = PackedStringArray([ - "env", "enabled", "startup_timeout_sec", "tool_timeout_sec", - ]) - command_timeout_fields = PackedStringArray(["startup_timeout_sec", "tool_timeout_sec"]) - command_supports_url_fallback = true - detect_paths = PackedStringArray(path_template.values()) diff --git a/addons/godot_ai/clients/grok.gd.uid b/addons/godot_ai/clients/grok.gd.uid deleted file mode 100644 index f2d12c2..0000000 --- a/addons/godot_ai/clients/grok.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckchsj5s3q1b0 diff --git a/addons/godot_ai/clients/hermes.gd b/addons/godot_ai/clients/hermes.gd deleted file mode 100644 index 64a66bb..0000000 --- a/addons/godot_ai/clients/hermes.gd +++ /dev/null @@ -1,46 +0,0 @@ -@tool -extends McpClient - -func _init() -> void: - id = "hermes" - display_name = "Hermes Agent" - config_type = "yaml" - - # Hermes reads MCP config from ~/.hermes/config.yaml (YAML), NOT mcp.json. - # Verified against the official docs: - # https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp - # Windows: Hermes stores config under $LOCALAPPDATA/hermes (NOT $APPDATA, - # which is Roaming) — confirmed by where the running Hermes process reads. - # NOTE: _path_template.expand() only substitutes $VAR tokens, not %VAR%. - path_template = { - "unix": "~/.hermes/config.yaml", - "windows": "$LOCALAPPDATA/hermes/config.yaml" - } - - # Hermes uses the snake_case `mcp_servers` key (not `mcpServers`). - # PackedStringArray explicitly, matching every other descriptor — an - # untyped Array literal relies on implicit conversion that newer Godot - # builds enforce more strictly (the #722 CI lesson for Array[String]). - server_key_path = PackedStringArray(["mcp_servers"]) - - # HTTP entries use `url` (+ optional `headers`); transport is inferred — - # there is no `type` field in Hermes MCP config. - entry_url_field = "url" - - # No transport pin: Hermes infers streamable-http from the URL. - entry_extra_fields = {} - entry_initial_fields = {} - - ## Attach migration (#838). Hermes stdio entries are flat command/args/env - ## (hermes-agent.nousresearch.com/docs/user-guide/features/mcp), transport - ## inferred exactly like the URL form — which is why `url` and the - ## HTTP-only `headers` must not survive next to a command: an entry with - ## both picks the wrong transport. `enabled`/`tools`/`env` stay user-owned. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_user_fields = PackedStringArray(["enabled", "tools", "env"]) - command_supports_url_fallback = true - - # Hermes is "installed" wherever the config.yaml lives; presence of the - # file is sufficient for the dock's installed badge. - detect_paths = PackedStringArray() diff --git a/addons/godot_ai/clients/hermes.gd.uid b/addons/godot_ai/clients/hermes.gd.uid deleted file mode 100644 index 14cabd8..0000000 --- a/addons/godot_ai/clients/hermes.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ewmadhrvs5d7 diff --git a/addons/godot_ai/clients/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd deleted file mode 100644 index 4f7b3f7..0000000 --- a/addons/godot_ai/clients/kilo_code.gd +++ /dev/null @@ -1,38 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "kilo_code" - display_name = "Kilo Code" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", - "windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", - "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which - ## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin - ## the type explicitly. Parallel to the Roo fix in #190. - entry_extra_fields = {"type": "streamable-http"} - ## `disabled` and `alwaysAllow` are user-state (they may have flipped the - ## entry off, or auto-approved specific tools). Seed on first Configure - ## but preserve across reconfigure — see `entry_initial_fields` in `_base.gd`. - entry_initial_fields = {"disabled": false, "alwaysAllow": []} - ## Attach migration (#838). UNLIKE its Roo siblings the stdio entry must be - ## TYPELESS: Kilo's v7 platform treats this file as a migration source and - ## routes on `type` — any http type value sends the entry down the remote - ## branch where it is dropped for lack of a url, and only a bare - ## command/args/env entry is verified to work in BOTH the legacy extension - ## and the v7 migrator (packages/opencode/src/kilocode/mcp-migrator.ts). - ## `type` therefore joins the legacy keys instead of being repinned. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "type", "headers"]) - command_initial_fields = {"disabled": false, "alwaysAllow": []} - command_user_fields = PackedStringArray([ - "disabled", "alwaysAllow", "timeout", "cwd", "watchPaths", - "disabledTools", "env", - ]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kilo_code.gd.uid b/addons/godot_ai/clients/kilo_code.gd.uid deleted file mode 100644 index 3ee5152..0000000 --- a/addons/godot_ai/clients/kilo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dc1x77i1cmb6w diff --git a/addons/godot_ai/clients/kimi_code.gd b/addons/godot_ai/clients/kimi_code.gd deleted file mode 100644 index a80fef8..0000000 --- a/addons/godot_ai/clients/kimi_code.gd +++ /dev/null @@ -1,34 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "kimi_code" - display_name = "Kimi Code" - ## Kimi Code has no `mcp` CLI subcommand (verified against v0.28.1 — - ## `kimi mcp` falls through to the root --help, and the docs at - ## moonshotai.github.io/kimi-code/en/customization/mcp confirm servers are - ## managed via ~/.kimi-code/mcp.json, not a CLI verb). JSON is therefore - ## the only working config method, not a fallback. - config_type = "json" - path_template = {"unix": "~/.kimi-code/mcp.json", "windows": "~/.kimi-code/mcp.json"} - server_key_path = PackedStringArray(["mcpServers"]) - entry_extra_fields = {"transport": "http"} - ## Documented: `$KIMI_CODE_HOME/mcp.json` relocates the config - ## (moonshotai.github.io/kimi-code/en/customization/mcp) — same - ## false-success-write class as CODEX_HOME (#617). - config_home_env = "KIMI_CODE_HOME" - config_home_env_subpath = "mcp.json" - ## Attach migration (#838). Kimi Code stdio entries are flat - ## command/args/env(+cwd): "Entries with a `command` field are stdio - ## servers". `transport` is only defined for SSE-with-url, so the legacy - ## `transport: "http"` must be removed alongside `url`. Timeout fields are - ## camelCase, unlike Codex's snake_case. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "transport"]) - command_user_fields = PackedStringArray([ - "enabled", "startupTimeoutMs", "toolTimeoutMs", "enabledTools", - "disabledTools", "env", "cwd", - ]) - command_timeout_fields = PackedStringArray(["startupTimeoutMs", "toolTimeoutMs"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kimi_code.gd.uid b/addons/godot_ai/clients/kimi_code.gd.uid deleted file mode 100644 index 5a05a0e..0000000 --- a/addons/godot_ai/clients/kimi_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2whd6a5fofhg diff --git a/addons/godot_ai/clients/kiro.gd b/addons/godot_ai/clients/kiro.gd deleted file mode 100644 index c9d4247..0000000 --- a/addons/godot_ai/clients/kiro.gd +++ /dev/null @@ -1,23 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "kiro" - display_name = "Kiro" - config_type = "json" - path_template = { - "unix": "~/.kiro/settings/mcp.json", - "windows": "$USERPROFILE/.kiro/settings/mcp.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## `disabled` is user-state — preserved across reconfigure. - entry_initial_fields = {"disabled": false} - ## Attach migration (#838). Kiro stdio entries are flat command/args/env - ## with no type discriminator (kiro.dev/docs/mcp/configuration). - ## `autoApprove` is user-state, same contract as the URL entry's fields. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url"]) - command_initial_fields = {"disabled": false} - command_user_fields = PackedStringArray(["disabled", "autoApprove", "disabledTools", "env"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/kiro.gd.uid b/addons/godot_ai/clients/kiro.gd.uid deleted file mode 100644 index dde3e87..0000000 --- a/addons/godot_ai/clients/kiro.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dqdmd2jw5qen7 diff --git a/addons/godot_ai/clients/opencode.gd b/addons/godot_ai/clients/opencode.gd deleted file mode 100644 index cee555b..0000000 --- a/addons/godot_ai/clients/opencode.gd +++ /dev/null @@ -1,39 +0,0 @@ -@tool -extends McpClient - -## OpenCode stores MCP servers under `mcp.` (not the typical mcpServers -## map) and uses `type: "remote"` for HTTP servers. - - -func _init() -> void: - id = "opencode" - display_name = "OpenCode" - config_type = "json" - ## `$HOME` on Windows is deliberate: OpenCode reads ~/.config/... on ALL - ## platforms (verified via `opencode debug paths`), and - ## McpPathTemplate._home() falls back to USERPROFILE when HOME is unset — - ## pinned by test_opencode_client_uses_home_config_on_windows. The documented - ## `OPENCODE_CONFIG` override names an exact file and must win over this - ## default for configure, status, remove, and manual instructions. - path_template = { - "unix": "~/.config/opencode/opencode.json", - "windows": "$HOME/.config/opencode/opencode.json", - } - config_file_env = "OPENCODE_CONFIG" - server_key_path = PackedStringArray(["mcp"]) - entry_extra_fields = {"type": "remote"} - ## `enabled` is user-state (they may have toggled the server off). - entry_initial_fields = {"enabled": true} - ## Attach migration (#838). OpenCode local entries carry the launch as ONE - ## argv array — `"command": ["uvx", …]` with no separate args key — plus a - ## schema-REQUIRED `type: "local"` (McpLocalConfig in opencode.ai/config.json; - ## env lives under `environment`, not `env`). The pin rewrites the legacy - ## `type: "remote"` in place; url/headers are remote-only and must go. - command_shape = McpClient.CommandShape.COMMAND_ARRAY - command_transport_key = "type" - command_transport_value = "local" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_initial_fields = {"enabled": true} - command_user_fields = PackedStringArray(["enabled", "timeout", "environment", "cwd"]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/opencode.gd.uid b/addons/godot_ai/clients/opencode.gd.uid deleted file mode 100644 index dc2ad00..0000000 --- a/addons/godot_ai/clients/opencode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://s8n0vfirf2pj diff --git a/addons/godot_ai/clients/pi.gd b/addons/godot_ai/clients/pi.gd deleted file mode 100644 index 849fbb4..0000000 --- a/addons/godot_ai/clients/pi.gd +++ /dev/null @@ -1,63 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "pi" - display_name = "Pi Agent" - config_type = "json" - # pi-codemode-mcp reads MCP server definitions from ~/.pi/agent/mcp.json - # (first merge tier; ~/.pi/agent/.mcp.json, .pi/mcp.json, and - # .mcp.json merge after, project-scope last). Documented at - # github.com/mitsuhiko/pi-codemode-mcp README "Configuration files" - # section. Windows path mirrors antigravity's $USERPROFILE choice so - # the descriptor round-trips on every supported platform. - path_template = { - "unix": "~/.pi/agent/mcp.json", - "windows": "$USERPROFILE/.pi/agent/mcp.json", - } - # Pi merges these files in order; later definitions of the same server win. - # Keep this separate from config_path_candidates, whose entries are alternatives - # rather than merge tiers. - config_merge_path_templates = { - "unix": PackedStringArray([ - "~/.pi/agent/mcp.json", - "~/.pi/agent/.mcp.json", - ]), - "windows": PackedStringArray([ - "$USERPROFILE/.pi/agent/mcp.json", - "$USERPROFILE/.pi/agent/.mcp.json", - ]), - } - config_merge_project_paths = PackedStringArray([".pi/mcp.json", ".mcp.json"]) - server_key_path = PackedStringArray(["mcpServers"]) - # pi-codemode-mcp resolves these with nullish precedence: - # mcpServers ?? mcp-servers ?? servers. Reuse whichever map already exists - # so Configure cannot shadow and deactivate the user's existing servers. - var aliases: Array[PackedStringArray] = [ - PackedStringArray(["mcp-servers"]), - PackedStringArray(["servers"]), - ] - server_key_path_aliases = aliases - entry_url_field = "url" - # pi-codemode-mcp selects stdio from `command` and remote transport from - # `url`; it ignores `type`. Keep generated stdio entries minimal and typeless. - entry_extra_fields = {} - entry_initial_fields = {} - # Attach migration (#838). Pi stdio entries are flat command/args/env. - # When Configure replaces a legacy URL entry, remove `url`, `headers`, and - # any redundant `type` field so the generated stdio entry stays canonical. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "headers", "type"]) - # `env` is the only user-mutable field pi's docs surface. Future - # fields still survive — the strategy deep-copies unknown keys. - command_user_fields = PackedStringArray(["env"]) - # Pi's loader also accepts pure-URL entries (the example mcp.json in - # the pi-codemode-mcp repo ships stdio + URL entries side-by-side). - # Keep the manual-instruction URL fallback alive. - command_supports_url_fallback = true - # ~/.pi/agent/mcp.json is created by the first pi launch, so its - # presence is the strongest install signal. Mirror antigravity's - # `detect_paths = path_template.values()` pattern so the dock shows - # the installed badge before Configure has run. - detect_paths = PackedStringArray(path_template.values()) \ No newline at end of file diff --git a/addons/godot_ai/clients/pi.gd.uid b/addons/godot_ai/clients/pi.gd.uid deleted file mode 100644 index 53aeb7c..0000000 --- a/addons/godot_ai/clients/pi.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://it00ohwkm2nd diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd deleted file mode 100644 index 63bc7b2..0000000 --- a/addons/godot_ai/clients/qwen_code.gd +++ /dev/null @@ -1,26 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "qwen_code" - display_name = "Qwen Code" - config_type = "json" - path_template = { - "unix": "~/.qwen/settings.json", - "windows": "$USERPROFILE/.qwen/settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_url_field = "httpUrl" - ## Attach migration (#838). Qwen Code is a gemini-cli fork with the same - ## flat stdio shape and one-of `command` | `url` | `httpUrl` rule - ## (docs/users/features/mcp.md). Qwen adds `discoveryTimeoutMs` (stdio - ## discovery handshake cap, default 30s). - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["httpUrl", "url"]) - command_user_fields = PackedStringArray([ - "timeout", "trust", "includeTools", "excludeTools", "env", "cwd", - "discoveryTimeoutMs", - ]) - command_timeout_fields = PackedStringArray(["timeout", "discoveryTimeoutMs"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/qwen_code.gd.uid b/addons/godot_ai/clients/qwen_code.gd.uid deleted file mode 100644 index 5f2eb1a..0000000 --- a/addons/godot_ai/clients/qwen_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qwb5udkf423q diff --git a/addons/godot_ai/clients/roo_code.gd b/addons/godot_ai/clients/roo_code.gd deleted file mode 100644 index 5d7bd6a..0000000 --- a/addons/godot_ai/clients/roo_code.gd +++ /dev/null @@ -1,42 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "roo_code" - display_name = "Roo Code" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", - "windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", - "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## Roo defaults an entry with no "type" to SSE transport — which returns - ## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type - ## explicitly so Roo negotiates streamable-http (the current MCP spec's - ## recommended remote transport). See issue #189. The default verifier - ## requires every entry_extra_fields key to match, so a pre-#189 typeless - ## entry surfaces as drift instead of silently passing as configured. - entry_extra_fields = {"type": "streamable-http"} - ## `disabled` and `alwaysAllow` are user-state (they may have flipped the - ## entry off, or auto-approved specific tools like `session_manage`). - ## Seed on first Configure but preserve across reconfigure — without this - ## split, the Configure-All-Mismatched sweep silently wipes the user's - ## auto-approval list every time the type pin or URL drifts. - entry_initial_fields = {"disabled": false, "alwaysAllow": []} - ## Attach migration (#838). Roo stdio entries are flat command/args/env - ## (+cwd); docs state `type` defaults to "stdio" for command configs and - ## the stdio schema forbids url/headers. Pin type=stdio — it is documented - ## and repins the legacy "streamable-http" value in place. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_initial_fields = {"disabled": false, "alwaysAllow": []} - command_user_fields = PackedStringArray([ - "disabled", "alwaysAllow", "timeout", "disabledTools", "watchPaths", - "env", "cwd", - ]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/roo_code.gd.uid b/addons/godot_ai/clients/roo_code.gd.uid deleted file mode 100644 index 1dcae84..0000000 --- a/addons/godot_ai/clients/roo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://denjdf50qrf66 diff --git a/addons/godot_ai/clients/trae.gd b/addons/godot_ai/clients/trae.gd deleted file mode 100644 index afc7a68..0000000 --- a/addons/godot_ai/clients/trae.gd +++ /dev/null @@ -1,23 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "trae" - display_name = "Trae" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Trae/User/mcp.json", - "windows": "$APPDATA/Trae/User/mcp.json", - "linux": "$XDG_CONFIG_HOME/Trae/User/mcp.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## Attach migration (#838). Trae stdio entries are flat command/args/env - ## with no type discriminator (docs.trae.cn/ide/add-mcp-servers); transport - ## is inferred from `command` vs `url`, so url/headers are legacy next to a - ## command. Entry stays minimal — Trae manages enable/disable in its UI, - ## and its startup/run timeouts ride user-owned env vars, which survive. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_user_fields = PackedStringArray(["env"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/trae.gd.uid b/addons/godot_ai/clients/trae.gd.uid deleted file mode 100644 index f10046e..0000000 --- a/addons/godot_ai/clients/trae.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwpu48772vfj1 diff --git a/addons/godot_ai/clients/vscode.gd b/addons/godot_ai/clients/vscode.gd deleted file mode 100644 index a18b97d..0000000 --- a/addons/godot_ai/clients/vscode.gd +++ /dev/null @@ -1,30 +0,0 @@ -@tool -extends McpClient - -## VS Code (stable) reads MCP servers from per-user mcp.json under -## `servers.` with `{ "type": "http", "url": ... }`. - - -func _init() -> void: - id = "vscode" - display_name = "VS Code" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code/User/mcp.json", - "windows": "$APPDATA/Code/User/mcp.json", - "linux": "$XDG_CONFIG_HOME/Code/User/mcp.json", - } - server_key_path = PackedStringArray(["servers"]) - entry_extra_fields = {"type": "http"} - ## Attach migration (#838). VS Code stdio entries are flat command/args/env - ## under `servers` with a documented `type: "stdio"` discriminator - ## (code.visualstudio.com/docs/agents/reference/mcp-configuration). The - ## stdio schema is `additionalProperties: false` (mcpConfiguration.ts), so - ## removing the legacy url/headers is load-bearing — leftovers invalidate - ## the whole entry, and the pin flips the legacy `type: "http"` in place. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode.gd.uid b/addons/godot_ai/clients/vscode.gd.uid deleted file mode 100644 index 1c79881..0000000 --- a/addons/godot_ai/clients/vscode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dl6cm044pihub diff --git a/addons/godot_ai/clients/vscode_insiders.gd b/addons/godot_ai/clients/vscode_insiders.gd deleted file mode 100644 index 7f6a65b..0000000 --- a/addons/godot_ai/clients/vscode_insiders.gd +++ /dev/null @@ -1,23 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "vscode_insiders" - display_name = "VS Code Insiders" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code - Insiders/User/mcp.json", - "windows": "$APPDATA/Code - Insiders/User/mcp.json", - "linux": "$XDG_CONFIG_HOME/Code - Insiders/User/mcp.json", - } - server_key_path = PackedStringArray(["servers"]) - entry_extra_fields = {"type": "http"} - ## Attach migration (#838). Identical format to vscode.gd — Insiders ships - ## the same mcpConfiguration.ts schema; see that descriptor for citations. - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_user_fields = PackedStringArray(["env", "envFile", "cwd", "sandboxEnabled", "dev"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/vscode_insiders.gd.uid b/addons/godot_ai/clients/vscode_insiders.gd.uid deleted file mode 100644 index c763703..0000000 --- a/addons/godot_ai/clients/vscode_insiders.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cad5w4ofyg8a2 diff --git a/addons/godot_ai/clients/windsurf.gd b/addons/godot_ai/clients/windsurf.gd deleted file mode 100644 index b3ef210..0000000 --- a/addons/godot_ai/clients/windsurf.gd +++ /dev/null @@ -1,29 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - # #623: Windsurf was rebranded to Devin Desktop by Cognition (June 2026). - # The id stays "windsurf" — it is the stable registry key used for - # configured-status lookups. The MCP config path is unchanged by the - # rebrand: per the official docs (docs.devin.ai/desktop/cascade/mcp) the - # global config still lives under the platform's `.codeium/windsurf/` - # directory (~/.codeium/windsurf/ on unix, $USERPROFILE/.codeium/windsurf/ - # on Windows), and migrated installs carry their settings over in place. - id = "windsurf" - display_name = "Devin Desktop (Windsurf)" - config_type = "json" - path_template = { - "unix": "~/.codeium/windsurf/mcp_config.json", - "windows": "$USERPROFILE/.codeium/windsurf/mcp_config.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - entry_url_field = "serverUrl" - ## Attach migration (#838). Stdio entries are flat command/args/env - ## (docs.devin.ai/desktop/cascade/mcp); transport is inferred from - ## `command` vs `serverUrl` presence and no type field is documented — - ## do not write one. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["serverUrl"]) - command_user_fields = PackedStringArray(["env"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/windsurf.gd.uid b/addons/godot_ai/clients/windsurf.gd.uid deleted file mode 100644 index af34b60..0000000 --- a/addons/godot_ai/clients/windsurf.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6pqiok2mlsmg diff --git a/addons/godot_ai/clients/zed.gd b/addons/godot_ai/clients/zed.gd deleted file mode 100644 index fbd5f29..0000000 --- a/addons/godot_ai/clients/zed.gd +++ /dev/null @@ -1,29 +0,0 @@ -@tool -extends McpClient - -## Zed registers MCP servers under `context_servers.` and supports both -## stdio and streamable http transports. - - -func _init() -> void: - id = "zed" - display_name = "Zed" - config_type = "json" - path_template = { - "darwin": "~/.config/zed/settings.json", - "linux": "$XDG_CONFIG_HOME/zed/settings.json", - "windows": "$APPDATA/Zed/settings.json", - } - server_key_path = PackedStringArray(["context_servers"]) - ## Attach migration (#838). Current Zed's context_servers entries are an - ## untagged serde enum discriminated by shape: `command` (string) + args/env - ## → stdio, `url` → HTTP (zed.dev/docs/ai/mcp; settings_content/project.rs). - ## BECAUSE the enum is untagged, HTTP-only keys left next to `command` - ## (`url`, `headers`, `oauth`) make the entry match no variant and break it — - ## removing them on migration is load-bearing, not cosmetic. `enabled`, - ## `remote`, and `timeout` are user-state on the stdio variant. - command_shape = McpClient.CommandShape.FLAT - command_legacy_keys = PackedStringArray(["url", "headers", "oauth"]) - command_user_fields = PackedStringArray(["enabled", "remote", "timeout", "env"]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zed.gd.uid b/addons/godot_ai/clients/zed.gd.uid deleted file mode 100644 index b9b313a..0000000 --- a/addons/godot_ai/clients/zed.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d152l0u0r6fsc diff --git a/addons/godot_ai/clients/zoo_code.gd b/addons/godot_ai/clients/zoo_code.gd deleted file mode 100644 index 870ff39..0000000 --- a/addons/godot_ai/clients/zoo_code.gd +++ /dev/null @@ -1,36 +0,0 @@ -@tool -extends McpClient - - -func _init() -> void: - id = "zoo_code" - display_name = "Zoo Code" - config_type = "json" - path_template = { - "darwin": "~/Library/Application Support/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", - "windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", - "linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json", - } - server_key_path = PackedStringArray(["mcpServers"]) - ## Local validation against the installed extension shows Zoo stores MCP - ## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's - ## shape. Its changelog also references Streamable HTTP support, so pin the - ## transport explicitly to avoid any typeless entry falling back to SSE. - entry_extra_fields = {"type": "streamable-http"} - ## Preserve user-controlled state across reconfigure, parallel to Roo/Kilo. - entry_initial_fields = {"disabled": false, "alwaysAllow": []} - ## Attach migration (#838). Zoo's stdio zod schema is Roo's: flat - ## command/args/env(+cwd), `type: z.enum(["stdio"]).optional()`, and - ## url/headers explicitly forbidden on stdio entries (McpHub.ts) — so both - ## are legacy keys and the documented type pin repins "streamable-http". - command_shape = McpClient.CommandShape.FLAT - command_transport_key = "type" - command_transport_value = "stdio" - command_legacy_keys = PackedStringArray(["url", "headers"]) - command_initial_fields = {"disabled": false, "alwaysAllow": []} - command_user_fields = PackedStringArray([ - "disabled", "alwaysAllow", "timeout", "watchPaths", "disabledTools", - "env", "cwd", - ]) - command_timeout_fields = PackedStringArray(["timeout"]) - command_supports_url_fallback = true diff --git a/addons/godot_ai/clients/zoo_code.gd.uid b/addons/godot_ai/clients/zoo_code.gd.uid deleted file mode 100644 index dd3713e..0000000 --- a/addons/godot_ai/clients/zoo_code.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://fmp0nlzcm3ukl diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd deleted file mode 100644 index 6823500..0000000 --- a/addons/godot_ai/connection.gd +++ /dev/null @@ -1,1048 +0,0 @@ -@tool -class_name McpConnection -extends Node - -## WebSocket transport to the Godot AI Python server. -## Only handles connect, reconnect, send, and receive. -## Command dispatch is owned by McpDispatcher. - -const RECONNECT_DELAYS: Array[float] = [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 60.0] -const RECONNECT_VERBOSE_ATTEMPTS := 5 -const RECONNECT_LOG_HEARTBEAT_MSEC := 60_000 -## Backpressure policy: do not queue responses once the WebSocket's current -## outbound buffer plus the next payload would exceed this cap. Command -## responses get a compact structured error when that can still be sent; -## state events report failure so their callers can retry on a later tick. -const OUTBOUND_BUFFER_LIMIT_BYTES := 4 * 1024 * 1024 -## Cap the inbound packet drain per `_process` tick. A flooding peer or a -## fast batch could otherwise saturate `_handle_message` in one frame and -## blow the documented 4ms budget. Packets beyond this cap spill to the -## next frame; the cumulative spill counter is logged so flood patterns -## are observable in `logs_read`. See audit-v2 finding #12 (issue #356). -const PACKET_DRAIN_CAP_PER_TICK := 32 -## Mirror of the server's application close code for a handshake carrying a -## wrong auth token (#690; `websocket.py::_CLOSE_CODE_AUTH_TOKEN_MISMATCH`). -const CLOSE_CODE_AUTH_TOKEN_MISMATCH := 4003 -## After this many consecutive post-OPEN token-mismatch rejections, drop the -## token and handshake token-less (see `_note_post_open_close`). Two, not -## one: a transient stale-record race during a server swap gets one chance -## to resolve before the token is given up. -const AUTH_MISMATCH_FALLBACK_CLOSES := 2 -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Emitted whenever the underlying WebSocket open/closed state flips. -## Subscribers (e.g. the plugin-side telemetry helper) use this to drain -## events that were enqueued before the socket was ready. Emitted with -## ``true`` on first OPEN per connect, ``false`` on transition to CLOSED -## (including ``disconnect_from_server()``). -signal connection_state_changed(is_open: bool) - -var _peer := WebSocketPeer.new() -## Seeded by plugin.gd from the configured EditorSettings port before the -## first dial, then republished with the fully resolved port once the -## deferred startup walk (#678) finishes resolving/spawning. Each connect -## attempt recomputes the URL from the latest value, so reconnects keep -## dialing the port the Python server was asked to bind. -var ws_port := ClientConfigurator.DEFAULT_WS_PORT -## Per-launch handshake auth token (#690). Set by plugin.gd from the value -## it generated for the server spawn (also persisted in the managed-server -## editor-settings record so a reloaded plugin instance adopting the same -## server keeps sending it). Empty means "don't send the field" — servers -## we didn't spawn (dev servers, older servers) have no token to match. -var auth_token := "" -var _url := "" -var _connected := false -var _reconnect_attempt := 0 -var _reconnect_timer := 0.0 -## Pull-based reconnect observability. The peer owns CONNECTING/CLOSING -## timing; tracking entry time here lets logs and the dock distinguish those -## phases from the plugin-owned CLOSED-state backoff without changing policy. -var _observed_peer_state := WebSocketPeer.STATE_CLOSED -var _peer_state_entered_msec := 0 -var _last_reconnect_transition_log_msec := -1 -var _transient_diagnostic: Dictionary = {} -## One pre-OPEN failure diagnostic per WebSocketPeer. Without this guard the -## CLOSED state is polled every frame and would flood the editor log. -var _preopen_failure_logged_for_peer := false -var _session_id := "" -## Consecutive post-OPEN closes with CLOSE_CODE_AUTH_TOKEN_MISMATCH. NOT -## reset by `_clear_on_disconnect` — the streak is counted exactly at the -## close events it exists to observe, across reconnect attempts. Reset on -## any other close code and on a successful `handshake_ack`. -var _auth_mismatch_closes := 0 -## Godot-AI Python package version reported by the server in its `handshake_ack` -## reply. Empty until the ack lands. Older servers (pre-handshake_ack) leave -## this empty forever — callers that gate on it (the dock's mismatch banner) -## must treat empty as "unknown, don't raise a false alarm". -var server_version := "" - -var dispatcher -var log_buffer -var surfaced_error_tracker -## Set by plugin.gd. Lets the per-frame play-state poll end game-run -## bookkeeping when the game exits on its own (self-quit, crash) — the -## debugger session's stopped signal is not reliably connected, and no MCP -## stop op runs in that path (#642). -var debugger_plugin -## Set by plugin.gd when the HTTP port is occupied by an incompatible or -## unverified server. Keeping the Connection node alive lets handlers and the -## dock share one object, but no WebSocket is opened to the wrong server. -var connect_blocked := false -var connect_block_reason := "" -var _blocked_notice_logged := false -## Compatibility property used by existing handlers. Setting true increments -## the pause depth; setting false decrements it. Processing stays paused until -## every nested pause has resumed. -var pause_processing: bool: - get: return _pause_depth > 0 - set(value): - if value: - pause() - else: - resume() -var _pause_depth := 0 -## Cumulative count of inbound packets that didn't fit in their tick's drain -## budget and got deferred to a subsequent tick. Reset on disconnect so each -## connection starts with a clean spillover history. Logged whenever new -## spillover occurs so flood patterns surface in `logs_read`. -var _packet_spillover_total := 0 - - -func _ready() -> void: - _session_id = _make_session_id(ProjectSettings.globalize_path("res://")) - ## Increase outbound buffer for large messages (e.g. screenshot base64). - ## Default is 64 KB; screenshots can be several MB. - _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - ## Symmetric inbound bump (#690): the server sends up to 4 MB - ## (websocket.py max_size), but Godot's inbound default is 64 KB — a - ## large script/text write or batch_execute payload used to overflow - ## the peer buffer, drop the frame, and surface as an opaque 5s - ## timeout + reconnect with no error naming the size. - _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - if connect_blocked: - _log_blocked_notice_once() - set_process(false) - return - _connect_to_server() - _hook_editor_signals() - - -func _process(delta: float) -> void: - if pause_processing: - return - _peer.poll() - ## Run-stop bookkeeping must not wait behind the socket-state machine: - ## if the game stops while disconnected, the first command drained on - ## reconnect would still observe stale "live" state (PR #642 review). - _check_game_run_play_state(EditorInterface.is_playing_scene()) - - var peer_state := _peer.get_ready_state() - var transition := _observe_peer_state(peer_state, Time.get_ticks_msec()) - match peer_state: - WebSocketPeer.STATE_OPEN: - if not _connected: - _connected = true - _reconnect_attempt = 0 - log_buffer.log("connected to server") - _send_handshake() - ## Reset the edge detectors so the next _check_state_changes - ## tick re-emits any non-default scene/play state — the - ## handshake carries readiness only, so without this a - ## (re)connected server never learns the current scene. - _last_scene_path = "" - _last_play_state = false - connection_state_changed.emit(true) - - _drain_inbound_packets(_peer) - - _check_state_changes() - - if dispatcher: - for response in dispatcher.tick(): - _send_json(response) - - WebSocketPeer.STATE_CLOSED: - if _connected: - _connected = false - ## This peer reached OPEN, so its one close diagnostic is the - ## post-OPEN line below. Mark the peer consumed; otherwise a - ## stale reconnect delay leaves it in CLOSED for another frame - ## and the pre-OPEN branch emits a mislabeled duplicate. - _preopen_failure_logged_for_peer = true - _clear_on_disconnect() - var code := _peer.get_close_code() - var reason := _peer.get_close_reason() - var open_elapsed_sec := float(transition.get("previous_elapsed_sec", 0.0)) - var close_diagnostic := _note_post_open_close(code) - if close_diagnostic.is_empty(): - close_diagnostic = { - "reason_code": "connection_lost", - "reason": _close_reason_text(code, reason), - } - _transient_diagnostic = close_diagnostic - _log_reconnect_transition( - _postopen_close_diagnostic( - open_elapsed_sec, - code, - reason, - _url, - close_diagnostic, - ), - maxi(1, _reconnect_attempt), - true, - ) - connection_state_changed.emit(false) - elif not _preopen_failure_logged_for_peer: - _preopen_failure_logged_for_peer = true - ## A failed attempt never reached OPEN, so any post-OPEN reason - ## belongs to the previous peer and must not describe this one. - _transient_diagnostic.clear() - ## Initial failure is attempt 1 for diagnostics. Later transition - ## summaries are time-throttled so a missing listener stays - ## observable without tying log volume to attempt duration. - var failed_attempt := maxi(1, _reconnect_attempt) - var connecting_elapsed_sec := float( - transition.get("previous_elapsed_sec", 0.0) - ) - _log_reconnect_transition( - _preopen_failure_diagnostic( - failed_attempt, - connecting_elapsed_sec, - _reconnect_timer, - _peer.get_close_code(), - _peer.get_close_reason(), - _url - ), - failed_attempt, - ) - _reconnect_timer -= delta - if _reconnect_timer <= 0.0: - _attempt_reconnect() - - WebSocketPeer.STATE_CLOSING: - pass - WebSocketPeer.STATE_CONNECTING: - pass - -func get_session_id() -> String: - return _session_id - -## Drain up to PACKET_DRAIN_CAP_PER_TICK inbound packets and dispatch each -## via `_handle_message`. Anything past the cap stays in the peer's queue -## and gets picked up next tick. The cumulative spillover count is logged -## (via `log_buffer`) only when the cap was actually hit AND packets remain -## — sustained flood thus emits one log line per tick with the running -## total, while a normal-traffic frame stays silent. -## -## `peer` is untyped (Variant) so tests can inject a duck-typed fake with -## `get_available_packet_count()` + `get_packet()`. Production passes the -## real `_peer: WebSocketPeer`. -func _drain_inbound_packets(peer) -> Dictionary: - var drained := 0 - while peer.get_available_packet_count() > 0 and drained < PACKET_DRAIN_CAP_PER_TICK: - var raw: String = peer.get_packet().get_string_from_utf8() - _handle_message(raw) - drained += 1 - - var spilled := 0 - if drained >= PACKET_DRAIN_CAP_PER_TICK and peer.get_available_packet_count() > 0: - spilled = peer.get_available_packet_count() - _packet_spillover_total += spilled - if log_buffer: - log_buffer.log( - ( - "[backpressure] inbound drain capped at %d/tick;" - + " %d packets spilled to next frame (cumulative %d)" - ) - % [PACKET_DRAIN_CAP_PER_TICK, spilled, _packet_spillover_total] - ) - - return {"drained": drained, "spilled": spilled} - - -var is_connected: bool: - get: return _connected - - -func disconnect_from_server() -> void: - if _connected: - _peer.close(1000, "Plugin unloading") - _connected = false - ## This peer reached OPEN and is being closed deliberately, so neither - ## the post-OPEN nor pre-OPEN close diagnostic applies. Consume its one - ## diagnostic before the CLOSED tick observes the pre-cleared flag. - _preopen_failure_logged_for_peer = true - ## Pre-clearing _connected makes the STATE_CLOSED branch skip its - ## _clear_on_disconnect() — run it here so deliberate closes don't - ## leak the old server's version/deferred state into the next one. - _clear_on_disconnect() - connection_state_changed.emit(false) - - -## Reset per-connection state that was filled in by the previous server -## and must NOT bleed into the next one. `force_restart_server` swaps -## servers without reloading the plugin, so without this reset the dock -## would keep showing the killed server's version until the next ack. -## Also fires on plain reconnect-loop drops — correct either way. -func _clear_on_disconnect() -> void: - server_version = "" - ## Reset the spillover counter so a flood pattern from the previous - ## connection doesn't pollute the next one's `logs_read` baseline. - _packet_spillover_total = 0 - if dispatcher: - dispatcher.clear_deferred_responses() - ## Queued-but-unexecuted commands from the dead connection must not - ## run under the next one (#712): their requester's futures were - ## already failed server-side, so executing them after reconnect is - ## an uncorrelatable surprise write. - dispatcher.clear_command_queue() - - -## Full pre-free cleanup for plugin unload: stop _process, close the -## socket, and drop dispatcher/log_buffer refs so their Callable-held -## RefCounted handlers decref before plugin.gd clears _handlers. -## See issue #46 and plugin.gd::_exit_tree. -func teardown() -> void: - set_process(false) - disconnect_from_server() - dispatcher = null - log_buffer = null - - -func _connect_to_server() -> void: - _url = "ws://127.0.0.1:%d" % ws_port - var err := _peer.connect_to_url(_url) - if err != OK: - log_buffer.log("failed to initiate connection (error %d)" % err) - _observed_peer_state = _peer.get_ready_state() - _peer_state_entered_msec = Time.get_ticks_msec() - - -func _attempt_reconnect() -> void: - if connect_blocked: - _log_blocked_notice_once() - set_process(false) - return - var delay := _reconnect_delay_for_attempt(_reconnect_attempt) - _reconnect_attempt += 1 - _reconnect_timer = delay - _log_reconnect_transition( - "connecting to server (attempt %d)" % _reconnect_attempt, - _reconnect_attempt, - ) - ## Always create a fresh WebSocketPeer before reconnecting. A peer that has - ## reached STATE_CLOSED is terminal; reusing it can leave the editor stuck in - ## a quiet reconnect loop after the Python server restarts. - _peer = WebSocketPeer.new() - _preopen_failure_logged_for_peer = false - _peer.outbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - ## Keep the reconnect peer symmetric with _ready()'s (#690). - _peer.inbound_buffer_size = OUTBOUND_BUFFER_LIMIT_BYTES - _connect_to_server() - - -func pause() -> void: - _pause_depth += 1 - - -func resume() -> void: - _pause_depth = maxi(0, _pause_depth - 1) - - -func pause_depth() -> int: - return _pause_depth - - -static func _reconnect_delay_for_attempt(attempt_index: int) -> float: - var delay_idx := mini(attempt_index, RECONNECT_DELAYS.size() - 1) - return RECONNECT_DELAYS[delay_idx] - - -static func _should_log_reconnect_transition( - attempt_number: int, - now_msec: int, - last_log_msec: int -) -> bool: - ## Keep the first few transitions visible, then emit at most one summary per - ## minute. Attempt-number throttling goes quiet for minutes when the engine - ## spends a long time in CONNECTING, which is the state this log explains. - return ( - attempt_number <= RECONNECT_VERBOSE_ATTEMPTS - or last_log_msec < 0 - or now_msec - last_log_msec >= RECONNECT_LOG_HEARTBEAT_MSEC - ) - - -func _log_reconnect_transition(message: String, attempt_number: int, force := false) -> void: - if not log_buffer: - return - var now_msec := Time.get_ticks_msec() - if not force and not _should_log_reconnect_transition( - attempt_number, - now_msec, - _last_reconnect_transition_log_msec, - ): - return - _last_reconnect_transition_log_msec = now_msec - log_buffer.log(message) - - -static func _preopen_failure_diagnostic( - attempt_number: int, - connecting_elapsed_sec: float, - retry_in_sec: float, - code: int, - reason: String, - url: String -) -> String: - var retry_text := "retrying now" - if retry_in_sec > 0.0: - retry_text = "retrying in %.0fs" % retry_in_sec - return ( - "connection attempt %d failed before OPEN after %.1fs; %s" - + " (code %d, reason %s, url %s)" - ) % [ - attempt_number, - maxf(0.0, connecting_elapsed_sec), - retry_text, - code, - _sanitized_close_reason(reason), - url, - ] - - -static func _postopen_close_diagnostic( - open_elapsed_sec: float, - code: int, - reason: String, - url: String, - diagnostic: Dictionary = {} -) -> String: - var message := ( - "connection lost after being open for %.1fs (code %d, reason %s, url %s); reconnecting" - % [maxf(0.0, open_elapsed_sec), code, _sanitized_close_reason(reason), url] - ) - match str(diagnostic.get("recovery_action", "")): - "retry_authenticated": - message += " with the current auth token (rejection 1/%d)" % AUTH_MISMATCH_FALLBACK_CLOSES - "retry_tokenless": - message += " with a token-less handshake (rejection %d/%d)" % [ - int(diagnostic.get("occurrence", AUTH_MISMATCH_FALLBACK_CLOSES)), - AUTH_MISMATCH_FALLBACK_CLOSES, - ] - return message - - -static func _sanitized_close_reason(reason: String) -> String: - var reason_label := reason.strip_edges() - if reason_label.is_empty(): - return "" - return reason_label.replace("\r", "\\r").replace("\n", "\\n") - - -static func _close_reason_text(code: int, reason: String) -> String: - return "Close code %d: %s" % [code, _sanitized_close_reason(reason)] - - -## Token-mismatch fallback (#690 follow-up). The server's auth token is -## fixed for its whole launch, so redialing with the same wrong token can -## never succeed — without this the reconnect loop 4003s forever. The -## reproduced multi-editor failure: a duplicate spawn overwrites the shared -## managed-server record with its fresh token, dies unable to bind, and -## this editor is left holding a token the surviving server never saw. -## After AUTH_MISMATCH_FALLBACK_CLOSES consecutive rejections, drop to a -## token-less handshake, which the server accepts by design (older plugins -## and adopted servers have no token, and the field is attacker-omittable — -## see websocket.py; omitting it gives up no security). Scope note: only -## this connection's copy of the token is dropped — the plugin static and -## the persisted record heal via the startup walk's adoption arms. -func _note_post_open_close(code: int) -> Dictionary: - if code != CLOSE_CODE_AUTH_TOKEN_MISMATCH or auth_token.is_empty(): - _auth_mismatch_closes = 0 - return {} - _auth_mismatch_closes += 1 - var occurrence := _auth_mismatch_closes - if _auth_mismatch_closes < AUTH_MISMATCH_FALLBACK_CLOSES: - return { - "reason_code": "auth_token_mismatch", - "reason": "Server rejected the editor auth token; retrying once in case of a server-swap race.", - "occurrence": occurrence, - "recovery_action": "retry_authenticated", - } - auth_token = "" - _auth_mismatch_closes = 0 - return { - "reason_code": "auth_token_mismatch", - "reason": "Server rejected the editor auth token twice; the next handshake will omit it.", - "occurrence": occurrence, - "recovery_action": "retry_tokenless", - } - - -## Record one peer-state transition and return the duration of the state that -## just ended. Kept separate from `_transport_status_snapshot` so tests can -## exercise the status contract with injected values and no live socket. -func _observe_peer_state(state: int, now_msec: int) -> Dictionary: - if _peer_state_entered_msec <= 0: - _observed_peer_state = state - _peer_state_entered_msec = now_msec - return {"changed": false, "previous_elapsed_sec": 0.0} - if state == _observed_peer_state: - return {"changed": false, "previous_elapsed_sec": 0.0} - var previous_elapsed_sec := maxf( - 0.0, - (now_msec - _peer_state_entered_msec) / 1000.0, - ) - var previous_state := _observed_peer_state - _observed_peer_state = state - _peer_state_entered_msec = now_msec - return { - "changed": true, - "previous_state": previous_state, - "previous_elapsed_sec": previous_elapsed_sec, - } - - -## Pure transport-status contract shared by the connection log and dock. It -## intentionally knows nothing about lifecycle diagnoses; the thin public -## wrapper below applies generic `blocked`, while server_lifecycle.gd remains -## authoritative for exact terminal states such as incompatible/foreign port. -static func _transport_status_snapshot( - state: int, - state_elapsed_sec: float, - attempt: int, - retry_timer: float -) -> Dictionary: - var phase := "closing" - match state: - WebSocketPeer.STATE_OPEN: - phase = "connected" - WebSocketPeer.STATE_CONNECTING: - phase = "connecting" - WebSocketPeer.STATE_CLOSED: - phase = "retrying" - WebSocketPeer.STATE_CLOSING: - phase = "closing" - var snapshot := { - "phase": phase, - "attempt": maxi(0, attempt), - "state_elapsed_sec": maxf(0.0, state_elapsed_sec), - } - ## `retry_in_sec` is deliberately unrepresentable outside CLOSED-state - ## backoff. CONNECTING is an in-flight attempt, never "retrying in 0s". - if phase == "retrying": - snapshot["retry_in_sec"] = maxf(0.0, retry_timer) - return snapshot - - -func get_transport_status() -> Dictionary: - var now_msec := Time.get_ticks_msec() - var elapsed_sec := 0.0 - if _peer_state_entered_msec > 0: - elapsed_sec = maxf(0.0, (now_msec - _peer_state_entered_msec) / 1000.0) - var snapshot := _transport_status_snapshot( - _peer.get_ready_state(), - elapsed_sec, - _reconnect_attempt, - _reconnect_timer, - ) - if connect_blocked: - snapshot["phase"] = "blocked" - snapshot.erase("retry_in_sec") - snapshot["reason_code"] = "connection_blocked" - if not connect_block_reason.is_empty(): - snapshot["reason"] = connect_block_reason - elif not _transient_diagnostic.is_empty(): - for key in _transient_diagnostic: - snapshot[key] = _transient_diagnostic[key] - return snapshot - - -func _log_blocked_notice_once() -> void: - if _blocked_notice_logged: - return - _blocked_notice_logged = true - if log_buffer and not connect_block_reason.is_empty(): - log_buffer.log(connect_block_reason) - - -func _send_handshake() -> void: - _last_readiness = get_readiness() - _send_json(_build_handshake()) - - -## Split from _send_handshake so tests can assert the payload shape -## without a live WebSocket peer. -func _build_handshake() -> Dictionary: - var payload := { - "type": "handshake", - "session_id": _session_id, - "godot_version": Engine.get_version_info().get("string", "unknown"), - "project_path": ProjectSettings.globalize_path("res://"), - "plugin_version": ClientConfigurator.get_plugin_version(), - "protocol_version": 1, - "readiness": _last_readiness, - "editor_pid": OS.get_process_id(), - "server_launch_mode": ClientConfigurator.get_server_launch_mode(), - } - ## Omit rather than send "" — the server treats an ABSENT token as a - ## compat-accepted older plugin, but a PRESENT wrong one as hostile. - if not auth_token.is_empty(): - payload["auth_token"] = auth_token - return payload - - -## Classify one raw inbound frame. Shared by the normal dispatch path -## (`_handle_message`, which enqueues commands) and the exclusive-run -## service path (`_service_handle_message`, which rejects them) — one -## parser, two sinks, so the paths can't drift. `kind` is one of: -## "ack", "command", "malformed_command", "ignore". -func _classify_message(raw: String) -> Dictionary: - var parsed = JSON.parse_string(raw) - if parsed == null: - push_warning("MCP: failed to parse message: %s" % raw) - return {"kind": "ignore", "parsed": null} - if not (parsed is Dictionary): - return {"kind": "ignore", "parsed": null} - if parsed.get("type", "") == "handshake_ack": - return {"kind": "ack", "parsed": parsed} - if parsed.has("request_id") and parsed.has("command"): - if ( - parsed.get("request_id") is String - and parsed.get("command") is String - and (not parsed.has("params") or parsed.get("params") is Dictionary) - ): - return {"kind": "command", "parsed": parsed} - return {"kind": "malformed_command", "parsed": parsed} - return {"kind": "ignore", "parsed": parsed} - - -func _handle_message(raw: String) -> void: - var classified := _classify_message(raw) - match classified["kind"]: - "ack": - _handle_handshake_ack(classified["parsed"]) - "command": - if dispatcher: - dispatcher.enqueue(classified["parsed"]) - "malformed_command": - _reply_malformed_command(classified["parsed"]) - - -func _handle_handshake_ack(parsed: Dictionary) -> void: - server_version = str(parsed.get("server_version", "")) - ## The server accepted our handshake — any token-mismatch streak is - ## over; a later unrelated 4003 starts a fresh one. - _auth_mismatch_closes = 0 - _transient_diagnostic.clear() - - -## Never enqueue a malformed command frame: the dispatcher's typed casts -## would error on the queue head every tick, wedging every later command -## behind it. Reply with an error when the request_id is usable so the -## server's pending future resolves instead of waiting out the full -## command timeout. -func _reply_malformed_command(parsed: Dictionary) -> void: - push_warning("MCP: dropping malformed command frame (request_id/command must be String, params a Dictionary)") - var rid: Variant = parsed.get("request_id") - if rid is String and not String(rid).is_empty(): - var response := ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Malformed command frame: request_id/command must be strings and params a dict" - ) - response["request_id"] = rid - response["readiness"] = get_readiness() - _stamp_error_watermark(response) - _send_json(response) - - -## Send a state event to the server (not a command response). -func send_event(event_name: String, data: Dictionary = {}) -> bool: - return _send_json({"type": "event", "event": event_name, "data": data}) - - -## Push a command response for a request_id whose handler deferred its reply -## (see McpDispatcher.DEFERRED_RESPONSE). `payload` must carry either a `data` -## or `error` field in the same shape handlers normally return. -func send_deferred_response(request_id: String, payload: Dictionary) -> void: - if dispatcher != null and not dispatcher.has_pending_deferred_response(request_id): - if log_buffer: - log_buffer.log("[defer] dropped late response for expired request %s" % request_id) - return - var response := payload.duplicate() - response["request_id"] = request_id - if not response.has("status"): - response["status"] = "ok" if payload.has("data") else "error" - ## Symmetric with McpDispatcher::_dispatch — stamp live readiness on the - ## deferred reply so the server's session cache self-heals from any - ## response, not just the synchronous ones. Lets `project_stop` (the - ## main deferred-response producer) stay correct even if its bespoke - ## `readiness_after` payload field were ever dropped. - if not response.has("readiness"): - response["readiness"] = get_readiness() - if not response.has("error_watermark"): - _stamp_error_watermark(response) - if _send_json(response) and dispatcher != null: - dispatcher.complete_deferred_response(request_id) - - -## Result of one cooperative transport-servicing pass during an exclusive -## synchronous run (currently only the test runner). PAUSED is an -## invariant violation for callers, not a healthy state: a pause held -## across servicing checkpoints would silently starve the heartbeat. -enum ServiceStatus { SERVICED, DISCONNECTED, PAUSED, BLOCKED } - -## Cumulative cap on application packets processed across ONE exclusive -## run. Counts every drained packet — valid command, malformed frame, or -## ack-like — so no frame kind evades it. Past the cap the connection is -## closed (1013): bounded rejects, never unbounded stale buffering. 2048 -## leaves headroom under Godot's default max_queued_packets (4096) and -## sits above stormtest's ~1000-call default workload; tune with -## telemetry/benchmarks if rejection traffic ever extends a checkpoint. -const EXCLUSIVE_RUN_PACKET_CAP := 2048 -const CLOSE_CODE_EXCLUSIVE_RUN_FLOOD := 1013 -## Reject-log throttle: first few rejects verbatim, then periodic totals. -const _SERVICE_REJECT_LOG_FIRST := 5 -const _SERVICE_REJECT_LOG_EVERY := 100 - -## Service the WebSocket transport from inside a long synchronous handler -## (an "exclusive run" — the test runner). The editor main thread is -## blocked, so `_process` cannot poll; without this the server keepalive -## (20s ping interval / 20s timeout) closes the session mid-run. See -## docs/test-run-transport-starvation-plan.md. -## -## Contract — do NOT extend this method to dispatch: -## - `WebSocketPeer.poll()` has no heartbeat-only mode; it also buffers -## application frames. Buffering them past this call would replay them -## STALE after their server-side futures expire (the #712 hazard), so -## every drained command frame is REJECTED immediately with a retryable -## EDITOR_NOT_READY / EDITOR_TEST_RUNNING error instead. -## - Drains to quiescence: poll → drain everything available → poll again, -## until no packets remain. A full packet queue could hide a ping deeper -## in the TCP stream, so nothing may spill to a later checkpoint. -## - `run_state` is caller-owned mutable state carrying the cumulative -## packet counter under "packets_serviced" — no connection-global -## lifecycle that could leak if the run dies. -func service_transport_during_exclusive_run(run_state: Dictionary) -> ServiceStatus: - if connect_blocked: - return ServiceStatus.BLOCKED - if pause_processing: - return ServiceStatus.PAUSED - while true: - _peer.poll() - if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN: - return ServiceStatus.DISCONNECTED - if _peer.get_available_packet_count() == 0: - return ServiceStatus.SERVICED - while _peer.get_available_packet_count() > 0: - var raw: String = _peer.get_packet().get_string_from_utf8() - if _service_note_packet(run_state): - if log_buffer: - log_buffer.log( - "[busy] packet flood during test run (%d > cap %d) — closing connection" - % [int(run_state.get("packets_serviced", 0)), EXCLUSIVE_RUN_PACKET_CAP] - ) - _peer.close(CLOSE_CODE_EXCLUSIVE_RUN_FLOOD, "command flood during test run") - return ServiceStatus.DISCONNECTED - _service_handle_message(raw, int(run_state.get("packets_serviced", 0))) - ## Unreachable: every exit above returns. Keeps the typed signature happy. - return ServiceStatus.SERVICED - - -## Shared between-phase checkpoint for exclusive runs: deadline first -## (cheap), then transport servicing via `service_cb`. Returns "" to -## continue, or a terminal outcome: "timeout" | "transport_lost" | -## "paused". Static and stateless so the runner's between-test checkpoints -## and the handler's discovery checkpoints share ONE outcome mapping — -## PAUSED is abort-worthy (a held pause would silently skip every later -## poll and starve the heartbeat), and DISCONNECTED/BLOCKED both mean "no -## live transport". -static func exclusive_run_checkpoint( - service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary -) -> String: - if deadline_ticks_ms > 0 and Time.get_ticks_msec() >= deadline_ticks_ms: - return "timeout" - if not service_cb.is_valid(): - return "" - var status: int = service_cb.call(run_state) - if status == ServiceStatus.SERVICED: - return "" - if status == ServiceStatus.PAUSED: - return "paused" - return "transport_lost" - - -## Count one application packet against the exclusive-run cap. Counts EVERY -## drained packet regardless of kind (valid command, malformed, ack-like) -## so no frame kind can evade the flood limit. Returns true once the cap is -## exceeded — the caller closes the connection. -static func _service_note_packet(run_state: Dictionary) -> bool: - var count: int = int(run_state.get("packets_serviced", 0)) + 1 - run_state["packets_serviced"] = count - return count > EXCLUSIVE_RUN_PACKET_CAP - - -## Exclusive-run sink for `_classify_message`: acks are still processed, -## malformed frames keep their normal reply, and valid commands are -## rejected without touching the dispatcher. -func _service_handle_message(raw: String, packets_serviced: int) -> void: - var classified := _classify_message(raw) - match classified["kind"]: - "ack": - _handle_handshake_ack(classified["parsed"]) - "command": - _service_reject_command(classified["parsed"], packets_serviced) - "malformed_command": - _reply_malformed_command(classified["parsed"]) - - -func _service_reject_command(parsed: Dictionary, packets_serviced: int) -> void: - _send_json(_build_service_reject(parsed)) - if log_buffer and ( - packets_serviced <= _SERVICE_REJECT_LOG_FIRST - or packets_serviced % _SERVICE_REJECT_LOG_EVERY == 0 - ): - ## Ring-buffer only (echo=false): a flood must not bury the console. - log_buffer.log( - "[busy] rejected '%s' during test run (packet %d)" - % [parsed.get("command", ""), packets_serviced], - false, - ) - - -## Build the busy-reject response for a valid command frame that arrived -## mid-run. Split from the send so tests can assert the exact wire shape. -func _build_service_reject(parsed: Dictionary) -> Dictionary: - var command: String = parsed.get("command", "") - var response := ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_TEST_RUNNING, - ( - "A test run is in progress on this editor — '%s' was not executed. " - + "Retry when the run completes, or fetch results afterward with " - + "test_manage(op=\"results_get\")." - ) % command, - true, - ) - response["request_id"] = parsed.get("request_id", "") - response["readiness"] = get_readiness() - _stamp_error_watermark(response) - return response - - -func _hook_editor_signals() -> void: - # Scene change: poll in _process since there's no direct signal for scene switch - # Play state: EditorInterface signals - EditorInterface.get_editor_settings() # ensure interface is ready - _last_scene_path = _get_current_scene_path() - _last_play_state = EditorInterface.is_playing_scene() - _last_play_state_for_run = _last_play_state - - -var _last_scene_path := "" -var _last_play_state := false -## Separate edge tracker for game-run bookkeeping: _last_play_state only -## advances when the play_state_changed event sends successfully, but ending -## run tracking must not depend on the websocket being up. -var _last_play_state_for_run := false -var _last_readiness := "" - - -## Compute current editor readiness from live Godot state. -static func get_readiness() -> String: - if EditorInterface.get_resource_filesystem().is_scanning(): - return "importing" - if EditorInterface.is_playing_scene(): - return "playing" - if EditorInterface.get_edited_scene_root() == null: - return "no_scene" - return "ready" - - -## Check for scene/play state changes each frame (lightweight polling). -func _check_state_changes() -> void: - var scene_path := _get_current_scene_path() - if scene_path != _last_scene_path: - if send_event("scene_changed", {"current_scene": scene_path}): - _last_scene_path = scene_path - if log_buffer: - log_buffer.log("[event] scene_changed -> %s" % scene_path) - - var playing := EditorInterface.is_playing_scene() - if playing != _last_play_state: - var state := "playing" if playing else "stopped" - if send_event("play_state_changed", {"play_state": state}): - _last_play_state = playing - if log_buffer: - log_buffer.log("[event] play_state_changed -> %s" % state) - - var readiness := get_readiness() - if readiness != _last_readiness: - if send_event("readiness_changed", {"readiness": readiness}): - _last_readiness = readiness - if log_buffer: - ## echo=false: readiness flips on every filesystem scan - ## (each import cycles importing -> ready), so echoing to - ## console spams every install during normal editing (#626). - ## The line stays in the ring for the dock's log panel. - log_buffer.log("[event] readiness -> %s" % readiness, false) - - -## Playing→stopped edge for game-run bookkeeping. Runs every process tick -## (any socket state) so a self-quit game's run ends even while the -## transport is down or reconnecting. -func _check_game_run_play_state(playing: bool) -> void: - if playing == _last_play_state_for_run: - return - if not playing and debugger_plugin != null: - debugger_plugin.note_editor_play_stopped() - _last_play_state_for_run = playing - - -func _get_current_scene_path() -> String: - var scene_root := EditorInterface.get_edited_scene_root() - return scene_root.scene_file_path if scene_root else "" - - -func _send_json(data: Dictionary) -> bool: - if not _connected: - return false - var text := JSON.stringify(data) - var buffered_bytes := _peer.get_current_outbound_buffered_amount() - ## `send_text` encodes the string to UTF-8 internally, so an exact - ## `to_utf8_buffer().size()` here would encode every payload twice. Almost - ## all payloads sit far below the limit, so gate on a cheap upper bound - ## (<= 4 UTF-8 bytes per code point) and only pay for the exact count when - ## the estimate lands near the backpressure ceiling. - if _might_exceed_outbound_backpressure(buffered_bytes, text.length()): - var message_bytes := text.to_utf8_buffer().size() - if _would_exceed_outbound_backpressure(buffered_bytes, message_bytes): - return _handle_outbound_backpressure(data, buffered_bytes, message_bytes) - var err := _peer.send_text(text) - if err != OK: - if log_buffer: - log_buffer.log("[send] websocket send_text failed: %s" % error_string(err)) - return false - return true - - -static func _would_exceed_outbound_backpressure(buffered_bytes: int, message_bytes: int) -> bool: - return buffered_bytes + message_bytes > OUTBOUND_BUFFER_LIMIT_BYTES - - -## Cheap pre-check on the code-point count: UTF-8 uses at most 4 bytes per code -## point, so `char_count * 4` upper-bounds the encoded size. When even that -## upper bound fits under the ceiling the payload is definitely safe and we can -## skip the exact encode; only a positive here warrants `to_utf8_buffer()`. -static func _might_exceed_outbound_backpressure(buffered_bytes: int, char_count: int) -> bool: - return buffered_bytes + char_count * 4 > OUTBOUND_BUFFER_LIMIT_BYTES - - -func _handle_outbound_backpressure( - data: Dictionary, - buffered_bytes: int, - message_bytes: int, -) -> bool: - var request_id: String = data.get("request_id", "") - if request_id.is_empty(): - if log_buffer: - log_buffer.log( - "[send] requestless payload blocked by websocket backpressure " - + "(buffered=%d, message=%d, limit=%d)" - % [buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES] - ) - return false - - var err_response := _make_backpressure_error(request_id, buffered_bytes, message_bytes) - _stamp_error_watermark(err_response) - var err_text := JSON.stringify(err_response) - var err_bytes := err_text.to_utf8_buffer().size() - if _would_exceed_outbound_backpressure(buffered_bytes, err_bytes): - if log_buffer: - log_buffer.log( - "[send] dropped response for request %s due to websocket backpressure " - + "(buffered=%d, message=%d, limit=%d)" - % [request_id, buffered_bytes, message_bytes, OUTBOUND_BUFFER_LIMIT_BYTES] - ) - return false - - var send_err := _peer.send_text(err_text) - if send_err != OK: - if log_buffer: - log_buffer.log("[send] websocket backpressure error send failed: %s" % error_string(send_err)) - return false - if log_buffer: - log_buffer.log( - "[send] %s -> error: outbound websocket backpressure" - % data.get("command", "response") - ) - return true - - -static func _make_backpressure_error( - request_id: String, - buffered_bytes: int, - message_bytes: int, -) -> Dictionary: - return { - "request_id": request_id, - "status": "error", - "data": {}, - ## Stamp readiness on the backpressure error too — the server's - ## per-response self-heal applies to every response shape the - ## plugin emits, and the next legitimate reply may already be - ## queued behind this one. - "readiness": get_readiness(), - "error": { - "code": ErrorCodes.INTERNAL_ERROR, - "message": ( - "Outbound WebSocket buffer is full; dropped response before queueing " - + "more data. Retry with a smaller payload (for screenshots, lower " - + "max_resolution or set include_image=false)." - ), - "data": { - "buffered_bytes": buffered_bytes, - "message_bytes": message_bytes, - "limit_bytes": OUTBOUND_BUFFER_LIMIT_BYTES, - }, - }, - } - - -func _stamp_error_watermark(response: Dictionary) -> void: - McpSurfacedErrorTracker.stamp_watermark(response, surfaced_error_tracker) - - -## Build a human-readable session ID of form "@<4hex>" from the project path. -## The slug is derived from the project directory name so agents can recognize -## which editor they're targeting; the hex suffix disambiguates same-project twins. -static func _make_session_id(project_path: String) -> String: - var base := project_path.rstrip("/\\").get_file() - if base == "": - base = "project" - var slug := _slugify(base) - if slug == "": - slug = "project" - var suffix := _rand_hex(4) - return "%s@%s" % [slug, suffix] - - -static func _slugify(s: String) -> String: - var out := "" - var prev_dash := false - for c in s.to_lower(): - if (c >= "a" and c <= "z") or (c >= "0" and c <= "9"): - out += c - prev_dash = false - elif not prev_dash and out != "": - out += "-" - prev_dash = true - return out.trim_suffix("-") - - -static func _rand_hex(n: int) -> String: - var bytes := PackedByteArray() - var byte_count := int(ceil(float(n) / 2.0)) - for i in byte_count: - bytes.append(randi() % 256) - return bytes.hex_encode().substr(0, n) diff --git a/addons/godot_ai/connection.gd.uid b/addons/godot_ai/connection.gd.uid deleted file mode 100644 index ff78405..0000000 --- a/addons/godot_ai/connection.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmnk8rsotiks2 diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd deleted file mode 100644 index 45ace63..0000000 --- a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd +++ /dev/null @@ -1,66 +0,0 @@ -@tool -extends RefCounted - -## bridge between dispatcher and addon handler - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -var _spec: McpCustomToolSpec -var _locator: McpServiceLocator -var _handler_instance = null # lazily loaded from _spec.script_path - -func _init(spec: McpCustomToolSpec, locator: McpServiceLocator) -> void: - _spec = spec - _locator = locator - -## Invoked by dispatcher._call_handler as .call(params) — SINGLE ARG. -## Internally splits into (clean_params, ctx) for the addon handler. -func invoke(params: Dictionary) -> Dictionary: - ## Dock enable/disable gate: a disabled tool is dropped from the - ## catalog push, but a client holding a stale list (or batch_execute) - ## can still name it — reject at dispatch too. - var registry := McpToolRegistry.get_instance() - if registry != null and not registry.is_tool_enabled(_spec.name): - return ErrorCodes.make(ErrorCodes.CUSTOM_TOOL_DISABLED, - "Custom tool '%s' is disabled in the Godot AI dock" % _spec.name) - ## Readiness gate (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1): block writes during play/import. - ## Checked BEFORE the lazy load so a gated tool's handler script (and any - ## _init side effects) never runs while the editor is busy. - var _readiness := McpConnection.get_readiness() - if _spec.requires_writable and (_readiness == "importing" or _readiness == "playing"): - return ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "Editor is '%s' — write blocked for custom tool '%s'" % [_readiness, _spec.name]) - if _handler_instance == null: - var script := load(_spec.script_path) as GDScript - if script == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Cannot load %s" % _spec.script_path) - _handler_instance = script.new() # no-arg; per-call context arrives via ctx - if not _handler_instance.has_method(_spec.method): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s has no method '%s' for custom tool '%s'" % [_spec.script_path, _spec.method, _spec.name]) - ## Extract _request_id (dispatcher injected it) and strip from params - ## so the addon sees clean params matching its declared schema (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). - var request_id: String= params.get("_request_id", "") - var clean_params := params.duplicate() - clean_params.erase("_request_id") - ## Construct ctx with transport metadata + live-object locator. - var ctx := McpCallContext.new() - ctx.request_id = request_id - ctx.session_id = _locator.get_connection().get_session_id() - ctx.spec = _spec - ctx.attach_locator(_locator) - ctx.deadline_msec = Time.get_ticks_msec() + _spec.timeout_ms - var result: Dictionary = _handler_instance.call(_spec.method, clean_params, ctx) - if result.get("_deferred", false): - ## Enforce the declared contract: batch_execute and the server's - ## timeout budget both trust spec.deferred, so a non-deferred spec - ## whose handler defers anyway would report success while its real - ## reply arrives uncorrelated later. - if not _spec.deferred: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Custom tool '%s' returned a deferred response but its spec declares deferred=false" % _spec.name) - if _spec.timeout_ms > 0: - ## Handlers return the SHARED McpDispatcher.DEFERRED_RESPONSE - ## const, which Godot makes read-only — stamping the per-spec - ## budget on it directly is a script error that aborts the call. - result = result.duplicate() - result["_deferred_timeout_ms"] = _spec.timeout_ms - return result diff --git a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid b/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid deleted file mode 100644 index ff02cb9..0000000 --- a/addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b68uel6lsb7jr diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd b/addons/godot_ai/custom_tools/mcp_call_context.gd deleted file mode 100644 index 6e40d86..0000000 --- a/addons/godot_ai/custom_tools/mcp_call_context.gd +++ /dev/null @@ -1,44 +0,0 @@ -@tool -class_name McpCallContext -extends RefCounted - -## Per-call context handed to addon handlers. -## -## STABLE addon-facing surface (see AGENTS.md — published class_names are -## permanent compat surface): `request_id`, `session_id`, `deadline_msec`, -## `spec` (read-only), `is_expired()`, `send_deferred()`. Anything -## underscore-prefixed is internal wiring owned by the wrapper and may -## change between releases — addons must not reach into it. Keeping this -## surface narrow now is deliberate: it is much cheaper than breaking -## published addons later (#875 review). - -var request_id: String = "" -var session_id: String = "" -var deadline_msec: int = 0 -var spec: McpCustomToolSpec = null - -## Internal — injected by custom_tool_wrapper via attach_locator(). Do not -## use from addon code; the capability methods below are the contract. -var _locator: McpServiceLocator = null - - -## Internal — called by the wrapper during ctx construction. -func attach_locator(locator: McpServiceLocator) -> void: - _locator = locator - - -func is_expired() -> bool: - if deadline_msec == 0: - return false - return Time.get_ticks_msec() > deadline_msec - - -func send_deferred(payload: Dictionary) -> void: - if _locator == null: - push_error("McpCallContext: cannot send deferred response, locator is null") - return - var conn := _locator.get_connection() - if conn == null: - push_error("McpCallContext: cannot send deferred response, connection is null") - return - conn.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid b/addons/godot_ai/custom_tools/mcp_call_context.gd.uid deleted file mode 100644 index e37765b..0000000 --- a/addons/godot_ai/custom_tools/mcp_call_context.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bdgqe4gyian2o diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd deleted file mode 100644 index 8c5a551..0000000 --- a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd +++ /dev/null @@ -1,66 +0,0 @@ -@tool -class_name McpCustomToolSpec -extends RefCounted - -## Descriptor for one custom tool registered by an addon. Mirrors the -## clients/_base.gd data-only descriptor pattern: fields are set by the -## caller, NO Callables (handler instance is materialized lazily by the -## dispatcher from script_path + method, so hot-reload can't SEGV a -## worker mid-call — same rationale as McpClient, issue #229). -## -## Construction: instantiate and set fields, then pass to -## McpToolRegistry.register(spec). Do NOT subclass — custom tools are -## dynamic (one addon may register several), not file-system-scanned -## like clients. - -# --- identity --- -var name: String = "" ## tool name, e.g. "gdunit_run". Must not shadow a built-in. -var description: String = "" ## shown to the agent by the MCP server -var params_schema: Dictionary = {} ## JSON Schema; advertised to the agent for shaping calls. Params are forwarded UNVALIDATED — the handler must validate its own input. - -# --- handler resolution (lazy materialization by dispatcher) --- -var script_path: String = "" ## "res://addons/.../handler.gd"; load()ed on first call -var method: StringName = &"" ## method on the handler; signature: (params: Dictionary, ctx: McpCallContext) -> Dictionary - -# --- source identity (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) --- -var source_path: String = "" ## "plugin.cfg" path: "res://addons/gdunit4_mcp/plugin.cfg". Same path = same addon (replace allowed); different path colliding = reject + dock warning. NOTE: self-declared — a collision/ownership policy for cooperating addons, NOT a security boundary (any in-editor code can claim any path). -var source: String = "" ## display: "gdunit4_mcp". If empty, registry reads [plugin] name from source_path. - -# --- exposure --- -var promoted: bool = false ## opt-in: ask the server to ALSO register this tool as a first-class MCP tool ("custom_") with params_schema attached, so agents get native schemas/validation instead of the custom_manage indirection. The server caps promoted count; overflow stays reachable via custom_manage. - -# --- execution contract --- -var timeout_ms: int = 4500 ## deferred timeout → DEFERRED_TIMEOUT_MS_BY_COMMAND["custom:"] -var deferred: bool = false ## true → handler may return DEFERRED_RESPONSE, push later via ctx.send_deferred(payload) -var requires_writable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #1: readiness gate. false → reads run any time; true → blocks during play/import -var undoable: bool = false ## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #6: must be true to participate in undo=true batch_execute - -# --- budgets (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #5, enforced at register time) --- -const MAX_DESCRIPTION_CHARS := 600 -const MAX_SCHEMA_BYTES := 8192 -const MAX_TIMEOUT_MS := 120000 -const MIN_TIMEOUT_MS := 500 - - -## Returns an array of human-readable validation errors (empty = valid). -## Called by McpToolRegistry.register() and usable standalone for early -## feedback in addon _enter_tree before the registry is even live. -func validate() -> Array[String]: - var errors: Array[String] = [] - if name.is_empty(): - errors.append("name is empty\n") - if not name.is_valid_ascii_identifier(): - errors.append("name '%s' is not a valid identifier\n" % name) - if description.length() > MAX_DESCRIPTION_CHARS: - errors.append("description exceeds %d chars\n" % MAX_DESCRIPTION_CHARS) - if JSON.stringify(params_schema).to_utf8_buffer().size() > MAX_SCHEMA_BYTES: - errors.append("params_schema exceeds %d bytes\n" % MAX_SCHEMA_BYTES) - if script_path.is_empty() or not ResourceLoader.exists(script_path): - errors.append("script_path '%s' does not exist" % script_path) - if method.is_empty(): - errors.append("method is empty") - if timeout_ms < MIN_TIMEOUT_MS or timeout_ms > MAX_TIMEOUT_MS: - errors.append("timeout_ms %d out of range [%d, %d]" % [timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS]) - if source_path.is_empty() or not source_path.ends_with("plugin.cfg") or not FileAccess.file_exists(source_path): - errors.append("source_path '%s' must be an existing plugin.cfg path" % source_path) - return errors diff --git a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid b/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid deleted file mode 100644 index 42fe408..0000000 --- a/addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://h7k8j5tpscwy diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd b/addons/godot_ai/custom_tools/mcp_service_locator.gd deleted file mode 100644 index 4e50ac5..0000000 --- a/addons/godot_ai/custom_tools/mcp_service_locator.gd +++ /dev/null @@ -1,16 +0,0 @@ -@tool -class_name McpServiceLocator -extends RefCounted - -var _connection: McpConnection = null -var _log_buffer: McpLogBuffer = null - -func setup(connection: McpConnection, log_buffer: McpLogBuffer) -> void: - _connection = connection - _log_buffer = log_buffer - -func get_connection() -> McpConnection: - return _connection - -func get_log_buffer() -> McpLogBuffer: - return _log_buffer diff --git a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid b/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid deleted file mode 100644 index 1815007..0000000 --- a/addons/godot_ai/custom_tools/mcp_service_locator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dfvphmvo7en3i diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd b/addons/godot_ai/custom_tools/mcp_tool_registry.gd deleted file mode 100644 index 407d405..0000000 --- a/addons/godot_ai/custom_tools/mcp_tool_registry.gd +++ /dev/null @@ -1,277 +0,0 @@ -@tool -class_name McpToolRegistry -extends RefCounted - -## Central registry for custom tools. INSTANCE SINGLETON (not static) — -## unlike McpClientRegistry which is read-only/loaded-once/query-only (static -## is fine there), this registry is MUTABLE, has LIFECYCLE (clear on teardown), -## must EMIT SIGNALS to multiple consumers, and needs DEPENDENCY INJECTION -## (dispatcher, locator). Static funcs can't emit instance signals in GDScript, -## and static vars create #46 freed-instance regressions across reloads. -## -## plugin.gd owns the instance (new + setup + clear). Addons access it via -## get_instance() so they don't hold a reference. Source identity is -## spec.source_path (explicit plugin.cfg path, https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8) — same source_path -## = same addon (hot-reload replace); different source_path colliding on -## a name = reject + dock warning. - -## singleton instance -static var _instance: McpToolRegistry = null - -## Singleton accessor for addons. Returns null if Godot AI isn't loaded. -static func get_instance() -> McpToolRegistry: - return _instance - - -## Instance fields (NOT static — lifecycle tied to plugin.gd) -var _specs: Dictionary = {} # name -> McpCustomToolSpec -var _by_source_path: Dictionary = {} # source_path -> Array[McpCustomToolSpec] (batch unregister index) -var _dispatcher: McpDispatcher = null # injected by plugin.gd via setup() -var _locator: McpServiceLocator = null # injected by plugin.gd via setup() -var _ready: bool = false -var _promoted_cnt = 0 - -## Instance signals (LEGAL — instance funcs can emit these). Multiple consumers -## (plugin.gd, dock, addons) can connect. -signal tools_changed # triggers custom_tools_changed push to server -signal registry_ready # https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: emit after Godot AI reload so addons can re-register - - -## Called by plugin.gd in _enter_tree. Injects live dependencies and registers -## the singleton. Addons can now reach the registry via get_instance(). -func setup(dispatcher: McpDispatcher, locator: McpServiceLocator) -> void: - _dispatcher = dispatcher - _locator = locator - _load_disabled_tools() - _instance = self - - -func register(spec: McpCustomToolSpec) -> bool: - if not _validate_one(spec): - return false - _commit_one(spec) - tools_changed.emit() - return true - - -## Atomic batch registration: validate ALL specs first (phase 1, zero -## mutation), then commit ALL (phase 2, cannot fail), then emit ONCE. -## A phase-1 failure leaves the registry AND dispatcher untouched — a -## per-spec register-then-bail loop would leave earlier specs committed -## and dispatchable while the server's tool list never hears about them. -func batch_register(specs: Array[McpCustomToolSpec]) -> bool: - if specs.is_empty(): - return true - ## Phase 1 — validate only. Also reject duplicate names WITHIN the - ## batch: _validate_one checks against committed state (nothing from - ## this batch is committed yet), and silently letting the last - ## duplicate win would hide an addon packaging bug. - var batch_names := {} - for spec in specs: - if not _validate_one(spec): - return false - if batch_names.has(spec.name): - push_warning("McpToolRegistry: duplicate name '%s' within batch — rejected" % spec.name) - return false - batch_names[spec.name] = true - ## Phase 2 — commit everything, emit once. - for spec in specs: - _commit_one(spec) - tools_changed.emit() - return true - - -## Phase 1 helper: validation + collision policy. NO registry/dispatcher -## mutation — the only side effect is filling the display-source fallback -## on the spec itself, which is harmless on reject. -func _validate_one(spec: McpCustomToolSpec) -> bool: - var errors := spec.validate() - if not errors.is_empty(): - push_error("McpToolRegistry: rejecting spec '%s': %s" % [spec.name, ", ".join(errors)]) - return false - ## Fill display source from plugin.cfg if addon left it empty. - if spec.source.is_empty(): - spec.source = _read_plugin_name(spec.source_path) - ## reject-on-collision: same source_path (hot-reload) replaces, different rejects - var existing: McpCustomToolSpec= _specs.get(spec.name) - if existing != null and existing.source_path != spec.source_path: - push_warning("McpToolRegistry: name '%s' collision between %s and %s — rejected" % [spec.name, existing.source_path, spec.source_path]) - return false - return true - - -## Phase 2 helper: commit one ALREADY-VALIDATED spec. Handles the -## hot-reload replace (same source_path — guaranteed by _validate_one). -## Never fails, never emits — callers own the tools_changed emit. -func _commit_one(spec: McpCustomToolSpec) -> void: - var existing: McpCustomToolSpec = _specs.get(spec.name) - if existing != null: - ## Replace: purge the OLD registration from all four dispatcher - ## dicts. Command name is "custom_tool:" — passing the bare - ## name would leave a materialized Callable in _handlers and the - ## hot-reloaded spec would silently never take effect. - _dispatcher.unregister("custom_tool:" + existing.name, "custom:" + existing.name) - _erase_from_source_path_index_locked(existing) - _specs[spec.name] = spec - _by_source_path.get_or_add(spec.source_path, []).append(spec) - ## Bridge to dispatcher lazy registration: register_lazy_handler + register_lazy. - ## handler_key = "custom:"; wrapper is CustomToolWrapper (https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #2). - - var handler_key := "custom:" + spec.name - var wrapper_path := "res://addons/godot_ai/custom_tools/custom_tool_wrapper.gd" - _dispatcher.register_lazy_handler(handler_key, wrapper_path, [spec, _locator]) - _dispatcher.register_lazy("custom_tool:" + spec.name, handler_key, &"invoke") - - -func unregister(name: String) -> bool: - var spec: McpCustomToolSpec = _specs.get(name) - if spec == null: - return false - if _dispatcher != null: - _dispatcher.unregister("custom_tool:" + name, "custom:" + name) - _specs.erase(name) - _erase_from_source_path_index_locked(spec) - tools_changed.emit() - return true - - -## https://github.com/hi-godot/godot-ai/issues/781#issuecomment-5036376599 #8: batch-unregister all tools from one addon. Pass the source_path -## (plugin.cfg path) — the registry removes every spec whose source_path matches. -func unregister_source(source_path: String) -> int: - var specs: Array = _by_source_path.get(source_path, []) - var count := specs.size() - for spec in specs.duplicate(): - if _dispatcher != null: - _dispatcher.unregister("custom_tool:" + spec.name, "custom:" + spec.name) - _specs.erase(spec.name) - _by_source_path.erase(source_path) - if count > 0: - tools_changed.emit() - return count - - -func all() -> Array[McpCustomToolSpec]: - var out: Array[McpCustomToolSpec] = [] - for spec in _specs.values(): - out.append(spec) - return out - - -## Enabled specs only — the catalog-push source. Disabled tools stay -## registered (the dock still lists them for re-enabling) but are never -## advertised to the server and are rejected at dispatch. -func enabled() -> Array[McpCustomToolSpec]: - var out: Array[McpCustomToolSpec] = [] - for spec in _specs.values(): - if is_tool_enabled(spec.name): - out.append(spec) - return out - - -# --- per-tool enable state (dock UI) --- -## Persisted per-project via EditorSettings project metadata (the same -## store the editor uses for per-project UI state — no project.godot -## churn, no cross-project bleed). Applies LIVE: toggling re-emits -## tools_changed, which re-pushes the filtered catalog to the server. - -const _META_SECTION := "godot_ai" -const _META_KEY_DISABLED := "disabled_custom_tools" - -var _disabled_tools: Dictionary = {} # name -> true - - -func is_tool_enabled(name: String) -> bool: - return not _disabled_tools.has(name) - - -func set_tool_enabled(name: String, tool_enabled: bool) -> void: - if tool_enabled: - if not _disabled_tools.has(name): - return - _disabled_tools.erase(name) - else: - if _disabled_tools.has(name): - return - _disabled_tools[name] = true - _save_disabled_tools() - tools_changed.emit() - - -func _load_disabled_tools() -> void: - _disabled_tools.clear() - var es := _editor_settings() - if es == null: - return - var stored: Variant = es.get_project_metadata(_META_SECTION, _META_KEY_DISABLED, PackedStringArray()) - for name in PackedStringArray(stored): - _disabled_tools[String(name)] = true - - -func _save_disabled_tools() -> void: - var es := _editor_settings() - if es == null: - return - var names := PackedStringArray() - for name in _disabled_tools.keys(): - names.append(String(name)) - es.set_project_metadata(_META_SECTION, _META_KEY_DISABLED, names) - - -static func _editor_settings() -> EditorSettings: - if not Engine.is_editor_hint(): - return null - return EditorInterface.get_editor_settings() - - -func get_spec(name: String) -> McpCustomToolSpec: - var out: McpCustomToolSpec = _specs.get(name) - return out - - -func is_ready() -> bool: - return _ready - - -## Called from plugin.gd teardown — wipes everything so stale specs/Callables -## don't survive a Godot AI reload (#46 regression class). Signals die with -## the instance (consumers disconnect via plugin.gd's own teardown). -func clear() -> void: - _specs.clear() - _by_source_path.clear() - _disabled_tools.clear() # in-memory only; persistence reloads on next setup() - _ready = false - _dispatcher = null - _locator = null - _instance = null - - -## Called from plugin.gd _enter_tree after setup(). Addons that missed -## _enter_tree during a Godot AI reload listen for registry_ready (via -## get_instance().registry_ready.connect(...)) and re-register deterministically. -func mark_ready() -> void: - _ready = true - registry_ready.emit() - - -# --- internal helpers --- - -func _erase_from_source_path_index_locked(spec: McpCustomToolSpec) -> void: - var specs: Array = _by_source_path.get(spec.source_path, []) - specs.erase(spec) - if specs.is_empty(): - _by_source_path.erase(spec.source_path) - - -## Read [plugin] name from a plugin.cfg for display purposes. -## Returns "" on any error — display source is best-effort. -static func _read_plugin_name(plugin_cfg_path: String) -> String: - var cfg := ConfigFile.new() - if cfg.load(plugin_cfg_path) != OK: - return "" - return cfg.get_value("plugin", "name", "") - -static func _get_command_name(spec: McpCustomToolSpec) -> String: - return "custom_tool:" + spec.name - -static func _get_handler_name(spec: McpCustomToolSpec) -> String: - return "custom:" + spec.name diff --git a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid b/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid deleted file mode 100644 index 23b9822..0000000 --- a/addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bq2btxtm8flhb diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd deleted file mode 100644 index ceaacb9..0000000 --- a/addons/godot_ai/debugger/mcp_debugger_plugin.gd +++ /dev/null @@ -1,1579 +0,0 @@ -@tool -class_name McpDebuggerPlugin -extends EditorDebuggerPlugin - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Editor-side half of the game-process capture bridge. -## -## The game-side counterpart (`plugin/addons/godot_ai/runtime/game_helper.gd`, -## registered as autoload `_mcp_game_helper`) listens on EngineDebugger's -## message channel. This plugin sends "mcp:take_screenshot" requests and -## routes the replies back through the WebSocket McpConnection using the -## request_id the MCP dispatcher threaded through params. -## -## Why this exists: the game always runs as a separate OS process. Even -## "Embed Game Mode" on Windows/Linux (and macOS 4.5+) just reparents the -## game's window into the editor — the game's framebuffer is never reachable -## from the editor's Viewport. The debugger channel is the engine's own -## supported IPC and works identically regardless of embed mode. - -const CAPTURE_PREFIX := "mcp" -## CI runners under xvfb can be slow to spin up the game subprocess and -## register the autoload's capture. 8s keeps the message responsive for -## interactive users while still covering slow-CI startup. -const DEFAULT_TIMEOUT_SEC := 8.0 -## How long to wait for the game-side autoload to beacon mcp:hello -## before sending the screenshot request. Godot's debugger drops -## messages whose prefix has no registered capture, so sending -## take_screenshot before the game registers its "mcp" capture is a -## silent black hole. On CI the game subprocess has been observed -## taking ~15s to boot + register. -const GAME_READY_WAIT_SEC := 20.0 -## #500: how long to wait for the game-side autoload to beacon mcp:hello before -## issuing a game_eval. This is deliberately MUCH shorter than the 20s -## screenshot wait above: the eval path's total editor-side budget is this wait -## plus the 250ms liveness probe and 10s eval backstop -## (request_game_eval's timeout_sec), and that total MUST stay below the 15s -## game_eval timeout enforced at two layers: the Python server's send_command -## budget (src/godot_ai/handlers/editor.py::game_eval) and this plugin's own -## deferred budget (dispatcher.gd's 15000ms game_eval entry, editor/plugin-side -## — not server-side). Either firing produces the opaque tail. -## With the 20s screenshot wait, a not-yet-ready game made the editor poll past -## the 15s deadline, so the server gave up first with an opaque -## ~15s TimeoutError instead of the actionable "Is the game actually running?" -## error below ever reaching the client (#500's residual TimeoutError bucket). -## 3s wait + 0.25s probe + 10s backstop = 13.25s, comfortably under the 15s -## server timeout, so the actionable error always wins. A game launched moments -## before the eval still has the 3s grace to register; if it needs longer, the -## user gets a fast, clear "is it running?" rather than a 15s hang. -const EVAL_READY_WAIT_SEC := 3.0 -## #859: once the helper has registered, confirm its game loop is still -## advancing before dispatching an eval. The helper replies synchronously from -## the debugger capture, so 250ms is ample while keeping not-ready failures -## below the telemetry target of 0.5s. -const EVAL_LIVENESS_WAIT_SEC := 0.25 -## #490: how long to wait for the game's mcp:eval_compiled beacon before -## concluding the eval source failed to compile. A parse error aborts the -## game-side handler before it can reply, so without this we'd wait the -## full eval timeout for a syntax mistake. reload() of valid source is -## sub-millisecond, so 3s is comfortably clear of false positives. -const EVAL_COMPILE_GRACE_SEC := 3.0 -## #490: once an eval compiles, the editor polls the game every this many -## seconds with mcp:eval_check. A backgrounded play-in-editor game has a -## frozen idle loop (no _process / SceneTreeTimer ticks) so it can't -## self-report a runtime error that aborted the eval — but its debugger -## capture callback still answers a probe. The editor's own loop keeps -## ticking, so it drives the poll. 0.35s keeps detection well under a second -## without flooding the channel; most evals reply before the first probe. -const EVAL_PROBE_INTERVAL_SEC := 0.35 - -const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") - -var _log_buffer: McpLogBuffer -var _game_log_buffer: McpGameLogBuffer -var _editor_log_buffer: McpEditorLogBuffer -var _surfaced_error_tracker -var vision_routing: VisionRoutingScript = null - -## Pending request_id -> phase-specific data. Every eval phase carries kind -## and connection; timer-backed phases also retain their bound timeout lambda -## so `_clear_pending` can disconnect it on success/error. Otherwise the -## SceneTreeTimer pins the captured request_id until its timeout elapses. -var _pending: Dictionary = {} - -## Testing seam for the readiness wait below. Production leaves this invalid -## and awaits SceneTree.process_frame; editor tests inject a synchronous -## callable because their runner does not pump frames while a test is running. -var _eval_ready_frame_waiter: Callable = Callable() - -## Flipped true when the game-side autoload sends its "mcp:hello" boot -## beacon for the current project_run. Reset as soon as a new run is -## requested, before Godot has attached the fresh debugger session, so -## editor_state cannot leak readiness from the previous game process. -var _game_ready := false -var _game_run_token := 0 -var _ready_run_token := -1 -var _game_session_id := -1 -var _game_run_active := false -var _manual_run_armed := false -var _game_run_started_msec := 0 -var _game_run_started_editor_cursor := 0 -var _game_run_started_debugger_cursor := 0 -var _game_helper_expected := true - -## #645: a GDScript parse error hit while an editor-launched game boots calls -## GDScriptLanguage::debug_break_parse — the game parks in a remote-debugger -## break BEFORE the helper's mcp:hello and before any record reaches the -## Errors tab, the editor Logger, or the game log. The only editor-side traces -## are the debugger break signals; the stack frames land in the Stack Trace -## panel a few frames later. Track the break here so game_status can report -## status="break" and a synthesized error record can name the failure. -var _break_active := false -var _break_can_debug := false -var _break_reason := "" -var _break_pre_live := false -var _break_run_token := -1 -var _break_record_synthesized := false - -## #645: how long after the break signal to scrape the Stack Trace panel for -## frames. The editor requests the stack dump from the game separately, so the -## panel is empty at signal time; ~0.5s has it populated. The late tick -## synthesizes with whatever is available so a scrape failure still yields a -## record carrying the break reason. -const BREAK_FRAME_SCRAPE_DELAYS_SEC: Array[float] = [0.5, 2.0] - - - -func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void: - _log_buffer = log_buffer - _game_log_buffer = game_log_buffer - _editor_log_buffer = editor_log_buffer - _surfaced_error_tracker = surfaced_error_tracker - self.vision_routing = vision_routing - - -func _has_capture(prefix: String) -> bool: - return prefix == CAPTURE_PREFIX - - -## Fires when a debugger session attaches — once for the editor's own -## self-session at startup, and again each time the user hits Play and a -## new game subprocess connects. Reset _game_ready so the next capture -## request waits for the (new) game's mcp:hello beacon before sending, -## avoiding stale-flag timeouts across Play→Stop→Play cycles. -## -## Do NOT log here: add_debugger_plugin() triggers this virtual before -## plugin.gd's _enter_tree logs "plugin loaded", and ci-reload-test -## asserts "plugin loaded" is the first line after a plugin reload. -func _setup_session(session_id: int) -> void: - _connect_session_stopped(session_id) - _connect_session_break_signals(session_id) - if EditorInterface.is_playing_scene() and not _game_run_active: - _begin_game_run_tracking(_editor_log_cursor(), true, true, true, true, true) - else: - _game_ready = false - _ready_run_token = -1 - _game_session_id = session_id - - -func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void: - _begin_game_run_tracking(editor_log_cursor, helper_expected, true, true) - - -func _begin_game_run_tracking( - editor_log_cursor: int = 0, - helper_expected: bool = true, - rotate_game_log: bool = true, - sticky_debugger_scan: bool = true, - quiet: bool = false, - manual_armed: bool = false, -) -> void: - _game_run_token += 1 - _game_run_active = true - _manual_run_armed = manual_armed - _game_ready = false - _ready_run_token = -1 - _game_session_id = -1 - clear_debug_break() - _game_run_started_msec = Time.get_ticks_msec() - _game_run_started_editor_cursor = maxi(0, editor_log_cursor) - if _surfaced_error_tracker != null: - _surfaced_error_tracker.note_game_run_started(sticky_debugger_scan) - _game_run_started_debugger_cursor = _surfaced_error_tracker.debugger_promoted_total() - else: - _game_run_started_debugger_cursor = 0 - _game_helper_expected = helper_expected - var run_id := "" - if _game_log_buffer and rotate_game_log: - run_id = _game_log_buffer.clear_for_new_run() - if _log_buffer and not quiet: - var log_text := "[debug] game capture pending run token %d" % _game_run_token - if not run_id.is_empty(): - log_text += " (run %s)" % run_id - _log_buffer.log(log_text) - - -func _editor_log_cursor() -> int: - return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 - - -func end_game_run() -> void: - _fail_pending_evals_not_ready( - "The game run ended before game_eval completed — the game stopped, crashed, or is restarting. Confirm it is running and retry." - ) - _game_run_active = false - _manual_run_armed = false - _game_ready = false - _ready_run_token = -1 - _game_session_id = -1 - clear_debug_break() - if _surfaced_error_tracker != null: - _surfaced_error_tracker.note_game_run_stopped() - - -## Authoritative fallback for runs whose debugger `stopped` signal never -## fired or was never connected: the editor's play state falling to stopped -## means the game process is gone. A game that exits on its own -## (get_tree().quit(), crash) has no MCP stop op to run the bookkeeping, and -## without this game_status stayed "live" until the next run (#642 smoke). -## Called on the playing→stopped edge only, so the pre-play launch window -## (run tracking begun, is_playing_scene() not yet true) is never clipped. -func note_editor_play_stopped() -> void: - if not _game_run_active: - return - end_game_run() - - -func _connect_session_stopped(session_id: int) -> void: - var session = get_session(session_id) - if session == null: - return - var stopped := Callable(self, "_on_debugger_session_stopped").bind(session_id) - if not session.stopped.is_connected(stopped): - session.stopped.connect(stopped) - - -func _on_debugger_session_stopped(session_id: int) -> void: - if _game_session_id != -1 and session_id != _game_session_id: - return - ## MCP-started runs normally end via project_manage(op="stop"), but a game - ## that exits on its own (get_tree().quit(), crash) emits only this signal. - ## Without ending the run here, game_status stays "live" until the next - ## run's bookkeeping rewrites it (#642 live smoke). Before the game session - ## attaches (_game_session_id == -1) only manual runs may end on this - ## signal — a foreign session's stop must not cancel a launching MCP run. - if not _manual_run_armed and _game_session_id == -1: - return - end_game_run() - - -## #859: a closed debugger session is authoritative. Do not leave evals on -## their 10s backstop after the game process has already gone away. Other -## pending request kinds keep their existing ownership and timeout behavior. -func _fail_pending_evals_not_ready(message: String) -> void: - for request_id in _pending.keys(): - var pending: Dictionary = _pending.get(request_id, {}) - if str(pending.get("kind", "")) not in [ - "eval_ready_wait", "eval_liveness", "eval" - ]: - continue - var connection: McpConnection = pending.get("connection") - _clear_pending(str(request_id)) - if connection != null and is_instance_valid(connection): - _send_error(connection, str(request_id), ErrorCodes.EVAL_GAME_NOT_READY, message) - - -func _is_current_game_run(run_token: int) -> bool: - return _game_run_active and _game_run_token == run_token - - -func _fail_eval_not_ready(request_id: String, message: String) -> void: - var pending: Dictionary = _pending.get(request_id, {}) - var connection: McpConnection = pending.get("connection") - _clear_pending(request_id) - if connection != null and is_instance_valid(connection): - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, message) - - -## --- #645: boot-time debugger breaks --------------------------------------- - -func _connect_session_break_signals(session_id: int) -> void: - var session = get_session(session_id) - if session != null: - var breaked_cb := Callable(self, "_on_debugger_session_breaked").bind(session_id) - if not session.breaked.is_connected(breaked_cb): - session.breaked.connect(breaked_cb) - var continued_cb := Callable(self, "_on_debugger_session_continued").bind(session_id) - if not session.continued.is_connected(continued_cb): - session.continued.connect(continued_cb) - _connect_script_debugger_breaked() - - -## The session-level `breaked` signal carries only can_debug; the underlying -## ScriptEditorDebugger's own `breaked` also carries the human-readable break -## reason ("Parser Error: ..."), which is otherwise visible only in the -## Debugger UI. EditorDebuggerSession does not expose its debugger node, so -## locate ScriptEditorDebugger instances by walking the editor UI — the same -## approach as the tracker's Errors-tab scrape. -func _connect_script_debugger_breaked() -> void: - var base := EditorInterface.get_base_control() - if base == null: - return - var debuggers: Array[Node] = [] - _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) - for dbg in debuggers: - if not dbg.has_signal("breaked"): - continue - var cb := Callable(self, "_on_script_debugger_breaked") - if not dbg.is_connected("breaked", cb): - dbg.connect("breaked", cb) - - -static func _collect_nodes_of_class(node: Node, klass: String, out: Array[Node]) -> void: - if node.get_class() == klass: - out.append(node) - for child in node.get_children(): - _collect_nodes_of_class(child, klass, out) - - -func _on_debugger_session_breaked(can_debug: bool, session_id: int) -> void: - if _game_session_id != -1 and session_id != _game_session_id: - return - note_debug_break(can_debug, "") - - -func _on_debugger_session_continued(session_id: int) -> void: - if _game_session_id != -1 and session_id != _game_session_id: - return - clear_debug_break() - - -## reallydid=false is Godot's "left the break" notification (debug_exit). -func _on_script_debugger_breaked(reallydid: bool, can_debug: bool, reason: String, _has_stackdump: bool) -> void: - if not reallydid: - clear_debug_break() - return - note_debug_break(can_debug, reason) - - -## Record that the game process is parked in a remote-debugger break. Fires -## once per break from the session signal (no reason text) and again moments -## later from the ScriptEditorDebugger signal (with reason) — the notices -## merge into one break. Public so tests can drive break state directly. -func note_debug_break(can_debug: bool, reason: String) -> void: - var first_notice := not _break_active - _break_active = true - _break_can_debug = can_debug - if not reason.is_empty(): - _break_reason = reason - if not first_notice: - return - _break_run_token = _game_run_token - _break_pre_live = _game_run_active and not is_game_capture_ready() - _break_record_synthesized = false - if _log_buffer: - _log_buffer.log("[debug] debugger break (pre_live=%s can_debug=%s)" % [str(_break_pre_live), str(can_debug)]) - if _break_pre_live: - _schedule_break_record_synthesis() - - -func clear_debug_break() -> void: - _break_active = false - _break_can_debug = false - _break_reason = "" - _break_pre_live = false - _break_run_token = -1 - _break_record_synthesized = false - - -## Stack frames land in the Stack Trace panel a few frames after the break -## signal (the editor requests the stack dump separately), so the record is -## synthesized on short timers rather than at signal time. -func _schedule_break_record_synthesis() -> void: - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - return - var token := _break_run_token - for i in BREAK_FRAME_SCRAPE_DELAYS_SEC.size(): - var final := i == BREAK_FRAME_SCRAPE_DELAYS_SEC.size() - 1 - var timer := tree.create_timer(BREAK_FRAME_SCRAPE_DELAYS_SEC[i]) - timer.timeout.connect(func() -> void: _on_break_scrape_tick(token, final)) - - -func _on_break_scrape_tick(run_token: int, final: bool) -> void: - if not _break_active or _break_record_synthesized or run_token != _break_run_token: - return - var frames := _scrape_break_stack_frames() - if frames.is_empty() and not final: - return - synthesize_break_error_record(frames) - - -## Read the debugger's Stack Trace panel rows. Row metadata is a Dictionary -## {frame, file, function, line} regardless of editor locale, so stack trees -## are identified by metadata shape rather than the translated column title. -func _scrape_break_stack_frames() -> Array[Dictionary]: - var base := EditorInterface.get_base_control() - if base == null: - return [] - var debuggers: Array[Node] = [] - _collect_nodes_of_class(base, "ScriptEditorDebugger", debuggers) - for dbg in debuggers: - var trees: Array[Node] = [] - _collect_nodes_of_class(dbg, "Tree", trees) - for t in trees: - var frames := _frames_from_stack_tree(t as Tree) - if not frames.is_empty(): - return frames - return [] - - -static func _frames_from_stack_tree(tree: Tree) -> Array[Dictionary]: - var frames: Array[Dictionary] = [] - var root := tree.get_root() - if root == null: - return frames - var item := root.get_first_child() - while item != null: - var meta = item.get_metadata(0) - if not (meta is Dictionary and meta.has("file") and meta.has("frame")): - return [] - frames.append({ - "path": str(meta.get("file", "")), - "line": int(meta.get("line", 0)), - "function": str(meta.get("function", "")), - }) - item = item.get_next() - return frames - - -## Build the Errors-tab-shaped record for a boot-time break and promote it via -## the tracker so recent_editor_errors_since / logs_read / the watermark all -## surface it — the break itself produces no record anywhere else (#645). -## Public so tests can synthesize without waiting on scrape timers. -func synthesize_break_error_record(frames: Array[Dictionary]) -> void: - if _break_record_synthesized: - return - _break_record_synthesized = true - if _surfaced_error_tracker == null: - return - var reason := _break_reason - if reason.is_empty(): - reason = "Game process broke into the debugger during startup (script parse/load error; reason not captured)" - var top: Dictionary = frames[0] if not frames.is_empty() else {} - var location := { - "path": str(top.get("path", "")), - "line": int(top.get("line", 0)), - "function": str(top.get("function", "")), - } - var entry := { - "source": "editor", - "level": "error", - "text": reason, - "path": location["path"], - "line": location["line"], - "function": location["function"], - "details": { - "debugger_tab": "Stack Trace", - "message": reason, - "error_type_name": "debugger_break", - "source": location.duplicate(true), - "resolved": location.duplicate(true), - "frames": frames.duplicate(true), - }, - } - _surfaced_error_tracker.record_synthetic_error(entry) - if _log_buffer: - _log_buffer.log("[debug] synthesized boot-break error record: %s" % McpSurfacedErrorTracker.format_editor_error_summary(entry)) - - -## --- end #645 --------------------------------------------------------------- - - -func is_game_capture_ready() -> bool: - return _game_run_active and _game_ready and _ready_run_token == _game_run_token - - -static func with_liveness_flags(status: Dictionary) -> Dictionary: - var enriched := status.duplicate(true) - var state := str(enriched.get("status", "stopped")) - enriched["helper_live"] = state == "live" - enriched["session_active"] = not state in ["not_live", "stopped"] - return enriched - - -func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary: - var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec - var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0)) - var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0 - ## "stopped" also covers idle/never-ran; no game run is currently active. - var status := "stopped" - if _game_run_active: - ## #645: a parked process takes precedence over "live" — a game frozen - ## in a remote-debugger break cannot service game-side tools even when - ## its helper registered before the break. - if _break_active: - status = "break" - elif is_game_capture_ready(): - status = "live" - elif not _game_helper_expected: - status = "no_helper" - elif elapsed_msec >= ready_wait_msec: - status = "not_live" - else: - status = "launching" - var out := { - "status": status, - "run_token": _game_run_token, - "active": _game_run_active, - "ready": is_game_capture_ready(), - "helper_expected": _game_helper_expected, - "run_started_msec": _game_run_started_msec, - "elapsed_msec": elapsed_msec, - "ready_wait_msec": ready_wait_msec, - "editor_log_cursor": _game_run_started_editor_cursor, - } - if status == "break": - out["break"] = { - "reason": _break_reason, - "can_debug": _break_can_debug, - "pre_live": _break_pre_live, - } - return with_liveness_flags(out) - - -func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary: - var state := str(status.get("status", "stopped")) - var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0))) - var recent_errors: Array = errors_info.get("errors", []) - var recent_errors_scope := str(errors_info.get("scope", "none")) - var truncated := bool(errors_info.get("truncated", false)) - var data := { - "game_status": status.duplicate(true), - "recent_errors": recent_errors, - "recent_errors_scope": recent_errors_scope, - "recent_errors_may_predate_run": recent_errors_scope == "retained_recent", - "recent_errors_truncated": truncated, - } - data.merge(split_errors_by_scope(recent_errors, recent_errors_scope), true) - var message := "" - match state: - "not_live": - if not recent_errors.is_empty() and recent_errors_scope == "run": - message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) - if truncated: - message += " Editor logs since this run may be truncated; showing retained errors." - elif not recent_errors.is_empty(): - message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) - else: - message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry." - "break": - var break_info: Dictionary = status.get("break", {}) - var break_reason := str(break_info.get("reason", "")) - var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else "" - if bool(break_info.get("pre_live", true)): - message = "The game hit a script error during startup and is frozen at a debugger break%s. It cannot become live; call project_manage(op='stop') to end the run, fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % reason_suffix - else: - message = "The game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix - "no_helper": - message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game." - "launching": - message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0) - "stopped": - message = "The game is not running. Start the project and retry the game-side tool." - _: - message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state - var err := ErrorCodes.make(code, message) - var inner: Dictionary = err.get("error", {}) - inner["data"] = data - err["error"] = inner - return err - - -static func split_errors_by_scope(recent_errors: Array, scope: String) -> Dictionary: - var current_run_errors: Array = [] - var retained_errors: Array = [] - if scope == "run": - current_run_errors = recent_errors - elif scope == "retained_recent": - retained_errors = recent_errors - return { - "current_run_errors": current_run_errors, - "retained_errors": retained_errors, - } - - -## `force_debugger_scan` bypasses the tracker's scan gate for one read. Keep it -## false on per-frame polling paths (the run-liveness loop) — a forced scan -## walks the Debugger dock UI — and pass true only for one-shot reads that must -## see rows which landed after the last gated scan (#641). -func recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary: - return _recent_editor_errors_since(cursor, force_debugger_scan) - - -func _recent_editor_errors_since(cursor: int, force_debugger_scan: bool = false) -> Dictionary: - var out: Array[Dictionary] = [] - var truncated := false - if _surfaced_error_tracker != null: - var captured_by_tracker: Dictionary = _surfaced_error_tracker.editor_entries_since( - maxi(0, cursor), - _game_run_started_debugger_cursor, - force_debugger_scan, - ) - truncated = bool(captured_by_tracker.get("truncated", false)) - for raw_entry in captured_by_tracker.get("entries", []): - var compact := _compact_editor_error(raw_entry) - if compact.is_empty(): - continue - out.append(compact) - if out.size() >= 5: - break - if not out.is_empty(): - return {"errors": out, "truncated": truncated, "scope": "run"} - for raw_entry in _surfaced_error_tracker.retained_recent_editor_entries(): - var compact := _compact_editor_error(raw_entry, true) - if compact.is_empty(): - continue - out.append(compact) - if out.size() >= 5: - break - if not out.is_empty(): - return {"errors": out, "truncated": false, "scope": "retained_recent"} - return {"errors": out, "truncated": false, "scope": "none"} - if _editor_log_buffer == null: - return {"errors": out, "truncated": false, "scope": "none"} - var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1) - truncated = bool(captured.get("truncated", false)) - for raw_entry in captured.get("entries", []): - var compact := _compact_editor_error(raw_entry) - if compact.is_empty(): - continue - out.append(compact) - if out.size() >= 5: - break - if not out.is_empty(): - return {"errors": out, "truncated": truncated, "scope": "run"} - - for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)): - var compact := _compact_editor_error(raw_entry, true) - if compact.is_empty(): - continue - out.append(compact) - if out.size() >= 5: - break - if not out.is_empty(): - return {"errors": out, "truncated": false, "scope": "retained_recent"} - return {"errors": out, "truncated": false, "scope": "none"} - - -func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary: - if not raw_entry is Dictionary: - return {} - var entry := raw_entry as Dictionary - if str(entry.get("level", "info")) != "error": - return {} - var path := str(entry.get("path", "")) - if fallback_recent and _is_diagnostic_noise_path(path): - return {} - var compact := { - "source": "editor", - "level": "error", - "text": str(entry.get("text", "")), - "path": path, - "line": int(entry.get("line", 0)), - "function": str(entry.get("function", "")), - } - if entry.has("details"): - compact["details"] = entry["details"].duplicate(true) - return compact - - -func _is_diagnostic_noise_path(path: String) -> bool: - return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/") - - -func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]: - var out: Array[Dictionary] = [] - for i in range(entries.size() - 1, -1, -1): - out.append(entries[i]) - return out - - -func _format_editor_error_summary(entry: Dictionary) -> String: - return McpSurfacedErrorTracker.format_editor_error_summary(entry) - - -func _capture(message: String, data: Array, session_id: int) -> bool: - ## Godot passes the full "prefix:tail" string as `message`. - match message: - "mcp:screenshot_response": - _on_screenshot_response(data) - return true - "mcp:screenshot_error": - _on_screenshot_error(data) - return true - "mcp:log_batch": - _on_log_batch(data) - return true - "mcp:hello": - if not _game_run_active: - if _log_buffer: - _log_buffer.log("[debug] ignored mcp:hello with no active game run") - return true - if _game_session_id != -1 and session_id != _game_session_id: - if _log_buffer: - _log_buffer.log("[debug] ignored stale mcp:hello from debugger session %d (current %d)" % [session_id, _game_session_id]) - return true - ## Boot beacon from the game-side autoload. Tells us the - ## game has registered its "mcp" capture and is safe to send - ## take_screenshot to — before this, Godot's debugger would - ## drop our message silently. - _game_ready = true - _ready_run_token = _game_run_token - ## #641: boot-time parse errors race the hello beacon — both ride - ## the same debugger channel, and the editor inserts Errors-tab - ## rows with a per-frame throttle, so rows can land moments after - ## the run is declared live. Arm forced scans so those rows get - ## promoted into the watermark even if no tool call follows. - if _surfaced_error_tracker != null: - _surfaced_error_tracker.schedule_deferred_scans() - if _log_buffer: - if _game_log_buffer: - _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id()) - else: - _log_buffer.log("[debug] <- mcp:hello from game_helper") - return true - "mcp:eval_liveness_response": - _on_eval_liveness_response(data) - return true - "mcp:eval_response": - _on_eval_response(data) - return true - "mcp:eval_error": - _on_eval_error(data) - return true - "mcp:eval_ack": - _on_eval_ack(data) - return true - "mcp:eval_compiled": - _on_eval_compiled(data) - return true - "mcp:eval_runtime_error": - _on_eval_runtime_error(data) - return true - "mcp:game_command_response": - _on_game_command_response(data) - return true - "mcp:game_command_error": - _on_game_command_error(data) - return true - return false - - -func _on_log_batch(data: Array) -> void: - if _game_log_buffer == null: - return - ## data layout: [[[level, text, details?], ...]] - if data.is_empty() or not (data[0] is Array): - return - var entries: Array = data[0] - for entry in entries: - if entry is Dictionary: - var dict_details: Dictionary = {} - var raw_dict_details = entry.get("details", {}) - if raw_dict_details is Dictionary: - dict_details = raw_dict_details - _game_log_buffer.append(str(entry.get("level", "info")), str(entry.get("text", "")), dict_details) - continue - if not (entry is Array) or entry.size() < 2: - continue - var details: Dictionary = {} - if entry.size() > 2 and entry[2] is Dictionary: - details = entry[2] - _game_log_buffer.append(str(entry[0]), str(entry[1]), details) - - -## Request a game-process framebuffer capture over the debugger channel. -## Reply is pushed back through `connection` out-of-band because the MCP -## dispatcher has already returned a deferred-response marker for this -## request_id. Synchronous from the caller's perspective — if the -## game-side autoload hasn't beaconed yet, the wait + send run as a -## fire-and-forget coroutine kicked off from here. Structured this way -## so the call site in EditorHandler stays a plain non-await invocation. -func request_game_screenshot( - request_id: String, - max_resolution: int, - connection: McpConnection, - timeout_sec: float = DEFAULT_TIMEOUT_SEC, -) -> void: - if request_id.is_empty(): - push_warning("MCP debugger: screenshot request missing request_id") - return - - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Editor main loop is not a SceneTree — cannot schedule capture") - return - - if is_game_capture_ready(): - _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) - return - - ## Not ready yet — run the wait-then-send flow as a detached - ## coroutine. It keeps itself alive via the signal subscription on - ## tree.process_frame; the caller doesn't need to (and shouldn't) - ## await this entrypoint. - if _log_buffer: - _log_buffer.log("[debug] waiting for game_helper hello (%s)" % request_id) - _wait_then_send(tree, request_id, max_resolution, connection, timeout_sec) - - -## Coroutine: poll each editor frame until the mcp:hello beacon arrives -## (flipping _game_ready true) or the deadline elapses. Once resolved, -## either dispatch the capture or return an actionable timeout error. -func _wait_then_send( - tree: SceneTree, - request_id: String, - max_resolution: int, - connection: McpConnection, - timeout_sec: float, -) -> void: - var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) - ## #645: always yield at least one frame — the dispatcher registers the - ## deferred request only after the handler returns DEFERRED_RESPONSE, so a - ## same-frame error reply would be dropped as an expired request. The break - ## check then bails out with the actionable break error instead of waiting - ## out the full window (a game parked in a debugger break never beacons). - await tree.process_frame - while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline: - await tree.process_frame - if not is_game_capture_ready(): - _send_error_response(connection, request_id, - _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) - return - _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) - - -## Send the mcp:take_screenshot message and arm the reply timeout. -## Assumes _game_ready is true. -func _send_take_screenshot( - tree: SceneTree, - request_id: String, - max_resolution: int, - connection: McpConnection, - timeout_sec: float, -) -> void: - var session: EditorDebuggerSession = _first_active_session() - if session == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "No active debugger session — is the game actually running and started from this editor?") - return - - var timer: SceneTreeTimer = tree.create_timer(timeout_sec) - var timeout_callable := func() -> void: _on_timeout(request_id) - timer.timeout.connect(timeout_callable) - _pending[request_id] = { - "connection": connection, - "timer": timer, - "timeout_callable": timeout_callable, - } - - session.send_message("mcp:take_screenshot", [request_id, max_resolution]) - if _log_buffer: - _log_buffer.log("[debug] -> mcp:take_screenshot (%s)" % request_id) - - -func _first_active_session() -> EditorDebuggerSession: - for s in get_sessions(): - if s is EditorDebuggerSession and s.is_active(): - return s - return null - - -func _on_screenshot_response(data: Array) -> void: - if data.size() < 6: - push_warning("MCP debugger: malformed screenshot response (expected 6 fields, got %d)" % data.size()) - return - var request_id: String = data[0] - var pending = _pending.get(request_id) - if pending == null: - ## Timed out or unknown — silently drop. - return - _clear_pending(request_id) - - var connection: McpConnection = pending.connection - if connection == null or not is_instance_valid(connection): - return - - var payload := { - "source": "game", - "width": int(data[2]), - "height": int(data[3]), - "original_width": int(data[4]), - "original_height": int(data[5]), - "format": "png", - "image_base64": data[1], - } - ## #777: game helpers append frames_drawn + a stale flag so a capture - ## taken while the game's main loop is frozen (backgrounded window) is - ## honestly labeled instead of timing out. Older helpers send six fields - ## — leave the keys absent rather than guessing. - if data.size() >= 8: - payload["frames_drawn"] = int(data[6]) - payload["stale_frame"] = bool(data[7]) - if bool(data[7]): - payload["note"] = ("The game window appears backgrounded or its main loop is " - + "stalled; returning the last rendered frame. Focus the game window and " - + "retry for a current frame.") - ## Vision Routing: when enabled, describe the frame through the configured - ## vision provider on a - ## worker thread and reply with the text description instead of the raw - ## image (see vision_routing.gd). The router replies for us in that case. - if vision_routing != null and vision_routing.is_routing_enabled(): - if vision_routing.route_game_payload(connection, request_id, {"data": payload}): - return - connection.send_deferred_response(request_id, {"data": payload}) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:screenshot_response (%s)" % request_id) - - -func _on_screenshot_error(data: Array) -> void: - if data.size() < 2: - return - var request_id: String = data[0] - var message: String = data[1] - var pending = _pending.get(request_id) - if pending == null: - return - _clear_pending(request_id) - var connection: McpConnection = pending.connection - if connection == null or not is_instance_valid(connection): - return - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) - - -## #777: the 8s reply timer fired — the screenshot request reached (or should -## have reached) the game helper and no reply came back. Mirror the eval -## timeout split (#518): a not-live game gets the attributed -## _explain_not_live payload; a live game gets GAME_HELPER_TIMEOUT instead of -## the former opaque INTERNAL_ERROR. With the game side's stalled-loop -## stale-frame fallback, a live game only lands here when it has nothing -## rendered to fall back on, its debugger servicing is itself wedged, or the -## helper died mid-run. -func _on_timeout(request_id: String) -> void: - var pending = _pending.get(request_id) - if pending == null: - return - _pending.erase(request_id) - var connection: McpConnection = pending.connection - if connection == null or not is_instance_valid(connection): - return - var status := get_game_status(-1, GAME_READY_WAIT_SEC) - var err: Dictionary - if status.get("status", "") != "live": - err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR) - else: - err = ErrorCodes.make(ErrorCodes.GAME_HELPER_TIMEOUT, - "The game process did not return a frame. The game window may be backgrounded or its main loop blocked — focus the game window and retry, or use game_command to confirm liveness.") - _send_error_response(connection, request_id, err) - if _log_buffer: - _log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id) - - -func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void: - _send_error_response(connection, request_id, ErrorCodes.make(code, message)) - - -func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void: - if connection == null or not is_instance_valid(connection): - return - connection.send_deferred_response(request_id, err) - - -func _clear_pending(request_id: String) -> void: - var pending: Dictionary = _pending.get(request_id, {}) - var timer: SceneTreeTimer = pending.get("timer") - var cb: Callable = pending.get("timeout_callable", Callable()) - if timer != null and timer.timeout.is_connected(cb): - timer.timeout.disconnect(cb) - ## #490: eval requests also carry a compile-grace timer and a runtime probe. - var grace: SceneTreeTimer = pending.get("grace_timer") - var gcb: Callable = pending.get("grace_callable", Callable()) - if grace != null and grace.timeout.is_connected(gcb): - grace.timeout.disconnect(gcb) - var probe: SceneTreeTimer = pending.get("probe_timer") - var pcb: Callable = pending.get("probe_callable", Callable()) - if probe != null and probe.timeout.is_connected(pcb): - probe.timeout.disconnect(pcb) - _pending.erase(request_id) - - -## --- game_eval: execute arbitrary GDScript in the running game --- - -## Editor-side fallback timer for game_eval. MUST stay above the game-side -## EVAL_TIMEOUT_SEC (8.0) in runtime/game_helper.gd and below the dispatcher's -## game_eval budget (15000 ms) in dispatcher.gd — i.e. game 8s < editor 10s < -## dispatcher 15s. This timer only fires when the game never replies at all; -## _on_eval_timeout then attributes the failure (game not live vs never-acked -## vs started-and-hung, #518). Drop timeout_sec at/below 8s and it pre-empts -## the game's more specific "Eval exceeded 8s" message — see the TIMEOUT -## ORDERING note on EVAL_TIMEOUT_SEC. -## -## #500/#859: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) and the -## EVAL_LIVENESS_WAIT_SEC probe (0.25s) on top of this 10s backstop. That sum -## (13.25s) must also stay below the dispatcher/server 15s budget, or a -## not-yet-ready game makes the server time out opaquely before the editor's -## actionable error returns — exactly the residual ~15s TimeoutError bucket -## #500 tracked down. The cross-file contract is enforced by -## tests/unit/test_game_eval_timeout_ordering.py. -func request_game_eval( - code: String, - request_id: String, - connection: McpConnection, - timeout_sec: float = 10.0, -) -> void: - if request_id.is_empty(): - push_warning("MCP debugger: eval request missing request_id") - return - - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Editor main loop is not a SceneTree — cannot schedule eval") - return - - var run_token := _game_run_token - if is_game_capture_ready(): - _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) - return - - if _log_buffer: - _log_buffer.log("[debug] waiting for game_helper hello before eval (%s)" % request_id) - _wait_then_eval(tree, code, request_id, connection, timeout_sec, run_token) - - -func _wait_then_eval( - tree: SceneTree, - code: String, - request_id: String, - connection: McpConnection, - timeout_sec: float, - run_token: int, -) -> void: - ## #500: eval uses EVAL_READY_WAIT_SEC (not the 20s GAME_READY_WAIT_SEC) so - ## the not-ready path returns its actionable error before the 15s server-side - ## command timeout fires an opaque TimeoutError. See EVAL_READY_WAIT_SEC. - _pending[request_id] = { - "kind": "eval_ready_wait", - "connection": connection, - "run_token": run_token, - } - var deadline := Time.get_ticks_msec() + int(EVAL_READY_WAIT_SEC * 1000.0) - ## #645: the leading yield guarantees the dispatcher has registered the - ## deferred request before any reply (a same-frame reply is dropped as - ## expired); the break check bails out early because a parked game never - ## registers its capture. - if _eval_ready_frame_waiter.is_valid(): - await _eval_ready_frame_waiter.call() - else: - await tree.process_frame - while ( - _pending.has(request_id) - and _is_current_game_run(run_token) - and not is_game_capture_ready() - and not _break_active - and Time.get_ticks_msec() < deadline - ): - if _eval_ready_frame_waiter.is_valid(): - await _eval_ready_frame_waiter.call() - else: - await tree.process_frame - ## end_game_run() owns the reply once it has cleared this tracked wait. - if not _pending.has(request_id): - return - if not _is_current_game_run(run_token): - _fail_eval_not_ready(request_id, - "The game run changed before game_eval could start — the game stopped or restarted. Retry against the current run.") - return - if not is_game_capture_ready(): - ## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up - ## but the game-side capture didn't register within the short wait. Fast - ## and caller-actionable; classifying it apart from the opaque 10s hang - ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung". - _clear_pending(request_id) - _send_error_response(connection, request_id, - _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY)) - return - _clear_pending(request_id) - _probe_then_eval(tree, code, request_id, connection, timeout_sec, run_token) - - -## #859: registration is run-scoped but not a liveness guarantee. Ping the -## helper's debugger capture and let it report whether its _process beacon is -## still advancing before arming the much longer eval backstop. -func _probe_then_eval( - tree: SceneTree, - code: String, - request_id: String, - connection: McpConnection, - timeout_sec: float, - run_token: int, -) -> void: - if not _is_current_game_run(run_token) or not is_game_capture_ready(): - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "The game run changed before game_eval could be checked — the game stopped or restarted. Retry against the current run.") - return - var session: EditorDebuggerSession = _first_active_session() - if session == null: - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it is running and retry.") - return - - var timer := tree.create_timer(EVAL_LIVENESS_WAIT_SEC) - var timeout_callable := func() -> void: _on_eval_liveness_timeout(request_id) - timer.timeout.connect(timeout_callable) - _pending[request_id] = { - "kind": "eval_liveness", - "connection": connection, - "timer": timer, - "timeout_callable": timeout_callable, - "code": code, - "eval_timeout_sec": timeout_sec, - "run_token": run_token, - } - session.send_message("mcp:eval_liveness", [request_id]) - if _log_buffer: - _log_buffer.log("[debug] -> mcp:eval_liveness (%s)" % request_id) - - -func _on_eval_liveness_timeout(request_id: String) -> void: - var pending_entry = _pending.get(request_id) - if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": - return - var connection: McpConnection = pending_entry.get("connection") - _clear_pending(request_id) - if connection == null or not is_instance_valid(connection): - return - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - ("The game helper did not answer the liveness probe within %.0fms — the game may be backgrounded, frozen, stopped, or restarting. Focus the game window (or relaunch it) and retry." - % (EVAL_LIVENESS_WAIT_SEC * 1000.0))) - - -func _on_eval_liveness_response(data: Array) -> void: - if data.size() < 2: - push_warning("MCP debugger: malformed eval liveness response") - return - var request_id := str(data[0]) - var pending_entry = _pending.get(request_id) - if pending_entry == null or str(pending_entry.get("kind", "")) != "eval_liveness": - return - var connection: McpConnection = pending_entry.get("connection") - var code := str(pending_entry.get("code", "")) - var timeout_sec := float(pending_entry.get("eval_timeout_sec", 10.0)) - var run_token := int(pending_entry.get("run_token", -1)) - var loop_live := bool(data[1]) - _clear_pending(request_id) - if connection == null or not is_instance_valid(connection): - return - if not _is_current_game_run(run_token) or not is_game_capture_ready(): - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "The game run changed while game_eval liveness was being checked — the game stopped or restarted. Retry against the current run.") - return - if not loop_live: - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "The game helper is registered but its main loop is not advancing — the game window may be backgrounded or the game may be frozen. Focus the game window (or relaunch it) and retry.") - return - var current_tree := Engine.get_main_loop() as SceneTree - if current_tree == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Editor main loop is not a SceneTree — cannot schedule eval") - return - _send_eval(current_tree, code, request_id, connection, timeout_sec) - - -func _send_eval( - tree: SceneTree, - code: String, - request_id: String, - connection: McpConnection, - timeout_sec: float, -) -> void: - var session: EditorDebuggerSession = _first_active_session() - if session == null: - ## #518: capture reported ready but the debugger session is no longer live - ## (the game just stopped / is restarting) — a not-ready race, so the same - ## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket. - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.") - return - - var timer: SceneTreeTimer = tree.create_timer(timeout_sec) - var timeout_callable := func() -> void: _on_eval_timeout(request_id, timeout_sec) - timer.timeout.connect(timeout_callable) - - ## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error - ## only when the game acked the eval (it received the message and started - ## reload()) but never sent mcp:eval_compiled — see there for why a missing - ## ack must NOT be read as a compile error. - var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC) - var grace_callable := func() -> void: _on_eval_grace(request_id) - grace.timeout.connect(grace_callable) - - _pending[request_id] = { - "kind": "eval", - "connection": connection, - "run_token": _game_run_token, - "timer": timer, - "timeout_callable": timeout_callable, - "grace_timer": grace, - "grace_callable": grace_callable, - "acked": false, - "compiled": false, - } - - session.send_message("mcp:eval", [request_id, code]) - if _log_buffer: - _log_buffer.log("[debug] -> mcp:eval (%s)" % request_id) - - -## #518: the 10s editor-side backstop fired — the game never replied at all. -## Attribute the failure instead of emitting a one-size-fits-all INTERNAL_ERROR: -## -## - game not live anymore (parked in a debugger break, stopped, crashed): -## the eval couldn't run/finish for a *game-state* reason. Reply with the -## same caller-actionable EVAL_GAME_NOT_READY + `_explain_not_live` payload -## the pre-hello break path already uses — a break freezes the game's idle -## loop, so any awaiting eval parks here even though sync evals still work. -## - game live but never acked the eval: its main thread never serviced the -## debugger message (long frame/load, CPU-bound prior eval, or a -## backgrounded window whose idle loop is frozen). -## - game live, acked, compiled: the eval genuinely started and never -## finished, and the game couldn't even self-report via its own 8s guard -## (which needs a ticking idle loop) — hung await, CPU-bound loop, or a -## reply the debugger channel dropped. -## -## The live branches reply EVAL_HUNG: the eval code never finished. That code -## plus the game-side 8s guard (also EVAL_HUNG, via mcp:eval_error's code -## element) empties the former INTERNAL_ERROR bucket on this path. -func _on_eval_timeout(request_id: String, timeout_sec: float) -> void: - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - var run_token := int(pending_entry.get("run_token", _game_run_token)) - _clear_pending(request_id) - var conn: McpConnection = pending_entry.connection - if conn == null or not is_instance_valid(conn): - return - if run_token != _game_run_token: - _send_error(conn, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "The game run changed before game_eval completed — the original game stopped or restarted. Retry against the current run.") - if _log_buffer: - _log_buffer.log("[debug] !! eval timeout from prior run (%s, run=%d, current=%d)" - % [request_id, run_token, _game_run_token]) - return - var status := get_game_status(-1, EVAL_READY_WAIT_SEC) - if str(status.get("status", "")) != "live": - _send_error_response(conn, request_id, - _explain_not_live(status, ErrorCodes.EVAL_GAME_NOT_READY)) - if _log_buffer: - _log_buffer.log("[debug] !! eval timeout, game not live (%s, status=%s)" - % [request_id, str(status.get("status", ""))]) - return - var message: String - if not bool(pending_entry.get("acked", false)): - message = ("Game eval was sent but the game never picked it up within %.0fs — " - + "its main thread is busy or frozen (a long frame/load, a CPU-bound " - + "prior eval, or a backgrounded game window whose loop is throttled). " - + "Check logs_read(source='game') and retry.") % timeout_sec - else: - message = ("Game eval compiled and started running but never returned within " - + "%.0fs — the code is likely stuck in an infinite loop or awaiting a " - + "signal/timer that never fires (a backgrounded game window also freezes " - + "awaits). Check logs_read(source='game').") % timeout_sec - _send_error(conn, request_id, ErrorCodes.EVAL_HUNG, message) - if _log_buffer: - _log_buffer.log("[debug] !! eval timeout (%s)" % request_id) - - -func _on_eval_response(data: Array) -> void: - if data.size() < 2: - push_warning("MCP debugger: malformed eval response (expected 2 fields, got %d)" % data.size()) - return - var request_id: String = data[0] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _clear_pending(request_id) - - var connection: McpConnection = pending_entry.connection - if connection == null or not is_instance_valid(connection): - return - - var result_json: String = data[1] if data.size() > 1 else "null" - var json := JSON.new() - var parse_err := json.parse(result_json) - connection.send_deferred_response(request_id, { - "data": { - "result": json.data if parse_err == OK else result_json, - "source": "game", - } - }) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:eval_response (%s)" % request_id) - - -## #518: codes the game side may attach as mcp:eval_error's optional third -## payload element. Allowlisted so a game process can't mint arbitrary -## top-level error codes over the debugger channel; anything else (including -## the legacy two-element payload from an older game helper mid-update) -## falls back to INTERNAL_ERROR exactly as before. -const _GAME_EVAL_ERROR_CODES: Array[String] = [ - ErrorCodes.EVAL_HUNG, - ErrorCodes.EVAL_RESULT_TOO_LARGE, -] - - -func _on_eval_error(data: Array) -> void: - if data.size() < 2: - return - var request_id: String = data[0] - var message: String = data[1] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _clear_pending(request_id) - var connection: McpConnection = pending_entry.connection - if connection == null or not is_instance_valid(connection): - return - var code := ErrorCodes.INTERNAL_ERROR - if data.size() > 2 and str(data[2]) in _GAME_EVAL_ERROR_CODES: - code = str(data[2]) - _send_error(connection, request_id, code, message) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message]) - - -## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it -## survives a parse-error abort). It positively signals "the game received this -## eval and started compiling it" — letting _on_eval_grace tell a real parse -## error (acked, never compiled) apart from a message the game hasn't serviced -## yet (never acked — main thread blocked by a long frame/load or a CPU-bound -## prior eval). -func _on_eval_ack(data: Array) -> void: - if data.is_empty(): - return - var request_id: String = data[0] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - pending_entry["acked"] = true - if _log_buffer: - _log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id) - - -## #490: compile-grace timer fired. Conclude a parse error ONLY when the game -## acked the eval (started reload()) but never sent mcp:eval_compiled. If it -## never acked, the game simply hasn't serviced the message yet — NOT a parse -## error — so leave _pending intact and let the normal eval timeout handle it -## rather than false-failing a valid eval and dropping its eventual real reply. -func _on_eval_grace(request_id: String) -> void: - var pending_entry = _pending.get(request_id) - if pending_entry == null or pending_entry.get("compiled", false): - return - if not pending_entry.get("acked", false): - if _log_buffer: - _log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id) - return - _clear_pending(request_id) - var conn: McpConnection = pending_entry.connection - if conn == null or not is_instance_valid(conn): - return - _send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR, - "Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.") - if _log_buffer: - _log_buffer.log("[debug] !! eval compile error (%s)" % request_id) - - -## #490: the game sends this the instant reload() of the eval source -## succeeds. Flips the pending entry's `compiled` flag so the compile-grace -## timer won't fire a false EVAL_COMPILE_ERROR. -func _on_eval_compiled(data: Array) -> void: - if data.is_empty(): - return - var request_id: String = data[0] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - pending_entry["compiled"] = true - if _log_buffer: - _log_buffer.log("[debug] <- mcp:eval_compiled (%s)" % request_id) - ## #490: compiled OK — start polling for a runtime error that may have - ## aborted execute(). A backgrounded game can't self-report it, so the - ## editor probes via mcp:eval_check until the eval resolves. - _arm_eval_probe(request_id) - - -## #490: the game reported a runtime error that aborted the eval — either -## from its _process fast path (focused game) or in answer to an editor -## eval_check probe (backgrounded game). Reply fast with the real error text -## instead of waiting for the hang timeout. -func _on_eval_runtime_error(data: Array) -> void: - if data.size() < 2: - return - var request_id: String = data[0] - var message: String = data[1] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _clear_pending(request_id) - var connection: McpConnection = pending_entry.connection - if connection == null or not is_instance_valid(connection): - return - var msg := "Game eval raised a runtime error: %s" % message if not message.is_empty() else "Game eval raised a runtime error (no message captured). Check logs_read(source='game')." - _send_error(connection, request_id, ErrorCodes.EVAL_RUNTIME_ERROR, msg) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:eval_runtime_error (%s): %s" % [request_id, message]) - - -## #490: arm one probe tick for an in-flight eval. Re-arms itself each tick -## until the request resolves — eval_response / eval_runtime_error / -## eval_compile_error / hang-timeout all call _clear_pending, which erases the -## entry and stops the chain. Uses the editor's own SceneTreeTimer because the -## editor loop keeps ticking even while a backgrounded game's loop is frozen. -func _arm_eval_probe(request_id: String) -> void: - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - return - var probe_timer: SceneTreeTimer = tree.create_timer(EVAL_PROBE_INTERVAL_SEC) - var probe_callable := func() -> void: _on_eval_probe_tick(request_id) - pending_entry["probe_timer"] = probe_timer - pending_entry["probe_callable"] = probe_callable - probe_timer.timeout.connect(probe_callable) - - -## #490: poke the game for a runtime-error verdict, then re-arm. The game's -## _handle_eval_check answers with mcp:eval_runtime_error if a script error -## aborted this eval, else stays silent and we poll again next interval. -func _on_eval_probe_tick(request_id: String) -> void: - if not _pending.has(request_id): - return ## resolved — stop probing - var session: EditorDebuggerSession = _first_active_session() - if session != null and session.is_active(): - session.send_message("mcp:eval_check", [request_id]) - _arm_eval_probe(request_id) - - -## --- game_command: curated runtime game operations --- - -func request_game_command( - op: String, - params: Dictionary, - request_id: String, - connection: McpConnection, - timeout_sec: float = 10.0, -) -> void: - if request_id.is_empty(): - push_warning("MCP debugger: game command request missing request_id") - return - - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Editor main loop is not a SceneTree — cannot schedule game command") - return - - if is_game_capture_ready(): - _send_game_command(tree, op, params, request_id, connection, timeout_sec) - return - - if _log_buffer: - _log_buffer.log("[debug] waiting for game_helper hello before game_command (%s)" % request_id) - _wait_then_game_command(tree, op, params, request_id, connection, timeout_sec) - - -func _wait_then_game_command( - tree: SceneTree, - op: String, - params: Dictionary, - request_id: String, - connection: McpConnection, - timeout_sec: float, -) -> void: - var deadline := Time.get_ticks_msec() + int(GAME_READY_WAIT_SEC * 1000.0) - ## #645: the leading yield guarantees the dispatcher has registered the - ## deferred request before any reply (a same-frame reply is dropped as - ## expired); the break check bails out early because a parked game never - ## registers its capture. - await tree.process_frame - while not is_game_capture_ready() and not _break_active and Time.get_ticks_msec() < deadline: - await tree.process_frame - if not is_game_capture_ready(): - _send_error_response(connection, request_id, - _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) - return - _send_game_command(tree, op, params, request_id, connection, timeout_sec) - - -func _send_game_command( - tree: SceneTree, - op: String, - params: Dictionary, - request_id: String, - connection: McpConnection, - timeout_sec: float, -) -> void: - var session: EditorDebuggerSession = _first_active_session() - if session == null: - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "No active debugger session — is the game actually running?") - return - - var timer: SceneTreeTimer = tree.create_timer(timeout_sec) - var timeout_callable := func() -> void: - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _pending.erase(request_id) - var conn: McpConnection = pending_entry.connection - if conn == null or not is_instance_valid(conn): - return - _send_error(conn, request_id, ErrorCodes.INTERNAL_ERROR, - "Game command '%s' timed out after %.0fs" % [op, timeout_sec]) - if _log_buffer: - _log_buffer.log("[debug] !! game_command timeout (%s)" % request_id) - timer.timeout.connect(timeout_callable) - _pending[request_id] = { - "connection": connection, - "timer": timer, - "timeout_callable": timeout_callable, - } - - session.send_message("mcp:game_command", [request_id, op, JSON.stringify(params)]) - if _log_buffer: - _log_buffer.log("[debug] -> mcp:game_command %s (%s)" % [op, request_id]) - - -func _on_game_command_response(data: Array) -> void: - if data.size() < 2: - push_warning("MCP debugger: malformed game_command response (expected 2 fields, got %d)" % data.size()) - return - var request_id: String = data[0] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _clear_pending(request_id) - - var connection: McpConnection = pending_entry.connection - if connection == null or not is_instance_valid(connection): - return - - var result_json: String = data[1] if data.size() > 1 else "{}" - var json := JSON.new() - var parse_err := json.parse(result_json) - connection.send_deferred_response(request_id, { - "data": json.data if parse_err == OK else {"source": "game", "result": result_json} - }) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:game_command_response (%s)" % request_id) - - -func _on_game_command_error(data: Array) -> void: - if data.size() < 2: - return - var request_id: String = data[0] - var message: String = data[1] - var pending_entry = _pending.get(request_id) - if pending_entry == null: - return - _clear_pending(request_id) - var connection: McpConnection = pending_entry.connection - if connection == null or not is_instance_valid(connection): - return - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, message) - if _log_buffer: - _log_buffer.log("[debug] <- mcp:game_command_error (%s): %s" % [request_id, message]) diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid deleted file mode 100644 index 1d5c148..0000000 --- a/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bd1k63iye1bsl diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd deleted file mode 100644 index c834e8e..0000000 --- a/addons/godot_ai/dispatcher.gd +++ /dev/null @@ -1,476 +0,0 @@ -@tool -class_name McpDispatcher -extends RefCounted - -## Routes incoming commands to handlers and manages the command queue -## with a per-frame time budget. - -var _command_queue: Array[Dictionary] = [] -var _handlers: Dictionary = {} # command_name -> Callable -## Lazy handler registration (#736): plugin.gd registers command names -## against a handler key plus a per-handler script path and constructor -## args, and the handler script is load()ed and instantiated at the FIRST -## dispatch of one of its commands. This keeps the ~30 handler scripts -## (and everything they preload) out of plugin.gd's eager compile -## closure, which stalled "Initializing plugins" on every editor boot. -## Materialized commands are promoted into `_handlers`, so the lazy dicts -## are only consulted on the first call per command. -var _lazy_handler_specs: Dictionary = {} # handler_key -> {path: String, args: Array} -var _lazy_handler_cache: Dictionary = {} # handler_key -> handler instance -var _lazy_commands: Dictionary = {} # command_name -> {handler: String, method: StringName} -var _pending_deferred: Dictionary = {} # request_id -> {command, started_ms, timeout_ms} -var _log_buffer -var _surfaced_error_tracker -## The McpConnection whose pause_processing handlers flip around unsafe -## editor operations (#288 guard). Set by plugin.gd; untyped to honor the -## self-update field-storage policy. When set, _call_handler restores the -## pause depth a crashed handler left unbalanced (#712) — without this a -## single handler crash inside a pause window freezes the transport -## forever (pause has no watchdog or disconnect reset by design). -var pause_target -var mcp_logging := true -var deferred_timeout_overrides_ms: Dictionary = {} - -const DEFAULT_DEFERRED_TIMEOUT_MS := 4500 -const DEFERRED_TIMEOUT_MS_BY_COMMAND := { - "create_script": 4500, - ## Fresh-`.gd` writes defer through the same import-settle window as - ## create_script (#714) — same headroom over IMPORT_SETTLE_MAX_MSEC. - "write_file": 4500, - "stop_project": 4500, - "run_project": 6000, - "take_screenshot": 30000, - "check_client_status": 30000, - "game_eval": 15000, - "game_command": 15000, - "scan_filesystem": 30000, -} -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") - - -func _init(log_buffer: McpLogBuffer, surfaced_error_tracker = null) -> void: - _log_buffer = log_buffer - _surfaced_error_tracker = surfaced_error_tracker - - -## Register a command handler. The callable receives (params: Dictionary) -> Dictionary. -func register(command_name: String, handler: Callable) -> void: - _handlers[command_name] = handler - - -## Declare a lazily-constructed handler (#736). `script_path` is load()ed -## and instantiated with `ctor_args` at the first dispatch of any command -## registered against `handler_key` via register_lazy. `ctor_args` may hold -## plugin-lifetime objects (connection, buffers, the dispatcher itself for -## batch); clear() drops them so teardown ordering matches the old eager -## registration (#46). -func register_lazy_handler(handler_key: String, script_path: String, ctor_args: Array) -> void: - _lazy_handler_specs[handler_key] = {"path": script_path, "args": ctor_args} - - -## Register a command that resolves to `method` on the lazily-constructed -## handler declared under `handler_key`. Same dispatch semantics as -## register(); only construction timing differs. -func register_lazy(command_name: String, handler_key: String, method: StringName) -> void: - _lazy_commands[command_name] = {"handler": handler_key, "method": method} - -func unregister(command_name: String, handler_key: String) -> void: - _handlers.erase(command_name) - _lazy_commands.erase(command_name) - ## Drop the lazy spec/cache only when no remaining command routes to - ## this handler key. Custom tools use 1:1 keys ("custom:") so this - ## always erases for them, but built-in keys serve many commands — - ## erasing the shared spec would strand the siblings on - ## "No lazy handler declared" at dispatch time. - for entry in _lazy_commands.values(): - if entry.get("handler", "") == handler_key: - return - _lazy_handler_cache.erase(handler_key) - _lazy_handler_specs.erase(handler_key) - -## Drop registered handlers, queued commands, and the log buffer ref so -## plugin.gd can release RefCounted handlers before Godot reloads their -## class_name scripts (issue #46). After clear(), the dispatcher is inert. -func clear() -> void: - ## Stop lazy handlers before releasing the cache. Handler-owned polling - ## coroutines retain any in-flight worker and deferred-response connection - ## across frames, then join only after the worker is no longer alive. - for instance in _lazy_handler_cache.values(): - if is_instance_valid(instance) and instance.has_method("prepare_for_teardown"): - instance.call("prepare_for_teardown") - _handlers.clear() - ## Release lazily-constructed handler instances (and the ctor args that - ## reference plugin-lifetime objects) at the same teardown point where - ## eager handler Callables used to be dropped — their destructors must - ## run while their scripts are still loaded (#46). This also breaks the - ## dispatcher -> batch handler -> dispatcher ref cycle. - _lazy_handler_specs.clear() - _lazy_handler_cache.clear() - _lazy_commands.clear() - _command_queue.clear() - _pending_deferred.clear() - _log_buffer = null - _surfaced_error_tracker = null - pause_target = null -## Drop queued-but-unexecuted commands. Called by the connection on -## disconnect (#712): commands queued by the previous connection must not -## execute under the next one — the requester is gone, its in-flight -## futures were already failed server-side, and a mutation landing after -## reconnect is a surprise write nobody can correlate. Deferred bookkeeping -## has its own reset (clear_deferred_responses). -func clear_command_queue() -> void: - _command_queue.clear() - - -## Invoke a registered handler directly by name. Returns the handler's raw -## response dict (no request_id or status wrapping). Returns an UNKNOWN_COMMAND -## error dict if the command is not registered. Used by batch_execute. -func dispatch_direct(command: String, params: Dictionary) -> Dictionary: - if not has_command(command): - return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) - ## Strip the reserved deferred-reply key: only _dispatch may thread it. - ## A caller-supplied _request_id (e.g. inside a batch_execute - ## sub-command's params) would flip a deferred-capable handler into - ## deferred mode against a request id the dispatcher never registered — - ## the direct caller would get the DEFERRED sentinel instead of a result - ## and the out-of-band reply would be dropped as expired. - if params.has("_request_id"): - params = params.duplicate() - params.erase("_request_id") - return _call_handler(command, params) - - -## Whether a command is registered (eagerly or lazily). -func has_command(command: String) -> bool: - return _handlers.has(command) or _lazy_commands.has(command) - - -## Rank registered commands by similarity to `cmd_name` and return the top `limit` -## matches. Uses Godot's built-in String.similarity() (0.0–1.0). Returns an empty -## array if no candidates clear the threshold. Used by batch_execute to surface -## "did you mean" suggestions when an unknown command is passed. -func suggest_similar(cmd_name: String, limit: int = 3, threshold: float = 0.5) -> Array[String]: - return FuzzySuggestions.rank(cmd_name, _registered_command_names(), limit, threshold, 0.0, 0.0) - - -## Union of eagerly-registered and lazily-registered command names. -## Materialized lazy commands live in both dicts, so dedupe via keys. -func _registered_command_names() -> Array: - var names: Dictionary = {} - for command in _handlers: - names[command] = true - for command in _lazy_commands: - names[command] = true - return names.keys() - - -## Enqueue a raw command dict received from the WebSocket. -func enqueue(cmd: Dictionary) -> void: - _command_queue.append(cmd) - - -func pending_deferred_count() -> int: - return _pending_deferred.size() - - -func clear_deferred_responses() -> void: - _pending_deferred.clear() - - -func has_pending_deferred_response(request_id: String) -> bool: - return request_id.is_empty() or _pending_deferred.has(request_id) - - -func complete_deferred_response(request_id: String) -> bool: - if request_id.is_empty(): - return true - if not _pending_deferred.has(request_id): - return false - _pending_deferred.erase(request_id) - return true - - -## Handlers whose response flows out-of-band (e.g. debugger-channel capture) -## return this marker so tick() skips auto-sending a response. The handler is -## responsible for pushing the final response via McpConnection._send_json when -## the async operation completes. The dispatcher tracks the request_id and emits -## DEFERRED_TIMEOUT if the out-of-band response never arrives. The request_id is -## threaded through params under the "_request_id" key so the handler can -## correlate the response. -const DEFERRED_RESPONSE := {"_deferred": true} - - -## Process queued commands within a frame budget (milliseconds). -## Returns an array of response dictionaries to send back. -func tick(budget_ms: float = 4.0) -> Array[Dictionary]: - var responses: Array[Dictionary] = _collect_deferred_timeouts() - var start := Time.get_ticks_msec() - var idx := 0 - - while idx < _command_queue.size() and (Time.get_ticks_msec() - start) < budget_ms: - var cmd: Dictionary = _command_queue[idx] - var response := _dispatch(cmd) - if not response.get("_deferred", false): - responses.append(response) - idx += 1 - - if idx > 0: - _command_queue = _command_queue.slice(idx) - - return responses - - -func _dispatch(cmd: Dictionary) -> Dictionary: - var request_id: String = cmd.get("request_id", "") - var command: String = cmd.get("command", "") - var raw_params: Dictionary = cmd.get("params", {}) - ## Duplicate so the internal _request_id key we thread through doesn't - ## mutate the queued command's params (which is the same dict we're - ## about to JSON-log below, and which later readers like batch_execute - ## shouldn't see dispatcher-internal metadata from). - var params: Dictionary = raw_params.duplicate() - params["_request_id"] = request_id - - if mcp_logging: - _log_buffer.log("[recv] %s(%s)" % [command, JSON.stringify(raw_params)]) - - var result: Dictionary - - if has_command(command): - result = _call_handler(command, params) - else: - result = ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) - - if result.get("_deferred", false): - ## A handler may attach `_deferred_timeout_ms` to its deferred sentinel - ## to claim a per-request budget larger than its command's shared entry - ## (e.g. game_command's `input_sequence`, which steps frames well past - ## the 15s that suits one-shot game ops). 0/absent falls back to the - ## per-command table. - _register_deferred(request_id, command, int(result.get("_deferred_timeout_ms", 0))) - if mcp_logging: - _log_buffer.log("[defer] %s (request %s)" % [command, request_id]) - return result - - result["request_id"] = request_id - if not result.has("status"): - result["status"] = "ok" - ## Stamp live editor readiness onto every command-response envelope so - ## the server's `Session.readiness` cache self-heals on the very next - ## tool call. Without this, a single dropped `readiness_changed` event - ## (or a one-frame race around `pause_processing`) leaves the cache - ## stuck at "playing" / "importing" long after the editor has settled, - ## and write tools fail with EDITOR_NOT_READY against a writable editor. - ## See connection.gd::send_deferred_response for the deferred-response - ## counterpart, which stamps the same field. - result["readiness"] = McpConnection.get_readiness() - _stamp_error_watermark(result) - - if mcp_logging: - var status: String = result.get("status", "ok") - if status == "ok": - _log_buffer.log("[send] %s -> ok" % command) - else: - var err_msg: String = result.get("error", {}).get("message", "unknown") - _log_buffer.log("[send] %s -> error: %s" % [command, err_msg]) - - return result - - -## Truncate JSON-stringified args at this many chars when stuffing them into -## a malformed-result error message — large dicts shouldn't bloat the -## response, but a few hundred chars usually pinpoints which param was the -## wrong shape. -const _MALFORMED_ARGS_MAX := 400 - - -func _call_handler(command: String, params: Dictionary) -> Dictionary: - if not _handlers.has(command): - var materialize_error := _materialize_lazy_command(command) - if not materialize_error.is_empty(): - return materialize_error - ## #712: a handler that crashes between pause_processing = true and its - ## matching false leaves the pause depth unbalanced — GDScript swallows - ## the error, the dispatcher reports "malformed result", and the - ## transport stays paused FOREVER (no watchdog, no disconnect reset). - ## Restore balance at this boundary: the depth a handler leaves behind - ## must equal the depth it started with. - var pause_depth_before: int = pause_target.pause_depth() if pause_target != null else 0 - var result: Dictionary = _handlers[command].call(params) - if pause_target != null and pause_target.pause_depth() > pause_depth_before: - var leaked: int = pause_target.pause_depth() - pause_depth_before - while pause_target.pause_depth() > pause_depth_before: - pause_target.resume() - if mcp_logging and _log_buffer != null: - _log_buffer.log( - "[error] %s leaked %d pause_processing level(s) — restored (handler crash?)" - % [command, leaked] - ) - ## Handlers must return {"data": ...} on success or {"error": ...} on failure. - ## Anything else (null, empty, missing keys) means the handler crashed - ## mid-call — GDScript swallows the error and returns an empty dict. - if result == null or not (result.has("data") or result.has("error") or result.has("_deferred")): - var safe_params := params.duplicate() - safe_params.erase("_request_id") - var args_json := JSON.stringify(safe_params) - if args_json.length() > _MALFORMED_ARGS_MAX: - args_json = args_json.substr(0, _MALFORMED_ARGS_MAX) + "..." - var backtrace := _capture_compact_backtrace() - var msg := ( - "Handler '%s' returned malformed result — likely a runtime error in the handler " - + "(e.g. param type mismatch). Args received: %s" - ) % [command, args_json] - if not backtrace.is_empty(): - msg += "\nBacktrace:\n%s" % backtrace - if mcp_logging and _log_buffer != null: - var compact_backtrace := backtrace.replace("\n", " | ") - _log_buffer.log( - "[error] %s -> malformed result; args=%s; backtrace=%s" - % [command, args_json, compact_backtrace] - ) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, msg) - return result - - -## Resolve a lazily-registered command into a live Callable in `_handlers`. -## Loads + constructs the owning handler on first use (cached per handler -## key, so one load() covers every command the handler serves). Returns an -## empty dict on success or a protocol error dict on failure — a missing -## script or method is a plugin packaging bug and must surface loudly, not -## as a silent no-op. -func _materialize_lazy_command(command: String) -> Dictionary: - var command_spec: Dictionary = _lazy_commands.get(command, {}) - if command_spec.is_empty(): - return ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, "Unknown command: %s" % command) - var handler_key: String = command_spec["handler"] - var instance = _lazy_handler_cache.get(handler_key) - if instance == null: - var handler_spec: Dictionary = _lazy_handler_specs.get(handler_key, {}) - if handler_spec.is_empty(): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "No lazy handler '%s' declared for command '%s'" % [handler_key, command] - ) - ## Existence-check first so a missing script surfaces as one clean - ## protocol error instead of also spraying engine load errors. - if not ResourceLoader.exists(handler_spec["path"]): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Missing handler script '%s' for command '%s'" % [handler_spec["path"], command] - ) - var script := load(handler_spec["path"]) as GDScript - if script == null: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to load handler script '%s' for command '%s'" % [handler_spec["path"], command] - ) - instance = script.callv("new", handler_spec["args"]) - if instance == null: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to construct handler '%s' for command '%s'" % [handler_key, command] - ) - _lazy_handler_cache[handler_key] = instance - var method: StringName = command_spec["method"] - if not instance.has_method(method): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Handler '%s' has no method '%s' for command '%s'" % [handler_key, method, command] - ) - _handlers[command] = Callable(instance, method) - return {} - - -func _register_deferred(request_id: String, command: String, timeout_override_ms: int = 0) -> void: - if request_id.is_empty(): - return - ## A positive per-request override wins over the per-command table so a - ## single deferred call can claim more headroom without globally widening - ## the command's budget (see _dispatch: input_sequence needs ~30s, but the - ## other game_command ops must keep their tight 15s). - var timeout_ms: int = ( - timeout_override_ms if timeout_override_ms > 0 - else _deferred_timeout_ms_for_command(command) - ) - _pending_deferred[request_id] = { - "command": command, - "started_ms": Time.get_ticks_msec(), - "timeout_ms": timeout_ms, - } - - -func _deferred_timeout_ms_for_command(command: String) -> int: - if deferred_timeout_overrides_ms.has(command): - return int(deferred_timeout_overrides_ms[command]) - return int(DEFERRED_TIMEOUT_MS_BY_COMMAND.get(command, DEFAULT_DEFERRED_TIMEOUT_MS)) - - -func _collect_deferred_timeouts() -> Array[Dictionary]: - var responses: Array[Dictionary] = [] - if _pending_deferred.is_empty(): - return responses - var now := Time.get_ticks_msec() - for request_id in _pending_deferred.keys(): - var entry: Dictionary = _pending_deferred[request_id] - var timeout_ms: int = entry.get("timeout_ms", DEFAULT_DEFERRED_TIMEOUT_MS) - var elapsed_ms := now - int(entry.get("started_ms", now)) - if elapsed_ms < timeout_ms: - continue - _pending_deferred.erase(request_id) - var command: String = entry.get("command", "") - var response := ErrorCodes.make( - ErrorCodes.DEFERRED_TIMEOUT, - "Deferred response for '%s' timed out after %dms" % [command, timeout_ms] - ) - response["request_id"] = request_id - response["error"]["data"] = { - "command": command, - "elapsed_ms": elapsed_ms, - "timeout_ms": timeout_ms, - } - ## Same envelope-level readiness stamp as `_dispatch` — keep the - ## self-heal channel symmetric across every reply shape the - ## dispatcher emits so the server cache can't drift just because - ## the editor happened to time out a deferred command. - response["readiness"] = McpConnection.get_readiness() - _stamp_error_watermark(response) - responses.append(response) - if mcp_logging and _log_buffer != null: - _log_buffer.log("[defer] %s (request %s) -> timeout" % [command, request_id]) - return responses - - -func _stamp_error_watermark(response: Dictionary) -> void: - McpSurfacedErrorTracker.stamp_watermark(response, _surfaced_error_tracker) - - -static func _capture_compact_backtrace(max_frames: int = 8) -> String: - var traces: Array = Engine.capture_script_backtraces(false) - for bt in traces: - if bt != null and not bt.is_empty(): - return _trim_backtrace_string(bt.format(0, 2), max_frames) - return _format_stack_frames(get_stack(), max_frames) - - -static func _trim_backtrace_string(text: String, max_frames: int) -> String: - var lines := text.strip_edges().split("\n") - var kept: Array[String] = [] - for i in range(min(lines.size(), max_frames)): - kept.append(lines[i].strip_edges()) - return "\n".join(kept) - - -static func _format_stack_frames(frames: Array, max_frames: int) -> String: - var lines: Array[String] = [] - for i in range(min(frames.size(), max_frames)): - var frame: Dictionary = frames[i] - lines.append( - "%s:%s in %s" - % [ - frame.get("source", "?"), - frame.get("line", 0), - frame.get("function", "?"), - ] - ) - return "\n".join(lines) diff --git a/addons/godot_ai/dispatcher.gd.uid b/addons/godot_ai/dispatcher.gd.uid deleted file mode 100644 index 25a05bd..0000000 --- a/addons/godot_ai/dispatcher.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ctldk7ivsoo3i diff --git a/addons/godot_ai/dock_panels/log_viewer.gd b/addons/godot_ai/dock_panels/log_viewer.gd deleted file mode 100644 index 1d7b986..0000000 --- a/addons/godot_ai/dock_panels/log_viewer.gd +++ /dev/null @@ -1,100 +0,0 @@ -@tool -extends VBoxContainer - -## Dock subpanel — renders the MCP request/response log buffer. Owns its own -## UI subtree, the line-count cursor, and the display-visibility toggle. Emits -## `logging_enabled_changed` so the dock can route the flag onto the -## connection dispatcher without the panel knowing the routing exists. -## -## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at -## the top of mcp_dock.gd for the broader extraction story. - -signal logging_enabled_changed(enabled: bool) - -const Dock := preload("res://addons/godot_ai/mcp_dock.gd") -## Preload (not the McpSettings class_name) for consistency with the parse -## hazard note on `_log_buffer` below. -const Settings := preload("res://addons/godot_ai/utils/settings.gd") - -## Untyped: a `: McpLogBuffer` annotation hits the class_name registry at -## script-load and trips the self-update parse hazard (#398). The type fence -## stays on the `setup(log_buffer: McpLogBuffer)` parameter. -var _log_buffer -var _log_display: RichTextLabel -var _log_toggle: CheckButton -## Last `McpLogBuffer.total_logged()` value painted into the display. Tracking -## the buffer's monotonic sequence (rather than its bounded `total_count()`) -## keeps the viewer painting once the ring fills — a size-based cursor would -## freeze at MAX_LINES on every subsequent append. See PR #392 for the bug. -var _last_log_seq := 0 - - -## Build the UI synchronously here so callers (and detached-tree tests that -## instantiate the dock with `McpDockScript.new()` and never enter the tree) -## can interact with the panel's controls right after `setup()`. Mirrors the -## pre-extraction inline-build behavior that test_dock.gd relies on. -## -## Idempotent: `_log_display == null` covers an unlikely double-`setup()` call -## without rebuilding (which would orphan the prior controls). -func setup(log_buffer: McpLogBuffer) -> void: - _log_buffer = log_buffer - if _log_display == null: - _build_ui() - - -func _build_ui() -> void: - add_child(HSeparator.new()) - - var log_header_row := HBoxContainer.new() - var log_header := Dock._make_header("MCP Log") - log_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL - log_header_row.add_child(log_header) - - _log_toggle = CheckButton.new() - _log_toggle.text = "Log" - ## Restore the persisted choice — a hardcoded `true` here meant the - ## toggle reset to noisy on every editor restart (#626). - _log_toggle.button_pressed = Settings.mcp_logging_enabled() - _log_toggle.toggled.connect(_on_log_toggled) - log_header_row.add_child(_log_toggle) - - add_child(log_header_row) - - _log_display = RichTextLabel.new() - _log_display.custom_minimum_size = Vector2(0, 80) - _log_display.scroll_following = true - _log_display.bbcode_enabled = false - _log_display.selection_enabled = true - _log_display.visible = _log_toggle.button_pressed - add_child(_log_display) - - -## Called from McpDock._process when the panel is visible. Appends any new -## log lines since the last tick. -func tick() -> void: - if _log_buffer == null or _log_display == null: - return - var seq: int = _log_buffer.total_logged() - if seq == _last_log_seq: - return - if seq < _last_log_seq: - ## Buffer cleared via `McpLogBuffer.clear()` (the `clear_logs` MCP - ## tool / `logs_clear` handler). The buffer resets `_total_logged` - ## to 0, flipping the sequence backward. Without this branch the - ## display would keep showing pre-clear lines forever — the viewer - ## drifts permanently out of sync with the buffer. Reset display + - ## cursor so the next append paints over a clean slate. - _log_display.clear() - _last_log_seq = 0 - if seq == 0: - return - var new_lines: Array[String] = _log_buffer.get_recent(seq - _last_log_seq) - for line in new_lines: - _log_display.add_text(line + "\n") - _last_log_seq = seq - - -func _on_log_toggled(enabled: bool) -> void: - Settings.set_mcp_logging_enabled(enabled) - _log_display.visible = enabled - logging_enabled_changed.emit(enabled) diff --git a/addons/godot_ai/dock_panels/log_viewer.gd.uid b/addons/godot_ai/dock_panels/log_viewer.gd.uid deleted file mode 100644 index c261627..0000000 --- a/addons/godot_ai/dock_panels/log_viewer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cr5nbnd6vj3b8 diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd deleted file mode 100644 index 30c3ed3..0000000 --- a/addons/godot_ai/dock_panels/port_picker_panel.gd +++ /dev/null @@ -1,78 +0,0 @@ -@tool -extends VBoxContainer - -## Dock subpanel — port-change escape hatch surfaced inside the spawn-failure -## crash panel when the HTTP port is contested (PORT_EXCLUDED, FOREIGN_PORT). -## Emits `port_apply_requested(new_port)` after range-validation; the dock -## handles writing the EditorSetting and reloading the plugin. -## -## Extracted from mcp_dock.gd as part of audit-v2 #360 — see the comment at -## the top of mcp_dock.gd for the broader extraction story. - -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") - -signal port_apply_requested(new_port: int) - -var _spinbox: SpinBox - - -## Build the UI synchronously here so callers (and detached-tree tests that -## instantiate the dock with `McpDockScript.new()` and never enter the tree) -## can interact with the panel's controls right after `setup()`. Mirrors the -## pre-extraction inline-build behavior that test_dock.gd relies on. -## -## Idempotent: `_spinbox == null` covers an unlikely double-`setup()` call -## without rebuilding (which would orphan the prior controls). -func setup() -> void: - if _spinbox == null: - _build_ui() - - -func _build_ui() -> void: - add_theme_constant_override("separation", 4) - visible = false - - var picker_row := HBoxContainer.new() - picker_row.add_theme_constant_override("separation", 6) - - _spinbox = SpinBox.new() - _spinbox.min_value = ClientConfigurator.MIN_PORT - _spinbox.max_value = ClientConfigurator.MAX_PORT - _spinbox.step = 1 - _spinbox.value = ClientConfigurator.http_port() - _spinbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - picker_row.add_child(_spinbox) - - var apply_btn := Button.new() - apply_btn.text = "Apply + Reload" - apply_btn.tooltip_text = ( - "Saves godot_ai/http_port to Editor Settings and reloads the plugin so" - + " the server spawns on the new port." - ) - apply_btn.pressed.connect(_on_apply_pressed) - picker_row.add_child(apply_btn) - - add_child(picker_row) - - -## Re-seed the spinbox with a fresh suggestion every time the panel surfaces, -## so a stale value from a previous spawn-failure round can't carry over. Note -## that this OVERWRITES any unsaved user input — fine in practice because the -## dock's `_update_crash_panel` only calls this on `server_status` transitions -## (`if server_status == _last_server_status: return` short-circuit), so a -## user typing into the spinbox between transitions keeps their value. If the -## state flips while the picker is visible (e.g. `PORT_EXCLUDED` → `FOREIGN_PORT`), -## the in-flight edit is clobbered — accept that, the suggestion is more current. -func seed_suggested_port() -> void: - if _spinbox == null: - return - _spinbox.value = ClientConfigurator.suggest_free_port( - ClientConfigurator.http_port() + 1 - ) - - -func _on_apply_pressed() -> void: - var new_port: int = int(_spinbox.value) - if new_port < ClientConfigurator.MIN_PORT or new_port > ClientConfigurator.MAX_PORT: - return - port_apply_requested.emit(new_port) diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid deleted file mode 100644 index 38b6320..0000000 --- a/addons/godot_ai/dock_panels/port_picker_panel.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://hlggbo1q65eq diff --git a/addons/godot_ai/export/mcp_export_plugin.gd b/addons/godot_ai/export/mcp_export_plugin.gd deleted file mode 100644 index 5a8b885..0000000 --- a/addons/godot_ai/export/mcp_export_plugin.gd +++ /dev/null @@ -1,69 +0,0 @@ -@tool -extends EditorExportPlugin - -## Strips the MCP game-helper autoload from exported builds (#740). -## -## plugin.gd writes `autoload/_mcp_game_helper` into project.godot so the -## editor-spawned game process loads the helper. Exports bake project -## settings into the pack's project.binary, so without this plugin every -## export ships the autoload — and users who exclude addons/godot_ai/** -## in their export preset get three "Failed to instantiate an autoload" -## errors at game start. Even when the files ARE shipped, the helper is -## editor-tooling: no exported build should carry it. -## -## Strip mechanics: clear the in-memory ProjectSettings entry in -## _export_begin, restore it in _export_end. The export pipeline reads -## the live ProjectSettings when it bakes project.binary, which happens -## after _export_begin — verified end-to-end by -## script/ci-export-strip-smoke, which exports a real pack and asserts -## the autoload is absent inside it. We never call ProjectSettings.save() -## while stripped, so project.godot on disk keeps the autoload -## throughout; only the export snapshot loses it. -## -## Failure containment: if an export aborts so hard that _export_end -## never fires, the damage is bounded to the editor's in-memory settings -## — the running game reads project.godot from disk, and plugin.gd's -## _ensure_game_helper_autoload() re-asserts the entry on the next -## plugin enable / editor launch. - -## Must equal "autoload/" + plugin.gd's GAME_HELPER_AUTOLOAD_NAME. -## Duplicated (not preloaded from plugin.gd) to avoid a cyclic preload — -## plugin.gd preloads this script. The pairing is locked by -## test_export_strip.gd's constants-contract test. -const AUTOLOAD_KEY := "autoload/_mcp_game_helper" - -var _saved_value: Variant = null -var _stripped := false - - -func _get_name() -> String: - return "GodotAIStripAutoload" - - -func _export_begin(_features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void: - ## `_stripped` guard: if a previous export died before _export_end, - ## don't overwrite the genuinely-saved value with the already-cleared - ## state — restore semantics stay anchored to the original value. - if _stripped: - return - if not ProjectSettings.has_setting(AUTOLOAD_KEY): - return - _saved_value = ProjectSettings.get_setting(AUTOLOAD_KEY) - ## Setting a project setting to null erases it. - ProjectSettings.set_setting(AUTOLOAD_KEY, null) - _stripped = true - print("MCP | export: stripping %s from the exported pack (restored in the editor after export)" % AUTOLOAD_KEY) - - -func _export_end() -> void: - if not _stripped: - return - ProjectSettings.set_setting(AUTOLOAD_KEY, _saved_value) - ## Mirror _ensure_game_helper_autoload()'s registration shape so the - ## restored entry is indistinguishable from the original: initial - ## value "" keeps project.godot diff-clean, basic keeps it visible in - ## the non-advanced settings view. - ProjectSettings.set_initial_value(AUTOLOAD_KEY, "") - ProjectSettings.set_as_basic(AUTOLOAD_KEY, true) - _saved_value = null - _stripped = false diff --git a/addons/godot_ai/export/mcp_export_plugin.gd.uid b/addons/godot_ai/export/mcp_export_plugin.gd.uid deleted file mode 100644 index f2a3634..0000000 --- a/addons/godot_ai/export/mcp_export_plugin.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://np0cp6fwpim7 diff --git a/addons/godot_ai/handlers/_node_validator.gd b/addons/godot_ai/handlers/_node_validator.gd deleted file mode 100644 index 61bbe10..0000000 --- a/addons/godot_ai/handlers/_node_validator.gd +++ /dev/null @@ -1,71 +0,0 @@ -@tool -class_name McpNodeValidator -extends RefCounted - -## Shared resolve-or-error helper that subsumes the 38+ sites where -## handlers each rolled their own "is the editor ready, does the path -## resolve, otherwise return EDITOR_NOT_READY / NODE_NOT_FOUND" guard. -## -## audit-v2 #20 (issue #364). Uses the audit-v2 #21 (issue #365) error -## vocabulary. - -## Local const names alias the preloaded scripts. The naming choice is -## stylistic, not an upgrade-safety boundary: bare `McpErrorCodes.MEMBER` -## and `ErrorCodes.MEMBER` both depend on the Script object Godot has for -## `error_codes.gd`. The transient #398 parse errors were caused by the -## old runner scanning a mixed old/new plugin snapshot and seeing stale -## Script-object content; the runner now writes one v(N+1) snapshot before -## its scan. -const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd") -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - - -## Resolve a scene-relative path to the live Node, or return a structured -## error dict. -## -## Success shape: `{"node": Node, "scene_root": Node, "path": String}`. -## Error shape: matches `ErrorCodes.make(...)` so callers can -## `return resolved` to propagate. -## -## Errors (in order checked): -## - `MISSING_REQUIRED_PARAM`: `node_path` is empty -## - `EDITOR_NOT_READY`: no scene open -## - `EDITED_SCENE_MISMATCH`: caller pinned `scene_file` and the open -## scene's path doesn't match -## - `NODE_NOT_FOUND`: `node_path` doesn't resolve under the scene root -## -## `param_name` is the agent-facing name reported in the -## `MISSING_REQUIRED_PARAM` message — handlers pass "node_path", -## "player_path", "target_path", etc. so the error reads like the -## hand-written messages it replaces. -static func resolve_or_error( - node_path: String, - param_name: String = "path", - scene_file: String = "", -) -> Dictionary: - if node_path.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "Missing required param: %s" % param_name, - ) - var scene_check := ScenePath.require_edited_scene(scene_file) - if scene_check.has("error"): - return scene_check - var scene_root: Node = scene_check.node - var node := ScenePath.resolve(node_path, scene_root) - if node == null: - return ErrorCodes.make( - ErrorCodes.NODE_NOT_FOUND, - ScenePath.format_node_error(node_path, scene_root), - ) - return {"node": node, "scene_root": scene_root, "path": node_path} - - -## When the caller needs the scene root but no specific node yet — e.g. -## handlers that walk children or filter by group. Returns either -## `{"scene_root": Node}` or an `ErrorCodes.make(...)` error dict. -static func require_scene_or_error(scene_file: String = "") -> Dictionary: - var scene_check := ScenePath.require_edited_scene(scene_file) - if scene_check.has("error"): - return scene_check - return {"scene_root": scene_check.node} diff --git a/addons/godot_ai/handlers/_node_validator.gd.uid b/addons/godot_ai/handlers/_node_validator.gd.uid deleted file mode 100644 index b3153a6..0000000 --- a/addons/godot_ai/handlers/_node_validator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dn75jifad0ghx diff --git a/addons/godot_ai/handlers/_param_validators.gd b/addons/godot_ai/handlers/_param_validators.gd deleted file mode 100644 index a316b32..0000000 --- a/addons/godot_ai/handlers/_param_validators.gd +++ /dev/null @@ -1,30 +0,0 @@ -@tool -class_name McpParamValidators -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Type-check a JSON-decoded param Variant before assigning it into a typed -## GDScript local. The dispatcher only catches handler crashes as an opaque -## "malformed result" (issue #210), so a typed assignment like -## var group: String = params.get("group", "") -## will runtime-error and bubble up without telling the caller which param -## was the wrong shape. Only string params are guarded — int/bool params -## can't be: Godot's JSON parser decodes every number as float (a wire `5` -## arrives as `5.0`), so a strict int check would reject every legitimate -## integer a client sends, and GDScript's typed assignment already converts -## numeric Variants safely. Bool params arrive as real bools and a wrong -## type surfaces through the dispatcher's malformed-result path. - - -## Returns null iff `value` is a String or StringName. On any other type -## returns an INVALID_PARAMS error dict whose message names both `name` and -## the actual Variant type (via Godot's built-in `type_string`). -static func require_string(name: String, value: Variant) -> Variant: - var t := typeof(value) - if t == TYPE_STRING or t == TYPE_STRING_NAME: - return null - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Param '%s' must be a String, got %s" % [name, type_string(t)], - ) diff --git a/addons/godot_ai/handlers/_param_validators.gd.uid b/addons/godot_ai/handlers/_param_validators.gd.uid deleted file mode 100644 index cd2061e..0000000 --- a/addons/godot_ai/handlers/_param_validators.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://difa877m8dsla diff --git a/addons/godot_ai/handlers/_property_errors.gd b/addons/godot_ai/handlers/_property_errors.gd deleted file mode 100644 index 316b941..0000000 --- a/addons/godot_ai/handlers/_property_errors.gd +++ /dev/null @@ -1,82 +0,0 @@ -@tool -class_name McpPropertyErrors -extends RefCounted - -## Shared helper for building "Property not found" error messages that include -## "did you mean" suggestions and a tail of available property names. All -## handlers that validate user-supplied property names against a target Object -## (Node, Resource, …) should route through build_message() so agents get -## consistent, actionable errors on typos. -## -## Ranking combines Godot's built-in String.similarity() with a substring -## bonus so both "radus" → "radius" (edit distance) and "top" → "top_radius" -## (substring) surface naturally. - -const _SIMILARITY_THRESHOLD: float = 0.4 -const _SUBSTRING_BONUS: float = 0.5 -const _MAX_SUGGESTIONS: int = 5 -const _MAX_TAIL: int = 10 - - -static func build_message(target: Object, bad_name: String) -> String: - if target == null: - return "Property '%s' not found" % bad_name - var class_label := _class_label(target) - var available := _available_property_names(target) - if available.is_empty(): - return "Property '%s' not found on %s" % [bad_name, class_label] - - var msg := "Property '%s' not found on %s" % [bad_name, class_label] - var suggestions := _rank_suggestions(bad_name, available) - if not suggestions.is_empty(): - msg += ". Did you mean: %s?" % ", ".join(suggestions) - - var tail_names := available.slice(0, min(_MAX_TAIL, available.size())) - msg += " (available: %s" % ", ".join(tail_names) - if available.size() > tail_names.size(): - msg += ", ..." - msg += ")" - return msg - - -## Prefer a scripted class_name if the target has one, else the engine class. -static func _class_label(target: Object) -> String: - var scr := target.get_script() - if scr != null and scr.has_method("get_global_name"): - var gcn: String = scr.get_global_name() - if not gcn.is_empty(): - return gcn - return target.get_class() - - -## Editor-visible properties, alphabetised, with internal/category entries dropped. -static func _available_property_names(target: Object) -> Array: - var names: Array = [] - for p in target.get_property_list(): - var usage: int = int(p.get("usage", 0)) - if (usage & PROPERTY_USAGE_EDITOR) == 0: - continue - var name: String = p.get("name", "") - if name.is_empty() or name.begins_with("_"): - continue - names.append(name) - names.sort() - return names - - -static func _rank_suggestions(bad: String, available: Array) -> Array: - if bad.is_empty(): - return [] - var bad_lower := bad.to_lower() - var scored: Array = [] - for n in available: - var score: float = bad.similarity(n) - if n.to_lower().find(bad_lower) != -1 or bad_lower.find(n.to_lower()) != -1: - score += _SUBSTRING_BONUS - if score >= _SIMILARITY_THRESHOLD: - scored.append([score, n]) - scored.sort_custom(func(a, b): return a[0] > b[0]) - var result: Array = [] - for i in range(min(_MAX_SUGGESTIONS, scored.size())): - result.append(scored[i][1]) - return result diff --git a/addons/godot_ai/handlers/_property_errors.gd.uid b/addons/godot_ai/handlers/_property_errors.gd.uid deleted file mode 100644 index c29d21c..0000000 --- a/addons/godot_ai/handlers/_property_errors.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c74d560g4l86b diff --git a/addons/godot_ai/handlers/animation_handler.gd b/addons/godot_ai/handlers/animation_handler.gd deleted file mode 100644 index 8000b78..0000000 --- a/addons/godot_ai/handlers/animation_handler.gd +++ /dev/null @@ -1,825 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles AnimationPlayer authoring: creating players, animations, tracks, -## keyframes, autoplay, and dev-ergonomics playback. -## -## Animations live inside an AnimationLibrary attached to an AnimationPlayer -## node in the scene. They save with the .tscn — no separate resource file -## needed. Undo callables hold direct Animation references (not paths). -## -## Split (issue #342, audit finding #13): -## - animation_presets.gd → preset_fade / slide / shake / pulse + helpers -## - animation_values.gd → animation_list / get / validate + shared -## value coercion / serialization -## Both submodules hold a WeakRef back to this handler. The handler's -## preset_* / list / get / validate methods are thin proxies so existing -## dispatcher registrations and test fixtures don't change. - -const AnimationPresets := preload("res://addons/godot_ai/handlers/animation_presets.gd") -const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd") - -var _undo_redo: EditorUndoRedoManager -var _presets -var _values - -const _LOOP_MODES := { - "none": Animation.LOOP_NONE, - "linear": Animation.LOOP_LINEAR, - "pingpong": Animation.LOOP_PINGPONG, -} - -const _INTERP_MODES := { - "nearest": Animation.INTERPOLATION_NEAREST, - "linear": Animation.INTERPOLATION_LINEAR, - "cubic": Animation.INTERPOLATION_CUBIC, -} - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - _presets = AnimationPresets.new(self) - _values = AnimationValues.new(self) - - -# ============================================================================ -# animation_player_create -# ============================================================================ - -func create_player(params: Dictionary) -> Dictionary: - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "AnimationPlayer") - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var player := AnimationPlayer.new() - if not node_name.is_empty(): - player.name = node_name - - # Attach the default library before adding to tree — it persists on redo. - var library := AnimationLibrary.new() - player.add_animation_library("", library) - - _undo_redo.create_action("MCP: Create AnimationPlayer %s" % player.name) - _undo_redo.add_do_method(parent, "add_child", player, true) - _undo_redo.add_do_method(player, "set_owner", scene_root) - _undo_redo.add_do_reference(player) - _undo_redo.add_do_reference(library) - _undo_redo.add_undo_method(parent, "remove_child", player) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(player, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": String(player.name), - "undoable": true, - } - } - - -# ============================================================================ -# animation_create -# ============================================================================ - -func create_animation(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("name", "") - var length: float = float(params.get("length", 1.0)) - var loop_mode_str: String = params.get("loop_mode", "none") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - if length <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "length must be > 0 (got %s)" % length) - - if not _LOOP_MODES.has(loop_mode_str): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) - - var resolved := _resolve_player(player_path, true) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_player: bool = resolved.get("player_created", false) - var player_parent: Node = resolved.get("player_parent", null) - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var overwrite: bool = params.get("overwrite", false) - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - var anim := Animation.new() - anim.length = length - anim.loop_mode = _LOOP_MODES[loop_mode_str] - - _commit_animation_add("MCP: Create animation %s" % anim_name, - player, library, created_library, anim_name, anim, old_anim, - created_player, player_parent) - - return { - "data": { - "player_path": player_path, - "name": anim_name, - "length": length, - "loop_mode": loop_mode_str, - "library_created": created_library or created_player, - "animation_player_created": created_player, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# animation_delete -# ============================================================================ - -func delete_animation(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - # Use _resolve_animation so we can delete from ANY library, not just the - # default. Mirrors the read-side symmetry with animation_get / animation_play - # which already search all libraries via _resolve_animation. - var anim_resolved := _resolve_animation(player, anim_name) - if anim_resolved.has("error"): - return anim_resolved - var old_anim: Animation = anim_resolved.animation - var library: AnimationLibrary = anim_resolved.library - # Clip key within the owning library — strips the "libname/" prefix if the - # caller passed a qualified name. - var clip_key: String = anim_name - var slash := anim_name.find("/") - if slash >= 0: - clip_key = anim_name.substr(slash + 1) - - _undo_redo.create_action("MCP: Delete animation %s" % anim_name) - _undo_redo.add_do_method(library, "remove_animation", clip_key) - _undo_redo.add_undo_method(library, "add_animation", clip_key, old_anim) - _undo_redo.add_do_reference(old_anim) # prevent GC so undo→redo works - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "library_key": anim_resolved.get("library_key", ""), - "undoable": true, - } - } - - -# ============================================================================ -# animation_add_property_track -# ============================================================================ - -func add_property_track(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - var track_path: String = params.get("track_path", "") - var keyframes = params.get("keyframes", []) - var interp_str: String = params.get("interpolation", "linear") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") - if track_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "Missing required param: track_path (format: 'NodeName:property', e.g. 'Panel:modulate')") - if not track_path.contains(":"): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "track_path must include ':property' suffix (e.g. 'Panel:modulate', '.:position')") - if not _INTERP_MODES.has(interp_str): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid interpolation '%s'. Valid: %s" % [interp_str, ", ".join(_INTERP_MODES.keys())]) - if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - var anim_resolved := _resolve_animation(player, anim_name) - if anim_resolved.has("error"): - return anim_resolved - var anim: Animation = anim_resolved.animation - - # Validate + pre-coerce keyframes before mutating. Coercion errors - # surface as INVALID_PARAMS rather than silently inserting garbage keys. - # Resolve the target property's type ONCE — dense clips used to re-walk - # get_property_list() per keyframe. - var ctx := AnimationValues.resolve_track_prop_context(track_path, player) - if ctx.has("error"): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, ctx.error) - var coerced_keyframes: Array = [] - for kf in keyframes: - if typeof(kf) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") - if not "time" in kf: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") - if not "value" in kf: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'value' field") - var coerce_result := AnimationValues.coerce_with_context(kf.get("value"), ctx) - if coerce_result.has("error"): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, coerce_result.error) - coerced_keyframes.append({ - "time": kf.get("time"), - "value": coerce_result.ok, - "transition": kf.get("transition", "linear"), - }) - - _create_scene_pinned_action("MCP: Add property track %s to %s" % [track_path, anim_name]) - _undo_redo.add_do_method(self, "_do_add_property_track", anim, track_path, interp_str, coerced_keyframes) - # Undo locates the track by (path, type) at undo time rather than caching - # an index captured at do time. Cached indices go stale if any other track - # mutation lands between do and undo (Godot editor, another MCP call, etc.) - _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, track_path, Animation.TYPE_VALUE) - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "track_path": track_path, - "interpolation": interp_str, - "keyframe_count": keyframes.size(), - "undoable": true, - } - } - - -## Insert a pre-coerced track into the animation. Callers must coerce -## values against the target property before calling this (see -## AnimationValues.coerce_value_for_track) — this method runs inside the -## undo do-method path where error propagation isn't possible. -func _do_add_property_track( - anim: Animation, - track_path: String, - interp_str: String, - keyframes: Array, -) -> void: - var idx := anim.add_track(Animation.TYPE_VALUE) - anim.track_set_path(idx, NodePath(track_path)) - anim.track_set_interpolation_type(idx, _INTERP_MODES.get(interp_str, Animation.INTERPOLATION_LINEAR)) - for kf in keyframes: - var t: float = float(kf.get("time", 0.0)) - var trans: float = AnimationValues.parse_transition(kf.get("transition", "linear")) - anim.track_insert_key(idx, t, kf.get("value"), trans) - - -# ============================================================================ -# animation_add_method_track -# ============================================================================ - -func add_method_track(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - var target_path: String = params.get("target_node_path", "") - var keyframes = params.get("keyframes", []) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_node_path") - if target_path.contains(":"): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "target_node_path is a bare NodePath without ':property' (got '%s'). " % target_path + - "Method name goes in each keyframe's 'method' field, not the path.") - if typeof(keyframes) != TYPE_ARRAY or keyframes.is_empty(): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "keyframes must be a non-empty array") - - for kf in keyframes: - if typeof(kf) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each keyframe must be a dictionary") - if not "time" in kf: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'time' field") - if not "method" in kf: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Each keyframe must have a 'method' field") - var method_field = kf.get("method") - if typeof(method_field) != TYPE_STRING or (method_field as String).is_empty(): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'method' must be a non-empty string") - if kf.has("args") and typeof(kf.get("args")) != TYPE_ARRAY: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "'args' must be an array if provided (got %s)" % type_string(typeof(kf.get("args")))) - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - var anim_resolved := _resolve_animation(player, anim_name) - if anim_resolved.has("error"): - return anim_resolved - var anim: Animation = anim_resolved.animation - - _create_scene_pinned_action("MCP: Add method track %s to %s" % [target_path, anim_name]) - _undo_redo.add_do_method(self, "_do_add_method_track", anim, target_path, keyframes) - # Undo locates the track by (path, type) at undo time — see add_property_track. - _undo_redo.add_undo_method(self, "_undo_remove_track_by_path", anim, target_path, Animation.TYPE_METHOD) - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "target_node_path": target_path, - "keyframe_count": keyframes.size(), - "undoable": true, - } - } - - -## Remove a track identified by (path, type) at undo time. Robust to -## history interleaving: if another track was added since the do, the -## find_track call still resolves to the correct index. Returns silently -## if the track is no longer present (e.g. a prior undo already removed it). -func _undo_remove_track_by_path(anim: Animation, track_path: String, track_type: int) -> void: - var idx := anim.find_track(NodePath(track_path), track_type) - if idx >= 0: - anim.remove_track(idx) - - -func _do_add_method_track(anim: Animation, target_path: String, keyframes: Array) -> void: - var idx := anim.add_track(Animation.TYPE_METHOD) - anim.track_set_path(idx, NodePath(target_path)) - for kf in keyframes: - var t: float = float(kf.get("time", 0.0)) - var method_name: String = str(kf.get("method", "")) - var args: Array = kf.get("args", []) - anim.track_insert_key(idx, t, {"method": method_name, "args": args}) - - -# ============================================================================ -# animation_set_autoplay -# ============================================================================ - -func set_autoplay(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - # Allow empty string to clear autoplay; otherwise validate the name exists. - if not anim_name.is_empty() and not player.has_animation(anim_name): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Animation '%s' not found on player at %s" % [anim_name, player_path]) - - var old_autoplay: String = player.autoplay - - _undo_redo.create_action("MCP: Set autoplay %s on %s" % [anim_name, player_path]) - _undo_redo.add_do_property(player, "autoplay", anim_name) - _undo_redo.add_undo_property(player, "autoplay", old_autoplay) - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "previous_autoplay": old_autoplay, - "cleared": anim_name.is_empty(), - "undoable": true, - } - } - - -# ============================================================================ -# animation_play (dev ergonomics — not saved with scene) -# ============================================================================ - -func play(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - if not anim_name.is_empty() and not player.has_animation(anim_name): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Animation '%s' not found on player at %s" % [anim_name, player_path]) - - player.play(anim_name) - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "undoable": false, - "reason": "Runtime playback state — not saved with scene", - } - } - - -# ============================================================================ -# animation_stop (dev ergonomics — not saved with scene) -# ============================================================================ - -func stop(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - player.stop() - - return { - "data": { - "player_path": player_path, - "undoable": false, - "reason": "Runtime playback state — not saved with scene", - } - } - - -# ============================================================================ -# animation_create_simple (composer) -# ============================================================================ - -func create_simple(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("name", "") - var tweens = params.get("tweens", []) - var loop_mode_str: String = params.get("loop_mode", "none") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - if typeof(tweens) != TYPE_ARRAY or tweens.is_empty(): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tweens must be a non-empty array") - if not _LOOP_MODES.has(loop_mode_str): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid loop_mode '%s'. Valid: %s" % [loop_mode_str, ", ".join(_LOOP_MODES.keys())]) - - # Validate all tween specs before touching the scene. - var seen_paths := {} - for spec in tweens: - if typeof(spec) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Each tween spec must be a dictionary") - for field in ["target", "property", "from", "to", "duration"]: - if not field in spec: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "Each tween spec must have '%s'" % field) - if float(spec.get("duration", 0.0)) <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "tween 'duration' must be > 0") - var dup_key: String = str(spec.target) + ":" + str(spec.property) - if seen_paths.has(dup_key): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Duplicate tween target '%s' — merge keyframes into a single track " % dup_key + - "via animation_add_property_track instead of two separate tweens.") - seen_paths[dup_key] = true - - # Compute/validate length before resolving the player — a fresh auto-created - # AnimationPlayer is a detached Node that leaks if we return after creation. - var has_length: bool = params.has("length") and params.get("length") != null - var computed_length: float = 0.0 - if has_length: - computed_length = float(params.get("length")) - if computed_length <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "'length' must be > 0 when provided (got %s)" % str(params.get("length"))) - else: - for spec in tweens: - var end_time: float = float(spec.get("delay", 0.0)) + float(spec.get("duration", 0.0)) - if end_time > computed_length: - computed_length = end_time - if computed_length <= 0.0: - computed_length = 1.0 - - var resolved := _resolve_player(player_path, true) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_player: bool = resolved.get("player_created", false) - var player_parent: Node = resolved.get("player_parent", null) - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var overwrite: bool = params.get("overwrite", false) - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - if created_player: - player.queue_free() - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - # Pre-coerce all tween values before touching the anim — coercion errors - # surface as INVALID_PARAMS, not silent garbage keyframes. - # When the player was auto-created, it isn't in the tree yet — pass its - # future parent so the coercer can still resolve target property types. - var coerce_root: Node = player_parent if created_player else null - var per_track_keyframes: Array = [] - for spec in tweens: - var target: String = str(spec.get("target", "")) - var property: String = str(spec.get("property", "")) - var track_path: String = target + ":" + property - var duration: float = float(spec.get("duration", 1.0)) - var delay: float = float(spec.get("delay", 0.0)) - var trans_str = spec.get("transition", "linear") - var from_result := AnimationValues.coerce_value_for_track(spec.get("from"), track_path, player, coerce_root) - if from_result.has("error"): - if created_player: - player.queue_free() - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, from_result.error]) - var to_result := AnimationValues.coerce_value_for_track(spec.get("to"), track_path, player, coerce_root) - if to_result.has("error"): - if created_player: - player.queue_free() - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "tween '%s': %s" % [track_path, to_result.error]) - per_track_keyframes.append({ - "track_path": track_path, - "keyframes": [ - {"time": delay, "value": from_result.ok, "transition": trans_str}, - {"time": delay + duration, "value": to_result.ok, "transition": trans_str}, - ], - }) - - # Build the animation fully in memory before touching the undo stack. - var anim := Animation.new() - anim.length = computed_length - anim.loop_mode = _LOOP_MODES[loop_mode_str] - - for entry in per_track_keyframes: - _do_add_property_track(anim, entry.track_path, "linear", entry.keyframes) - - # One atomic undo action — bundles player creation (if any), library - # creation (if any), and the animation add. A single Ctrl-Z rolls back all. - _commit_animation_add("MCP: Create animation %s (%d tracks)" % [anim_name, anim.get_track_count()], - player, library, created_library, anim_name, anim, old_anim, - created_player, player_parent) - - return { - "data": { - "player_path": player_path, - "name": anim_name, - "length": computed_length, - "loop_mode": loop_mode_str, - "track_count": anim.get_track_count(), - "library_created": created_library or created_player, - "animation_player_created": created_player, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# Proxies — preset_* and read methods live in the submodules. Kept here so -# the dispatcher registrations and `_handler.method(...)` test fixtures stay -# unchanged across the split. -# ============================================================================ - -func preset_fade(params: Dictionary) -> Dictionary: - return _presets.preset_fade(params) - - -func preset_slide(params: Dictionary) -> Dictionary: - return _presets.preset_slide(params) - - -func preset_shake(params: Dictionary) -> Dictionary: - return _presets.preset_shake(params) - - -func preset_pulse(params: Dictionary) -> Dictionary: - return _presets.preset_pulse(params) - - -func list_animations(params: Dictionary) -> Dictionary: - return _values.list_animations(params) - - -func get_animation(params: Dictionary) -> Dictionary: - return _values.get_animation(params) - - -func validate_animation(params: Dictionary) -> Dictionary: - return _values.validate_animation(params) - - -# ============================================================================ -# Helpers — undo -# ============================================================================ - -## Shared undo setup for create_animation and create_simple. Handles fresh- -## create, overwrite, library auto-create, and player auto-create in a single -## atomic action. When `created_player` is true, the player already has the -## library attached (eagerly, from `_instantiate_player`) and the library -## doesn't need its own undo bookkeeping — it rides along with the add_child. -func _commit_animation_add( - action_label: String, - player: AnimationPlayer, - library: AnimationLibrary, - created_library: bool, - anim_name: String, - anim: Animation, - old_anim: Animation, ## null when not overwriting - created_player: bool = false, - player_parent: Node = null, -) -> void: - _undo_redo.create_action(action_label) - if created_player: - var scene_root := EditorInterface.get_edited_scene_root() - _undo_redo.add_do_method(player_parent, "add_child", player, true) - _undo_redo.add_do_method(player, "set_owner", scene_root) - _undo_redo.add_do_reference(player) - _undo_redo.add_do_reference(library) - _undo_redo.add_undo_method(player_parent, "remove_child", player) - elif created_library: - _undo_redo.add_do_method(player, "add_animation_library", "", library) - _undo_redo.add_undo_method(player, "remove_animation_library", "") - _undo_redo.add_do_reference(library) - if old_anim != null: - _undo_redo.add_do_method(library, "remove_animation", anim_name) - _undo_redo.add_do_method(library, "add_animation", anim_name, anim) - if old_anim != null: - _undo_redo.add_undo_method(library, "remove_animation", anim_name) - _undo_redo.add_undo_method(library, "add_animation", anim_name, old_anim) - _undo_redo.add_do_reference(old_anim) - else: - _undo_redo.add_undo_method(library, "remove_animation", anim_name) - _undo_redo.add_do_reference(anim) - _undo_redo.commit_action() - - -## Open a `create_action` pinned to the edited scene's history. -## -## Without an explicit context, `add_do_method(self, ...)` against a -## RefCounted handler lands in GLOBAL_HISTORY while sibling actions whose -## first do-target is a Resource (e.g. AnimationLibrary) land in the scene's -## history. Mismatched histories make the test-side `editor_undo` helper -## (walks scene first) undo the wrong action, and break batch_handler's -## rollback. Mirrors `camera_handler.gd`'s identical pinning rationale. -func _create_scene_pinned_action(action_label: String) -> void: - _undo_redo.create_action( - action_label, UndoRedo.MERGE_DISABLE, EditorInterface.get_edited_scene_root(), - ) - - -# ============================================================================ -# Helpers — resolution -# ============================================================================ - -## Resolve an AnimationPlayer and its default library for write operations. -## Returns {player, library, player_created, player_parent} on success, or an -## error dict. library is null if the player exists but has no default library -## yet — callers bundle an `add_animation_library` step into their undo action. -## -## When `create_if_missing` is true and `player_path` resolves to nothing, a -## fresh AnimationPlayer is instantiated (with an empty default library attached -## eagerly) but is NOT added to the scene tree — callers must bundle the -## add_child step into their undo action via `_commit_animation_add`. -## If the resolved node exists but isn't an AnimationPlayer, that's still an -## error — we don't clobber an existing node of a different type. -func _resolve_player(player_path: String, create_if_missing: bool = false) -> Dictionary: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - var node := McpScenePath.resolve(player_path, scene_root) - if node == null: - if not create_if_missing: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(player_path, scene_root)) - return _instantiate_player(player_path, scene_root) - if not node is AnimationPlayer: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()]) - var player := node as AnimationPlayer - var lib: AnimationLibrary = null - if player.has_animation_library(""): - lib = player.get_animation_library("") - return {"player": player, "library": lib, "player_created": false, "player_parent": null} - - -## Build a new AnimationPlayer (with empty default library) for insertion under -## the parent implied by `player_path`. Returns an error dict if the parent -## can't be resolved or the path has no usable leaf name. -func _instantiate_player(player_path: String, scene_root: Node) -> Dictionary: - var slash := player_path.rfind("/") - var parent_path: String - var player_name: String - if slash < 0: - parent_path = "" - player_name = player_path - else: - parent_path = player_path.substr(0, slash) - player_name = player_path.substr(slash + 1) - if player_name.is_empty(): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Cannot auto-create AnimationPlayer: player_path '%s' has no leaf name" % player_path) - var parent: Node - if parent_path.is_empty(): - parent = scene_root - else: - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, - "Cannot auto-create AnimationPlayer at %s: %s" % [ - player_path, McpScenePath.format_parent_error(parent_path, scene_root)]) - var new_player := AnimationPlayer.new() - new_player.name = player_name - var lib := AnimationLibrary.new() - new_player.add_animation_library("", lib) - return { - "player": new_player, - "library": lib, - "player_created": true, - "player_parent": parent, - } - - -## Resolve for read operations (no library requirement). -func _resolve_player_read(player_path: String) -> Dictionary: - var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path") - if resolved.has("error"): - return resolved - var node: Node = resolved.node - if not node is AnimationPlayer: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Node at %s is not an AnimationPlayer (got %s)" % [player_path, node.get_class()]) - return {"player": node as AnimationPlayer} - - -## Resolve an animation by name, searching all libraries. -## Accepts bare clip names ("idle") and library-qualified names ("moves/idle") -## as returned by `list_animations` for non-default libraries. -func _resolve_animation(player: AnimationPlayer, anim_name: String) -> Dictionary: - if not player.has_animation(anim_name): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Animation '%s' not found on player. Available: %s" % [ - anim_name, - ", ".join(Array(player.get_animation_list())) - ]) - # If the caller passed "library/clip", look up in that specific library. - var slash := anim_name.find("/") - if slash >= 0: - var lib_key := anim_name.substr(0, slash) - var clip_key := anim_name.substr(slash + 1) - if player.has_animation_library(lib_key): - var lib: AnimationLibrary = player.get_animation_library(lib_key) - if lib.has_animation(clip_key): - return {"animation": lib.get_animation(clip_key), "library": lib, "library_key": lib_key} - # Otherwise scan libraries for a bare clip name. - for lib_name in player.get_animation_library_list(): - var lib2: AnimationLibrary = player.get_animation_library(lib_name) - if lib2.has_animation(anim_name): - return {"animation": lib2.get_animation(anim_name), "library": lib2, "library_key": lib_name} - # Fallback — shouldn't happen if has_animation returned true. - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Animation found by player but not in any library") diff --git a/addons/godot_ai/handlers/animation_handler.gd.uid b/addons/godot_ai/handlers/animation_handler.gd.uid deleted file mode 100644 index 934f665..0000000 --- a/addons/godot_ai/handlers/animation_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0jrius46xsd4 diff --git a/addons/godot_ai/handlers/animation_presets.gd b/addons/godot_ai/handlers/animation_presets.gd deleted file mode 100644 index 40f1ddc..0000000 --- a/addons/godot_ai/handlers/animation_presets.gd +++ /dev/null @@ -1,536 +0,0 @@ -@tool -extends RefCounted - -## Curated motion presets for the AnimationPlayer surface. -## -## Each preset_* method: -## 1. Validates params + resolves the player (auto-creating its default lib). -## 2. Resolves the target node + classifies it as control / 2d / 3d. -## 3. Builds a single-track Animation with shape-appropriate keyframes. -## 4. Commits the add through the handler's shared `_commit_animation_add` -## so a single Ctrl-Z rolls back any auto-created library + the animation. -## -## Holds a WeakRef back to the AnimationHandler instance so the handler can -## continue to own this module strongly via `_presets` without forming a -## RefCounted cycle. Resolution / undo helpers live on the handler — keeping -## the `_undo_redo` member single-source there avoids drift. - - -const AnimationValues := preload("res://addons/godot_ai/handlers/animation_values.gd") -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const ScenePath := preload("res://addons/godot_ai/utils/scene_path.gd") - - -var _handler_weak: WeakRef - - -func _init(handler) -> void: - _handler_weak = weakref(handler) - - -func _h(): - return _handler_weak.get_ref() - - -# ============================================================================ -# animation_preset_fade -# ============================================================================ - -func preset_fade(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var target_path: String = params.get("target_path", "") - var mode: String = params.get("mode", "in") - var duration: float = float(params.get("duration", 0.5)) - var anim_name: String = params.get("animation_name", "") - var overwrite: bool = params.get("overwrite", false) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") - if mode != "in" and mode != "out": - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid mode '%s'. Valid: 'in', 'out'" % mode) - if duration <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var target_resolved := _resolve_preset_target(player, target_path) - if target_resolved.has("error"): - return target_resolved - var target: Node = target_resolved.node - var track_target: String = target_resolved.track_path_root - - # Fade requires a `modulate` property (CanvasItem/Control/Node2D/Sprite3D/etc). - var has_modulate := false - for p in target.get_property_list(): - if p.name == "modulate": - has_modulate = true - break - if not has_modulate: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Target '%s' (class %s) has no 'modulate' property — fade requires a CanvasItem, Control, Node2D, or Sprite3D" - % [target_path, target.get_class()]) - - if anim_name.is_empty(): - anim_name = "fade_%s" % mode - - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - var start_a: float = 0.0 if mode == "in" else 1.0 - var end_a: float = 1.0 if mode == "in" else 0.0 - - var anim := Animation.new() - anim.length = duration - anim.loop_mode = Animation.LOOP_NONE - - var track_path := "%s:modulate:a" % track_target - handler._do_add_property_track(anim, track_path, "linear", [ - {"time": 0.0, "value": start_a, "transition": "linear"}, - {"time": duration, "value": end_a, "transition": "linear"}, - ]) - - handler._commit_animation_add( - "MCP: Create animation %s" % anim_name, - player, library, created_library, anim_name, anim, old_anim, - ) - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "mode": mode, - "length": duration, - "track_count": anim.get_track_count(), - "library_created": created_library, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# animation_preset_slide -# ============================================================================ - -func preset_slide(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var target_path: String = params.get("target_path", "") - var direction: String = params.get("direction", "left") - var mode: String = params.get("mode", "in") - var duration: float = float(params.get("duration", 0.4)) - var anim_name: String = params.get("animation_name", "") - var overwrite: bool = params.get("overwrite", false) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") - if not ["left", "right", "up", "down"].has(direction): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid direction '%s'. Valid: 'left', 'right', 'up', 'down'" % direction) - if mode != "in" and mode != "out": - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid mode '%s'. Valid: 'in', 'out'" % mode) - if duration <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var target_resolved := _resolve_preset_target(player, target_path) - if target_resolved.has("error"): - return target_resolved - var target = target_resolved.node - var kind: String = target_resolved.kind - var track_target: String = target_resolved.track_path_root - - # Default distance picks 3D units vs screen pixels based on target kind. - var default_distance: float = 1.0 if kind == "3d" else 100.0 - var distance: float = float(params.get("distance", default_distance)) - if distance == 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'distance' must be non-zero") - - var offset: Variant = _direction_offset(kind, direction, distance) - var current_pos: Variant = target.position - var start_pos: Variant - var end_pos: Variant - if mode == "in": - start_pos = current_pos + offset - end_pos = current_pos - else: - start_pos = current_pos - end_pos = current_pos + offset - - if anim_name.is_empty(): - anim_name = "slide_%s_%s" % [mode, direction] - - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - var anim := Animation.new() - anim.length = duration - anim.loop_mode = Animation.LOOP_NONE - - var track_path := "%s:position" % track_target - handler._do_add_property_track(anim, track_path, "linear", [ - {"time": 0.0, "value": start_pos, "transition": "linear"}, - {"time": duration, "value": end_pos, "transition": "linear"}, - ]) - - handler._commit_animation_add( - "MCP: Create animation %s" % anim_name, - player, library, created_library, anim_name, anim, old_anim, - ) - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "direction": direction, - "mode": mode, - "distance": distance, - "length": duration, - "track_count": anim.get_track_count(), - "library_created": created_library, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# animation_preset_shake -# ============================================================================ - -func preset_shake(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var target_path: String = params.get("target_path", "") - var duration: float = float(params.get("duration", 0.3)) - var frequency: float = float(params.get("frequency", 30.0)) - var rng_seed: int = int(params.get("seed", 0)) - var anim_name: String = params.get("animation_name", "") - var overwrite: bool = params.get("overwrite", false) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") - if duration <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") - if frequency <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'frequency' must be > 0") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var target_resolved := _resolve_preset_target(player, target_path) - if target_resolved.has("error"): - return target_resolved - var target = target_resolved.node - var kind: String = target_resolved.kind - var track_target: String = target_resolved.track_path_root - - var default_intensity: float = 0.1 if kind == "3d" else 10.0 - var intensity: float = float(params.get("intensity", default_intensity)) - if intensity <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'intensity' must be > 0") - - if anim_name.is_empty(): - anim_name = "shake" - - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - var rng := RandomNumberGenerator.new() - if rng_seed != 0: - rng.seed = rng_seed - else: - rng.randomize() - - # Samples between t=0 and t=duration (exclusive); bookended by at-rest keys. - var sample_count: int = int(ceil(frequency * duration)) - if sample_count < 2: - sample_count = 2 - - var current_pos: Variant = target.position - var kfs: Array = [] - kfs.append({"time": 0.0, "value": current_pos, "transition": "linear"}) - for i in range(1, sample_count): - var t: float = (float(i) / float(sample_count)) * duration - var jx: float = rng.randf_range(-intensity, intensity) - var jy: float = rng.randf_range(-intensity, intensity) - var jittered: Variant - if kind == "3d": - var jz: float = rng.randf_range(-intensity, intensity) - jittered = current_pos + Vector3(jx, jy, jz) - else: - jittered = current_pos + Vector2(jx, jy) - kfs.append({"time": t, "value": jittered, "transition": "linear"}) - kfs.append({"time": duration, "value": current_pos, "transition": "linear"}) - - var anim := Animation.new() - anim.length = duration - anim.loop_mode = Animation.LOOP_NONE - - var track_path := "%s:position" % track_target - handler._do_add_property_track(anim, track_path, "linear", kfs) - - handler._commit_animation_add( - "MCP: Create animation %s" % anim_name, - player, library, created_library, anim_name, anim, old_anim, - ) - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "length": duration, - "frequency": frequency, - "intensity": intensity, - "keyframe_count": kfs.size(), - "track_count": anim.get_track_count(), - "library_created": created_library, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# animation_preset_pulse -# ============================================================================ - -func preset_pulse(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var target_path: String = params.get("target_path", "") - var from_scale: float = float(params.get("from_scale", 1.0)) - var to_scale: float = float(params.get("to_scale", 1.1)) - var duration: float = float(params.get("duration", 0.4)) - var anim_name: String = params.get("animation_name", "") - var overwrite: bool = params.get("overwrite", false) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") - if duration <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'duration' must be > 0") - if from_scale <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'from_scale' must be > 0") - if to_scale <= 0.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "'to_scale' must be > 0") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - var library: AnimationLibrary = resolved.library - var created_library := false - if library == null: - library = AnimationLibrary.new() - created_library = true - - var target_resolved := _resolve_preset_target(player, target_path) - if target_resolved.has("error"): - return target_resolved - var kind: String = target_resolved.kind - var track_target: String = target_resolved.track_path_root - - if anim_name.is_empty(): - anim_name = "pulse" - - var old_anim: Animation = null - if library.has_animation(anim_name): - if not overwrite: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Animation '%s' already exists. Pass overwrite=true or delete it first." % anim_name) - old_anim = library.get_animation(anim_name) - - var from_vec: Variant - var to_vec: Variant - if kind == "3d": - from_vec = Vector3(from_scale, from_scale, from_scale) - to_vec = Vector3(to_scale, to_scale, to_scale) - else: - from_vec = Vector2(from_scale, from_scale) - to_vec = Vector2(to_scale, to_scale) - - var anim := Animation.new() - anim.length = duration - anim.loop_mode = Animation.LOOP_NONE - - var track_path := "%s:scale" % track_target - handler._do_add_property_track(anim, track_path, "linear", [ - {"time": 0.0, "value": from_vec, "transition": "linear"}, - {"time": duration * 0.5, "value": to_vec, "transition": "linear"}, - {"time": duration, "value": from_vec, "transition": "linear"}, - ]) - - handler._commit_animation_add( - "MCP: Create animation %s" % anim_name, - player, library, created_library, anim_name, anim, old_anim, - ) - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "from_scale": from_scale, - "to_scale": to_scale, - "length": duration, - "track_count": anim.get_track_count(), - "library_created": created_library, - "overwritten": old_anim != null, - "undoable": true, - } - } - - -# ============================================================================ -# Helpers — preset resolution -# ============================================================================ - -## Resolve a preset target node and classify its transform kind. -## -## Accepts two `target_path` shapes: -## * Scene-absolute (starts with "/") — resolved through `ScenePath.resolve`, -## matching the convention used by every other scene-mutating tool. Targets -## outside the player's `root_node` subtree are converted to `..`-prefixed -## paths via `root_node.get_path_to(target)`, mirroring what the relative -## form accepts and how Godot stores track paths. -## * Relative — used as-is against the player's `root_node`, matching how -## animation tracks themselves are stored. -## -## Returns `{node, kind, track_path_root}` where `track_path_root` is the path -## (relative to `root_node`) that callers should embed in the track path. For -## scene-absolute inputs this is the converted relative path; for relative -## inputs it equals the input. `kind` ∈ {"control", "2d", "3d"}. -## -## Mirrors the same root-node fallback that -## `AnimationValues.resolve_track_prop_context` uses so tool inputs match how -## the track path will resolve at playback. -func _resolve_preset_target(player: AnimationPlayer, target_path: String) -> Dictionary: - var root_node := AnimationValues.player_root_node(player) - if root_node == null: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "AnimationPlayer at %s has no resolvable root_node (is the scene open?)" % str(player.get_path())) - - var target: Node = null - var track_path_root: String = target_path - if target_path.begins_with("/"): - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Cannot resolve scene-absolute target_path '%s': no scene open" % target_path) - target = ScenePath.resolve(target_path, scene_root) - if target == null: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - ScenePath.format_node_error(target_path, scene_root)) - # Convert to a root_node-relative path. For targets outside the - # subtree this yields a `..`-prefixed path, matching what the - # relative form already accepts (root_node.get_node_or_null - # resolves `..` segments) and what Godot's animation engine - # stores natively. - track_path_root = str(root_node.get_path_to(target)) - else: - target = root_node.get_node_or_null(target_path) - if target == null: - # root_node.get_path() leaks the editor's SubViewport-wrapped - # path; use the clean scene-relative form so the hint is - # actionable. - var scene_root := EditorInterface.get_edited_scene_root() - var root_hint := ScenePath.from_node(root_node, scene_root) if scene_root != null else str(root_node.name) - var abs_example := "/%s/path/to/target" % scene_root.name if scene_root != null else "/SceneRoot/path/to/target" - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - ("Target node not found at '%s' (resolved relative to AnimationPlayer's root_node '%s'). " - + "Pass a path relative to root_node (e.g. \"path/to/target\") or a scene-absolute path (e.g. \"%s\").") - % [target_path, root_hint, abs_example]) - - var kind: String - if target is Control: - kind = "control" - elif target is Node2D: - kind = "2d" - elif target is Node3D: - kind = "3d" - else: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Target '%s' must be a Control, Node2D, or Node3D (got %s)" % [target_path, target.get_class()]) - return {"node": target, "kind": kind, "track_path_root": track_path_root} - - -## Build a directional offset for slide presets. -## Axis conventions: -## Control + Node2D (screen-space, y-down): left/right = ∓x, up = -y, down = +y -## Node3D (world-up): left/right = ∓x, up = +y, down = -y -static func _direction_offset(kind: String, direction: String, distance: float) -> Variant: - if kind == "3d": - match direction: - "left": return Vector3(-distance, 0.0, 0.0) - "right": return Vector3(distance, 0.0, 0.0) - "up": return Vector3(0.0, distance, 0.0) - "down": return Vector3(0.0, -distance, 0.0) - else: - match direction: - "left": return Vector2(-distance, 0.0) - "right": return Vector2(distance, 0.0) - "up": return Vector2(0.0, -distance) - "down": return Vector2(0.0, distance) - return null diff --git a/addons/godot_ai/handlers/animation_presets.gd.uid b/addons/godot_ai/handlers/animation_presets.gd.uid deleted file mode 100644 index f463501..0000000 --- a/addons/godot_ai/handlers/animation_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4s3h78bwvr6w diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd deleted file mode 100644 index b1553df..0000000 --- a/addons/godot_ai/handlers/animation_values.gd +++ /dev/null @@ -1,442 +0,0 @@ -@tool -extends RefCounted - -const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") - -## Read-only animation introspection + shared value-coercion / serialization. -## -## Holds: -## - Static helpers used by both the write handler (track building, simple -## composer) and the preset module (target/property resolution). -## - Instance methods that back the read MCP ops: animation_list, -## animation_get, animation_validate. -## -## The instance methods need the handler to resolve players / animations. -## To keep that without introducing a RefCounted cycle (the handler holds a -## strong ref to this module via `_values`), the back-pointer is a WeakRef. -## When the handler is freed during plugin teardown, _h() returns null and -## the (no-longer-routable) calls short-circuit to a generic editor-not-ready -## error — matches the dispatcher already being torn down at that point. - - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const PropertyErrors := preload("res://addons/godot_ai/handlers/_property_errors.gd") - - -const _NAMED_TRANSITIONS := { - "linear": 1.0, - "ease_in": 2.0, - "ease_out": 0.5, - "ease_in_out": -2.0, -} - -## Component letters accepted on each aggregate base type, paired with the -## scalar Variant type the component resolves to. A subpath like `position:y` -## on a Vector3 maps to TYPE_FLOAT; on a Vector3i it maps to TYPE_INT. -const _SUBPATH_COMPONENTS := { - TYPE_VECTOR2: ["xy", TYPE_FLOAT], - TYPE_VECTOR3: ["xyz", TYPE_FLOAT], - TYPE_VECTOR4: ["xyzw", TYPE_FLOAT], - TYPE_QUATERNION: ["xyzw", TYPE_FLOAT], - TYPE_COLOR: ["rgba", TYPE_FLOAT], - TYPE_VECTOR2I: ["xy", TYPE_INT], - TYPE_VECTOR3I: ["xyz", TYPE_INT], - TYPE_VECTOR4I: ["xyzw", TYPE_INT], -} - - -var _handler_weak: WeakRef - - -func _init(handler) -> void: - _handler_weak = weakref(handler) - - -func _h(): - return _handler_weak.get_ref() - - -# ============================================================================ -# animation_list (read) -# ============================================================================ - -func list_animations(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player_read(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - var animations: Array[Dictionary] = [] - for lib_name in player.get_animation_library_list(): - var lib: AnimationLibrary = player.get_animation_library(lib_name) - for anim_name in lib.get_animation_list(): - var anim: Animation = lib.get_animation(anim_name) - var display_name: String = anim_name if lib_name == "" else "%s/%s" % [lib_name, anim_name] - animations.append({ - "name": display_name, - "length": anim.length, - "loop_mode": loop_mode_to_string(anim.loop_mode), - "track_count": anim.get_track_count(), - }) - - return { - "data": { - "player_path": player_path, - "animations": animations, - "count": animations.size(), - } - } - - -# ============================================================================ -# animation_get (read) -# ============================================================================ - -func get_animation(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player_read(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - var anim_resolved: Dictionary = handler._resolve_animation(player, anim_name) - if anim_resolved.has("error"): - return anim_resolved - var anim: Animation = anim_resolved.animation - - var tracks: Array[Dictionary] = [] - for i in anim.get_track_count(): - var track_type := anim.track_get_type(i) - var type_name := track_type_to_string(track_type) - var keys: Array[Dictionary] = [] - for k in anim.track_get_key_count(i): - var key_val = anim.track_get_key_value(i, k) - keys.append({ - "time": anim.track_get_key_time(i, k), - "value": serialize_value(key_val), - "transition": anim.track_get_key_transition(i, k), - }) - tracks.append({ - "index": i, - "type": type_name, - "path": str(anim.track_get_path(i)), - "interpolation": interp_to_string(anim.track_get_interpolation_type(i)), - "key_count": keys.size(), - "keys": keys, - }) - - return { - "data": { - "player_path": player_path, - "name": anim_name, - "length": anim.length, - "loop_mode": loop_mode_to_string(anim.loop_mode), - "track_count": anim.get_track_count(), - "tracks": tracks, - } - } - - -# ============================================================================ -# animation_validate (read-only) -# ============================================================================ - -func validate_animation(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var anim_name: String = params.get("animation_name", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if anim_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: animation_name") - - var handler = _h() - if handler == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "AnimationHandler not available", false) - var resolved: Dictionary = handler._resolve_player_read(player_path) - if resolved.has("error"): - return resolved - var player: AnimationPlayer = resolved.player - - if not player.has_animation(anim_name): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Animation '%s' not found on player at %s" % [anim_name, player_path]) - - var anim: Animation = player.get_animation(anim_name) - - var root_node := player_root_node(player) - - var broken_tracks: Array[Dictionary] = [] - var valid_count := 0 - - for i in anim.get_track_count(): - var track_path_str := str(anim.track_get_path(i)) - # Split on the FIRST colon (node↔property boundary), not the last. - # Godot's get_node_or_null strips the ":property" tail natively, so - # the valid/broken classification is the same either way — but for - # BROKEN tracks the broken_tracks[].node_path field is what callers - # read to diagnose the missing node, and rfind would surface - # "MissingTarget:modulate" instead of "MissingTarget" for subpath - # tracks like the "Target:modulate:a" shape preset_fade emits. - var colon := track_path_str.find(":") - var node_part: String - if colon >= 0: - node_part = track_path_str.substr(0, colon) - else: - node_part = track_path_str - - var target_node: Node = null - if root_node != null: - target_node = root_node.get_node_or_null(node_part) - - if target_node == null: - broken_tracks.append({ - "index": i, - "path": track_path_str, - "type": track_type_to_string(anim.track_get_type(i)), - "issue": "node_not_found", - "node_path": node_part, - }) - else: - valid_count += 1 - - return { - "data": { - "player_path": player_path, - "animation_name": anim_name, - "track_count": anim.get_track_count(), - "valid_count": valid_count, - "broken_count": broken_tracks.size(), - "broken_tracks": broken_tracks, - "valid": broken_tracks.is_empty(), - } - } - - -# ============================================================================ -# Static helpers — shared with handler + presets -# ============================================================================ - -## Resolve the effective root node an AnimationPlayer animates against. -## Falls back to the player's parent when the explicit root_node NodePath is -## empty or unresolvable. Returns null when the player isn't in the tree. -## -## Mirrors the resolution Godot does at playback time so the validator, -## preset target resolver, and track-property coercer all see the same root. -static func player_root_node(player: AnimationPlayer) -> Node: - if not player.is_inside_tree(): - return null - var rn := player.root_node - if rn != NodePath(): - var n := player.get_node_or_null(rn) - if n != null: - return n - return player.get_parent() - - -## Coerce a JSON value to match the expected Godot type for the given -## track_path. Returns {"ok": value} or {"error": msg}. -## Passes the raw value through when the target node isn't in the scene -## yet (authoring-time path). Errors when the target exists but the -## property doesn't, or when parsing a typed value (Color/Vector2/Vector3) -## clearly fails — better to reject than silently store garbage. -## `override_root_node` lets callers supply the root to resolve target paths -## against when the player isn't in the tree yet (auto-create flow) — the -## player's future parent stands in for the root the AnimationPlayer will -## eventually use. -static func coerce_value_for_track(value: Variant, track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary: - var ctx := resolve_track_prop_context(track_path, player, override_root_node) - if ctx.has("error"): - return {"error": ctx.error} - return coerce_with_context(value, ctx) - - -## Resolve a track_path's target property type once, so callers coercing many -## keyframes avoid walking `get_property_list()` on every one. Returns: -## {pass_through: true} — no resolution / authoring-time -## {pass_through: false, prop_type, prop_name} — coerce against this type -## {error: msg} — property not found on target -## -## Supports Godot's native NodePath subpath form `property:sub` (e.g. -## `position:y`, `modulate:a`) — splits on the FIRST colon (node↔property -## boundary), resolves the base property on the target, and for known -## scalar subpaths (x/y/z/w on vectors, r/g/b/a on Color) narrows the -## coerce target to TYPE_FLOAT so JSON numbers land as floats, not dicts. -static func resolve_track_prop_context(track_path: String, player: AnimationPlayer, override_root_node: Node = null) -> Dictionary: - var colon := track_path.find(":") - if colon < 0: - return {"pass_through": true} - - var node_part := track_path.substr(0, colon) - var prop_full := track_path.substr(colon + 1) - - # Property may include a subpath: "position:y", "modulate:a", etc. - var sub_colon := prop_full.find(":") - var prop_base := prop_full if sub_colon < 0 else prop_full.substr(0, sub_colon) - var prop_sub := "" if sub_colon < 0 else prop_full.substr(sub_colon + 1) - - var root_node: Node = override_root_node - if root_node == null: - root_node = player_root_node(player) - if root_node == null: - return {"pass_through": true} - - var target: Node = root_node.get_node_or_null(node_part) - if target == null: - # Target node isn't in the scene yet — authoring-time path. Pass through. - return {"pass_through": true} - - for p in target.get_property_list(): - if p.name == prop_base: - var base_type: int = p.get("type", TYPE_NIL) - var coerce_type := base_type - if not prop_sub.is_empty(): - var sub_type := subpath_component_type(base_type, prop_sub) - if sub_type == TYPE_NIL: - # Unknown subpath component — pass through so Godot's own - # NodePath resolution raises at playback if it's truly bogus, - # rather than fabricating a coerce error for a valid-but- - # uncommon form (e.g. Transform3D subpaths). - return {"pass_through": true} - coerce_type = sub_type - return { - "pass_through": false, - "prop_type": coerce_type, - "prop_name": prop_full, - } - - # Target exists but the property doesn't. Reject loudly — silently storing - # the raw value here produces garbage keyframes at playback time. - return {"error": - "%s (target path: '%s')" % - [PropertyErrors.build_message(target, prop_base), node_part]} - - -## Map a `property:sub` subpath to its scalar component type. Returns -## TYPE_NIL when the base type / subkey pair isn't one we recognise — -## callers pass-through in that case rather than mis-coerce. -static func subpath_component_type(base_type: int, sub: String) -> int: - var entry = _SUBPATH_COMPONENTS.get(base_type) - if entry == null or sub.length() != 1: - return TYPE_NIL - return entry[1] if (entry[0] as String).contains(sub) else TYPE_NIL - - -static func coerce_with_context(value: Variant, ctx: Dictionary) -> Dictionary: - if ctx.get("pass_through", false): - return {"ok": value} - return coerce_for_type(value, ctx.prop_type, ctx.prop_name) - - -## Coerce a single value to the given Godot variant type. Returns -## {"ok": coerced} or {"error": msg}. Unknown types pass through. -static func coerce_for_type(value: Variant, prop_type: int, prop_name: String) -> Dictionary: - match prop_type: - TYPE_COLOR: - ## Canonical strict parser (#714): same shapes as every other - ## color-accepting handler, including [r,g,b(,a)] arrays. - var col = McpJsonValues.parse_color(value) - if col != null: - return {"ok": col} - return {"error": "Cannot coerce value to Color for property '%s' (expected \"#rrggbb(aa)\"/named string, {r,g,b[,a]}, [r,g,b(,a)], or Color)" % prop_name} - TYPE_VECTOR2: - var v2 = McpJsonValues.parse_vector2(value) - if v2 != null: - return {"ok": v2} - return {"error": "Cannot coerce value to Vector2 for property '%s' (expected {x,y}, [x,y], or Vector2)" % prop_name} - TYPE_VECTOR3: - var v3 = McpJsonValues.parse_vector3(value) - if v3 != null: - return {"ok": v3} - return {"error": "Cannot coerce value to Vector3 for property '%s' (expected {x,y,z}, [x,y,z], or Vector3)" % prop_name} - TYPE_FLOAT: - if value is int or value is float: - return {"ok": float(value)} - TYPE_INT: - if value is float or value is int: - return {"ok": int(value)} - TYPE_BOOL: - if value is int or value is float or value is bool: - return {"ok": bool(value)} - return {"ok": value} - - -# ============================================================================ -# Static helpers — parsing + serializing -# ============================================================================ - -## Parse a transition value: named string or raw float. -## Named values live in `_NAMED_TRANSITIONS` so the mapping has a single source. -static func parse_transition(v: Variant) -> float: - if v is float or v is int: - return float(v) - if v is String: - var key: String = (v as String).to_lower() - if _NAMED_TRANSITIONS.has(key): - return float(_NAMED_TRANSITIONS[key]) - return 1.0 - - -## Map an Animation.TrackType enum to a stable string. Unknown types report -## as "unknown" rather than being silently coerced to "method" — callers that -## only produce value/method tracks can ignore the others; clients that want -## to round-trip bezier/audio/etc. get an honest label to key off. -static func track_type_to_string(track_type: int) -> String: - match track_type: - Animation.TYPE_VALUE: return "value" - Animation.TYPE_METHOD: return "method" - Animation.TYPE_POSITION_3D: return "position_3d" - Animation.TYPE_ROTATION_3D: return "rotation_3d" - Animation.TYPE_SCALE_3D: return "scale_3d" - Animation.TYPE_BLEND_SHAPE: return "blend_shape" - Animation.TYPE_BEZIER: return "bezier" - Animation.TYPE_AUDIO: return "audio" - Animation.TYPE_ANIMATION: return "animation" - _: return "unknown" - - -static func loop_mode_to_string(mode: int) -> String: - match mode: - Animation.LOOP_LINEAR: return "linear" - Animation.LOOP_PINGPONG: return "pingpong" - _: return "none" - - -static func interp_to_string(mode: int) -> String: - match mode: - Animation.INTERPOLATION_NEAREST: return "nearest" - Animation.INTERPOLATION_CUBIC: return "cubic" - _: return "linear" - - -## Convert a Godot Variant to a JSON-safe value. -static func serialize_value(value: Variant) -> Variant: - ## Delegates to the shared serializer (#714) — the drifted private copy - ## stringified rotation_3d keyframe Quaternions into opaque text where - ## McpVariantSerializer emits the {x,y,z,w} dict callers can round-trip - ## (it also NaN/Inf-guards floats, matching the wire contract). - return VariantSerializer.serialize(value) diff --git a/addons/godot_ai/handlers/animation_values.gd.uid b/addons/godot_ai/handlers/animation_values.gd.uid deleted file mode 100644 index 5d2a8b7..0000000 --- a/addons/godot_ai/handlers/animation_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bguta2eb8blgf diff --git a/addons/godot_ai/handlers/api_handler.gd b/addons/godot_ai/handlers/api_handler.gd deleted file mode 100644 index acfeda5..0000000 --- a/addons/godot_ai/handlers/api_handler.gd +++ /dev/null @@ -1,89 +0,0 @@ -@tool -extends RefCounted - -## Read-only access to version-correct Godot class metadata. - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") -const FuzzySuggestions := preload("res://addons/godot_ai/utils/fuzzy_suggestions.gd") - -func get_class_info(params: Dictionary) -> Dictionary: - var requested_class: String = params.get("class_name", "") - if requested_class.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "Missing required param: class_name" - ) - if not ClassDB.class_exists(requested_class): - var script_class := _global_script_class(requested_class) - if not script_class.is_empty(): - return _script_class_error(requested_class, script_class) - return _unknown_class_error(requested_class) - if params.has("limit") and int(params.get("limit")) < 0: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "limit must be >= 0; use limit=0 only when an unlimited section is needed" - ) - var section_check := ClassIntrospection.validate_sections( - params.get("sections", ClassIntrospection.DEFAULT_SECTIONS) - ) - if not section_check.invalid.is_empty(): - return _invalid_sections_error(section_check.invalid) - return {"data": ClassIntrospection.build(requested_class, params)} - - -static func _unknown_class_error(requested_class: String) -> Dictionary: - var suggestions := _suggest_classes(requested_class) - var message := "Unknown Godot class: %s" % requested_class - if not suggestions.is_empty(): - message += ". Did you mean: %s?" % ", ".join(suggestions) - var result := ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, message) - result["error"]["data"] = {"suggestions": suggestions} - return result - - -static func _suggest_classes(requested_class: String) -> Array[String]: - return FuzzySuggestions.rank(requested_class, ClassDB.get_class_list()) - - -static func _global_script_class(requested_class: String) -> Dictionary: - for raw_info in ProjectSettings.get_global_class_list(): - var info: Dictionary = raw_info - if info.get("class", "") == requested_class: - return info - return {} - - -static func _script_class_error(requested_class: String, script_class: Dictionary) -> Dictionary: - var path := str(script_class.get("path", "")) - var base := str(script_class.get("base", "")) - var message := ( - "%s is a project script class, not a ClassDB class. " - + "Use script_manage(op=\"find_symbols\", params={\"path\": \"%s\"}) for script symbols." - ) % [requested_class, path] - var result := ErrorCodes.make(ErrorCodes.WRONG_TYPE, message) - result["error"]["data"] = { - "script_class": true, - "class_name": requested_class, - "base_class": base, - "path": path, - } - return result - - -static func _invalid_sections_error(invalid_sections: Array[String]) -> Dictionary: - var suggestions := {} - for section in invalid_sections: - suggestions[section] = FuzzySuggestions.rank( - section, - ClassIntrospection.SUGGESTABLE_SECTION_TOKENS, - 3, - 0.3 - ) - var message := "Unknown class-info section(s): %s. Valid sections: %s (or \"all\" for all documentation sections; \"inheritors\" must be requested by name)" % [ - ", ".join(invalid_sections), - ", ".join(ClassIntrospection.KNOWN_SECTIONS), - ] - var result := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, message) - result["error"]["data"] = {"suggestions": suggestions} - return result diff --git a/addons/godot_ai/handlers/api_handler.gd.uid b/addons/godot_ai/handlers/api_handler.gd.uid deleted file mode 100644 index f519c48..0000000 --- a/addons/godot_ai/handlers/api_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://v3rkd7ueunii diff --git a/addons/godot_ai/handlers/audio_handler.gd b/addons/godot_ai/handlers/audio_handler.gd deleted file mode 100644 index 79dea56..0000000 --- a/addons/godot_ai/handlers/audio_handler.gd +++ /dev/null @@ -1,361 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles AudioStreamPlayer / 2D / 3D authoring — node creation, stream -## assignment, playback-property edits, and real editor preview playback. -## -## Stream assignment loads a Godot-imported AudioStream resource from -## res:// (the editor's import step converts .ogg / .wav / .mp3 into a -## streamable AudioStream subclass before we ever see it). -## -## play() / stop() call the live node method directly — no undo, no -## persistence; they match what the inspector's play button does. - - -const _VALID_TYPES := { - "1d": "AudioStreamPlayer", - "2d": "AudioStreamPlayer2D", - "3d": "AudioStreamPlayer3D", -} - -## Whitelist of playback properties settable via audio_player_set_playback. -## Each value is the expected Variant type of the param dict value. -const _PLAYBACK_KEYS := { - "volume_db": TYPE_FLOAT, - "pitch_scale": TYPE_FLOAT, - "autoplay": TYPE_BOOL, - "bus": TYPE_STRING, -} - - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -# ============================================================================ -# audio_player_create -# ============================================================================ - -func create_player(params: Dictionary) -> Dictionary: - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "AudioStreamPlayer") - var type_str: String = params.get("type", "1d") - - if not _VALID_TYPES.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid audio player type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] - ) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var node := _instantiate_player(type_str) - if node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate audio player") - if not node_name.is_empty(): - node.name = node_name - - _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name]) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": String(node.name), - "type": type_str, - "class": _VALID_TYPES[type_str], - "undoable": true, - } - } - - -# ============================================================================ -# audio_player_set_stream -# ============================================================================ - -func set_stream(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var stream_path: String = params.get("stream_path", "") - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - if stream_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: stream_path") - - var stream_path_err = McpPathValidator.loadable_error(stream_path, "stream_path") - if stream_path_err != null: - return stream_path_err - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: Node = resolved.player - - if not ResourceLoader.exists(stream_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "AudioStream not found: %s" % stream_path) - var loaded := ResourceLoader.load(stream_path) - if loaded == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load AudioStream: %s" % stream_path) - if not (loaded is AudioStream): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Resource at %s is not an AudioStream (got %s)" % [stream_path, loaded.get_class()] - ) - - var old_stream: AudioStream = player.stream - - _undo_redo.create_action("MCP: Set audio stream on %s" % player.name) - _undo_redo.add_do_property(player, "stream", loaded) - _undo_redo.add_undo_property(player, "stream", old_stream) - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "stream_path": stream_path, - "stream_class": loaded.get_class(), - "duration_seconds": float(loaded.get_length()), - "undoable": true, - } - } - - -# ============================================================================ -# audio_player_set_playback -# ============================================================================ - -func set_playback(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: Node = resolved.player - - var updates: Dictionary = {} - for key in _PLAYBACK_KEYS: - if params.has(key): - var expected_type: int = _PLAYBACK_KEYS[key] - var value = params.get(key) - var coerced = _coerce_playback_value(value, expected_type) - if coerced == null: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Invalid value for %s: expected %s, got %s" % [ - key, type_string(expected_type), type_string(typeof(value)) - ] - ) - updates[key] = coerced - - if updates.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "At least one of %s is required" % ", ".join(_PLAYBACK_KEYS.keys()) - ) - - var old_values: Dictionary = {} - for key in updates: - old_values[key] = player.get(key) - - _undo_redo.create_action("MCP: Update playback on %s" % player.name) - for key in updates: - _undo_redo.add_do_property(player, key, updates[key]) - _undo_redo.add_undo_property(player, key, old_values[key]) - _undo_redo.commit_action() - - return { - "data": { - "player_path": player_path, - "applied": updates.keys(), - "values": updates, - "undoable": true, - } - } - - -# ============================================================================ -# audio_play (runtime preview — not saved with scene) -# ============================================================================ - -func play(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - var from_position: float = float(params.get("from_position", 0.0)) - - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: Node = resolved.player - - if player.stream == null: - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "Player has no stream assigned — call audio_player_set_stream first" - ) - - player.play(from_position) - - return { - "data": { - "player_path": player_path, - "from_position": from_position, - "playing": bool(player.playing), - "undoable": false, - "reason": "Runtime playback state — not saved with scene", - } - } - - -# ============================================================================ -# audio_stop (runtime preview — not saved with scene) -# ============================================================================ - -func stop(params: Dictionary) -> Dictionary: - var player_path: String = params.get("player_path", "") - if player_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: player_path") - - var resolved := _resolve_player(player_path) - if resolved.has("error"): - return resolved - var player: Node = resolved.player - - player.stop() - - return { - "data": { - "player_path": player_path, - "playing": bool(player.playing), - "undoable": false, - "reason": "Runtime playback state — not saved with scene", - } - } - - -# ============================================================================ -# audio_list (read — scan project for AudioStream resources) -# ============================================================================ - -func list_streams(params: Dictionary) -> Dictionary: - var root: String = params.get("root", "res://") - var include_duration: bool = bool(params.get("include_duration", true)) - - var root_err = McpPathValidator.path_error(root, "root") - if root_err != null: - return root_err - - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var results: Array[Dictionary] = [] - var start_dir := efs.get_filesystem_path(root) - if start_dir == null: - start_dir = efs.get_filesystem() - _scan_audio(start_dir, root, include_duration, results) - return { - "data": { - "root": root, - "streams": results, - "count": results.size(), - } - } - - -func _scan_audio(dir: EditorFileSystemDirectory, root: String, include_duration: bool, out: Array[Dictionary]) -> void: - if dir == null: - return - for i in dir.get_file_count(): - var file_path := dir.get_file_path(i) - if not file_path.begins_with(root): - continue - var file_type := dir.get_file_type(i) - var is_audio := file_type == "AudioStream" or ClassDB.is_parent_class(file_type, "AudioStream") - if not is_audio: - continue - var entry: Dictionary = { - "path": file_path, - "class": file_type, - } - if include_duration: - var res := ResourceLoader.load(file_path) - if res is AudioStream: - entry["duration_seconds"] = float((res as AudioStream).get_length()) - else: - entry["duration_seconds"] = 0.0 - out.append(entry) - for i in dir.get_subdir_count(): - _scan_audio(dir.get_subdir(i), root, include_duration, out) - - -# ============================================================================ -# Helpers -# ============================================================================ - -static func _instantiate_player(type_str: String) -> Node: - match type_str: - "1d": - return AudioStreamPlayer.new() - "2d": - return AudioStreamPlayer2D.new() - "3d": - return AudioStreamPlayer3D.new() - return null - - -func _resolve_player(player_path: String) -> Dictionary: - var resolved := McpNodeValidator.resolve_or_error(player_path, "player_path") - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var is_player := node is AudioStreamPlayer \ - or node is AudioStreamPlayer2D \ - or node is AudioStreamPlayer3D - if not is_player: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node at %s is not an AudioStreamPlayer/2D/3D (got %s)" % [player_path, node.get_class()] - ) - return {"player": node} - - -## Coerce a playback param value to the expected type. int→float is allowed -## so JSON integers pass through; everything else requires the exact type. -## Returns the coerced value, or null on type mismatch. -static func _coerce_playback_value(value: Variant, expected_type: int) -> Variant: - match expected_type: - TYPE_FLOAT: - if value is float or value is int: - return float(value) - TYPE_BOOL: - if value is bool: - return value - TYPE_STRING: - if value is String: - return value - return null diff --git a/addons/godot_ai/handlers/audio_handler.gd.uid b/addons/godot_ai/handlers/audio_handler.gd.uid deleted file mode 100644 index 2510ee7..0000000 --- a/addons/godot_ai/handlers/audio_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cjtvod52xxocs diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd deleted file mode 100644 index 22e7764..0000000 --- a/addons/godot_ai/handlers/autoload_handler.gd +++ /dev/null @@ -1,91 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles autoload listing, adding, and removing via ProjectSettings. - - -func list_autoloads(_params: Dictionary) -> Dictionary: - var autoloads: Array[Dictionary] = [] - for prop in ProjectSettings.get_property_list(): - var key: String = prop.get("name", "") - if not key.begins_with("autoload/"): - continue - var name := key.substr("autoload/".length()) - var raw_value: String = ProjectSettings.get_setting(key, "") - var is_singleton := raw_value.begins_with("*") - var path := raw_value.substr(1) if is_singleton else raw_value - autoloads.append({ - "name": name, - "path": path, - "singleton": is_singleton, - }) - return {"data": {"autoloads": autoloads, "count": autoloads.size()}} - - -func add_autoload(params: Dictionary) -> Dictionary: - var name: String = params.get("name", "") - var path: String = params.get("path", "") - var singleton: bool = params.get("singleton", true) - - if name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - var path_err = McpPathValidator.path_error(path, "path") - if path_err != null: - return path_err - if not FileAccess.file_exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) - - var key := "autoload/%s" % name - if ProjectSettings.has_setting(key): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Autoload '%s' already exists" % name) - - var value := ("*" if singleton else "") + path - ProjectSettings.set_setting(key, value) - ProjectSettings.set_initial_value(key, "") - ProjectSettings.set_as_basic(key, true) - var err := ProjectSettings.save() - if err != OK: - ProjectSettings.clear(key) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while adding autoload '%s': %s (error %d)" % [name, error_string(err), err]) - - return { - "data": { - "name": name, - "path": path, - "singleton": singleton, - "undoable": false, - "reason": "Autoload changes are saved to project.godot", - } - } - - -func remove_autoload(params: Dictionary) -> Dictionary: - var name: String = params.get("name", "") - if name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - - var key := "autoload/%s" % name - if not ProjectSettings.has_setting(key): - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Autoload '%s' not found" % name) - - var old_value: String = ProjectSettings.get_setting(key, "") - ProjectSettings.clear(key) - var err := ProjectSettings.save() - if err != OK: - ProjectSettings.set_setting(key, old_value) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while removing autoload '%s': %s (error %d)" % [name, error_string(err), err]) - - return { - "data": { - "name": name, - "removed": true, - "undoable": false, - "reason": "Autoload changes are saved to project.godot", - } - } diff --git a/addons/godot_ai/handlers/autoload_handler.gd.uid b/addons/godot_ai/handlers/autoload_handler.gd.uid deleted file mode 100644 index 921ed4e..0000000 --- a/addons/godot_ai/handlers/autoload_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bb0inov044jn6 diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd deleted file mode 100644 index c6c1474..0000000 --- a/addons/godot_ai/handlers/batch_handler.gd +++ /dev/null @@ -1,190 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Executes a list of sub-commands through the dispatcher with stop-on-first-error -## semantics. When undo=true (default), any successful sub-commands are rolled -## back via the scene's UndoRedo history if a later sub-command fails. - -## Commands that cannot run as batch sub-commands, each with the reason a batch -## can't host it. -## - batch_execute: would recurse. -## - run_tests: a batch executes synchronously inside one dispatcher tick with -## NO transport servicing, so a full suite starves the WebSocket heartbeat -## (the exact disconnect the serviced test_run path exists to prevent) and -## the Python batch handler only allows 30s anyway — call the test_run tool -## directly. -## - game_command: it is deferred — its reply flows out-of-band correlated by a -## _request_id that dispatch_direct deliberately strips (see -## McpDispatcher.dispatch_direct), so a game op nested in a batch would have -## no completion channel and hang or lose its reply. input_sequence made this -## concrete (#814); the whole game_command surface shares the deferred path. -const FORBIDDEN_SUBCOMMANDS := { - "batch_execute": "batch_execute cannot be nested inside another batch", - "run_tests": - "run_tests is not allowed as a sub-command — a batch runs synchronously " - + "with no transport servicing; call the test_run tool directly", - "game_command": - "game_command ops are deferred (their reply arrives out-of-band) and " - + "have no completion channel inside a batch — run them as their own tool call", -} - -## The whole batch executes synchronously inside one dispatcher tick, -## outside the 4ms frame budget — an unbounded array freezes the editor -## for the batch's full duration. 500 is far above any legitimate scene -## edit while keeping worst-case stalls in check. -const MAX_BATCH_COMMANDS := 500 - -var _dispatcher: McpDispatcher -var _undo_redo: EditorUndoRedoManager - - -func _init(dispatcher: McpDispatcher, undo_redo: EditorUndoRedoManager) -> void: - _dispatcher = dispatcher - _undo_redo = undo_redo - - -func batch_execute(params: Dictionary) -> Dictionary: - var commands = params.get("commands", null) - if typeof(commands) != TYPE_ARRAY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands must be a list") - if commands.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands must not be empty") - if commands.size() > MAX_BATCH_COMMANDS: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "commands exceeds the %d-command batch cap (got %d) — split into multiple batches" % [MAX_BATCH_COMMANDS, commands.size()] - ) - - var undo: bool = params.get("undo", true) - - for idx in range(commands.size()): - var item = commands[idx] - if typeof(item) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d] must be a dict" % idx) - var cmd_name: String = item.get("command", "") - if cmd_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "commands[%d] missing 'command' field" % idx) - if FORBIDDEN_SUBCOMMANDS.has(cmd_name): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "commands[%d]: %s" % [idx, FORBIDDEN_SUBCOMMANDS[cmd_name]]) - if not _dispatcher.has_command(cmd_name): - return _unknown_command_error(idx, cmd_name) - ## Pre-validate params type: the execution loop's typed Dictionary - ## local would hard-error on a non-dict mid-batch, aborting AFTER - ## earlier mutations committed. Catching it here keeps the - ## all-or-nothing contract for malformed input. - if typeof(item.get("params", {})) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "commands[%d].params must be a dict" % idx) - if cmd_name.begins_with("custom_tool:"): - var _custom_tools := McpToolRegistry.get_instance() - if _custom_tools: - var spec := _custom_tools.get_spec(cmd_name.trim_prefix("custom_tool:")) - ## A deferred tool's real reply arrives via ctx.send_deferred - ## AFTER the batch has already returned its synchronous - ## results — the sub-command would report an opaque - ## "_deferred" placeholder and the actual payload would be - ## orphaned. Reject up-front, matching the FORBIDDEN_SUBCOMMANDS - ## contract for run_tests. - if spec != null and spec.deferred: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "commands[%d] custom_tool:%s is deferred — its reply outlives the batch response; call it directly instead" % [idx, cmd_name.trim_prefix("custom_tool:")] - ) - if undo and spec != null and not spec.undoable: - return ErrorCodes.make( - ErrorCodes.CUSTOM_TOOL_NOT_UNDOABLE, - "commands[%d] custom_tool:%s is not undoable" % [idx, cmd_name.trim_prefix("custom_tool:")] - ) - - var results: Array = [] - var succeeded := 0 - var stopped_at = null - var all_undoable := true - # Captured after the first successful commit — get_history_undo_redo() - # errors if called before any action exists in the history_map. - var histories: Array = [] - - for idx in range(commands.size()): - var item: Dictionary = commands[idx] - var cmd_name: String = item["command"] - var sub_params: Dictionary = item.get("params", {}) - - var raw_result: Dictionary = _dispatcher.dispatch_direct(cmd_name, sub_params) - var status: String = raw_result.get("status", "ok") - - var result_entry: Dictionary = {"command": cmd_name, "status": status} - if status == "error": - result_entry["error"] = raw_result.get("error", {}) - results.append(result_entry) - stopped_at = idx - break - else: - var data: Dictionary = raw_result.get("data", raw_result) - result_entry["data"] = data - if typeof(data) == TYPE_DICTIONARY and data.get("undoable", false) != true: - all_undoable = false - results.append(result_entry) - succeeded += 1 - _capture_histories(histories) - - var rolled_back := false - if stopped_at != null and undo and succeeded > 0: - rolled_back = _rollback(succeeded, histories) - - var response_data: Dictionary = { - "succeeded": succeeded, - "stopped_at": stopped_at, - "results": results, - "undo": undo, - "rolled_back": rolled_back, - "undoable": stopped_at == null and all_undoable and not rolled_back, - } - if stopped_at != null: - response_data["error"] = results[-1]["error"] - return {"data": response_data} - - -## Capture the scene's UndoRedo reference for batch rollback. Safe to call -## multiple times; appends only the new reference. MCP write handlers all pin -## their actions to the scene history, so the scene UndoRedo is the only one -## rollback needs. Must be called only after at least one action has been -## committed to the scene history. -func _capture_histories(histories: Array) -> void: - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return - var scene_id := _undo_redo.get_object_history_id(scene_root) - var scene_ur := _undo_redo.get_history_undo_redo(scene_id) - if scene_ur != null and not scene_ur in histories: - histories.append(scene_ur) - - -## Build the unknown-command error for a sub-command. Clarifies that -## batch_execute expects plugin command names (not MCP tool names) and -## surfaces fuzzy suggestions in both the message and structured data. -func _unknown_command_error(idx: int, cmd_name: String) -> Dictionary: - var suggestions := _dispatcher.suggest_similar(cmd_name) - var msg := "commands[%d]: unknown plugin command '%s'. batch_execute expects plugin command names (e.g. 'create_node'), not MCP tool names (e.g. 'node_create')." % [idx, cmd_name] - if not suggestions.is_empty(): - msg += " Did you mean: %s?" % ", ".join(suggestions) - var err := ErrorCodes.make(ErrorCodes.UNKNOWN_COMMAND, msg) - err["error"]["data"] = {"suggestions": suggestions} - return err - - -## Undo `count` actions by calling undo() on captured histories in LIFO order. -## Returns true iff all undo calls succeeded. -func _rollback(count: int, histories: Array) -> bool: - if histories.is_empty(): - return false - for _i in range(count): - var undone := false - for ur in histories: - if ur.undo(): - undone = true - break - if not undone: - return false - return true diff --git a/addons/godot_ai/handlers/batch_handler.gd.uid b/addons/godot_ai/handlers/batch_handler.gd.uid deleted file mode 100644 index 630a596..0000000 --- a/addons/godot_ai/handlers/batch_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dt7um75oofdrh diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd deleted file mode 100644 index fb986e5..0000000 --- a/addons/godot_ai/handlers/camera_handler.gd +++ /dev/null @@ -1,1145 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles Camera2D / Camera3D authoring — create, configure, bounds, damping, -## node-parent-based follow, presets. -## -## All writes are bundled into a single EditorUndoRedoManager action. -## Setting current=true auto-unmarks previously-current cameras of the same -## class in the same action so one Ctrl-Z reverts the switch. - -const CameraValues := preload("res://addons/godot_ai/handlers/camera_values.gd") -const CameraPresets := preload("res://addons/godot_ai/handlers/camera_presets.gd") - -const _VALID_TYPES := { - "2d": "Camera2D", - "3d": "Camera3D", -} - -const _KEYS_2D := [ - "zoom", - "offset", - "anchor_mode", - "ignore_rotation", - "enabled", - "current", - "process_callback", - "position_smoothing_enabled", - "position_smoothing_speed", - "rotation_smoothing_enabled", - "rotation_smoothing_speed", - "drag_horizontal_enabled", - "drag_vertical_enabled", - "drag_horizontal_offset", - "drag_vertical_offset", - "drag_left_margin", - "drag_top_margin", - "drag_right_margin", - "drag_bottom_margin", - "limit_left", - "limit_right", - "limit_top", - "limit_bottom", - "limit_smoothed", -] - -const _KEYS_3D := [ - "fov", - "near", - "far", - "size", - "projection", - "keep_aspect", - "cull_mask", - "doppler_tracking", - "h_offset", - "v_offset", - "current", -] - -# Transform-shaped keys live on Node2D / Node3D, not in the camera-specific -# schema — rejecting them without a hint sends agents searching for the wrong -# tool. -const _NODE_TRANSFORM_KEYS := [ - "position", "rotation", "scale", "transform", - "global_position", "global_rotation", "global_scale", "global_transform", -] - -const _DAMPING_MARGIN_KEYS := ["left", "top", "right", "bottom"] -const _CURRENT_SETTLE_ATTEMPTS := 8 -const _CURRENT_SETTLE_DELAY_MSEC := 10 - - -var _undo_redo: EditorUndoRedoManager - -# Per-scene logical-current bookkeeping. Keys are scene-root InstanceIDs; -# values are { "2d": NodePath-as-String, "3d": NodePath-as-String } with -# missing keys meaning "no logical current for that class." -# -# Stored on the handler instance (NOT as Node metadata on the scene root) -# because set_meta() persists into the .tscn on save, contaminating user -# scene files with MCP-internal sidecar state that lingers across reloads -# and travels in commits. -var _logical_current: Dictionary = {} - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -# Camera2D doesn't expose `current` as a settable property in Godot 4 — -# only is_current() / make_current() / clear_current(). Camera3D exposes -# both, but using methods uniformly avoids per-class branching. -static func _is_current(cam: Node) -> bool: - if cam == null: - return false - return bool(cam.is_current()) - - -static func _viewport_current_camera(scene_root: Node) -> Node: - if scene_root == null: - return null - var viewport := scene_root.get_viewport() - if viewport == null: - return null - var current_2d := viewport.get_camera_2d() - if current_2d != null and scene_root.is_ancestor_of(current_2d): - return current_2d - var current_3d := viewport.get_camera_3d() - if current_3d != null and scene_root.is_ancestor_of(current_3d): - return current_3d - return null - - -static func _is_effective_current(cam: Node) -> bool: - if _is_current(cam): - return true - if cam is Camera2D: - var viewport_2d := cam.get_viewport() - return viewport_2d != null and viewport_2d.get_camera_2d() == cam - if cam is Camera3D: - var viewport_3d := cam.get_viewport() - return viewport_3d != null and viewport_3d.get_camera_3d() == cam - return false - - -# Logical-current bookkeeping. Updated from inside _apply_make_current / -# _apply_clear_current so DO and UNDO callables stamp the same logical -# slot they touch in the viewport. Reads consult the logical slot first -# and treat it as authoritative when set — the viewport read is the -# fallback for "MCP never touched this scene's cameras." - -func _set_logical_current(cam: Node) -> void: - if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): - return - var type_str := _camera_type_str(cam) - if type_str.is_empty(): - return - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null or not scene_root.is_ancestor_of(cam): - return - var slot: Dictionary = _logical_current.get(scene_root.get_instance_id(), {}) - slot[type_str] = McpScenePath.from_node(cam, scene_root) - _logical_current[scene_root.get_instance_id()] = slot - - -func _clear_logical_current(cam: Node) -> void: - if cam == null: - return - var type_str := _camera_type_str(cam) - if type_str.is_empty(): - return - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return - var key := scene_root.get_instance_id() - if not _logical_current.has(key): - return - var slot: Dictionary = _logical_current[key] - if not slot.has(type_str): - return - # Only clear if the logical slot still points at this camera; otherwise - # a later make_current already took the slot and we'd stomp it. - var current_path := "" - if is_instance_valid(cam) and cam.is_inside_tree() and scene_root.is_ancestor_of(cam): - current_path = McpScenePath.from_node(cam, scene_root) - if String(slot[type_str]) == current_path: - slot.erase(type_str) - if slot.is_empty(): - _logical_current.erase(key) - else: - _logical_current[key] = slot - - -func _logical_current_camera(scene_root: Node, type_str: String = "") -> Node: - if scene_root == null: - return null - var key := scene_root.get_instance_id() - if not _logical_current.has(key): - return null - var slot: Dictionary = _logical_current[key] - var types: Array[String] = [] - if type_str == "2d" or type_str == "3d": - types = [type_str] - else: - types = ["2d", "3d"] - for t in types: - if not slot.has(t): - continue - var path := String(slot[t]) - if path.is_empty(): - slot.erase(t) - continue - var node := McpScenePath.resolve(path, scene_root) - if node == null or not _is_camera(node) or _camera_type_str(node) != t: - slot.erase(t) - continue - return node - if slot.is_empty(): - _logical_current.erase(key) - else: - _logical_current[key] = slot - return null - - -# Public introspection for tests that need to distinguish "handler has a -# logical marker" from "handler is falling back to engine state". `get_camera` -# / `list_cameras` both use `_resolve_current` which falls through to -# `_is_effective_current` when no marker is set — that's correct for callers -# but masks the marker presence from anyone trying to gate on -# "did the handler actually record this state?". Returns the logical-current -# Camera2D / Camera3D for the given type ("2d" / "3d" / "" for either), or -# null when no marker is set. See #316 PR #372 review feedback. -func peek_logical_current(scene_root: Node, type_str: String = "") -> Node: - return _logical_current_camera(scene_root, type_str) - - -# Authoritative answer for "is `cam` the current camera of its class?" -# -# When a logical marker exists for the camera's class, it is the single -# source of truth — only the marker's referenced camera reports current, -# every other camera of that class reports false even if the viewport -# slot still points at one of them (the headless-CI lag in #140 / #278 / -# #301). Without a logical marker, fall through to the viewport read so -# scenes MCP never touched still answer correctly. -func _resolve_current(scene_root: Node, cam: Node) -> bool: - if scene_root == null or cam == null: - return false - var logical := _logical_current_camera(scene_root, _camera_type_str(cam)) - if logical != null: - return logical == cam - return _is_effective_current(cam) - - -# list_cameras pre-fetches the per-class logical pointers once; this -# variant takes those pointers to avoid an O(n²) walk over the meta -# bookkeeping for each camera in the scene. -func _resolve_current_with_logicals(cam: Node, logical_2d: Node, logical_3d: Node) -> bool: - if cam == null: - return false - if cam is Camera2D: - if logical_2d != null: - return logical_2d == cam - elif cam is Camera3D: - if logical_3d != null: - return logical_3d == cam - return _is_effective_current(cam) - - -# Register a current=true switch on `node` in the open undo action, -# unmarking previously-current siblings of the same class so a single -# Ctrl-Z reverts the whole switch. -# -# Both DO and UNDO route through `_apply_make_current` / `_apply_clear_current` -# on the handler itself rather than calling Camera.make_current() directly. -# The helpers do the make_current (or clear_current) call plus bounded sync -# settling when the viewport hasn't yet reflected the change — headless CI -# occasionally reports `is_current() == false` immediately after a committed -# make_current (observed CI run 24682342469) and symmetrically still reports -# the displaced camera as current immediately after an undo (observed CI runs -# 24682342469, 24692250322, 24696571517, 25079965242 — tracked in #140). -# Later #278 runs broadened the same current-camera timing flake across more -# platforms and assertions, so the settle budget is deliberately above one -# fast local frame. -# -# Because those callables bind to `self` (a RefCounted handler, not a scene -# node), every action that calls this helper must pin its history via -# `create_action(name, MERGE_DISABLE, scene_root)` — otherwise the -# handler-bound ops land in GLOBAL_HISTORY while the scene-node ops land in -# the scene's history, and a single editor_undo reverts only half the action. -# -# Both DO and UNDO use a single make_current() call — never a -# clear_current() + make_current() pair. make_current() takes over the -# viewport slot atomically (Godot enforces one current camera per class -# per viewport), so the displaced camera naturally returns -# is_current() == false without an explicit clear. The two-step approach -# leaves the viewport temporarily with no current camera between the -# clear and the make, which races with editor cleanup on macOS headless -# (observed flaking CI runs 24674252085, 24675424785). -func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void: - var prev_current: Node = null - for cam in _list_cameras_in_scene(scene_root, type_str): - if cam == node: - continue - if _resolve_current(scene_root, cam): - prev_current = cam - break - _undo_redo.add_do_method(self, "_apply_make_current", node) - if prev_current != null: - _undo_redo.add_undo_method(self, "_apply_make_current", prev_current) - else: - _undo_redo.add_undo_method(self, "_apply_clear_current", node) - - -# Apply make_current on `cam` with bounded synchronous settling. Registered as the -# do/undo callable by `_add_make_current_to_action`. See that function's -# comment for why the undo path needs the retry inside the action itself. -# Safe against a freed camera node — short-circuits if the node is gone -# or not in the tree. -func _apply_make_current(cam: Node) -> void: - if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): - return - _set_logical_current(cam) - var scene_root := EditorInterface.get_edited_scene_root() - var type_str := _camera_type_str(cam) - for attempt in range(_CURRENT_SETTLE_ATTEMPTS): - cam.make_current() - _force_camera_refresh(cam) - # Godot's make_current is supposed to atomically displace siblings, - # but on macOS headless the displaced camera occasionally still - # answers is_current() == true after this returns (#140 / #278 / #301). - # Sweep same-class siblings and clear any that lag. - _force_clear_other_currents(cam, type_str, scene_root) - if not _is_current_settled(cam): - _displace_stale_camera_2d(cam) - _force_clear_other_currents(cam, type_str, scene_root) - var waited_this_attempt := false - if _is_current_settled(cam): - if not (cam is Camera2D): - return - OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) - waited_this_attempt = true - _force_camera_refresh(cam) - _force_clear_other_currents(cam, type_str, scene_root) - if _is_current_settled(cam): - return - if attempt < _CURRENT_SETTLE_ATTEMPTS - 1 and not waited_this_attempt: - OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) - - -# Walk same-class siblings and force-clear any that still report is_current(). -# Best-effort: clear_current errors when called on a non-current camera, so -# guard. Camera2D's clear_current path also flushes the viewport slot, which -# is the one we actually care about settling for #301. -func _force_clear_other_currents(target: Node, type_str: String, scene_root: Node) -> void: - if scene_root == null or type_str.is_empty(): - return - for sibling in _list_cameras_in_scene(scene_root, type_str): - if sibling == target: - continue - if not is_instance_valid(sibling) or not sibling.is_inside_tree(): - continue - if not _is_current(sibling): - # Even if is_current() reports false, the viewport slot can - # still point at this sibling on macOS — re-make target to - # take it back. Cheap (idempotent) when the slot is fine. - if sibling is Camera2D: - var vp_other: Viewport = (sibling as Camera2D).get_viewport() - if vp_other != null and vp_other.get_camera_2d() == sibling: - target.make_current() - _force_camera_refresh(target) - continue - sibling.clear_current() - if sibling is Camera2D: - (sibling as Camera2D).force_update_scroll() - - -# Call after commit_action() whenever the action registered a make_current DO. -# The undo path cannot use a post-undo hook, so it relies on `_apply_make_current` -# directly; create/configure/apply_preset get this extra post-commit verifier. -func _verify_current_after_commit(node: Node) -> void: - _apply_make_current(node) - - -func _force_camera_refresh(cam: Node) -> void: - if cam is Camera2D: - (cam as Camera2D).force_update_scroll() - - -func _is_current_settled(cam: Node) -> bool: - if not _is_current(cam): - return false - if cam is Camera2D: - var viewport := cam.get_viewport() - if viewport != null and viewport.get_camera_2d() != cam: - return false - return true - - -func _displace_stale_camera_2d(target: Node) -> void: - if not (target is Camera2D): - return - var viewport := target.get_viewport() - if viewport == null: - return - var stale := viewport.get_camera_2d() - if stale == null or stale == target or not is_instance_valid(stale): - _nudge_camera_2d_current(target) - return - var was_enabled := stale.enabled - if was_enabled: - stale.enabled = false - target.make_current() - _force_camera_refresh(target) - if was_enabled: - stale.enabled = true - target.make_current() - _force_camera_refresh(target) - - -func _nudge_camera_2d_current(target: Node) -> void: - if not (target is Camera2D): - return - var cam := target as Camera2D - if not cam.enabled: - return - cam.enabled = false - _force_camera_refresh(cam) - cam.enabled = true - cam.make_current() - _force_camera_refresh(cam) - - -# Symmetric counterpart to `_apply_make_current` for the "no previous -# current camera" branch (create_camera with make_current=true and no -# sibling was current). clear_current errors in Godot if called on a -# non-current camera, so guard on is_current first. -func _apply_clear_current(cam: Node) -> void: - if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): - return - _clear_logical_current(cam) - for attempt in range(_CURRENT_SETTLE_ATTEMPTS): - if _is_clear_settled(cam): - return - if _is_current(cam): - cam.clear_current() - _force_camera_refresh(cam) - # Camera2D-only: is_current() may answer false while the viewport - # slot still points at cam. Toggle enabled to force the viewport - # to release, then restore. - if cam is Camera2D: - var vp := cam.get_viewport() - if vp != null and vp.get_camera_2d() == cam: - var was_enabled := (cam as Camera2D).enabled - if was_enabled: - (cam as Camera2D).enabled = false - _force_camera_refresh(cam) - if was_enabled: - (cam as Camera2D).enabled = true - if _is_clear_settled(cam): - return - if attempt < _CURRENT_SETTLE_ATTEMPTS - 1: - OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) - - -func _is_clear_settled(cam: Node) -> bool: - if cam == null: - return true - if _is_current(cam): - return false - if cam is Camera2D: - var vp := cam.get_viewport() - if vp != null and vp.get_camera_2d() == cam: - return false - return true - - -# ============================================================================ -# camera_create -# ============================================================================ - -func create_camera(params: Dictionary) -> Dictionary: - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "Camera") - var type_str: String = params.get("type", "2d") - var make_current: bool = bool(params.get("make_current", false)) - - if not _VALID_TYPES.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] - ) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var node := _instantiate_camera(type_str) - if node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate camera") - if not node_name.is_empty(): - node.name = node_name - - _undo_redo.create_action( - "MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name], - UndoRedo.MERGE_DISABLE, scene_root - ) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - if make_current: - # Must land AFTER add_child: making current before the node is in the - # tree is a silent no-op on the viewport. - _add_make_current_to_action(node, type_str, scene_root) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - if make_current: - _verify_current_after_commit(node) - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": String(node.name), - "type": type_str, - "class": _VALID_TYPES[type_str], - "current": bool(make_current), - "undoable": true, - } - } - - -# ============================================================================ -# camera_configure -# ============================================================================ - -func configure(params: Dictionary) -> Dictionary: - var resolved := _resolve_camera(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var type_str: String = resolved.type - var scene_root: Node = resolved.scene_root - - var properties: Dictionary = params.get("properties", {}) - if properties.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") - - var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D - var prop_types := _property_type_map(node) - var coerced: Dictionary = {} - var old_values: Dictionary = {} - # `current` is special-cased via methods (Camera2D doesn't expose it as a property). - var current_request: Variant = null - - for property in properties: - var prop_name: String = String(property) - if not (prop_name in valid_keys): - var msg := "Property '%s' not valid for %s. Valid: %s" % [ - prop_name, _VALID_TYPES[type_str], ", ".join(valid_keys) - ] - if prop_name in _NODE_TRANSFORM_KEYS: - msg += ( - ". Transforms live on the Node, not on the camera config — " - + "use node_set_property(path=%s, property=\"%s\", value=...)" % [node_path, prop_name] - ) - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) - if prop_name == "current": - current_request = bool(properties[prop_name]) - continue - var prop_type: int = prop_types.get(prop_name, TYPE_NIL) - if prop_type == TYPE_NIL: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' not present on %s" % [prop_name, node.get_class()] - ) - var coerce_result := CameraValues.coerce(prop_name, properties[prop_name], prop_type) - if not coerce_result.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - coerced[prop_name] = coerce_result.value - old_values[prop_name] = node.get(prop_name) - - _undo_redo.create_action( - "MCP: Configure camera %s" % node.name, - UndoRedo.MERGE_DISABLE, scene_root - ) - for prop_name in coerced: - _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) - _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) - var verify_current_after := false - if current_request != null: - var want_on: bool = bool(current_request) - var was_on: bool = _resolve_current(scene_root, node) - if want_on and not was_on: - _add_make_current_to_action(node, type_str, scene_root) - verify_current_after = true - elif not want_on and was_on: - _undo_redo.add_do_method(self, "_apply_clear_current", node) - _undo_redo.add_undo_method(self, "_apply_make_current", node) - _undo_redo.commit_action() - if verify_current_after: - _verify_current_after_commit(node) - - var applied: Array[String] = [] - var serialized: Dictionary = {} - for prop_name in coerced: - applied.append(prop_name) - serialized[prop_name] = CameraValues.serialize(coerced[prop_name]) - if current_request != null: - applied.append("current") - serialized["current"] = bool(current_request) - - return { - "data": { - "path": node_path, - "type": type_str, - "class": node.get_class(), - "applied": applied, - "values": serialized, - "undoable": true, - } - } - - -# ============================================================================ -# camera_set_limits_2d -# ============================================================================ - -func set_limits_2d(params: Dictionary) -> Dictionary: - var resolved := _resolve_camera(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var type_str: String = resolved.type - - if type_str != "2d": - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "camera_set_limits_2d requires a Camera2D (got %s)" % node.get_class() - ) - - var applied: Dictionary = {} - var old_values: Dictionary = {} - var edges := { - "left": "limit_left", - "right": "limit_right", - "top": "limit_top", - "bottom": "limit_bottom", - } - for edge in edges: - var v = params.get(edge) - if v != null: - var prop_name: String = edges[edge] - applied[prop_name] = int(v) - old_values[prop_name] = node.get(prop_name) - - var smoothed = params.get("smoothed") - if smoothed != null: - applied["limit_smoothed"] = bool(smoothed) - old_values["limit_smoothed"] = node.get("limit_smoothed") - - if applied.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "No limits specified; provide at least one of left, right, top, bottom, smoothed" - ) - - _undo_redo.create_action("MCP: Set camera limits on %s" % node.name) - for prop_name in applied: - _undo_redo.add_do_property(node, prop_name, applied[prop_name]) - _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) - _undo_redo.commit_action() - - var values: Dictionary = {} - for prop_name in applied: - values[prop_name] = applied[prop_name] - - return { - "data": { - "path": node_path, - "applied": applied.keys(), - "values": values, - "undoable": true, - } - } - - -# ============================================================================ -# camera_set_damping_2d -# ============================================================================ - -func set_damping_2d(params: Dictionary) -> Dictionary: - var resolved := _resolve_camera(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var type_str: String = resolved.type - - if type_str != "2d": - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "camera_set_damping_2d requires a Camera2D (got %s)" % node.get_class() - ) - - var applied: Dictionary = {} - var old_values: Dictionary = {} - - # position_speed: set position_smoothing_speed AND toggle position_smoothing_enabled. - var pos_v = params.get("position_speed") - if pos_v != null: - var pos_speed := float(pos_v) - var pos_enable := pos_speed > 0.0 - applied["position_smoothing_enabled"] = pos_enable - old_values["position_smoothing_enabled"] = node.get("position_smoothing_enabled") - if pos_enable: - applied["position_smoothing_speed"] = pos_speed - old_values["position_smoothing_speed"] = node.get("position_smoothing_speed") - - # rotation_speed: same pattern for rotation_smoothing_*. - var rot_v = params.get("rotation_speed") - if rot_v != null: - var rot_speed := float(rot_v) - var rot_enable := rot_speed > 0.0 - applied["rotation_smoothing_enabled"] = rot_enable - old_values["rotation_smoothing_enabled"] = node.get("rotation_smoothing_enabled") - if rot_enable: - applied["rotation_smoothing_speed"] = rot_speed - old_values["rotation_smoothing_speed"] = node.get("rotation_smoothing_speed") - - for flag in ["drag_horizontal_enabled", "drag_vertical_enabled"]: - var flag_v = params.get(flag) - if flag_v != null: - applied[flag] = bool(flag_v) - old_values[flag] = node.get(flag) - - # drag_margins: dict {left, top, right, bottom} floats in [0,1]; null/missing keys untouched. - var margins_v = params.get("drag_margins") - if margins_v != null: - if not (margins_v is Dictionary): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "drag_margins must be a dict with optional keys left/top/right/bottom" - ) - var margins: Dictionary = margins_v - for edge in _DAMPING_MARGIN_KEYS: - var margin_v = margins.get(edge) - if margin_v == null: - continue - var v := float(margin_v) - if v < 0.0 or v > 1.0: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "drag_margins.%s must be in [0, 1] (got %s)" % [edge, v] - ) - var prop_name: String = "drag_%s_margin" % edge - applied[prop_name] = v - old_values[prop_name] = node.get(prop_name) - - if applied.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "No damping params specified; provide at least one of position_speed, rotation_speed, drag_margins, drag_horizontal_enabled, drag_vertical_enabled" - ) - - _undo_redo.create_action("MCP: Set camera damping on %s" % node.name) - for prop_name in applied: - _undo_redo.add_do_property(node, prop_name, applied[prop_name]) - _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "applied": applied.keys(), - "values": applied, - "undoable": true, - } - } - - -# ============================================================================ -# camera_follow_2d -# ============================================================================ - -func follow_2d(params: Dictionary) -> Dictionary: - var resolved := _resolve_camera(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var type_str: String = resolved.type - var scene_root: Node = resolved.scene_root - - if type_str != "2d": - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "camera_follow_2d requires a Camera2D (got %s)" % node.get_class() - ) - - var target_path: String = params.get("target_path", "") - if target_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") - var target := McpScenePath.resolve(target_path, scene_root) - if target == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, - "target_path: %s" % McpScenePath.format_node_error(target_path, scene_root)) - if not (target is Node2D) and target != scene_root: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Follow target must be a Node2D (got %s)" % target.get_class() - ) - if target == node: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Camera cannot follow itself") - if target.is_ancestor_of(node) and node.get_parent() != target: - # A non-parent ancestor — still valid to reparent under (direct parent). - pass - if node.is_ancestor_of(target): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Cannot follow a descendant of the camera" - ) - - var smoothing_speed := float(params.get("smoothing_speed", 5.0)) - var zero_transform: bool = bool(params.get("zero_transform", true)) - - var old_parent := node.get_parent() - var old_idx: int = node.get_index() if old_parent != null else 0 - var old_position = node.get("position") - var old_rotation = node.get("rotation") - var old_smoothing_enabled: bool = bool(node.get("position_smoothing_enabled")) - var old_smoothing_speed: float = float(node.get("position_smoothing_speed")) - - var already_child: bool = old_parent == target - var reparented: bool = not already_child - - _undo_redo.create_action("MCP: Camera follow %s" % target.name) - if reparented: - _undo_redo.add_do_method(old_parent, "remove_child", node) - _undo_redo.add_do_method(target, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - if zero_transform: - if target is Node2D: - _undo_redo.add_do_property(node, "position", Vector2.ZERO) - _undo_redo.add_undo_property(node, "position", old_position) - _undo_redo.add_do_property(node, "rotation", 0.0) - _undo_redo.add_undo_property(node, "rotation", old_rotation) - _undo_redo.add_do_property(node, "position_smoothing_enabled", true) - _undo_redo.add_undo_property(node, "position_smoothing_enabled", old_smoothing_enabled) - if smoothing_speed > 0.0: - _undo_redo.add_do_property(node, "position_smoothing_speed", smoothing_speed) - _undo_redo.add_undo_property(node, "position_smoothing_speed", old_smoothing_speed) - if reparented: - _undo_redo.add_undo_method(target, "remove_child", node) - _undo_redo.add_undo_method(old_parent, "add_child", node, true) - _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) - _undo_redo.add_undo_method(node, "set_owner", scene_root) - _undo_redo.add_undo_reference(node) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "target_path": McpScenePath.from_node(target, scene_root), - "reparented": reparented, - "smoothing_speed": smoothing_speed, - "zero_transform": zero_transform and (target is Node2D), - "undoable": true, - } - } - - -# ============================================================================ -# camera_get -# ============================================================================ - -func get_camera(params: Dictionary) -> Dictionary: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var camera_path: String = params.get("camera_path", "") - var node: Node = null - var resolved_via: String = "" - if camera_path.is_empty(): - # Empty: prefer the viewport's active camera. In headless editor CI, - # Camera2D.is_current() can lag make_current() briefly even after the - # viewport slot has switched; falling through to "first" during that - # window makes camera_get("") nondeterministic. - var all_cams := _list_cameras_in_scene(scene_root, "") - var logical_current := _logical_current_camera(scene_root) - if logical_current != null and all_cams.has(logical_current): - node = logical_current - resolved_via = "current" - var viewport_current := _viewport_current_camera(scene_root) - if node == null and viewport_current != null and all_cams.has(viewport_current): - node = viewport_current - resolved_via = "current" - for cam in all_cams: - if node != null: - break - if _is_current(cam): - node = cam - resolved_via = "current" - break - if node == null and not all_cams.is_empty(): - node = all_cams[0] - resolved_via = "first" - else: - node = McpScenePath.resolve(camera_path, scene_root) - if node == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(camera_path, scene_root)) - if not _is_camera(node): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a camera (got %s)" % [camera_path, node.get_class()] - ) - resolved_via = "path" - - if node == null: - return { - "data": { - "path": "", - "type": "", - "class": "", - "current": false, - "properties": {}, - "resolved_via": "not_found", - } - } - - var type_str := _camera_type_str(node) - var keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D - var prop_types := _property_type_map(node) - var props: Dictionary = {} - var is_current_effective := _resolve_current(scene_root, node) - for key in keys: - if key == "current": - props[key] = is_current_effective - continue - if prop_types.has(key): - props[key] = CameraValues.serialize(node.get(key)) - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "type": type_str, - "class": node.get_class(), - "current": is_current_effective, - "properties": props, - "resolved_via": resolved_via, - } - } - - -# ============================================================================ -# camera_list -# ============================================================================ - -func list_cameras(_params: Dictionary) -> Dictionary: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var cams := _list_cameras_in_scene(scene_root, "") - var out: Array[Dictionary] = [] - var logical_2d := _logical_current_camera(scene_root, "2d") - var logical_3d := _logical_current_camera(scene_root, "3d") - for cam in cams: - out.append({ - "path": McpScenePath.from_node(cam, scene_root), - "class": cam.get_class(), - "type": _camera_type_str(cam), - "current": _resolve_current_with_logicals(cam, logical_2d, logical_3d), - }) - return {"data": {"cameras": out}} - - -# ============================================================================ -# camera_apply_preset -# ============================================================================ - -func apply_preset(params: Dictionary) -> Dictionary: - var preset_name: String = params.get("preset", "") - if preset_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") - - var overrides: Dictionary = params.get("overrides", {}) - var blueprint = CameraPresets.build(preset_name, overrides) - if blueprint == null: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(CameraPresets.list_presets())] - ) - - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "") - var type_str: String = params.get("type", String(blueprint.get("default_type", "2d"))) - var make_current: bool = bool(params.get("make_current", true)) - if node_name.is_empty(): - node_name = preset_name.capitalize() - if not _VALID_TYPES.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] - ) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var node := _instantiate_camera(type_str) - node.name = node_name - - var preset_props: Dictionary = blueprint.get("properties", {}) - var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D - var prop_types := _property_type_map(node) - var applied: Array[String] = [] - for prop in preset_props: - var prop_name := String(prop) - if not (prop_name in valid_keys): - continue # Silently skip preset keys that don't apply to this camera class. - # `current` lives on methods, not as a writable property on Camera2D — - # always handled via the make_current path below. - if prop_name == "current": - continue - var prop_type: int = prop_types.get(prop_name, TYPE_NIL) - if prop_type == TYPE_NIL: - continue - var coerce_result := CameraValues.coerce(prop_name, preset_props[prop_name], prop_type) - if not coerce_result.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - node.set(prop_name, coerce_result.value) - applied.append(prop_name) - - _undo_redo.create_action( - "MCP: Apply camera preset %s" % preset_name, - UndoRedo.MERGE_DISABLE, scene_root - ) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - if make_current: - _add_make_current_to_action(node, type_str, scene_root) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - if make_current: - _verify_current_after_commit(node) - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": node_name, - "preset": preset_name, - "type": type_str, - "class": _VALID_TYPES[type_str], - "applied": applied, - "current": bool(make_current), - "undoable": true, - } - } - - -# ============================================================================ -# Helpers -# ============================================================================ - -static func _instantiate_camera(type_str: String) -> Node: - match type_str: - "2d": - return Camera2D.new() - "3d": - return Camera3D.new() - return null - - -static func _is_camera(node: Node) -> bool: - return node is Camera2D or node is Camera3D - - -static func _camera_type_str(node: Node) -> String: - if node is Camera2D: - return "2d" - if node is Camera3D: - return "3d" - return "" - - -func _resolve_camera(params: Dictionary) -> Dictionary: - var resolved := McpNodeValidator.resolve_or_error( - params.get("camera_path", ""), "camera_path", - ) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - if not _is_camera(node): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a camera (got %s)" % [node_path, node.get_class()] - ) - return { - "node": node, - "path": node_path, - "type": _camera_type_str(node), - "scene_root": scene_root, - } - - -## Walk the edited scene for cameras. class_filter: "2d", "3d", or "" for all. -static func _list_cameras_in_scene(scene_root: Node, class_filter: String) -> Array: - var result: Array = [] - if scene_root == null: - return result - _collect_cameras(scene_root, class_filter, result) - return result - - -static func _collect_cameras(node: Node, class_filter: String, out: Array) -> void: - var matches := false - match class_filter: - "2d": - matches = node is Camera2D - "3d": - matches = node is Camera3D - _: - matches = node is Camera2D or node is Camera3D - if matches: - out.append(node) - for child in node.get_children(): - _collect_cameras(child, class_filter, out) - - -## Build a name -> property-type dict from the object's property list. -## Single walk of get_property_list() amortizes lookups across a batch of -## properties in configure / apply_preset. -static func _property_type_map(obj: Object) -> Dictionary: - var out: Dictionary = {} - if obj == null: - return out - for prop in obj.get_property_list(): - out[prop.name] = int(prop.get("type", TYPE_NIL)) - return out diff --git a/addons/godot_ai/handlers/camera_handler.gd.uid b/addons/godot_ai/handlers/camera_handler.gd.uid deleted file mode 100644 index 86d2aae..0000000 --- a/addons/godot_ai/handlers/camera_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c0lcviccrlrl8 diff --git a/addons/godot_ai/handlers/camera_presets.gd b/addons/godot_ai/handlers/camera_presets.gd deleted file mode 100644 index a1922c2..0000000 --- a/addons/godot_ai/handlers/camera_presets.gd +++ /dev/null @@ -1,81 +0,0 @@ -@tool -extends RefCounted - -## Opinionated Camera2D / Camera3D presets. -## -## build(preset_name, overrides) -> {default_type, properties} | null -## properties are merged with caller overrides (overrides win). - - -const _PRESETS := { - # Top-down roguelite / arena — damped follow feel, drag deadzone. - "topdown_2d": { - "default_type": "2d", - "properties": { - "zoom": {"x": 2.0, "y": 2.0}, - "anchor_mode": "drag_center", - "position_smoothing_enabled": true, - "position_smoothing_speed": 5.0, - "rotation_smoothing_enabled": false, - "drag_horizontal_enabled": true, - "drag_vertical_enabled": true, - "drag_left_margin": 0.2, - "drag_right_margin": 0.2, - "drag_top_margin": 0.2, - "drag_bottom_margin": 0.2, - }, - }, - # Platformer — tight horizontal follow, vertical snap with smoothing on. - "platformer_2d": { - "default_type": "2d", - "properties": { - "zoom": {"x": 1.5, "y": 1.5}, - "anchor_mode": "drag_center", - "position_smoothing_enabled": true, - "position_smoothing_speed": 8.0, - "drag_horizontal_enabled": true, - "drag_vertical_enabled": false, - "drag_left_margin": 0.15, - "drag_right_margin": 0.15, - }, - }, - # Cinematic 3D — narrow FOV, long range. Good for dramatic wide shots. - "cinematic_3d": { - "default_type": "3d", - "properties": { - "fov": 40.0, - "near": 0.1, - "far": 500.0, - "projection": "perspective", - }, - }, - # Action 3D — wider FOV for first/third-person action gameplay. - "action_3d": { - "default_type": "3d", - "properties": { - "fov": 70.0, - "near": 0.1, - "far": 200.0, - "projection": "perspective", - }, - }, -} - - -static func list_presets() -> Array: - return _PRESETS.keys() - - -## Build a preset blueprint. Returns null if preset_name is unknown. -## overrides is merged on top of preset defaults (caller values win). -static func build(preset_name: String, overrides: Dictionary) -> Variant: - if not _PRESETS.has(preset_name): - return null - var preset: Dictionary = _PRESETS[preset_name] - var properties: Dictionary = (preset.get("properties", {}) as Dictionary).duplicate(true) - for key in overrides: - properties[key] = overrides[key] - return { - "default_type": preset.get("default_type", "2d"), - "properties": properties, - } diff --git a/addons/godot_ai/handlers/camera_presets.gd.uid b/addons/godot_ai/handlers/camera_presets.gd.uid deleted file mode 100644 index 9f9f839..0000000 --- a/addons/godot_ai/handlers/camera_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bl3rfy72o3wy5 diff --git a/addons/godot_ai/handlers/camera_values.gd b/addons/godot_ai/handlers/camera_values.gd deleted file mode 100644 index e3c8dea..0000000 --- a/addons/godot_ai/handlers/camera_values.gd +++ /dev/null @@ -1,132 +0,0 @@ -@tool -extends RefCounted - -## Value coercion helpers for camera authoring. -## -## Handles: -## - enum-by-name (keep_aspect="keep_height" -> Camera3D.KEEP_HEIGHT) -## - {x, y} dict -> Vector2 (zoom, offset, drag_*_offset) -## - serialization back to JSON-friendly shapes - - -const _ENUM_TABLES := { - "projection": { - "perspective": Camera3D.PROJECTION_PERSPECTIVE, - "orthogonal": Camera3D.PROJECTION_ORTHOGONAL, - "frustum": Camera3D.PROJECTION_FRUSTUM, - }, - "keep_aspect": { - "keep_width": Camera3D.KEEP_WIDTH, - "keep_height": Camera3D.KEEP_HEIGHT, - }, - "anchor_mode": { - "fixed_top_left": Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT, - "drag_center": Camera2D.ANCHOR_MODE_DRAG_CENTER, - }, - "doppler_tracking": { - "disabled": Camera3D.DOPPLER_TRACKING_DISABLED, - "idle_step": Camera3D.DOPPLER_TRACKING_IDLE_STEP, - "physics_step": Camera3D.DOPPLER_TRACKING_PHYSICS_STEP, - }, - "process_callback": { - "physics": Camera2D.CAMERA2D_PROCESS_PHYSICS, - "idle": Camera2D.CAMERA2D_PROCESS_IDLE, - }, -} - - -## Return the enum int for (property, string_name), or null if not a known enum string. -static func resolve_enum(property: String, value: Variant) -> Variant: - if not (value is String): - return null - if not _ENUM_TABLES.has(property): - return null - var table: Dictionary = _ENUM_TABLES[property] - var key: String = String(value).to_lower() - if table.has(key): - return table[key] - return null - - -## Valid enum names for a property, for error messages. -static func enum_keys(property: String) -> Array: - if not _ENUM_TABLES.has(property): - return [] - return (_ENUM_TABLES[property] as Dictionary).keys() - - -static func parse_vector2(value: Variant) -> Variant: - ## Camera-specific sugar kept from the pre-#714 copy: a bare number is - ## a uniform zoom, splatted to both axes. Everything else goes through - ## the canonical strict parser. - if value is int or value is float: - return Vector2(float(value), float(value)) - return McpJsonValues.parse_vector2(value) - - -static func parse_vector3(value: Variant) -> Variant: - return McpJsonValues.parse_vector3(value) - - -## Coerce a JSON-shaped value for a camera property against the declared type. -## Returns {ok: true, value: ...} or {ok: false, error: "..."}. -static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: - # Enum-by-name: must match before generic TYPE_INT coercion. - if _ENUM_TABLES.has(property): - if value is String: - var enum_val = resolve_enum(property, value) - if enum_val == null: - return { - "ok": false, - "error": "Invalid %s value: '%s'. Valid: %s" % [ - property, value, ", ".join(enum_keys(property)) - ], - } - return {"ok": true, "value": int(enum_val)} - if value is int or value is float: - return {"ok": true, "value": int(value)} - - match target_type: - TYPE_VECTOR2: - var v2 = parse_vector2(value) - if v2 == null: - return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} - return {"ok": true, "value": v2} - TYPE_VECTOR3: - var v3 = parse_vector3(value) - if v3 == null: - return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} - return {"ok": true, "value": v3} - TYPE_BOOL: - if value is bool: - return {"ok": true, "value": value} - if value is int or value is float: - return {"ok": true, "value": bool(value)} - return {"ok": false, "error": "Expected bool for %s" % property} - TYPE_INT: - if value is int: - return {"ok": true, "value": value} - if value is float: - return {"ok": true, "value": int(value)} - return {"ok": false, "error": "Expected int for %s" % property} - TYPE_FLOAT: - if value is float: - return {"ok": true, "value": value} - if value is int: - return {"ok": true, "value": float(value)} - return {"ok": false, "error": "Expected number for %s" % property} - TYPE_STRING: - return {"ok": true, "value": String(value)} - - return {"ok": true, "value": value} - - -## Serialize a Variant into a JSON-friendly shape for responses. -static func serialize(value: Variant) -> Variant: - if value == null: - return null - if value is Vector2: - return {"x": value.x, "y": value.y} - if value is Vector3: - return {"x": value.x, "y": value.y, "z": value.z} - return value diff --git a/addons/godot_ai/handlers/camera_values.gd.uid b/addons/godot_ai/handlers/camera_values.gd.uid deleted file mode 100644 index 45e3c09..0000000 --- a/addons/godot_ai/handlers/camera_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bgjnubgnv6ses diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd deleted file mode 100644 index 40e019f..0000000 --- a/addons/godot_ai/handlers/client_handler.gd +++ /dev/null @@ -1,127 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles MCP client configuration commands. - -var _connection -var _fallback_launch_context := {} -var _status_workers: Array[Thread] = [] -var _status_tearing_down := false - - -func _init(connection = null, fallback_launch_context = null) -> void: - _connection = connection - # Lazy-loading this handler can reload ClientConfigurator's static script and - # clear its warmed snapshot. Retain the plugin-start capture as a safe - # fallback; the worker still prefers capture_launch_context()'s live snapshot. - if fallback_launch_context is Dictionary: - _fallback_launch_context = fallback_launch_context.duplicate(true) - - -func configure_client(params: Dictionary) -> Dictionary: - var client_id: String = params.get("client", "") - if not McpClientConfigurator.has_client(client_id): - var valid := ", ".join(McpClientConfigurator.client_ids()) - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) - var result := McpClientConfigurator.configure(client_id) - if result.get("status") == "error": - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - result.get("message", "Configuration failed for '%s'" % client_id)) - return {"data": result} - - -func remove_client(params: Dictionary) -> Dictionary: - var client_id: String = params.get("client", "") - if not McpClientConfigurator.has_client(client_id): - var valid := ", ".join(McpClientConfigurator.client_ids()) - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown client: %s. Use one of: %s" % [client_id, valid]) - var result := McpClientConfigurator.remove(client_id) - if result.get("status") == "error": - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - result.get("message", "Removal failed for '%s'" % client_id)) - return {"data": result} - - -func check_client_status(params: Dictionary) -> Dictionary: - var request_id: String = params.get("_request_id", "") - if _connection == null or request_id.is_empty(): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Client status requires a deferred request context.", - ) - if _status_tearing_down: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Client status handler is shutting down.") - # Match the dock refresh worker's cold-load guard. This is pure-memory and - # performs no launcher discovery, CLI lookup, or config/status probe. - McpClientConfigurator.warm_status_worker_bytecode() - # Refresh cwd/project roots on main before the status worker starts while - # preserving the startup launch snapshot for the deferred worker. - var status_context := _fallback_launch_context.duplicate(true) - status_context["project_roots"] = McpClientConfigurator.capture_project_roots() - # The aggregate command has a 30-second budget. Its worker resolves the - # shared Claude Desktop/Codex attach launch once, then reuses it for every - # command-shaped client instead of repeating cold launcher discovery. - # Start the worker from the deferred finisher after its first frame. That - # lets the dispatcher register this request before any probe can complete. - _finish_client_status_deferred( - McpClientConfigurator.run_client_status_sweep.bind(status_context), - request_id, - _connection, - ) - return McpDispatcher.DEFERRED_RESPONSE - - -## Called by McpDispatcher.clear() before releasing this lazy handler. Marking -## teardown is deliberately non-blocking: the in-flight coroutine retains this -## handler and its connection across frames, then joins each worker only after -## is_alive() becomes false. New sweeps are rejected immediately. -func prepare_for_teardown() -> void: - _status_tearing_down = true - - -## This instance coroutine intentionally keeps the lazily-created handler and -## deferred-response connection alive until its worker has been polled and -## joined. The first-frame yield is load-bearing: check_client_status() must -## return the deferred sentinel before the worker can produce a response. -func _finish_client_status_deferred( - worker_callable: Callable, request_id: String, connection -) -> void: - if not is_instance_valid(connection): - return - var tree: SceneTree = connection.get_tree() - if tree == null: - return - await tree.process_frame - if not is_instance_valid(connection) or _status_tearing_down: - return - var worker := Thread.new() - _status_workers.append(worker) - var start_error := worker.start(worker_callable) - if start_error != OK: - _status_workers.erase(worker) - connection.send_deferred_response(request_id, ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Could not start client status worker (error %d)." % start_error, - )) - return - while worker.is_alive(): - await tree.process_frame - var payload: Variant = worker.wait_to_finish() - _status_workers.erase(worker) - if _status_tearing_down: - return - if not is_instance_valid(connection): - return - if not payload is Dictionary: - payload = ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Client status worker returned an invalid response.", - ) - elif payload.has("worker_error"): - payload = ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - str(payload.get("worker_error", "Client status worker failed.")), - ) - connection.send_deferred_response(request_id, payload) diff --git a/addons/godot_ai/handlers/client_handler.gd.uid b/addons/godot_ai/handlers/client_handler.gd.uid deleted file mode 100644 index 25687f5..0000000 --- a/addons/godot_ai/handlers/client_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bmo4foc5fq75c diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd deleted file mode 100644 index 29e65be..0000000 --- a/addons/godot_ai/handlers/control_draw_recipe_handler.gd +++ /dev/null @@ -1,318 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles the control_draw_recipe MCP command. Attaches a shared DrawRecipe -## script to a Control and stores the caller's ordered draw ops in node -## metadata under "_ops". The DrawRecipe script dispatches each op to a -## CanvasItem draw_* call in _draw(). One Ctrl+Z reverts script + meta as a -## single undo step. - -const DRAW_RECIPE_SCRIPT := preload("res://addons/godot_ai/runtime/draw_recipe.gd") -const UiHandler := preload("res://addons/godot_ai/handlers/ui_handler.gd") - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -func control_draw_recipe(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var ops_raw: Variant = params.get("ops", null) - var clear_existing: bool = bool(params.get("clear_existing", true)) - - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - if typeof(ops_raw) != TYPE_ARRAY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "ops must be an Array") - - var _resolved := McpNodeValidator.resolve_or_error(path, "path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var scene_root: Node = _resolved.scene_root - if not node is Control: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "control_draw_recipe requires a Control node, got %s" % node.get_class() - ) - - var coerced := _coerce_ops(ops_raw) - if coerced.has("error"): - return coerced - var coerced_ops: Array = coerced.ops - - var old_script: Variant = node.get_script() - if old_script != null and old_script != DRAW_RECIPE_SCRIPT: - if not clear_existing: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - ( - "Node %s already has a script. Pass clear_existing=true to replace." - % path - ) - ) - - var had_meta := node.has_meta("_ops") - var old_ops: Variant = node.get_meta("_ops") if had_meta else null - - _undo_redo.create_action("MCP: Draw recipe on %s" % node.name) - _undo_redo.add_do_method(node, "set_script", DRAW_RECIPE_SCRIPT) - _undo_redo.add_do_method(node, "set_meta", "_ops", coerced_ops) - _undo_redo.add_do_method(node, "queue_redraw") - _undo_redo.add_undo_method(node, "set_script", old_script) - if had_meta: - _undo_redo.add_undo_method(node, "set_meta", "_ops", old_ops) - else: - _undo_redo.add_undo_method(node, "remove_meta", "_ops") - _undo_redo.add_undo_method(node, "queue_redraw") - _undo_redo.commit_action() - - return { - "data": - { - "path": McpScenePath.from_node(node, scene_root), - "ops_count": coerced_ops.size(), - "script_attached": old_script == null, - "script_replaced": old_script != null and old_script != DRAW_RECIPE_SCRIPT, - "undoable": true, - } - } - - - -## Validate and coerce every op dict. Returns {"ops": Array} or an error dict. -func _coerce_ops(ops: Array) -> Dictionary: - var result: Array = [] - for i in ops.size(): - var op: Variant = ops[i] - if typeof(op) != TYPE_DICTIONARY: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, "ops[%d] must be a dictionary" % i - ) - var coerced := _coerce_single_op(op, i) - if coerced.has("error"): - return coerced - result.append(coerced.op) - return {"ops": result} - - -func _coerce_single_op(op: Dictionary, idx: int) -> Dictionary: - var draw_type: String = op.get("draw", "") - if draw_type.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d]: missing 'draw' field" % idx - ) - match draw_type: - "line": - return _coerce_line(op, idx) - "rect": - return _coerce_rect(op, idx) - "arc": - return _coerce_arc(op, idx) - "circle": - return _coerce_circle(op, idx) - "polyline": - return _coerce_polyline_or_polygon(op, idx, "polyline") - "polygon": - return _coerce_polyline_or_polygon(op, idx, "polygon") - "string": - return _coerce_string(op, idx) - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "ops[%d]: unknown draw type '%s'" % [idx, draw_type] - ) - - -func _require_fields(op: Dictionary, idx: int, kind: String, fields: Array) -> Dictionary: - for f in fields: - if not op.has(f): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "ops[%d] (%s): missing '%s'" % [idx, kind, f] - ) - return {} - - -func _coerce_typed(value: Variant, prop_type: int, idx: int, kind: String, field: String) -> Dictionary: - var r := UiHandler._coerce_for_type(value, prop_type) - if r.ok: - return {"ok": true, "value": r.value} - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid '%s'" % [idx, kind, field] - ) - - -func _coerce_line(op: Dictionary, idx: int) -> Dictionary: - var missing := _require_fields(op, idx, "line", ["from", "to", "color"]) - if missing.has("error"): - return missing - var frm := _coerce_typed(op.from, TYPE_VECTOR2, idx, "line", "from") - if frm.has("error"): - return frm - var to_ := _coerce_typed(op.to, TYPE_VECTOR2, idx, "line", "to") - if to_.has("error"): - return to_ - var c := _coerce_typed(op.color, TYPE_COLOR, idx, "line", "color") - if c.has("error"): - return c - var out := {"draw": "line", "from": frm.value, "to": to_.value, "color": c.value} - if op.has("width"): - out["width"] = float(op.width) - if op.has("antialiased"): - out["antialiased"] = bool(op.antialiased) - return {"op": out} - - -func _coerce_rect(op: Dictionary, idx: int) -> Dictionary: - var missing := _require_fields(op, idx, "rect", ["rect", "color"]) - if missing.has("error"): - return missing - var r := _coerce_typed(op.rect, TYPE_RECT2, idx, "rect", "rect") - if r.has("error"): - return r - var c := _coerce_typed(op.color, TYPE_COLOR, idx, "rect", "color") - if c.has("error"): - return c - var out := {"draw": "rect", "rect": r.value, "color": c.value} - if op.has("filled"): - out["filled"] = bool(op.filled) - if op.has("width"): - out["width"] = float(op.width) - return {"op": out} - - -func _coerce_arc(op: Dictionary, idx: int) -> Dictionary: - var missing := _require_fields( - op, idx, "arc", ["center", "radius", "start_angle", "end_angle", "color"] - ) - if missing.has("error"): - return missing - var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "arc", "center") - if center.has("error"): - return center - var c := _coerce_typed(op.color, TYPE_COLOR, idx, "arc", "color") - if c.has("error"): - return c - var out := { - "draw": "arc", - "center": center.value, - "radius": float(op.radius), - "start_angle": float(op.start_angle), - "end_angle": float(op.end_angle), - "color": c.value, - } - if op.has("point_count"): - out["point_count"] = int(op.point_count) - if op.has("width"): - out["width"] = float(op.width) - if op.has("antialiased"): - out["antialiased"] = bool(op.antialiased) - return {"op": out} - - -func _coerce_circle(op: Dictionary, idx: int) -> Dictionary: - var missing := _require_fields(op, idx, "circle", ["center", "radius", "color"]) - if missing.has("error"): - return missing - var center := _coerce_typed(op.center, TYPE_VECTOR2, idx, "circle", "center") - if center.has("error"): - return center - var c := _coerce_typed(op.color, TYPE_COLOR, idx, "circle", "color") - if c.has("error"): - return c - return { - "op": - { - "draw": "circle", - "center": center.value, - "radius": float(op.radius), - "color": c.value, - } - } - - -func _coerce_polyline_or_polygon(op: Dictionary, idx: int, kind: String) -> Dictionary: - if not op.has("points"): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, "ops[%d] (%s): missing 'points'" % [idx, kind] - ) - if typeof(op.points) != TYPE_ARRAY: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "ops[%d] (%s): 'points' must be an Array" % [idx, kind] - ) - var points := PackedVector2Array() - for j in op.points.size(): - var p := UiHandler._coerce_for_type(op.points[j], TYPE_VECTOR2) - if not p.ok: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "ops[%d] (%s): points[%d] invalid" % [idx, kind, j] - ) - points.append(p.value) - - var out := {"draw": kind, "points": points} - - if op.has("colors"): - if typeof(op.colors) != TYPE_ARRAY: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "ops[%d] (%s): 'colors' must be an Array" % [idx, kind] - ) - var colors := PackedColorArray() - for k in op.colors.size(): - var ck := UiHandler._coerce_for_type(op.colors[k], TYPE_COLOR) - if not ck.ok: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "ops[%d] (%s): colors[%d] invalid" % [idx, kind, k] - ) - colors.append(ck.value) - out["colors"] = colors - elif op.has("color"): - var c := UiHandler._coerce_for_type(op.color, TYPE_COLOR) - if not c.ok: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, "ops[%d] (%s): invalid 'color'" % [idx, kind] - ) - out["color"] = c.value - else: - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "ops[%d] (%s): missing 'color' or 'colors'" % [idx, kind] - ) - - if op.has("width"): - out["width"] = float(op.width) - if op.has("antialiased"): - out["antialiased"] = bool(op.antialiased) - return {"op": out} - - -func _coerce_string(op: Dictionary, idx: int) -> Dictionary: - var missing := _require_fields(op, idx, "string", ["position", "text", "color"]) - if missing.has("error"): - return missing - var pos := _coerce_typed(op.position, TYPE_VECTOR2, idx, "string", "position") - if pos.has("error"): - return pos - var c := _coerce_typed(op.color, TYPE_COLOR, idx, "string", "color") - if c.has("error"): - return c - var out := { - "draw": "string", - "position": pos.value, - "text": str(op.text), - "color": c.value, - } - if op.has("font_size"): - out["font_size"] = int(op.font_size) - if op.has("align"): - out["align"] = int(op.align) - if op.has("max_width"): - out["max_width"] = float(op.max_width) - return {"op": out} diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid deleted file mode 100644 index da0aaf9..0000000 --- a/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buat1mt0fjlqb diff --git a/addons/godot_ai/handlers/csg_handler.gd b/addons/godot_ai/handlers/csg_handler.gd deleted file mode 100644 index 6380a9a..0000000 --- a/addons/godot_ai/handlers/csg_handler.gd +++ /dev/null @@ -1,118 +0,0 @@ -@tool -extends RefCounted - -## CSG authoring — create CSG shapes (box, sphere, cylinder, torus, prism) -## and set their boolean operation (union / intersection / subtraction) so -## agents can carve geometry (holes, caves, tunnels) directly in the editor. -## -## All ops target nodes in the currently edited scene by scene-relative -## path. All write ops are undoable via EditorUndoRedoManager. Sibling CSG -## shapes under the same parent combine automatically; use a CSGCombiner3D -## parent when you need explicit grouping. - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -const SHAPES := { - "box": "CSGBox3D", - "sphere": "CSGSphere3D", - "cylinder": "CSGCylinder3D", - "torus": "CSGTorus3D", - "polygon": "CSGPolygon3D", -} - -const OPERATIONS := { - "union": CSGShape3D.OPERATION_UNION, - "intersection": CSGShape3D.OPERATION_INTERSECTION, - "subtraction": CSGShape3D.OPERATION_SUBTRACTION, -} - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -## Create a CSG shape under a Node3D parent. -## params: {parent_path, name="", shape="box", operation="union"} -## Returns: {path, name, shape, operation, undoable} -func create(params: Dictionary) -> Dictionary: - var parent_path: String = params.get("parent_path", "") - var shape: String = params.get("shape", "box") - var operation: String = params.get("operation", "union") - var scene_file: String = params.get("scene_file", "") - - var shape_class: String = SHAPES.get(shape, "") - if shape_class.is_empty(): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown shape: %s. Valid shapes: %s" % [shape, ", ".join(SHAPES.keys())]) - if not OPERATIONS.has(operation): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())]) - - var scene_check := McpScenePath.require_edited_scene(scene_file) - if scene_check.has("error"): - return scene_check - var scene_root: Node = scene_check.node - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, - McpScenePath.format_parent_error(parent_path, scene_root)) - if not parent is Node3D: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "CSG parent must be a Node3D (got %s)" % parent.get_class()) - - var node: CSGShape3D = ClassDB.instantiate(shape_class) - if node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class) - var node_name: String = params.get("name", "") - if node_name.is_empty(): - node_name = shape_class - node.name = node_name - node.operation = OPERATIONS[operation] - - _undo_redo.create_action("MCP: Create %s" % node.name) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - - return {"data": { - "path": McpScenePath.from_node(node, scene_root), - "name": node.name, - "shape": shape, - "operation": operation, - "undoable": true, - }} - - -## Set the boolean operation of a CSG shape. -## params: {path, operation} -## Returns: {operation, undoable} -func set_operation(params: Dictionary) -> Dictionary: - var operation: String = params.get("operation", "") - if not OPERATIONS.has(operation): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown operation: %s. Valid operations: %s" % [operation, ", ".join(OPERATIONS.keys())]) - var resolved := McpNodeValidator.resolve_or_error( - params.get("path", ""), "path", params.get("scene_file", "")) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - if not node is CSGShape3D: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Node is not a CSGShape3D: %s" % params.get("path", "")) - var shape: CSGShape3D = node - var prev: int = shape.operation - var next: int = OPERATIONS[operation] - ## Target the node in both callbacks so the action lands in the scene - ## undo history (first-target routing); `set` exists on every Object. - _undo_redo.create_action("MCP: CSG set_operation") - _undo_redo.add_do_method(shape, "set", "operation", next) - _undo_redo.add_undo_method(shape, "set", "operation", prev) - _undo_redo.commit_action() - return {"data": {"operation": operation, "undoable": true}} diff --git a/addons/godot_ai/handlers/csg_handler.gd.uid b/addons/godot_ai/handlers/csg_handler.gd.uid deleted file mode 100644 index 0f7b976..0000000 --- a/addons/godot_ai/handlers/csg_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4mb0tptu52x2 diff --git a/addons/godot_ai/handlers/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd deleted file mode 100644 index e21c47a..0000000 --- a/addons/godot_ai/handlers/curve_handler.gd +++ /dev/null @@ -1,243 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Replaces all points on a Curve / Curve2D / Curve3D resource. The point -## list shape depends on resource type (see `set_points` for the schemas). -## -## Dedicated tool rather than a property set because Curve2D/Curve3D.add_point -## is a method call, not a property — resource_create's `properties` dict can't -## reach it. - -const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -func set_points(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - var property: String = params.get("property", "") - var resource_path: String = params.get("resource_path", "") - var new_points: Array = params.get("points", []) - - var home_err := McpResourceIO.validate_home(params) - if home_err != null: - return home_err - var has_file_target := not resource_path.is_empty() - if not (new_points is Array): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "points must be an array") - - var curve: Resource - var node: Node = null - var curve_created := false - if has_file_target: - var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") - if rpath_err != null: - return rpath_err - if not ResourceLoader.exists(resource_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) - # ResourceLoader.load() returns Godot's cached Resource. Duplicate - # before mutating so: (a) open scenes holding a reference to this - # .tres don't silently see the new points outside any undo action, - # and (b) if ResourceSaver.save() fails we haven't corrupted the - # in-memory cache (cache/disk divergence). Also guard against - # ResourceLoader.exists() succeeding but load() returning null - # (corrupt .tres, unregistered class) — otherwise curve.get_class() - # on the response line below would crash the plugin. - var loaded_curve: Resource = ResourceLoader.load(resource_path) - if loaded_curve == null: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to load curve from %s (file exists but load returned null — may be corrupt)" % resource_path - ) - curve = loaded_curve.duplicate() - else: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - node = McpScenePath.resolve(node_path, scene_root) - if node == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(node_path, scene_root)) - if not (property in node): - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(node, property) - ) - curve = node.get(property) - # Auto-create a fresh Curve subclass if the slot is empty. Infer the - # concrete class from the property's hint_string (e.g. Path3D.curve's - # hint is "Curve3D"). Creation is bundled into the same undo action - # as the point-set below, so Ctrl-Z rolls back both. - if curve == null: - var inferred := _infer_curve_class(node, property) - if inferred.is_empty(): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Curve slot on %s.%s is null and the Curve class can't be inferred from the property hint — create one first with resource_create (type=Curve3D/Curve2D/Curve)" % [node.get_class(), property] - ) - curve = ClassDB.instantiate(inferred) - if curve == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % inferred) - curve_created = true - - if not (curve is Curve or curve is Curve2D or curve is Curve3D): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Resource is %s — must be Curve, Curve2D, or Curve3D" % curve.get_class() - ) - - var coerced := _coerce_points(curve, new_points) - if coerced.has("error"): - return coerced.error - - var new_snapshot: Array = coerced.snapshot - - if has_file_target: - _apply_snapshot_to_curve(curve, new_snapshot) - # curve_set_points EDITS an existing .tres, so override the default - # "delete to revert" message via extra_fields. - return McpResourceIO.save_to_disk(curve, resource_path, true, "Curve", { - "curve_class": curve.get_class(), - "point_count": new_snapshot.size(), - "reason": "File save is persistent; edit the .tres file manually to revert", - }, _connection) - - # Inline (node-attached) path: swap the curve property so the action lands - # cleanly in scene history, mirroring the resource-swap pattern used by - # material_handler::assign_material. When curve_created is true the - # "old" value is null — undo clears the slot back to empty. - var new_curve: Resource = curve if curve_created else curve.duplicate() - _apply_snapshot_to_curve(new_curve, new_snapshot) - var old_curve: Resource = null if curve_created else curve - - _undo_redo.create_action("MCP: Set %d points on %s.%s" % [new_snapshot.size(), node.name, property]) - _undo_redo.add_do_property(node, property, new_curve) - _undo_redo.add_undo_property(node, property, old_curve) - _undo_redo.add_do_reference(new_curve) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "property": property, - "curve_class": new_curve.get_class(), - "point_count": new_snapshot.size(), - "curve_created": curve_created, - "undoable": true, - } - } - - -## Infer the concrete Curve class to instantiate for a null property slot. -## Reads the property's hint_string (set by Godot on resource-typed exports) -## to get the exact accepted class name (e.g. "Curve3D" for Path3D.curve). -## Returns empty string if no viable curve class can be determined. -static func _infer_curve_class(node: Node, property: String) -> String: - for prop in node.get_property_list(): - if prop.name != property: - continue - var hint_string: String = prop.get("hint_string", "") - if hint_string.is_empty(): - return "" - if not ClassDB.class_exists(hint_string): - return "" - if hint_string == "Curve" or hint_string == "Curve2D" or hint_string == "Curve3D": - return hint_string - # Some custom properties may list a parent class; require an exact - # match against our three supported types to avoid surprises. - return "" - return "" - - -## Convert input `points` into a normalized snapshot of typed values for -## the given curve type. Returns {snapshot: Array} on success or -## {error: ...} on failure. -static func _coerce_points(curve: Resource, points: Array) -> Dictionary: - var snapshot: Array = [] - if curve is Curve: - for i in range(points.size()): - var p = points[i] - if not (p is Dictionary) or not p.has("offset") or not p.has("value"): - return {"error": ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Curve points[%d] must be {offset, value, [left_tangent, right_tangent]}" % i - )} - snapshot.append({ - "offset": float(p["offset"]), - "value": float(p["value"]), - "left_tangent": float(p.get("left_tangent", 0.0)), - "right_tangent": float(p.get("right_tangent", 0.0)), - }) - elif curve is Curve2D: - var zero2 := {"x": 0, "y": 0} - for i in range(points.size()): - var p2 = points[i] - if not (p2 is Dictionary) or not p2.has("position"): - return {"error": ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Curve2D points[%d] must have 'position' (and optional 'in', 'out')" % i - )} - var axes2 := { - "position": p2["position"], - "in": p2.get("in", zero2), - "out": p2.get("out", zero2), - } - var coerced2 := {} - for field in ["position", "in", "out"]: - var v = NodeHandler._coerce_value(axes2[field], TYPE_VECTOR2) - var err := NodeHandler._check_coerced(v, TYPE_VECTOR2, "Curve2D points[%d].%s" % [i, field]) - if err != null: - return {"error": err} - coerced2[field] = v - snapshot.append(coerced2) - else: # Curve3D - var zero3 := {"x": 0, "y": 0, "z": 0} - for i in range(points.size()): - var p3 = points[i] - if not (p3 is Dictionary) or not p3.has("position"): - return {"error": ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Curve3D points[%d] must have 'position' (and optional 'in', 'out', 'tilt')" % i - )} - var axes3 := { - "position": p3["position"], - "in": p3.get("in", zero3), - "out": p3.get("out", zero3), - } - var coerced3 := {} - for field in ["position", "in", "out"]: - var v = NodeHandler._coerce_value(axes3[field], TYPE_VECTOR3) - var err := NodeHandler._check_coerced(v, TYPE_VECTOR3, "Curve3D points[%d].%s" % [i, field]) - if err != null: - return {"error": err} - coerced3[field] = v - coerced3["tilt"] = float(p3.get("tilt", 0.0)) - snapshot.append(coerced3) - return {"snapshot": snapshot} - - -func _apply_snapshot_to_curve(curve: Resource, snapshot: Array) -> void: - curve.clear_points() - if curve is Curve: - for p: Dictionary in snapshot: - curve.add_point( - Vector2(p.offset, p.value), - p.left_tangent, - p.right_tangent - ) - elif curve is Curve2D: - for p: Dictionary in snapshot: - curve.add_point(p.position, p["in"], p.out) - elif curve is Curve3D: - for i in range(snapshot.size()): - var p: Dictionary = snapshot[i] - curve.add_point(p.position, p["in"], p.out) - curve.set_point_tilt(i, p.tilt) diff --git a/addons/godot_ai/handlers/curve_handler.gd.uid b/addons/godot_ai/handlers/curve_handler.gd.uid deleted file mode 100644 index 8eb5b25..0000000 --- a/addons/godot_ai/handlers/curve_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dboqr06a1fvqx diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd deleted file mode 100644 index 8516eed..0000000 --- a/addons/godot_ai/handlers/editor_handler.gd +++ /dev/null @@ -1,1060 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const Telemetry := preload("res://addons/godot_ai/telemetry.gd") -const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") - -## Handles editor state, selection, log, screenshot, and performance commands. - -const UpdateMixedState := preload("res://addons/godot_ai/utils/update_mixed_state.gd") - -var _log_buffer: McpLogBuffer -var _connection: McpConnection -var _debugger_plugin: McpDebuggerPlugin -var _game_log_buffer: McpGameLogBuffer -var _editor_log_buffer: McpEditorLogBuffer -var _debugger_errors_root: Node -var _surfaced_error_tracker -var _vision_routing: VisionRoutingScript = null - - -func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_plugin: McpDebuggerPlugin = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null, debugger_errors_root: Node = null, surfaced_error_tracker = null, vision_routing: VisionRoutingScript = null) -> void: - _log_buffer = log_buffer - _connection = connection - _debugger_plugin = debugger_plugin - _game_log_buffer = game_log_buffer - _editor_log_buffer = editor_log_buffer - _debugger_errors_root = debugger_errors_root - _surfaced_error_tracker = surfaced_error_tracker - _vision_routing = vision_routing - if _surfaced_error_tracker == null: - _surfaced_error_tracker = McpSurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer, _debugger_errors_root) - - -func get_editor_state(_params: Dictionary) -> Dictionary: - var scene_root := EditorInterface.get_edited_scene_root() - var game_status := _current_game_status() - var data := { - "godot_version": Engine.get_version_info().get("string", "unknown"), - "project_name": ProjectSettings.get_setting("application/config/name", ""), - "current_scene": scene_root.scene_file_path if scene_root else "", - "is_playing": EditorInterface.is_playing_scene(), - "readiness": McpConnection.get_readiness(), - ## True once the game subprocess autoload has beaconed mcp:hello; - ## false between Play→Stop cycles. Lets capture-source=game callers - ## poll for a real ready signal instead of guessing with sleep(). - "game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(), - "game_status": game_status, - "helper_live": bool(game_status.get("helper_live", false)), - "session_active": bool(game_status.get("session_active", false)), - } - ## Half-installed addon tree from a failed self-update rollback. When - ## non-empty, the agent / dock paint the operator-facing recovery copy - ## from `update_mixed_state.gd::diagnose`. Field omitted when the - ## addons tree is clean so editor_state's normal payload stays small. - ## See issue #354 / audit-v2 #10. - var mixed_state := UpdateMixedState.diagnose() - if not mixed_state.is_empty(): - data["mixed_state"] = mixed_state - return {"data": data} - - -func get_selection(_params: Dictionary) -> Dictionary: - var scene_root := EditorInterface.get_edited_scene_root() - var selected := EditorInterface.get_selection().get_selected_nodes() - var paths: Array[String] = [] - for node in selected: - paths.append(McpScenePath.from_node(node, scene_root)) - return {"data": {"selected_paths": paths, "count": paths.size()}} - - -const VALID_LOG_SOURCES := ["plugin", "game", "editor", "all"] - -## Deferred budget for the `input_sequence` game op. Unlike the one-shot game -## ops (covered by game_command's 15s entry), it drives the game forward one -## frame per step, so the reply legitimately takes seconds. The game side caps -## the sequence length (GameHelper.MAX_SEQUENCE_FRAMES) well inside this; the -## budget is the backstop for a frozen game loop, mirroring take_screenshot. -const INPUT_SEQUENCE_TIMEOUT_SEC := 30.0 - - -func get_logs(params: Dictionary) -> Dictionary: - ## Coerce defensively — MCP clients can send JSON numbers as floats or - ## stray `null` values that would otherwise fail the typed locals - ## before we ever reach the INVALID_PARAMS return below. - var count: int = maxi(0, int(params.get("count", 50))) - var offset: int = maxi(0, int(params.get("offset", 0))) - var source: String = str(params.get("source", "plugin")) - var include_details: bool = bool(params.get("include_details", false)) - var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null - var since_cursor: int = maxi(0, int(params.get("since_cursor", 0))) - var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", "")) - if not source in VALID_LOG_SOURCES: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid source '%s' — use 'plugin', 'game', 'editor', or 'all'" % source, - ) - - match source: - "plugin": - return _get_plugin_logs(count, offset) - "game": - return _get_game_logs(count, offset, include_details, since_run_id) - "editor": - return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor) - "all": - return _get_all_logs(count, offset, include_details) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable") - - -func _current_game_status() -> Dictionary: - if _debugger_plugin == null: - return McpDebuggerPlugin.with_liveness_flags({ - "status": "stopped", - "active": false, - "ready": false, - "helper_expected": true, - }) - return _debugger_plugin.get_game_status() - - -func _get_plugin_logs(count: int, offset: int) -> Dictionary: - var all_lines := _log_buffer.get_recent(_log_buffer.total_count()) - var page: Array[Dictionary] = [] - var stop := mini(all_lines.size(), offset + count) - for i in range(mini(offset, all_lines.size()), stop): - page.append({"source": "plugin", "level": "info", "text": all_lines[i]}) - return { - "data": { - "source": "plugin", - "lines": page, - "total_count": all_lines.size(), - "returned_count": page.size(), - "offset": offset, - } - } - - -func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary: - var game_status := _current_game_status() - var helper_live := bool(game_status.get("helper_live", false)) - var session_active := bool(game_status.get("session_active", false)) - if _game_log_buffer == null: - return { - "data": { - "source": "game", - "lines": [], - "total_count": 0, - "returned_count": 0, - "offset": offset, - "run_id": "", - "current_run_id": "", - "is_running": session_active, - "helper_live": helper_live, - "session_active": session_active, - "game_status": game_status, - "dropped_count": 0, - "stale_run_id": false, - } - } - var current_run_id := _game_log_buffer.run_id() - var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id - var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id - var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count) - var page := _entries_for_response(run_page.get("entries", []), include_details) - var data := { - "source": "game", - "lines": page, - "total_count": int(run_page.get("total_count", 0)), - "returned_count": page.size(), - "offset": offset, - "run_id": target_run_id, - "current_run_id": current_run_id, - "is_running": session_active, - "helper_live": helper_live, - "session_active": session_active, - "game_status": game_status, - "dropped_count": _game_log_buffer.dropped_count(), - "stale_run_id": stale_run_id, - } - _merge_editor_errors_hint(data, game_status) - return {"data": data} - - -## #641: boot-time parse errors happen while autoload scripts compile — before -## the game helper's logger attaches via OS.add_logger — so they can NEVER -## appear in the game buffer. They surface only through the editor scope -## (Errors-tab rows + editor logger). Cross-reference them here so an -## empty/clean game log is not mistaken for a clean launch. -func _merge_editor_errors_hint(data: Dictionary, game_status: Dictionary) -> void: - if _debugger_plugin == null: - return - ## A since_run_id read of a prior run must not carry the CURRENT run's - ## editor errors — the hint interprets the run being read. - if bool(data.get("stale_run_id", false)): - return - ## run_token == 0 means no tracked run ever started this session; the - ## run-start cursor would be 0 and every retained editor error would be - ## misattributed to "this run". - if int(game_status.get("run_token", 0)) <= 0: - return - ## One-shot read — force the scan so rows that landed after the last - ## gated scan (and before the deferred timers fire) make the FIRST - ## logs_read(source='game') response, not just a later one. - var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since( - int(game_status.get("editor_log_cursor", 0)), true) - if str(errors_info.get("scope", "none")) != "run": - return - var errors: Array = errors_info.get("errors", []) - if errors.is_empty(): - return - data["editor_errors_count"] = errors.size() - data["editor_errors_hint"] = ( - "%d editor-side error%s from this run (first: %s) missing from the game log — boot-time parse/load errors occur before the game helper's logger attaches. Read logs_read(source='editor', include_details=true)." - % [errors.size(), "s" if errors.size() != 1 else "", _format_editor_error_summary(errors[0])] - ) - - -func _format_editor_error_summary(entry: Dictionary) -> String: - return McpSurfacedErrorTracker.format_editor_error_summary(entry) - - -func _get_editor_logs(count: int, offset: int, include_details: bool, has_since_cursor: bool = false, since_cursor: int = 0) -> Dictionary: - ## Editor-process script errors (parse errors, @tool runtime errors, - ## EditorPlugin errors, push_error/push_warning). Captured by - ## editor_logger.gd via OS.add_logger and gated on Godot 4.5+; on older - ## engines the buffer can be null. Godot also sends GDScript reload - ## warnings/errors straight to the Debugger dock's Errors tab; those do - ## not flow through OS.add_logger, so merge the visible tree rows here. - if has_since_cursor: - return _get_editor_logs_since(count, since_cursor, include_details) - var all_entries := _collect_editor_log_entries() - var page := _entries_for_response(_slice_entries(all_entries, offset, count), include_details) - var appended_total := _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 - return { - "data": { - "source": "editor", - "lines": page, - "total_count": all_entries.size(), - "returned_count": page.size(), - "offset": offset, - "dropped_count": _editor_log_buffer.dropped_count() if _editor_log_buffer != null else 0, - "next_cursor": appended_total, - "appended_total": appended_total, - } - } - - -func _get_editor_logs_since(count: int, since_cursor: int, include_details: bool) -> Dictionary: - ## Cursor reads are defined over the monotonic editor logger ring only. - ## Visible Debugger Errors-tab rows are live UI state, not ring entries, - ## so regular offset reads still merge them while since_cursor polling - ## reports only Logger-backed entries. - var captured := { - "cursor": since_cursor, - "oldest_cursor": 0, - "next_cursor": 0, - "appended_total": 0, - "truncated": false, - "has_more": false, - "entries": [], - } - var dropped := 0 - if _editor_log_buffer != null: - captured = _editor_log_buffer.get_since(since_cursor, count) - dropped = _editor_log_buffer.dropped_count() - var page := _entries_for_response(captured.get("entries", []), include_details) - return { - "data": { - "source": "editor", - "lines": page, - "total_count": int(captured.get("appended_total", 0)), - "returned_count": page.size(), - "offset": 0, - "dropped_count": dropped, - "cursor": int(captured.get("cursor", since_cursor)), - "oldest_cursor": int(captured.get("oldest_cursor", 0)), - "next_cursor": int(captured.get("next_cursor", 0)), - "appended_total": int(captured.get("appended_total", 0)), - "truncated": bool(captured.get("truncated", false)), - "has_more": bool(captured.get("has_more", false)), - } - } - - -func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary: - ## Plugin lines have no timestamp, so we can't merge chronologically. - ## Concatenate plugin → editor → game and apply the offset/count window - ## over the combined list. The per-line `source` field tells callers - ## where each entry came from. Editor goes between plugin and game so - ## script errors stay grouped near the plugin recv/send traffic that - ## triggered them, with game runtime logs at the end. - var combined: Array[Dictionary] = [] - for line in _log_buffer.get_recent(_log_buffer.total_count()): - combined.append({"source": "plugin", "level": "info", "text": line}) - for entry in _collect_editor_log_entries(): - combined.append(entry) - var run_id := "" - var current_run_id := "" - var dropped := 0 - if _game_log_buffer != null: - run_id = _game_log_buffer.run_id() - current_run_id = run_id - dropped = _game_log_buffer.dropped_count() - var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES) - for entry in run_page.get("entries", []): - combined.append(entry) - var stop := mini(combined.size(), offset + count) - var page: Array[Dictionary] = [] - for i in range(mini(offset, combined.size()), stop): - page.append(combined[i]) - page = _entries_for_response(page, include_details) - if _editor_log_buffer != null: - dropped += _editor_log_buffer.dropped_count() - var game_status := _current_game_status() - return { - "data": { - "source": "all", - "lines": page, - "total_count": combined.size(), - "returned_count": page.size(), - "offset": offset, - "run_id": run_id, - "current_run_id": current_run_id, - "is_running": bool(game_status.get("session_active", false)), - "helper_live": bool(game_status.get("helper_live", false)), - "session_active": bool(game_status.get("session_active", false)), - "game_status": game_status, - "dropped_count": dropped, - } - } - - -func _entries_for_response(entries: Array[Dictionary], include_details: bool) -> Array[Dictionary]: - ## Compact responses only drop the top-level "details" key, so a shallow - ## copy is enough; the deep copy is reserved for the opt-in details path - ## where nested dicts leave the buffer. - var out: Array[Dictionary] = [] - for entry in entries: - if include_details: - out.append(entry.duplicate(true)) - else: - var copy: Dictionary = entry.duplicate(false) - copy.erase("details") - out.append(copy) - return out - - -func _collect_editor_log_entries() -> Array[Dictionary]: - return _surfaced_error_tracker.collect_editor_log_entries() - - -static func _slice_entries(entries: Array[Dictionary], offset: int, count: int) -> Array[Dictionary]: - var page: Array[Dictionary] = [] - var stop := mini(entries.size(), offset + count) - for i in range(mini(offset, entries.size()), stop): - page.append(entries[i]) - return page - - -## Map of human-readable monitor names to Performance.Monitor enum values. -const MONITORS := { - "time/fps": Performance.TIME_FPS, - "time/process": Performance.TIME_PROCESS, - "time/physics_process": Performance.TIME_PHYSICS_PROCESS, - "time/navigation_process": Performance.TIME_NAVIGATION_PROCESS, - "memory/static": Performance.MEMORY_STATIC, - "memory/static_max": Performance.MEMORY_STATIC_MAX, - "memory/message_buffer_max": Performance.MEMORY_MESSAGE_BUFFER_MAX, - "object/count": Performance.OBJECT_COUNT, - "object/resource_count": Performance.OBJECT_RESOURCE_COUNT, - "object/node_count": Performance.OBJECT_NODE_COUNT, - "object/orphan_node_count": Performance.OBJECT_ORPHAN_NODE_COUNT, - "render/total_objects_in_frame": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME, - "render/total_primitives_in_frame": Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME, - "render/total_draw_calls_in_frame": Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME, - "render/video_mem_used": Performance.RENDER_VIDEO_MEM_USED, - "physics_2d/active_objects": Performance.PHYSICS_2D_ACTIVE_OBJECTS, - "physics_2d/collision_pairs": Performance.PHYSICS_2D_COLLISION_PAIRS, - "physics_2d/island_count": Performance.PHYSICS_2D_ISLAND_COUNT, - "physics_3d/active_objects": Performance.PHYSICS_3D_ACTIVE_OBJECTS, - "physics_3d/collision_pairs": Performance.PHYSICS_3D_COLLISION_PAIRS, - "physics_3d/island_count": Performance.PHYSICS_3D_ISLAND_COUNT, - "navigation/active_maps": Performance.NAVIGATION_ACTIVE_MAPS, - "navigation/region_count": Performance.NAVIGATION_REGION_COUNT, - "navigation/agent_count": Performance.NAVIGATION_AGENT_COUNT, - "navigation/link_count": Performance.NAVIGATION_LINK_COUNT, - "navigation/polygon_count": Performance.NAVIGATION_POLYGON_COUNT, - "navigation/edge_count": Performance.NAVIGATION_EDGE_COUNT, - "navigation/edge_merge_count": Performance.NAVIGATION_EDGE_MERGE_COUNT, - "navigation/edge_connection_count": Performance.NAVIGATION_EDGE_CONNECTION_COUNT, - "navigation/edge_free_count": Performance.NAVIGATION_EDGE_FREE_COUNT, -} - - -## Compute coverage angles from the target's AABB geometry. -## Returns an establishing perspective shot (faces the longest ground axis) -## and an orthographic top-down for spatial layout. The AI iterates from -## there with explicit elevation/azimuth/fov for closeups and detail shots. -func _compute_coverage_angles(aabb: AABB) -> Array[Dictionary]: - var size := aabb.size - var ground_x := maxf(size.x, 0.01) - var ground_z := maxf(size.z, 0.01) - - ## Face the longest ground axis — establishing shot shows maximum extent - var estab_azimuth: float - if ground_x >= ground_z: - estab_azimuth = 0.0 # face along Z, showing X width - else: - estab_azimuth = 90.0 # face along X, showing Z width - - ## FOV: wider for spread-out subjects, narrower for compact ones - var ground_ratio := maxf(ground_x, ground_z) / minf(ground_x, ground_z) - var estab_fov := clampf(40.0 + ground_ratio * 5.0, 45.0, 65.0) - - return [ - {"label": "establishing", "elevation": 25.0, "azimuth": estab_azimuth + 20.0, - "fov": estab_fov, "ortho": false, "padding": 1.8}, - {"label": "top", "elevation": 90.0, "azimuth": 0.0, - "fov": 0.0, "ortho": true}, - ] - - -func take_screenshot(params: Dictionary) -> Dictionary: - ## Vision Routing hook: when enabled, the capture is described by the - ## configured vision provider on a worker thread and the text description - ## is returned instead of the raw image (see vision_routing.gd). Off, no - ## key, or non-image results keep the original behavior. The single source - ## of truth for the `match source:` dispatch lives in _take_screenshot_impl - ## (pinned by tests/unit/test_docs_screenshot_sources.py). - if _vision_routing != null and _vision_routing.is_routing_enabled(): - return _vision_routing.route_editor_screenshot(params, Callable(self, "_take_screenshot_impl"), _connection) - return _take_screenshot_impl(params) - - -func _take_screenshot_impl(params: Dictionary) -> Dictionary: - var source: String = params.get("source", "viewport") - var max_resolution: int = params.get("max_resolution", 0) - var view_target: String = params.get("view_target", "") - var coverage: bool = params.get("coverage", false) - var custom_elevation = params.get("elevation", null) - var custom_azimuth = params.get("azimuth", null) - var custom_fov = params.get("fov", null) - - var viewport: Viewport - match source: - "viewport": - viewport = EditorInterface.get_editor_viewport_3d() - if viewport == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, - "No 3D viewport available", false) - ## The 3D viewport's texture is empty when the edited scene - ## has no Node3D content (2D-only scene, or no scene open), - ## and the empty-image guard further down used to surface - ## that as INTERNAL_ERROR — leaving callers with no signal - ## that the failure was caller-side. Reject up front with a - ## structured hint so the LLM can pick a sensible next step - ## (open a 3D scene, switch to source="cinematic", etc.). - var precheck := viewport_screenshot_precheck(EditorInterface.get_edited_scene_root()) - if precheck.has("error"): - return precheck - "game": - if not EditorInterface.is_playing_scene(): - ## Same editor state as game_eval/game_command's gate below — - ## same EDITOR_NOT_READY shape, not INVALID_PARAMS (the params - ## were fine; the editor just isn't in the required state). - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, - "Game is not running — start the project first", false, - "Use source='viewport' for the editor viewport, or start the game with project_run and retry.") - ## The game is always a separate OS process (embedded mode just - ## reparents its window into the editor). Reach the framebuffer - ## via the debugger channel: the `_mcp_game_helper` autoload - ## inside the game process replies with a PNG, and - ## McpDebuggerPlugin pushes the response back through our - ## WebSocket with the same request_id via McpConnection.send_deferred_response. - if _debugger_plugin == null or _connection == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Debugger bridge unavailable — plugin may not be fully initialised") - var request_id: String = params.get("_request_id", "") - if request_id.is_empty(): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Missing request_id — cannot correlate deferred response") - _debugger_plugin.request_game_screenshot(request_id, max_resolution, _connection) - return McpDispatcher.DEFERRED_RESPONSE - "cinematic": - return _take_cinematic_screenshot(max_resolution) - "viewport_2d": - viewport = EditorInterface.get_editor_viewport_2d() - if viewport == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, - "No 2D viewport available", false) - var scene_root_2d := EditorInterface.get_edited_scene_root() - if scene_root_2d == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_NO_SCENE, - "No scene open — open a scene first", false, - "Call scene_open with a scene path (e.g. \"res://main.tscn\") first.") - if not view_target.is_empty() or coverage or custom_elevation != null or custom_azimuth != null or custom_fov != null: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "view_target, coverage, elevation, azimuth, and fov are not supported with source='viewport_2d'" - ) - ## Capture the 2D editor viewport directly; no view_target/coverage for 2D. - RenderingServer.force_draw(false) - var image_2d: Image = viewport.get_texture().get_image() - if image_2d == null or image_2d.is_empty(): - return _empty_image_error( - "viewport_2d", - "Captured an empty image from the 2D viewport. The 2D viewport produced no output — typically headless mode or the 2D viewport has not drawn a frame yet." - ) - return _finalize_image(image_2d, "viewport_2d", max_resolution) - _: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid source '%s' — use 'viewport', 'viewport_2d', 'cinematic', or 'game'" % source) - - ## Handle view_target: temporarily reposition the editor's own camera to - ## frame one or more target nodes, force a render, capture, then restore. - if not view_target.is_empty() and source == "viewport": - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - ## Parse comma-separated paths, deduplicate - var raw_paths := view_target.split(",") - var seen := {} - var unique_paths: Array[String] = [] - for rp in raw_paths: - var p := rp.strip_edges() - if not p.is_empty() and not seen.has(p): - seen[p] = true - unique_paths.append(p) - - ## Resolve each path, collect valid Node3D targets - var targets: Array[Node3D] = [] - var not_found: Array[String] = [] - for p in unique_paths: - var node := McpScenePath.resolve(p, scene_root) - if node == null: - not_found.append(p) - elif not node is Node3D: - not_found.append(p) - else: - targets.append(node as Node3D) - - if targets.is_empty(): - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "No valid Node3D targets found: %s" % ", ".join(not_found)) - - var cam := viewport.get_camera_3d() - if cam == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_VIEWPORT_UNAVAILABLE, - "No camera in 3D viewport", false) - - ## Merge AABBs from all targets - var combined_aabb := _get_visual_aabb(targets[0]) - for i in range(1, targets.size()): - combined_aabb = combined_aabb.merge(_get_visual_aabb(targets[i])) - - var cam_rid := cam.get_camera_rid() - var saved_xform := cam.global_transform - var saved_fov := cam.fov - var saved_near := cam.near - var saved_far := cam.far - - ## --- Coverage path: multi-angle sweep --- - if coverage: - var images: Array[Dictionary] = [] - for preset in _compute_coverage_angles(combined_aabb): - if preset.get("ortho", false): - ## Orthographic top-down view - var ortho_size := combined_aabb.size.length() * 1.8 - var cam_height := maxf(combined_aabb.size.length() * 3.0, 10.0) - var center := combined_aabb.get_center() - var xform := Transform3D(Basis.IDENTITY, center + Vector3.UP * cam_height) - xform = xform.looking_at(center, Vector3.FORWARD) - RenderingServer.camera_set_orthogonal(cam_rid, ortho_size, saved_near, maxf(saved_far, cam_height * 2.0)) - RenderingServer.camera_set_transform(cam_rid, xform) - else: - ## Perspective view — padding per preset (wide for establishing, tight for detail) - var pad: float = preset.get("padding", 2.5) - var xform := _frame_transform_for_aabb(combined_aabb, preset.fov, preset.elevation, preset.azimuth, pad) - RenderingServer.camera_set_perspective(cam_rid, preset.fov, saved_near, saved_far) - RenderingServer.camera_set_transform(cam_rid, xform) - RenderingServer.force_draw(false) - var img: Image = viewport.get_texture().get_image() - if img != null and not img.is_empty(): - var entry := _finalize_image(img, "viewport", max_resolution) - entry.data["label"] = preset.label - entry.data["elevation"] = preset.elevation - entry.data["azimuth"] = preset.azimuth - entry.data["fov"] = preset.fov - entry.data["ortho"] = preset.get("ortho", false) - images.append(entry.data) - - ## Restore camera state (back to perspective + original transform) - RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far) - RenderingServer.camera_set_transform(cam_rid, saved_xform) - - ## Consistent with single-shot path: error if no frames rendered - ## (e.g. headless mode where force_draw produces no output). - if images.is_empty(): - return _empty_image_error( - "viewport", - "Coverage sweep rendered no images. The 3D viewport produced no output across any of the preset angles — typically because the editor is in headless mode (force_draw has no rendered output) or the 3D viewport has not drawn a frame yet." - ) - - var aabb_center := combined_aabb.get_center() - var aabb_size := combined_aabb.size - var result_data := { - "source": "viewport", - "view_target": view_target, - "view_target_count": targets.size(), - "coverage": true, - "images": images, - "aabb_center": [aabb_center.x, aabb_center.y, aabb_center.z], - "aabb_size": [aabb_size.x, aabb_size.y, aabb_size.z], - "aabb_longest_ground_axis": "x" if aabb_size.x >= aabb_size.z else "z", - } - if not not_found.is_empty(): - result_data["view_target_not_found"] = not_found - return {"data": result_data} - - ## --- Custom angle / FOV path --- - var use_elev: float = 25.0 if custom_elevation == null else float(custom_elevation) - var use_azim: float = 30.0 if custom_azimuth == null else float(custom_azimuth) - var use_fov: float = saved_fov if custom_fov == null else float(custom_fov) - - var cam_xform := _frame_transform_for_aabb(combined_aabb, use_fov, use_elev, use_azim) - - if custom_fov != null: - RenderingServer.camera_set_perspective(cam_rid, use_fov, saved_near, saved_far) - RenderingServer.camera_set_transform(cam_rid, cam_xform) - RenderingServer.force_draw(false) - - var image: Image = viewport.get_texture().get_image() - - ## Restore camera state - if custom_fov != null: - RenderingServer.camera_set_perspective(cam_rid, saved_fov, saved_near, saved_far) - RenderingServer.camera_set_transform(cam_rid, saved_xform) - - if image == null or image.is_empty(): - return _empty_image_error( - "viewport", - "Framed viewport rendered an empty image after repositioning the camera onto the view_target. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." - ) - - var result := _finalize_image(image, "viewport", max_resolution) - result.data["view_target"] = view_target - result.data["view_target_count"] = targets.size() - var aabb_c := combined_aabb.get_center() - var aabb_s := combined_aabb.size - result.data["aabb_center"] = [aabb_c.x, aabb_c.y, aabb_c.z] - result.data["aabb_size"] = [aabb_s.x, aabb_s.y, aabb_s.z] - result.data["aabb_longest_ground_axis"] = "x" if aabb_s.x >= aabb_s.z else "z" - if custom_elevation != null or custom_azimuth != null: - result.data["elevation"] = use_elev - result.data["azimuth"] = use_azim - if custom_fov != null: - result.data["fov"] = use_fov - if not not_found.is_empty(): - result.data["view_target_not_found"] = not_found - return result - - var image: Image = viewport.get_texture().get_image() - - if image == null or image.is_empty(): - return _empty_image_error( - source, - "Captured an empty image from %s. The 3D viewport produced no output — typically headless mode or the 3D viewport has not drawn a frame yet." % source - ) - - return _finalize_image(image, source, max_resolution) - - -## Render the edited scene through its active Camera3D without running the -## game. Mirrors Godot's "Cinematic Preview" display mode but via a -## throwaway SubViewport, so the output has no editor gizmos, selection -## outlines, or grid lines. -func _take_cinematic_screenshot(max_resolution: int) -> Dictionary: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var scene_camera := _find_current_camera_3d(scene_root) - if scene_camera == null: - return ErrorCodes.make( - ErrorCodes.NODE_NOT_FOUND, - "No current Camera3D in scene — mark a Camera3D as `current` or add one to the scene", - ) - - ## Default to a 16:9 HD capture; size is overridden by _finalize_image's - ## `max_resolution` downscale step when requested. - var render_size := Vector2i(1920, 1080) - var edit_vp := EditorInterface.get_editor_viewport_3d() - if edit_vp != null: - var vs := edit_vp.get_visible_rect().size - if vs.x >= 1.0 and vs.y >= 1.0: - render_size = Vector2i(int(vs.x), int(vs.y)) - - var sub_vp := SubViewport.new() - sub_vp.size = render_size - sub_vp.own_world_3d = false - sub_vp.transparent_bg = false - sub_vp.render_target_update_mode = SubViewport.UPDATE_ONCE - - var cam := Camera3D.new() - cam.fov = scene_camera.fov - cam.near = scene_camera.near - cam.far = scene_camera.far - cam.projection = scene_camera.projection - cam.size = scene_camera.size - cam.keep_aspect = scene_camera.keep_aspect - cam.cull_mask = scene_camera.cull_mask - cam.environment = scene_camera.environment - cam.attributes = scene_camera.attributes - cam.current = true - - sub_vp.add_child(cam) - scene_root.add_child(sub_vp) - ## global_transform is resolved against the ancestor Node3D chain, so it - ## must be set after parenting — otherwise the camera ends up at origin. - cam.global_transform = scene_camera.global_transform - ## NOTIFICATION_TRANSFORM_CHANGED is delivered deferred (next frame's - ## flush_transform_notifications), but force_draw renders immediately — - ## without this flush the RenderingServer still has the identity - ## transform pushed at ENTER_WORLD and the capture shows only sky - ## instead of the camera's actual view (issue #650). - cam.force_update_transform() - - RenderingServer.force_draw(false) - var image: Image = sub_vp.get_texture().get_image() - - scene_root.remove_child(sub_vp) - sub_vp.queue_free() - - if image == null or image.is_empty(): - return _empty_image_error( - "cinematic", - "Cinematic render produced an empty image. The SubViewport returned no texture — typically headless mode (force_draw has no rendered output) or the scene's Camera3D is positioned so nothing visible is in frame." - ) - - var result := _finalize_image(image, "cinematic", max_resolution) - result.data["camera_path"] = McpScenePath.from_node(scene_camera, scene_root) - return result - - -## Reject a `source="viewport"` screenshot before we ever pull the -## texture if the edited scene has no Node3D content. The 3D viewport -## returns an empty (or stale) image in that case; surfacing it as -## INTERNAL_ERROR ("Failed to capture image from viewport") gave LLM -## callers no signal that the right move is to switch source or open a -## 3D scene. 152 hits / 63 uuids in 24h across plugin versions 2.5.0 -> -## 2.5.6 traced back to this. Returns `{}` on success. -## -## Caller passes `EditorInterface.get_edited_scene_root()`; the static -## form lets tests exercise the branches with a synthetic scene root -## without driving the editor. -static func viewport_screenshot_precheck(scene_root: Node) -> Dictionary: - if scene_root == null: - var no_scene_err := _make_viewport_not_3d_error( - "", - "The editor 3D viewport is empty because no scene is open. Open a scene with `scene_open` first." - ) - ## The honest state here is "no scene", not "scene lacks 3D content" - ## — relabel the sub-code so telemetry doesn't conflate the two. - ## `editor_state` stays "viewport_not_3d" for pre-#651 consumers. - no_scene_err["error"]["data"]["sub_code"] = ErrorCodes.SUB_EDITOR_NO_SCENE - return no_scene_err - ## A scene with any Node3D content — root or descendant — has - ## something the 3D viewport can render. Walking the tree (rather - ## than only checking the root type) avoids a false reject on the - ## common `Node` / `Node2D` root + Node3D descendant pattern. - if _scene_has_node3d_content(scene_root): - return {} - var root_type := scene_root.get_class() - var hint: String - var is_2d_scene := scene_root is CanvasItem - if is_2d_scene: - hint = ( - "The 3D viewport is empty because the current scene is 2D (%s root) with no Node3D descendants. " - + "Options: (a) open a 3D scene, " - + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, " - + "(c) use source=\"viewport_2d\" to capture the 2D editor viewport directly, " - + "(d) call scene_get_hierarchy first to inspect what's available." - ) % root_type - else: - hint = ( - "The 3D viewport is empty because the current scene (%s root) has no Node3D content anywhere in the tree. " - + "Options: (a) open or add a Node3D, " - + "(b) use source=\"cinematic\" if a Camera3D exists in the scene, " - + "(c) call scene_get_hierarchy first to inspect what's available." - ) % root_type - var err := _make_viewport_not_3d_error(root_type, hint) - if is_2d_scene: - err["error"]["data"]["suggestion"] = "use source='viewport_2d' for 2D scenes" - return err - - -## True if scene_root is itself a Node3D or owns any Node3D descendant. -## DFS short-circuits on the first hit so empty 2D scenes stay cheap. -static func _scene_has_node3d_content(scene_root: Node) -> bool: - if scene_root is Node3D: - return true - var stack: Array[Node] = [scene_root] - while not stack.is_empty(): - var node: Node = stack.pop_back() - for child in node.get_children(): - if child is Node3D: - return true - stack.append(child) - return false - - -static func _make_viewport_not_3d_error(scene_root_type: String, hint: String) -> Dictionary: - ## `hint` becomes `error.message`; not duplicated into `data` because - ## `GodotCommandError`'s string form already appends every `data` key - ## as a suffix on the agent-visible error. - var err := ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_VIEWPORT_NOT_3D, hint, false) - err["error"]["data"]["editor_state"] = "viewport_not_3d" - err["error"]["data"]["scene_root_type"] = scene_root_type - return err - - -## Reached only when the precheck passed but the texture still came -## back empty — headless rendering, a freshly opened editor whose 3D -## viewport hasn't drawn a frame, or a SubViewport that lost its target. -static func _empty_image_error(source: String, hint: String) -> Dictionary: - ## retryable=false: an empty capture is usually headless mode, where a - ## retry loops forever — the not-yet-drawn-frame case is transient but - ## indistinguishable from here, so don't invite a retry loop. - var err := ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_VIEWPORT_EMPTY, hint, false) - err["error"]["data"]["editor_state"] = "viewport_empty" - err["error"]["data"]["source"] = source - return err - - -## Return the Camera3D that would be active if the scene were running. -## Preference: a descendant with `current=true`, else the first Camera3D -## found in a depth-first walk. -func _find_current_camera_3d(root: Node) -> Camera3D: - var first: Camera3D = null - var stack: Array[Node] = [root] - while not stack.is_empty(): - var node: Node = stack.pop_back() - if node is Camera3D: - if node.current: - return node - if first == null: - first = node - for child in node.get_children(): - stack.append(child) - return first - - -func _finalize_image(image: Image, source: String, max_resolution: int) -> Dictionary: - ## Shared with the game-process copy in runtime/game_helper.gd (#716). - var encoded := McpScreenshotEncode.downscale_and_encode(image, max_resolution) - return { - "data": { - "source": source, - "width": encoded.width, - "height": encoded.height, - "original_width": encoded.original_width, - "original_height": encoded.original_height, - "format": "png", - "image_base64": encoded.base64, - } - } - - -## Recursively compute the visual bounding box of a Node3D and its children. -func _get_visual_aabb(node: Node3D) -> AABB: - var aabb := AABB() - var found := false - if node is VisualInstance3D: - aabb = node.global_transform * node.get_aabb() - found = true - for child in node.get_children(): - if child is Node3D: - var child_aabb := _get_visual_aabb(child) - if child_aabb.size != Vector3.ZERO: - if found: - aabb = aabb.merge(child_aabb) - else: - aabb = child_aabb - found = true - if not found: - aabb = AABB(node.global_position - Vector3(0.5, 0.5, 0.5), Vector3(1, 1, 1)) - return aabb - - -## Calculate a camera Transform3D that frames the given AABB nicely. -## elevation_deg: camera elevation (0 = level, 90 = directly above). Default 25. -## azimuth_deg: camera azimuth (0 = front, 90 = right side). Default 30. -## padding: distance multiplier for breathing room (1.2 = tight, 2.5 = context). Default 1.8. -func _frame_transform_for_aabb(aabb: AABB, fov_degrees: float = 75.0, elevation_deg: float = 25.0, azimuth_deg: float = 30.0, padding: float = 1.8) -> Transform3D: - var center := aabb.get_center() - var radius := aabb.size.length() * 0.5 - var fov_rad := deg_to_rad(fov_degrees) - var distance := radius / tan(fov_rad * 0.5) * padding - ## Floor with an absolute offset so unit-scale AABBs don't place the camera - ## inside or against the target. `radius * 2.0` alone scales to zero as the - ## AABB shrinks; the +1.0 guarantees a minimum of ~1 world-unit of standoff. - distance = maxf(distance, radius * 2.0 + 1.0) - var elev := deg_to_rad(elevation_deg) - var azim := deg_to_rad(azimuth_deg) - var cam_pos := center + Vector3( - distance * cos(elev) * sin(azim), - distance * sin(elev), - distance * cos(elev) * cos(azim), - ) - var xform := Transform3D(Basis.IDENTITY, cam_pos) - ## At ~90° elevation the view direction is parallel to Vector3.UP — use - ## FORWARD as the up hint so looking_at doesn't degenerate. - var up := Vector3.FORWARD if elevation_deg > 85.0 else Vector3.UP - return xform.looking_at(center, up) - - -func get_performance_monitors(params: Dictionary) -> Dictionary: - var filter: Array = params.get("monitors", []) - var result := {} - - if filter.is_empty(): - for key in MONITORS: - result[key] = Performance.get_monitor(MONITORS[key]) - else: - for key in filter: - if MONITORS.has(key): - result[key] = Performance.get_monitor(MONITORS[key]) - - return { - "data": { - "monitors": result, - "monitor_count": result.size(), - } - } - - -func clear_logs(params: Dictionary) -> Dictionary: - var count := _log_buffer.total_count() - _log_buffer.clear() - var data := {"cleared_count": count} - ## The Debugger Errors panel is user-visible editor UI, not an MCP-owned - ## buffer — wiping it stays behind an explicit opt-in. - if bool(params.get("clear_debugger_errors", false)): - data["debugger_errors_cleared"] = _clear_debugger_error_trees() - return {"data": data} - - -func _clear_debugger_error_trees() -> int: - return _surfaced_error_tracker.clear_debugger_error_trees() - - -func reload_plugin(_params: Dictionary) -> Dictionary: - _log_buffer.log("reload_plugin requested, reloading next frame") - ## Persist a pending plugin_reload telemetry event *before* the - ## disable kills the live WebSocket. The re-enabled plugin's - ## _enter_tree flushes via `_telemetry.flush_pending_plugin_reload()`. - Telemetry.record_pending_plugin_reload("mcp_tool") - _do_reload_plugin.call_deferred() - return {"data": {"status": "reloading", "message": "Plugin reload initiated"}} - - -## Force a filesystem rescan before toggling the plugin, so Godot's -## class-name registry picks up any .gd files added since the last scan -## (e.g. via git pull or an agent-driven sync). Without this, re-enable can -## fail with "Could not find type X" when new class_name scripts are on disk -## but not yet registered, leaving the plugin disabled with no recovery path -## short of killing the editor. See issue #83. -# `static` is load-bearing: the deferred coroutine captures no `self`, so -# it survives even if the EditorHandler RefCounted is freed mid-await — -# which is exactly what reload does to this handler's owner. An instance -# coroutine here resumes on a freed object under reload churn. -static func _do_reload_plugin() -> void: - var fs := EditorInterface.get_resource_filesystem() - fs.scan() - var tree := Engine.get_main_loop() as SceneTree - # Cap the wait so a long scan (huge project) doesn't hang reload. - var deadline_ms := Time.get_ticks_msec() + 5000 - while fs.is_scanning() and Time.get_ticks_msec() < deadline_ms: - await tree.process_frame - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -func quit_editor(_params: Dictionary) -> Dictionary: - _log_buffer.log("quit_editor requested, quitting next frame") - ## Defer the quit so the response is sent back before the editor exits. - EditorInterface.get_base_control().get_tree().call_deferred("quit") - return {"data": {"status": "quitting", "message": "Editor quit initiated"}} - - -func game_eval(params: Dictionary) -> Dictionary: - var code: String = params.get("code", "") - if code.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "code is required") - - if _debugger_plugin == null or _connection == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Debugger bridge unavailable — plugin may not be fully initialised") - - if not EditorInterface.is_playing_scene(): - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, - "Game is not running — start the project first", false, - "Start the game with project_run (or wait for the user to run it), then retry.") - - var request_id: String = params.get("_request_id", "") - if request_id.is_empty(): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Missing request_id — cannot correlate deferred response") - - _debugger_plugin.request_game_eval(code, request_id, _connection) - return McpDispatcher.DEFERRED_RESPONSE - - -func game_command(params: Dictionary) -> Dictionary: - var op: String = str(params.get("op", "")) - if op.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "op is required") - - if _debugger_plugin == null or _connection == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Debugger bridge unavailable — plugin may not be fully initialised") - - if not EditorInterface.is_playing_scene(): - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_GAME_NOT_RUNNING, - "Game is not running — start the project first", false, - "Start the game with project_run (or wait for the user to run it), then retry.") - - var request_id: String = params.get("_request_id", "") - if request_id.is_empty(): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Missing request_id — cannot correlate deferred response") - - var command_params: Dictionary = params.get("params", {}) - - ## input_sequence steps the game forward frame-by-frame in one call, so it - ## needs a far larger budget than the one-shot game ops that share the - ## `game_command` deferred entry (15s). Widen both timers only for it: the - ## debugger-side pending timer (below) and the dispatcher-side deferred - ## budget (via the sentinel's `_deferred_timeout_ms`). Every other op keeps - ## request_game_command's tight default. - if op == "input_sequence": - _debugger_plugin.request_game_command( - op, command_params, request_id, _connection, INPUT_SEQUENCE_TIMEOUT_SEC - ) - return { - "_deferred": true, - "_deferred_timeout_ms": int(INPUT_SEQUENCE_TIMEOUT_SEC * 1000.0), - } - - _debugger_plugin.request_game_command(op, command_params, request_id, _connection) - return McpDispatcher.DEFERRED_RESPONSE diff --git a/addons/godot_ai/handlers/editor_handler.gd.uid b/addons/godot_ai/handlers/editor_handler.gd.uid deleted file mode 100644 index 16d785f..0000000 --- a/addons/godot_ai/handlers/editor_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dcro7yc8bor6v diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd deleted file mode 100644 index 6729f1e..0000000 --- a/addons/godot_ai/handlers/environment_handler.gd +++ /dev/null @@ -1,181 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Creates an Environment (+ optional Sky + ProceduralSkyMaterial) chain and -## either assigns it to a WorldEnvironment node or saves it to a .tres file. -## Bundles sub-resource creation + assignment in a single undo action. - -const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -const _PRESETS := { - "default": {"sky": true, "fog": false}, - "clear": {"sky": true, "fog": false}, - "sunset": {"sky": true, "fog": false}, - "night": {"sky": true, "fog": false}, - "fog": {"sky": true, "fog": true}, -} - - -func create_environment(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - var resource_path: String = params.get("resource_path", "") - var overwrite: bool = params.get("overwrite", false) - var preset: String = params.get("preset", "default") - var properties: Dictionary = params.get("properties", {}) - var sky_param = params.get("sky", null) # nullable — falls back to preset default - - # environment_create targets the whole WorldEnvironment node (no separate - # `property` param) — pass require_property=false. - var home_err := McpResourceIO.validate_home(params, false) - if home_err != null: - return home_err - - if not _PRESETS.has(preset): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid preset '%s'. Valid: %s" % [preset, ", ".join(_PRESETS.keys())] - ) - - var preset_config: Dictionary = _PRESETS[preset] - var want_sky: bool = preset_config.sky - var sky_properties: Dictionary = {} - if sky_param != null: - if sky_param is bool: - want_sky = sky_param - elif sky_param is Dictionary: - var sky_config: Dictionary = (sky_param as Dictionary).duplicate() - var material_type: String = String(sky_config.get("sky_material", "procedural")).to_lower() - if material_type != "procedural": - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "sky.sky_material must be 'procedural' when sky is a dictionary" - ) - sky_config.erase("sky_material") - sky_properties = sky_config - want_sky = true - else: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "sky must be a bool, null, or dictionary of ProceduralSkyMaterial properties" - ) - - var env := Environment.new() - var sky: Sky = null - var sky_material: ProceduralSkyMaterial = null - if want_sky: - sky_material = ProceduralSkyMaterial.new() - sky = Sky.new() - sky.sky_material = sky_material - env.background_mode = Environment.BG_SKY - env.sky = sky - else: - env.background_mode = Environment.BG_CLEAR_COLOR - - _apply_preset(env, sky_material, preset) - if not sky_properties.is_empty(): - var sky_apply_err := ResourceHandler._apply_resource_properties(sky_material, sky_properties) - if sky_apply_err != null: - return sky_apply_err - if preset_config.fog: - env.volumetric_fog_enabled = true - env.volumetric_fog_density = 0.03 - - if not properties.is_empty(): - var apply_err := ResourceHandler._apply_resource_properties(env, properties) - if apply_err != null: - return apply_err - - if not resource_path.is_empty(): - return _save_environment(env, sky, sky_material, resource_path, overwrite, preset) - return _assign_environment(env, sky, sky_material, node_path, preset) - - -static func _apply_preset(env: Environment, sky_material: ProceduralSkyMaterial, preset: String) -> void: - match preset: - "default", "clear": - if sky_material != null: - sky_material.sky_top_color = Color(0.38, 0.45, 0.55) - sky_material.sky_horizon_color = Color(0.65, 0.67, 0.7) - sky_material.ground_horizon_color = Color(0.65, 0.67, 0.7) - sky_material.ground_bottom_color = Color(0.2, 0.17, 0.13) - sky_material.sun_angle_max = 30.0 - env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY - env.ambient_light_energy = 1.0 - "sunset": - if sky_material != null: - sky_material.sky_top_color = Color(0.25, 0.3, 0.55) - sky_material.sky_horizon_color = Color(1.0, 0.55, 0.3) - sky_material.ground_horizon_color = Color(0.85, 0.4, 0.25) - sky_material.ground_bottom_color = Color(0.2, 0.12, 0.1) - env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY - env.ambient_light_color = Color(1.0, 0.75, 0.55) - env.ambient_light_energy = 0.8 - "night": - if sky_material != null: - sky_material.sky_top_color = Color(0.02, 0.02, 0.07) - sky_material.sky_horizon_color = Color(0.05, 0.07, 0.15) - sky_material.ground_horizon_color = Color(0.04, 0.05, 0.1) - sky_material.ground_bottom_color = Color(0.0, 0.0, 0.02) - env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - env.ambient_light_color = Color(0.2, 0.22, 0.35) - env.ambient_light_energy = 0.4 - "fog": - if sky_material != null: - sky_material.sky_top_color = Color(0.65, 0.65, 0.7) - sky_material.sky_horizon_color = Color(0.8, 0.8, 0.82) - sky_material.ground_horizon_color = Color(0.7, 0.7, 0.72) - sky_material.ground_bottom_color = Color(0.3, 0.3, 0.32) - env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY - env.ambient_light_energy = 0.7 - - -func _assign_environment(env: Environment, sky: Sky, sky_material: ProceduralSkyMaterial, node_path: String, preset: String) -> Dictionary: - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - if not (node is WorldEnvironment): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node at %s is %s — must be WorldEnvironment" % [node_path, node.get_class()] - ) - - var old_env = (node as WorldEnvironment).environment - - _undo_redo.create_action("MCP: Create Environment (%s) for %s" % [preset, node.name]) - _undo_redo.add_do_property(node, "environment", env) - _undo_redo.add_undo_property(node, "environment", old_env) - _undo_redo.add_do_reference(env) - if sky != null: - _undo_redo.add_do_reference(sky) - if sky_material != null: - _undo_redo.add_do_reference(sky_material) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "preset": preset, - "sky_created": sky != null, - "sky_material_class": sky_material.get_class() if sky_material != null else "", - "undoable": true, - } - } - - -func _save_environment(env: Environment, _sky: Sky, _sky_material: ProceduralSkyMaterial, resource_path: String, overwrite: bool, preset: String) -> Dictionary: - return McpResourceIO.save_to_disk(env, resource_path, overwrite, "Environment", { - "preset": preset, - }, _connection) diff --git a/addons/godot_ai/handlers/environment_handler.gd.uid b/addons/godot_ai/handlers/environment_handler.gd.uid deleted file mode 100644 index f495f8f..0000000 --- a/addons/godot_ai/handlers/environment_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b1k7jldwjp5jt diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd deleted file mode 100644 index b17ccc9..0000000 --- a/addons/godot_ai/handlers/filesystem_handler.gd +++ /dev/null @@ -1,312 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const ScriptHandler := preload("res://addons/godot_ai/handlers/script_handler.gd") - -## Handles file read/write operations and reimport within the Godot project. - -## Bounds for the deferred scan wait. `write_file`/`reimport` register single -## files with `update_file()` (cheap, no global-class rebuild); `scan_filesystem` -## is the heavier, explicit "rebuild the class registry" path agents call after -## adding `class_name` scripts headlessly (no window focus to trigger it). -## Kept under the dispatcher's "scan_filesystem" deferred timeout (30s) so we -## always send a real reply before a DEFERRED_TIMEOUT is synthesised. -const _SCAN_START_GRACE_MSEC := 750 -const _SCAN_SETTLE_MAX_MSEC := 28000 - -## Sidecar the editor writes next to every imported resource. `reimport` reads -## it to tell imported assets from files that merely have a filesystem entry -## (see `_is_imported_resource`). -const IMPORT_SIDECAR_SUFFIX := ".import" - -## Shared single-flight latch for scan_filesystem. `is_scanning()` alone can't -## enforce single-flight: `EditorFileSystem.scan()` doesn't flip `is_scanning()` -## for a frame or two (hence _SCAN_START_GRACE_MSEC), so a second request landing -## in that window would observe `false` and stack another scan() — the exact -## stacked-worker SIGABRT this op exists to avoid (dsarno/godot#6). The latch is -## set before the first scan() and cleared when its settle coroutine finishes; -## concurrent requests coalesce onto the running scan instead of starting one. -## `static` so it's shared across handler instances; it resets on plugin reload -## (script re-parse), which self-heals any latch orphaned by a mid-await teardown. -static var _scan_in_flight := false - -var _connection: McpConnection - - -func _init(connection: McpConnection = null) -> void: - _connection = connection - - -func read_file(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - - var path_err = McpPathValidator.path_error(path, "path") - if path_err != null: - return path_err - - if not FileAccess.file_exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) - - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) - - var content := file.get_as_text() - file.close() - - return { - "data": { - "path": path, - "content": content, - "size": content.length(), - "line_count": content.count("\n") + (1 if not content.is_empty() else 0), - } - } - - -func write_file(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var content: String = params.get("content", "") - - var path_err = McpPathValidator.path_error(path, "path", true) - if path_err != null: - return path_err - - var existed_before := FileAccess.file_exists(path) - - # Shared write path (#714): parent mkdir + write/flush + explicit error - # check live on McpResourceIO so this can't drift from create_script. - var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content) - if write_failure != null: - return write_failure - - # Single-file register, not a full scan() — a scan() per write stacks - # filesystem WorkerThreadPool tasks under concurrent writes and can SIGABRT - # in the global-class update (see dsarno/godot#6 and create_script in - # script_handler.gd). update_file() is what reimport()/material/theme use. - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - var data := { - "path": path, - "size": content.length(), - "undoable": false, - "reason": "File system operations cannot be undone via editor undo", - } - var is_gdscript := path.ends_with(".gd") - ## A .gd written through the filesystem tool used to skip the parse - ## diagnostics create_script attaches (#714) — the agent's broken - ## script reported plain success and the parse error surfaced only in - ## later editor logs. Same shared check, same response fields. A bare - ## ScriptHandler works here: the diagnostics path touches no instance - ## state (it stays an instance method only for test stubbing). - if is_gdscript: - ScriptHandler.new(null)._attach_gdscript_diagnostics(data, path, content) - data["committed"] = true - data["import_settled"] = existed_before - data["import_settle"] = "already_known" if existed_before else "not_waited" - McpResourceIO.attach_cleanup_hint(data, existed_before, [path]) - - ## Fresh `.gd` writes take create_script's import-settle deferral (#714, - ## #261): reply only once ResourceLoader can see the new resource (or the - ## bounded window elapses), so write_file -> script_attach back-to-back - ## can't 404 on the not-yet-imported script. This CHANGES write_file's - ## response timing for that case — the reply lands up to - ## McpResourceIO.IMPORT_SETTLE_MAX_MSEC later instead of immediately. - ## Scoped to .gd: ResourceLoader never learns plain text files, so an - ## unconditional wait would burn the full window on every fresh .txt. - ## Overwrites, batch_execute (no request_id) and unit-test contexts (no - ## connection) keep the synchronous reply. - var request_id: String = params.get("_request_id", "") - if is_gdscript and not existed_before and _connection != null and not request_id.is_empty(): - McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data) - return McpDispatcher.DEFERRED_RESPONSE - - return {"data": data} - - -func reimport(params: Dictionary) -> Dictionary: - var paths: Array = params.get("paths", []) - - if paths.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: paths (non-empty array)") - - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var reimported: Array[String] = [] - var skipped_non_imported: Array[String] = [] - var not_found: Array[String] = [] - - for path_variant in paths: - var path: String = str(path_variant) - var path_err := McpPathValidator.validate_resource_path(path) - if not path_err.is_empty(): - not_found.append("%s (%s)" % [path, path_err]) - continue - if not FileAccess.file_exists(path): - not_found.append("%s (file does not exist)" % path) - continue - efs.update_file(path) - if _is_imported_resource(path): - reimported.append(path) - else: - skipped_non_imported.append(path) - - var data := { - "reimported": reimported, - "skipped_non_imported": skipped_non_imported, - "not_found": not_found, - "reimported_count": reimported.size(), - "skipped_non_imported_count": skipped_non_imported.size(), - "not_found_count": not_found.size(), - "undoable": false, - "reason": "Reimport is a file system operation", - } - ## Only when it applies: a hint on every call would cost tokens on the - ## all-assets path this op is actually for. - if not skipped_non_imported.is_empty(): - data["skipped_non_imported_hint"] = ( - "%d path(s) are not imported resources. Their editor filesystem entry was " - + "refreshed, but no import ran — a success here is not evidence that a " - + "script parsed or that diagnostics were produced. Use script_patch/" - + "script_create for GDScript diagnostics, or filesystem_manage(op=\"scan\") " - + "for an asset the editor has not imported yet." - ) % skipped_non_imported.size() - return {"data": data} - - -## #778: `update_file()` registers a path with the resource pipeline; it only -## runs an *import* for files that have one. Scripts, scenes and hand-written -## `.tres` are not imported resources, so listing them under `reimported` reads -## as proof that a parse or import ran when nothing did. -## -## The `.import` sidecar is the editor's own record that a path goes through -## the import pipeline, so it decides the split. An extension allow-list was -## rejected: importers come and go with plugins, so the list would drift out of -## agreement with the editor it claims to describe. -## -## Known edge: an asset the editor has never imported (just written, no scan -## yet) has no sidecar and reports as non-imported. That is accurate at the -## moment of the call — `update_file()` did not import it either — and the -## hint names `scan` as the way through. -## -## Behaviour is unchanged for every path: `update_file()` still runs on all of -## them, because refreshing an externally-edited `.tscn`/`.tres` is a real use -## of this op. This splits the report, not the work. -static func _is_imported_resource(path: String) -> bool: - if path.ends_with(IMPORT_SIDECAR_SUFFIX): - return false ## The sidecar itself is not an imported resource. - return FileAccess.file_exists(path + IMPORT_SIDECAR_SUFFIX) - - -## Force a full EditorFileSystem scan and wait for it to settle. This is the -## headless equivalent of the editor regaining window focus: `update_file()` -## (used by write_file/reimport/script_create) registers a single file with the -## resource pipeline but does NOT rebuild the global `class_name` table, so a -## freshly-created `class_name MyThing extends Resource` stays invisible to -## `ClassDB`/`ProjectSettings.get_global_class_list()` until a scan runs. Agents -## driving the editor without focus call this once after a batch of script -## creates to make new types instantiable/referenceable. See issue #83. -func scan_filesystem(params: Dictionary) -> Dictionary: - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var request_id: String = params.get("_request_id", "") - # Async path: a scan can't be awaited on the calling frame without freezing - # the editor, so hand control back to the dispatcher (DEFERRED_RESPONSE) and - # push the real reply from a static coroutine once the scan settles — by - # which point new class_names are registered. - if _connection != null and not request_id.is_empty(): - _finish_scan_deferred(_connection, request_id, efs) - return McpDispatcher.DEFERRED_RESPONSE - - # Synchronous fallback: batch_execute (no request_id) and unit-test contexts - # (no connection) can't await, so kick a single-flight scan and return - # immediately without the settle confirmation. Respect the latch so we don't - # stack onto a deferred scan; don't set it (there's no coroutine here to - # clear it — the brief is_scanning() window covers the rest). - var already := _scan_in_flight or efs.is_scanning() - if not already: - efs.scan() - return { - "data": { - "scan_completed": false, - "scan_settle": "not_waited", - "was_already_scanning": already, - "global_class_count": ProjectSettings.get_global_class_list().size(), - # Present in both paths for a consistent response shape; the sync - # path doesn't await, so it can't measure a delta. - "global_classes_registered_delta": 0, - "undoable": false, - "reason": "Filesystem scan is an editor operation", - } - } - - -## `static` is load-bearing for the same reason as ScriptHandler's deferred -## finish: the coroutine must outlive the handler RefCounted, which can be freed -## mid-await (e.g. an editor_reload_plugin fired during the scan). Parameterise -## everything; reference no instance state. -static func _finish_scan_deferred( - connection: McpConnection, - request_id: String, - efs: EditorFileSystem, -) -> void: - if not is_instance_valid(connection): - return - var tree := connection.get_tree() - if tree == null: - return - var classes_before := ProjectSettings.get_global_class_list().size() - # Single-flight via the shared `_scan_in_flight` latch (NOT is_scanning(), - # which lags scan() by a frame or two — see the latch declaration). Only the - # request that sets the latch calls scan(); concurrent requests coalesce and - # just await the running scan. This is what actually prevents the stacked - # scan() SIGABRT (dsarno/godot#6), even within the start-grace window. - var was_already_scanning := _scan_in_flight or efs.is_scanning() - var we_started := not was_already_scanning - if we_started: - _scan_in_flight = true - efs.scan() - # Hand back a frame so _dispatch() registers this request as deferred before - # the coroutine can push a reply (mirrors McpResourceIO.finish_text_write_deferred). - await tree.process_frame - var deadline_ms := Time.get_ticks_msec() + _SCAN_SETTLE_MAX_MSEC - var start_grace_ms := Time.get_ticks_msec() + _SCAN_START_GRACE_MSEC - var saw_scanning := efs.is_scanning() - while Time.get_ticks_msec() < deadline_ms: - if efs.is_scanning(): - saw_scanning = true - elif saw_scanning or Time.get_ticks_msec() > start_grace_ms: - # Either the scan ran and finished, or it never flipped is_scanning() - # within the grace window (a no-op scan because nothing changed). - break - await tree.process_frame - # Clear the latch in all paths (no try/finally in GDScript): do it before the - # is_instance_valid early-return so a freed connection can't orphan it. - if we_started: - _scan_in_flight = false - if not is_instance_valid(connection): - return - var completed := not efs.is_scanning() - var classes_after := ProjectSettings.get_global_class_list().size() - connection.send_deferred_response(request_id, { - "data": { - "scan_completed": completed, - "scan_settle": "settled" if completed else "timeout", - "was_already_scanning": was_already_scanning, - "global_class_count": classes_after, - "global_classes_registered_delta": classes_after - classes_before, - "undoable": false, - "reason": "Filesystem scan is an editor operation", - } - }) diff --git a/addons/godot_ai/handlers/filesystem_handler.gd.uid b/addons/godot_ai/handlers/filesystem_handler.gd.uid deleted file mode 100644 index 94135a4..0000000 --- a/addons/godot_ai/handlers/filesystem_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c7ovtpdiumtju diff --git a/addons/godot_ai/handlers/gridmap_handler.gd b/addons/godot_ai/handlers/gridmap_handler.gd deleted file mode 100644 index 75016e4..0000000 --- a/addons/godot_ai/handlers/gridmap_handler.gd +++ /dev/null @@ -1,192 +0,0 @@ -@tool -extends RefCounted - -## GridMap authoring — set, fill, clear, and read 3D cells plus mesh-library -## items directly in the editor scene with full undo/redo support. -## -## All ops target GridMap nodes in the currently edited scene by -## scene-relative path (e.g. "/Main/Terrain"). All write ops are undoable -## via EditorUndoRedoManager. - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const MAX_FILL_CELLS := 4096 - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -## Set a single cell item. item = -1 erases the cell. -## params: {path, item, map_x, map_y, map_z, orientation=0} -## Returns: {map_x, map_y, map_z, item, orientation, undoable} -func set_item(params: Dictionary) -> Dictionary: - var gm := _resolve_gridmap(params) - if gm.has("error"): return gm - var node: GridMap = gm.node - var pos := Vector3i(int(params.get("map_x", 0)), int(params.get("map_y", 0)), int(params.get("map_z", 0))) - var item := int(params.get("item", 0)) - var orientation := int(params.get("orientation", 0)) - if orientation < 0 or orientation > 24: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation) - var prev := _capture_cell_state(node, pos) - _undo_redo.create_action("MCP: GridMap set_item") - _undo_redo.add_do_method(node, "set_cell_item", pos, item, orientation) - _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev) - _undo_redo.commit_action() - return {"data": {"map_x": pos.x, "map_y": pos.y, "map_z": pos.z, - "item": item, "orientation": orientation, "undoable": true}} - - -## Fill a box region with one item in a single undo action. -## params: {path, item, rect_x, rect_y, rect_z, rect_w, rect_h, rect_d, orientation=0} -## Returns: {cells_filled, rect: {x, y, z, w, h, d}} -func fill(params: Dictionary) -> Dictionary: - var gm := _resolve_gridmap(params) - if gm.has("error"): return gm - var node: GridMap = gm.node - var item := int(params.get("item", 0)) - var orientation := int(params.get("orientation", 0)) - if orientation < 0 or orientation > 24: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "orientation must be in 0..24 (GridMap baked rotations), got %d" % orientation) - var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)); var rz := int(params.get("rect_z", 0)) - var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)); var rd := int(params.get("rect_d", 1)) - if rw <= 0 or rh <= 0 or rd <= 0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "rect_w, rect_h and rect_d must be > 0 (got %d x %d x %d)" % [rw, rh, rd]) - var cell_count := rw * rh * rd - if cell_count > MAX_FILL_CELLS: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Region too large: %d cells exceeds max %d" % [cell_count, MAX_FILL_CELLS]) - var snapshot: Array[Dictionary] = [] - for x in range(rx, rx + rw): - for y in range(ry, ry + rh): - for z in range(rz, rz + rd): - var pos := Vector3i(x, y, z) - snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) - _undo_redo.create_action("MCP: GridMap fill %dx%dx%d" % [rw, rh, rd]) - ## First callback targets the node so the action lands in the scene undo - ## history (first-target routing); _apply_fill batches the rest of the - ## region into a single history entry instead of one per cell. - _undo_redo.add_do_method(node, "set_cell_item", snapshot[0].pos, item, orientation) - _undo_redo.add_do_method(self, "_apply_fill", node, snapshot, item, orientation) - _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot) - _undo_redo.commit_action() - return {"data": {"cells_filled": snapshot.size(), - "rect": {"x": rx, "y": ry, "z": rz, "w": rw, "h": rh, "d": rd}, "undoable": true}} - - -## Remove all cells from the GridMap. -## params: {path} -## Returns: {cleared: true} -func clear_layer(params: Dictionary) -> Dictionary: - var gm := _resolve_gridmap(params) - if gm.has("error"): return gm - var node: GridMap = gm.node - var used := node.get_used_cells() - if used.size() > MAX_FILL_CELLS: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "GridMap has %d cells, exceeds max %d for undoable clear" - % [used.size(), MAX_FILL_CELLS]) - var snapshot := _capture_used_cells_snapshot(node) - _undo_redo.create_action("MCP: GridMap clear") - _undo_redo.add_do_method(node, "clear") - _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot) - _undo_redo.commit_action() - return {"data": {"cleared": true, "undoable": true}} - - -## Return all used cell coordinates. -## params: {path} -## Returns: {cells: [{x, y, z}, ...], count: int} -func get_used_cells(params: Dictionary) -> Dictionary: - var gm := _resolve_gridmap(params) - if gm.has("error"): return gm - var node: GridMap = gm.node - var result: Array = [] - for c in node.get_used_cells(): - result.append({"x": c.x, "y": c.y, "z": c.z}) - return {"data": {"cells": result, "count": result.size()}} - - -## List the items available in the GridMap's MeshLibrary, so agents can -## discover item ids and names before placing cells (the 3D analogue of -## tileset atlas inspection). -## params: {path} -## Returns: {library: res:// path or "", items: [{item, name, mesh}...], count} -func list_library_items(params: Dictionary) -> Dictionary: - var gm := _resolve_gridmap(params) - if gm.has("error"): return gm - var node: GridMap = gm.node - var library: MeshLibrary = node.mesh_library - if library == null: - return {"data": {"library": "", "items": [], "count": 0}} - var items: Array = [] - var ids := library.get_item_list() - ids.sort() - for item in ids: - var mesh: Mesh = library.get_item_mesh(item) - var mesh_path := mesh.resource_path if mesh != null else "" - items.append({ - "item": item, - "name": library.get_item_name(item), - "mesh": mesh_path, - }) - return {"data": {"library": library.resource_path, "items": items, "count": items.size()}} - - -## Resolve a GridMap node from params["path"] in the currently edited -## scene. Returns {"node": GridMap} on success, or an error dict. -func _resolve_gridmap(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var scene_file: String = params.get("scene_file", "") - var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - if not node is GridMap: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Node is not a GridMap: %s" % path) - return {"node": node} - - -func _capture_cell_state(node: GridMap, pos: Vector3i) -> Dictionary: - var item := node.get_cell_item(pos) - if item == -1: - return {"has_item": false} - return {"has_item": true, "item": item, "orientation": node.get_cell_item_orientation(pos)} - - -func _capture_used_cells_snapshot(node: GridMap) -> Array[Dictionary]: - var snapshot: Array[Dictionary] = [] - for pos in node.get_used_cells(): - snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) - return snapshot - - -func _restore_cells_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void: - node.clear() - for entry in snapshot: - _restore_cell_state(node, entry.pos, entry.state) - - -## Batched do-method for fill: one undo-history entry applies the whole -## region instead of one entry per cell. -func _apply_fill(node: GridMap, snapshot: Array[Dictionary], item: int, orientation: int) -> void: - for entry in snapshot: - node.set_cell_item(entry.pos, item, orientation) - - -func _restore_rect_snapshot(node: GridMap, snapshot: Array[Dictionary]) -> void: - for entry in snapshot: - _restore_cell_state(node, entry.pos, entry.state) - - -func _restore_cell_state(node: GridMap, pos: Vector3i, state: Dictionary) -> void: - if not state.get("has_item", false): - node.set_cell_item(pos, -1) - return - node.set_cell_item(pos, int(state.get("item", 0)), int(state.get("orientation", 0))) diff --git a/addons/godot_ai/handlers/gridmap_handler.gd.uid b/addons/godot_ai/handlers/gridmap_handler.gd.uid deleted file mode 100644 index 35bb1d0..0000000 --- a/addons/godot_ai/handlers/gridmap_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdv1yrjeyo5es diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd deleted file mode 100644 index 78ddd22..0000000 --- a/addons/godot_ai/handlers/input_handler.gd +++ /dev/null @@ -1,462 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles input action listing, creation, removal, and event binding. -## Actions are persisted via ProjectSettings so they survive editor restarts. - - -func list_actions(params: Dictionary) -> Dictionary: - var include_builtin: bool = params.get("include_builtin", false) - ## Authoritative source for user-authored actions is the ``[input]`` - ## section of ``project.godot``. ``ProjectSettings.has_setting`` is not - ## reliable here because Godot registers ``ui_*`` defaults via - ## ``GLOBAL_DEF_BASIC``, which makes ``has_setting`` return true for - ## them. Reading the file via ``ConfigFile`` distinguishes the user's - ## entries from engine-registered defaults regardless of namespace. - ## See #213. - var user_authored := _read_user_authored_actions() - var actions: Array[Dictionary] = [] - var seen := {} - for action_name in InputMap.get_actions(): - var name_str := str(action_name) - var is_user_action := user_authored.has(name_str) - if not include_builtin and not is_user_action: - continue - seen[name_str] = true - var events: Array[Dictionary] = [] - for event in InputMap.action_get_events(action_name): - events.append(_serialize_event(event)) - actions.append({ - "name": name_str, - "events": events, - "event_count": events.size(), - "is_builtin": not is_user_action, - "loaded_in_input_map": true, - }) - for action_name in user_authored.keys(): - var name_str := str(action_name) - if seen.has(name_str): - continue - var setting: Dictionary = user_authored.get(name_str, {}) - var events: Array[Dictionary] = [] - for event in setting.get("events", []): - if event is InputEvent: - events.append(_serialize_event(event)) - else: - events.append({"type": type_string(typeof(event)), "string": str(event)}) - actions.append({ - "name": name_str, - "events": events, - "event_count": events.size(), - "is_builtin": false, - "loaded_in_input_map": false, - }) - return {"data": {"actions": actions, "count": actions.size()}} - - -func _read_user_authored_actions() -> Dictionary: - var cfg := ConfigFile.new() - if cfg.load("res://project.godot") != OK: - return {} - if not cfg.has_section("input"): - return {} - var result: Dictionary = {} - for key in cfg.get_section_keys("input"): - var value = cfg.get_value("input", key, {}) - result[key] = value if value is Dictionary else {} - return result - - -func add_action(params: Dictionary) -> Dictionary: - var action: String = params.get("action", "") - var deadzone: float = params.get("deadzone", 0.5) - - if action.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") - - var deadzone_error := _validate_deadzone(deadzone) - if deadzone_error.has("error"): - return deadzone_error - - if InputMap.has_action(action): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Action '%s' already exists" % action) - - InputMap.add_action(action, deadzone) - - var key := "input/%s" % action - ProjectSettings.set_setting(key, { - "deadzone": deadzone, - "events": [], - }) - var err := ProjectSettings.save() - if err != OK: - InputMap.erase_action(action) - ProjectSettings.clear(key) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while adding action '%s': %s (error %d)" % [action, error_string(err), err]) - - return { - "data": { - "action": action, - "deadzone": deadzone, - "undoable": false, - "reason": "Input actions are saved to project.godot", - } - } - - -func ensure_action(params: Dictionary) -> Dictionary: - var action: String = params.get("action", "") - var deadzone: float = params.get("deadzone", 0.5) - - if action.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") - - var deadzone_error := _validate_deadzone(deadzone) - if deadzone_error.has("error"): - return deadzone_error - - var result := _ensure_action_state(action, deadzone) - if result.has("error"): - return result - return {"data": result} - - -func remove_action(params: Dictionary) -> Dictionary: - var action: String = params.get("action", "") - if action.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") - - var key := "input/%s" % action - var was_loaded := InputMap.has_action(action) - var old_setting = ProjectSettings.get_setting(key) if ProjectSettings.has_setting(key) else null - - ## An action can live in the editor process's InputMap, in project.godot, - ## or both. Actions persisted by a previous editor session exist only on - ## disk (`loaded_in_input_map: false` in list_actions) — those must still - ## be removable. #632 - if not was_loaded and old_setting == null: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Action '%s' not found" % action) - - if was_loaded: - InputMap.erase_action(action) - - if old_setting != null: - ProjectSettings.clear(key) - var err := ProjectSettings.save() - if err != OK: - if was_loaded: - var dz: float = old_setting.get("deadzone", 0.5) if old_setting is Dictionary else 0.5 - InputMap.add_action(action, dz) - if old_setting is Dictionary: - for ev in old_setting.get("events", []): - if ev is InputEvent: - InputMap.action_add_event(action, ev) - ProjectSettings.set_setting(key, old_setting) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while removing action '%s': %s (error %d)" % [action, error_string(err), err]) - - return { - "data": { - "action": action, - "removed": true, - "was_loaded": was_loaded, - "undoable": false, - "reason": "Input actions are saved to project.godot", - } - } - - -func bind_event(params: Dictionary) -> Dictionary: - var action: String = params.get("action", "") - var event_type: String = params.get("event_type", "") - - if action.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") - if event_type.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type") - - if not InputMap.has_action(action): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Action '%s' not found. Call input_map_manage(op='add_action', params={action: '%s'}) first." % [action, action]) - - var event_or_error = _create_event(event_type, params) - if event_or_error is Dictionary: - return event_or_error - var event: InputEvent = event_or_error - - InputMap.action_add_event(action, event) - - var err := _save_action_events(action) - if err != OK: - InputMap.action_erase_event(action, event) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err]) - - return { - "data": { - "action": action, - "event": _serialize_event(event), - "undoable": false, - "reason": "Input bindings are saved to project.godot", - } - } - - -func ensure_binding(params: Dictionary) -> Dictionary: - var action: String = params.get("action", "") - var event_type: String = params.get("event_type", "") - var deadzone: float = params.get("deadzone", 0.5) - - if action.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: action") - if event_type.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: event_type") - - var deadzone_error := _validate_deadzone(deadzone) - if deadzone_error.has("error"): - return deadzone_error - - var event_or_error = _create_event(event_type, params) - if event_or_error is Dictionary: - return event_or_error - var event: InputEvent = event_or_error - - var ensured := _ensure_action_state(action, deadzone) - if ensured.has("error"): - return ensured - - for existing in InputMap.action_get_events(action): - if _events_match(existing, event): - return { - "data": { - "action": action, - "event": _serialize_event(existing), - "already_bound": true, - "action_created": ensured.get("created", false), - "undoable": false, - "reason": "Input binding already exists", - } - } - - InputMap.action_add_event(action, event) - var err := _save_action_events(action) - if err != OK: - InputMap.action_erase_event(action, event) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while binding event to action '%s': %s (error %d)" % [action, error_string(err), err]) - - return { - "data": { - "action": action, - "event": _serialize_event(event), - "already_bound": false, - "action_created": ensured.get("created", false), - "undoable": false, - "reason": "Input bindings are saved to project.godot", - } - } - - -func _validate_deadzone(deadzone: float) -> Dictionary: - if deadzone < 0.0 or deadzone > 1.0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "deadzone must be in [0.0, 1.0] (got %s). Typical values are 0.2-0.5; default is 0.5." % deadzone) - return {} - - -func _ensure_action_state(action: String, deadzone: float) -> Dictionary: - var key := "input/%s" % action - var user_authored := _read_user_authored_actions() - var existed_in_input_map := InputMap.has_action(action) - var existed_in_project := user_authored.has(action) or ProjectSettings.has_setting(key) - var old_setting = user_authored.get(action, null) if user_authored.has(action) else null - if old_setting == null and ProjectSettings.has_setting(key): - old_setting = ProjectSettings.get_setting(key) - - if not existed_in_input_map: - var dz := deadzone - if old_setting is Dictionary: - dz = float(old_setting.get("deadzone", deadzone)) - InputMap.add_action(action, dz) - if old_setting is Dictionary: - for ev in old_setting.get("events", []): - if ev is InputEvent: - InputMap.action_add_event(action, ev) - - if not existed_in_project: - var err := _save_action_events(action) - if err != OK: - if not existed_in_input_map: - InputMap.erase_action(action) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, - "Failed to save project settings while ensuring action '%s': %s (error %d)" % [action, error_string(err), err]) - - var stored_deadzone := deadzone - if ProjectSettings.has_setting(key): - var stored = ProjectSettings.get_setting(key) - if stored is Dictionary: - stored_deadzone = float(stored.get("deadzone", deadzone)) - return { - "action": action, - "deadzone": stored_deadzone, - "created": not existed_in_input_map and not existed_in_project, - "already_exists": existed_in_input_map or existed_in_project, - "loaded_in_input_map": true, - "persisted": true, - "undoable": false, - "reason": "Input actions are saved to project.godot", - } - - -## Returns an InputEvent on success, or a Dictionary error on failure. -## Caller must check ``result is Dictionary`` before treating it as an event. -func _create_event(event_type: String, params: Dictionary): - match event_type: - "key": - var ev := InputEventKey.new() - var keycode_str: String = params.get("keycode", "") - if keycode_str.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "event_type='key' requires keycode (e.g. 'Space', 'A', 'Enter', 'Escape', 'F1').") - ev.keycode = OS.find_keycode_from_string(keycode_str) - if ev.keycode == KEY_NONE: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid keycode '%s'. Use Godot keycode names like 'A', 'Space', 'Enter', 'Escape', 'F1', 'Left', 'Right'." % keycode_str) - ev.ctrl_pressed = params.get("ctrl", false) - ev.alt_pressed = params.get("alt", false) - ev.shift_pressed = params.get("shift", false) - ev.meta_pressed = params.get("meta", false) - ev.device = -1 - return ev - "mouse_button": - if not params.has("button"): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "event_type='mouse_button' requires button (1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down).") - var button: int = int(params.get("button", 0)) - if button <= 0: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "mouse_button button must be > 0 (got %d). Use 1=left, 2=right, 3=middle, 4=wheel up, 5=wheel down." % button) - var ev := InputEventMouseButton.new() - ev.button_index = button - ev.device = -1 - return ev - "joy_button": - if not params.has("button"): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "event_type='joy_button' requires button (JoyButton index, e.g. 0=A/Cross, 1=B/Circle).") - var ev := InputEventJoypadButton.new() - ev.button_index = int(params.get("button", 0)) - return ev - "joy_axis": - var axis_param = params.get("axis", null) - if axis_param == null: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, - "event_type='joy_axis' requires axis (JoyAxis index, e.g. 0=left stick X, 1=left stick Y).") - var axis: int - match typeof(axis_param): - TYPE_INT: - axis = axis_param - TYPE_FLOAT: - if axis_param != floor(axis_param): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "joy_axis axis must be an integer JoyAxis index (got %s)." % str(axis_param)) - axis = int(axis_param) - TYPE_STRING: - var axis_text := str(axis_param) - if not axis_text.is_valid_int(): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "joy_axis axis must be an integer JoyAxis index (got '%s')." % axis_text) - axis = int(axis_text) - _: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "joy_axis axis must be an integer JoyAxis index (got %s)." % type_string(typeof(axis_param))) - var ev := InputEventJoypadMotion.new() - ev.axis = axis - ev.axis_value = float(params.get("axis_value", 1.0)) - return ev - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Unsupported event_type: '%s'. Use 'key', 'mouse_button', 'joy_button', or 'joy_axis'." % event_type) - - -func _serialize_event(event: InputEvent) -> Dictionary: - if event is InputEventKey: - return { - "type": "key", - "keycode": OS.get_keycode_string(event.keycode), - "physical_keycode": OS.get_keycode_string(event.physical_keycode), - "ctrl": event.ctrl_pressed, - "alt": event.alt_pressed, - "shift": event.shift_pressed, - "meta": event.meta_pressed, - } - if event is InputEventMouseButton: - return { - "type": "mouse_button", - "button": event.button_index, - } - if event is InputEventJoypadButton: - return { - "type": "joy_button", - "button": event.button_index, - } - if event is InputEventJoypadMotion: - return { - "type": "joy_axis", - "axis": event.axis, - "axis_value": event.axis_value, - } - return {"type": event.get_class(), "string": str(event)} - - -func _events_match(a: InputEvent, b: InputEvent) -> bool: - if a is InputEventKey and b is InputEventKey: - return _key_events_match(a as InputEventKey, b as InputEventKey) - return _serialize_event(a) == _serialize_event(b) - - -func _key_events_match(a: InputEventKey, b: InputEventKey) -> bool: - if a.ctrl_pressed != b.ctrl_pressed: - return false - if a.alt_pressed != b.alt_pressed: - return false - if a.shift_pressed != b.shift_pressed: - return false - if a.meta_pressed != b.meta_pressed: - return false - var a_codes := [a.keycode, a.physical_keycode] - var b_codes := [b.keycode, b.physical_keycode] - for a_code in a_codes: - if int(a_code) == KEY_NONE: - continue - for b_code in b_codes: - if int(b_code) != KEY_NONE and int(a_code) == int(b_code): - return true - return false - - -func _save_action_events(action: String) -> int: - var events: Array = [] - for event in InputMap.action_get_events(action): - events.append(event) - var key := "input/%s" % action - var had_setting := ProjectSettings.has_setting(key) - var old_setting = ProjectSettings.get_setting(key) if had_setting else null - var deadzone: float = 0.5 - if old_setting is Dictionary: - deadzone = old_setting.get("deadzone", 0.5) - elif InputMap.has_action(action): - deadzone = InputMap.action_get_deadzone(action) - ProjectSettings.set_setting(key, { - "deadzone": deadzone, - "events": events, - }) - var err := ProjectSettings.save() - if err != OK: - if had_setting: - ProjectSettings.set_setting(key, old_setting) - else: - ProjectSettings.clear(key) - return err diff --git a/addons/godot_ai/handlers/input_handler.gd.uid b/addons/godot_ai/handlers/input_handler.gd.uid deleted file mode 100644 index 7d509f4..0000000 --- a/addons/godot_ai/handlers/input_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://buk68rbwssqwp diff --git a/addons/godot_ai/handlers/material_handler.gd b/addons/godot_ai/handlers/material_handler.gd deleted file mode 100644 index d8a4981..0000000 --- a/addons/godot_ai/handlers/material_handler.gd +++ /dev/null @@ -1,809 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles Material authoring: creating .tres files, setting BaseMaterial3D -## properties / shader uniforms, assigning to nodes, high-level presets. -## -## File-resource lifecycle mirrors ThemeHandler (create/load/mutate/save). -## Undo pattern mirrors AnimationHandler (single create_action bundles -## every dependency spawn). - -const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") -const MaterialPresets := preload("res://addons/godot_ai/handlers/material_presets.gd") - -const _TYPE_TO_CLASS := { - "standard": "StandardMaterial3D", - "orm": "ORMMaterial3D", - "canvas_item": "CanvasItemMaterial", - "shader": "ShaderMaterial", -} - -const _SUPPORTED_SUFFIXES := [".tres", ".material", ".res"] - - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -# ============================================================================ -# material_create -# ============================================================================ - -func create_material(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var type_str: String = params.get("type", "standard") - var shader_path: String = params.get("shader_path", "") - var overwrite: bool = params.get("overwrite", false) - - var err := _validate_material_path(path, "path", true) - if err != null: - return err - - if not _TYPE_TO_CLASS.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())] - ) - - var existed_before := FileAccess.file_exists(path) - if existed_before and not overwrite: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Material already exists at %s (pass overwrite=true to replace)" % path - ) - - var mat := _instantiate_material(type_str) - if mat == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate material") - - if type_str == "shader": - if shader_path.is_empty(): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "ShaderMaterial requires shader_path (res:// / uid:// / user:// path to a .gdshader)" - ) - var shader_path_err = McpPathValidator.loadable_error(shader_path, "shader_path") - if shader_path_err != null: - return shader_path_err - if not ResourceLoader.exists(shader_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Shader not found: %s" % shader_path) - var shader_res := ResourceLoader.load(shader_path) - if not (shader_res is Shader): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Shader" % shader_path) - (mat as ShaderMaterial).shader = shader_res - - var dir_path := path.get_base_dir() - var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) - if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err] - ) - - var save_err := McpResourceIO.guarded_save(mat, path, _connection) - if save_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to save material to %s (error %d)" % [path, save_err] - ) - - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - return { - "data": { - "path": path, - "type": type_str, - "class": mat.get_class(), - "shader_path": shader_path, - "overwritten": existed_before, - "undoable": false, - "reason": "File creation is persistent; delete the file manually to revert", - } - } - - -# ============================================================================ -# material_set_param -# ============================================================================ - -func set_param(params: Dictionary) -> Dictionary: - var load_result := _load_material_from_path(params.get("path", ""), true) - if load_result.has("error"): - return load_result - var mat: Material = load_result.material - var mat_path: String = load_result.path - - var property: String = params.get("param", "") - if property.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param") - - if not ("value" in params): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") - - var raw_value = params.get("value") - - # Probe the property. We allow any property present in get_property_list, - # plus `shader` on ShaderMaterial. - var prop_type: int = TYPE_NIL - var property_exists := false - for prop in mat.get_property_list(): - if prop.name == property: - property_exists = true - prop_type = prop.get("type", TYPE_NIL) - break - if not property_exists: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(mat, property) - ) - - var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type) - if not coerced.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) - var new_value = coerced.value - - var old_value = mat.get(property) - - _undo_redo.create_action("MCP: Set material %s.%s" % [mat_path.get_file(), property]) - _undo_redo.add_do_method(self, "_apply_param", mat_path, property, new_value, false) - _undo_redo.add_undo_method(self, "_apply_param", mat_path, property, old_value, false) - _undo_redo.commit_action() - - return { - "data": { - "path": mat_path, - "property": property, - "value": MaterialValues.serialize_value(new_value), - "previous_value": MaterialValues.serialize_value(old_value), - "undoable": true, - } - } - - -# ============================================================================ -# material_set_shader_param -# ============================================================================ - -func set_shader_param(params: Dictionary) -> Dictionary: - var load_result := _load_material_from_path(params.get("path", ""), true) - if load_result.has("error"): - return load_result - var mat: Material = load_result.material - var mat_path: String = load_result.path - - if not (mat is ShaderMaterial): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Material at %s is %s, not ShaderMaterial" % [mat_path, mat.get_class()] - ) - var shader_mat := mat as ShaderMaterial - if shader_mat.shader == null: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "ShaderMaterial at %s has no shader assigned" % mat_path - ) - - var param_name: String = params.get("param", "") - if param_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: param") - - if not ("value" in params): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") - - # Verify the uniform exists in the shader. - var uniform_type := _shader_uniform_type(shader_mat.shader, param_name) - if uniform_type == TYPE_NIL: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Shader uniform '%s' not declared on shader at %s" % [param_name, shader_mat.shader.resource_path] - ) - - var raw_value = params.get("value") - var coerced := MaterialValues.coerce_material_value(param_name, raw_value, uniform_type) - if not coerced.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) - var new_value = coerced.value - - var old_value = shader_mat.get_shader_parameter(param_name) - - _undo_redo.create_action("MCP: Set shader param %s.%s" % [mat_path.get_file(), param_name]) - _undo_redo.add_do_method(self, "_apply_shader_param", mat_path, param_name, new_value) - _undo_redo.add_undo_method(self, "_apply_shader_param", mat_path, param_name, old_value) - _undo_redo.commit_action() - - return { - "data": { - "path": mat_path, - "param": param_name, - "value": MaterialValues.serialize_value(new_value), - "previous_value": MaterialValues.serialize_value(old_value), - "undoable": true, - } - } - - -# ============================================================================ -# material_get -# ============================================================================ - -func get_material(params: Dictionary) -> Dictionary: - var load_result := _load_material_from_path(params.get("path", "")) - if load_result.has("error"): - return load_result - var mat: Material = load_result.material - var mat_path: String = load_result.path - - var properties: Array[Dictionary] = [] - for prop in mat.get_property_list(): - var usage: int = prop.get("usage", 0) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - var name: String = prop.name - if name.begins_with("shader_parameter/"): - continue # handled below - var value = mat.get(name) - if value == null and prop.type != TYPE_NIL: - continue - properties.append({ - "name": name, - "type": type_string(prop.type), - "value": MaterialValues.serialize_value(value), - }) - - var shader_params: Array[Dictionary] = [] - if mat is ShaderMaterial: - var shader_mat := mat as ShaderMaterial - if shader_mat.shader != null: - for u in shader_mat.shader.get_shader_uniform_list(): - var u_name: String = u.get("name", "") - if u_name.is_empty(): - continue - shader_params.append({ - "name": u_name, - "type": type_string(u.get("type", TYPE_NIL)), - "value": MaterialValues.serialize_value(shader_mat.get_shader_parameter(u_name)), - }) - - var reverse_type_map := _reverse_type_map() - - var shader_path_str := "" - if mat is ShaderMaterial: - var sm := mat as ShaderMaterial - if sm.shader != null: - shader_path_str = sm.shader.resource_path - - return { - "data": { - "path": mat_path, - "class": mat.get_class(), - "type": reverse_type_map.get(mat.get_class(), ""), - "properties": properties, - "property_count": properties.size(), - "shader_parameters": shader_params, - "shader_path": shader_path_str, - } - } - - -# ============================================================================ -# material_list -# ============================================================================ - -func list_materials(params: Dictionary) -> Dictionary: - var root: String = params.get("root", "res://") - var type_filter: String = params.get("type", "") - - var root_err = McpPathValidator.path_error(root, "root") - if root_err != null: - return root_err - - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var results: Array[Dictionary] = [] - var start_dir := efs.get_filesystem_path(root) - if start_dir == null: - start_dir = efs.get_filesystem() - _scan_materials(start_dir, type_filter, root, results) - - return {"data": {"materials": results, "count": results.size()}} - - -func _scan_materials(dir: EditorFileSystemDirectory, type_filter: String, root: String, out: Array[Dictionary]) -> void: - if dir == null: - return - for i in dir.get_file_count(): - var file_path := dir.get_file_path(i) - if not file_path.begins_with(root): - continue - var file_type := dir.get_file_type(i) - var is_material := file_type == "Material" or ClassDB.is_parent_class(file_type, "Material") - if not is_material: - # Some material variants serialize as specific classes. - if not (file_type in _TYPE_TO_CLASS.values()): - continue - - if not type_filter.is_empty(): - if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter): - continue - - out.append({"path": file_path, "class": file_type}) - - for i in dir.get_subdir_count(): - _scan_materials(dir.get_subdir(i), type_filter, root, out) - - -# ============================================================================ -# material_assign -# ============================================================================ - -func assign_material(params: Dictionary) -> Dictionary: - var node_path: String = params.get("node_path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - var slot: String = params.get("slot", "override") - var resource_path: String = params.get("resource_path", "") - var create_if_missing: bool = params.get("create_if_missing", false) - var type_str: String = params.get("type", "standard") - - var slot_result := _resolve_slot_property(node, slot) - if slot_result.has("error"): - return slot_result - var property: String = slot_result.property - - # Load or create the material. - var mat: Material = null - var material_created := false - if not resource_path.is_empty(): - var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") - if rpath_err != null: - return rpath_err - if not ResourceLoader.exists(resource_path): - if create_if_missing: - # We'd need to create a new file here — refuse; callers should - # use material_create first or omit resource_path to get an - # inline material. - return ErrorCodes.make( - ErrorCodes.RESOURCE_NOT_FOUND, - "Resource not found: %s. Create it first with material_create or omit resource_path for an inline material." % resource_path - ) - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) - var loaded := ResourceLoader.load(resource_path) - if not (loaded is Material): - var loaded_class := "null" - if loaded != null: - loaded_class = loaded.get_class() - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Resource at %s is not a Material (got %s)" % [resource_path, loaded_class] - ) - mat = loaded - else: - if not create_if_missing: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Missing resource_path (pass create_if_missing=true to create a new inline material)" - ) - if not _TYPE_TO_CLASS.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid material type '%s'" % type_str - ) - mat = _instantiate_material(type_str) - material_created = true - - var old_value = node.get(property) - - _undo_redo.create_action("MCP: Assign material to %s.%s" % [node.name, property]) - _undo_redo.add_do_property(node, property, mat) - _undo_redo.add_undo_property(node, property, old_value) - if material_created: - _undo_redo.add_do_reference(mat) - _undo_redo.commit_action() - - return { - "data": { - "node_path": node_path, - "property": property, - "slot": slot, - "resource_path": resource_path, - "material_class": mat.get_class(), - "material_created": material_created, - "undoable": true, - } - } - - -# ============================================================================ -# material_apply_to_node -# ============================================================================ - -func apply_to_node(params: Dictionary) -> Dictionary: - var node_path: String = params.get("node_path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") - - var type_str: String = params.get("type", "standard") - if not _TYPE_TO_CLASS.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid material type '%s'. Valid: %s" % [type_str, ", ".join(_TYPE_TO_CLASS.keys())] - ) - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - var slot: String = params.get("slot", "override") - var slot_result := _resolve_slot_property(node, slot) - if slot_result.has("error"): - return slot_result - var property: String = slot_result.property - - var mat := _instantiate_material(type_str) - - var props_to_set: Dictionary = params.get("params", {}) - var applied: Array[String] = [] - for prop_name in props_to_set: - var apply_err := _apply_one_param_on_instance(mat, String(prop_name), props_to_set[prop_name]) - if apply_err != null: - return apply_err - applied.append(String(prop_name)) - - var save_to: String = params.get("save_to", "") - var saved := false - var overwritten := false - if not save_to.is_empty(): - var save_err_validation := _validate_material_path(save_to, "save_to", true) - if save_err_validation != null: - return save_err_validation - # Same clobber guard as create_material/apply_preset: agents reuse - # names like res://materials/metal.tres, and a silent save here - # destroys a hand-authored file that undo can't restore (undo only - # reverts the node's slot assignment, not file contents). See #685. - var existed_before := FileAccess.file_exists(save_to) - if existed_before and not params.get("overwrite", false): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Material already exists at %s (pass overwrite=true to replace)" % save_to - ) - overwritten = existed_before - var dir_path := save_to.get_base_dir() - var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) - if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) - var save_err := McpResourceIO.guarded_save(mat, save_to, _connection) - if save_err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material to %s (error %d)" % [save_to, save_err]) - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(save_to) - # Prefer the on-disk reference (keeps the scene ref small), but fall - # back to the in-memory material if the reload fails — otherwise a null - # would clear the slot and crash mat.get_class() below. - var reloaded := ResourceLoader.load(save_to) - if reloaded != null: - mat = reloaded - saved = true - - var old_value = node.get(property) - - _undo_redo.create_action("MCP: Apply %s material to %s" % [type_str, node.name]) - _undo_redo.add_do_property(node, property, mat) - _undo_redo.add_undo_property(node, property, old_value) - _undo_redo.add_do_reference(mat) - _undo_redo.commit_action() - - return { - "data": { - "node_path": node_path, - "property": property, - "slot": slot, - "type": type_str, - "class": mat.get_class(), - "applied_params": applied, - "material_created": true, - "saved_to": save_to if saved else "", - "overwritten": overwritten, - "undoable": true, - } - } - - -# ============================================================================ -# material_apply_preset -# ============================================================================ - -func apply_preset(params: Dictionary) -> Dictionary: - var preset_name: String = params.get("preset", "") - if preset_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") - - var overrides: Dictionary = params.get("overrides", {}) - var blueprint = MaterialPresets.build(preset_name, overrides) - if blueprint == null: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(MaterialPresets.list())] - ) - - var type_str: String = blueprint.get("type", "standard") - var preset_params: Dictionary = blueprint.get("params", {}) - - var path: String = params.get("path", "") - var node_path: String = params.get("node_path", "") - - if path.is_empty() and node_path.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "Pass at least one of: path (save to disk), node_path (assign to node)" - ) - - # If both path and node_path, save to disk, then assign the saved resource. - # If only path, save to disk. - # If only node_path, inline material via apply_to_node. - - if not node_path.is_empty() and path.is_empty(): - # Inline - var inline_result := apply_to_node({ - "node_path": node_path, - "type": type_str, - "params": preset_params, - "slot": params.get("slot", "override"), - }) - if inline_result.has("data"): - inline_result.data["preset"] = preset_name - inline_result.data["assigned"] = true - inline_result.data["path"] = "" - inline_result.data["saved_to_disk"] = false - inline_result.data["reason"] = "Inline material assigned to node" - return inline_result - - # Save-to-disk path. Validate the path BEFORE the exists/overwrite - # check, matching create_material's order — an invalid path should - # always be reported as invalid, not as an overwrite conflict. - var path_err := _validate_material_path(path, "path", true) - if path_err != null: - return path_err - - var existed_before := FileAccess.file_exists(path) - if existed_before and not params.get("overwrite", false): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Material already exists at %s (pass overwrite=true to replace)" % path - ) - - var mat := _instantiate_material(type_str) - for prop_name in preset_params: - var apply_err := _apply_one_param_on_instance(mat, String(prop_name), preset_params[prop_name]) - if apply_err != null: - return apply_err - - var dir_path := path.get_base_dir() - var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) - if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) - - var save_err := McpResourceIO.guarded_save(mat, path, _connection) - if save_err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save material: %s" % path) - - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - var assigned := false - if not node_path.is_empty(): - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - var slot_result := _resolve_slot_property(node, params.get("slot", "override")) - if slot_result.has("error"): - return slot_result - var property: String = slot_result.property - var saved_mat := ResourceLoader.load(path) - var old_value = node.get(property) - _undo_redo.create_action("MCP: Apply preset %s to %s" % [preset_name, node.name]) - _undo_redo.add_do_property(node, property, saved_mat) - _undo_redo.add_undo_property(node, property, old_value) - _undo_redo.commit_action() - assigned = true - - return { - "data": { - "preset": preset_name, - "type": type_str, - "path": path, - "node_path": node_path, - "material_created": true, - "assigned": assigned, - "saved_to_disk": true, - "undoable": assigned, # assign is undoable; save is not - "reason": "" if assigned else "File save is not undoable", - } - } - - -# ============================================================================ -# Undo-callable: applies a param on the loaded resource and saves. -# ============================================================================ - -func _apply_param(mat_path: String, property: String, value: Variant, _is_shader: bool) -> void: - var mat: Material = ResourceLoader.load(mat_path) - if mat == null: - push_warning("MCP: Failed to load material for undo/redo: %s" % mat_path) - return - mat.set(property, value) - McpResourceIO.guarded_save(mat, mat_path, _connection) - - -func _apply_shader_param(mat_path: String, param_name: String, value: Variant) -> void: - var mat: Material = ResourceLoader.load(mat_path) - if mat == null or not (mat is ShaderMaterial): - push_warning("MCP: Failed to load shader material for undo/redo: %s" % mat_path) - return - (mat as ShaderMaterial).set_shader_parameter(param_name, value) - McpResourceIO.guarded_save(mat, mat_path, _connection) - - -# ============================================================================ -# Helpers -# ============================================================================ - -static func _instantiate_material(type_str: String) -> Material: - match type_str: - "standard": - return StandardMaterial3D.new() - "orm": - return ORMMaterial3D.new() - "canvas_item": - return CanvasItemMaterial.new() - "shader": - return ShaderMaterial.new() - return null - - -static func _reverse_type_map() -> Dictionary: - var out := {} - for k in _TYPE_TO_CLASS: - out[_TYPE_TO_CLASS[k]] = k - return out - - -static func _validate_material_path(path: String, param_name: String, for_write: bool = false) -> Variant: - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) - var path_err := McpPathValidator.validate_resource_path(path, for_write) - if not path_err.is_empty(): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err]) - var has_suffix := false - for s in _SUPPORTED_SUFFIXES: - if path.ends_with(s): - has_suffix = true - break - if not has_suffix: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "%s must end with one of %s (got %s)" % [param_name, ", ".join(_SUPPORTED_SUFFIXES), path] - ) - return null - - -func _load_material_from_path(path: String, for_write: bool = false) -> Dictionary: - var err := _validate_material_path(path, "path", for_write) - if err != null: - return err - if not ResourceLoader.exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % path) - var res := ResourceLoader.load(path) - if res == null or not (res is Material): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % path) - return {"material": res, "path": path} - - -## Map a slot name to a Godot property name on the given node. -## Returns {property: "..."} or an error dict. -func _resolve_slot_property(node: Node, slot: String) -> Dictionary: - if slot == "override": - if node is MeshInstance3D or node is CSGShape3D: - return {"property": "material_override"} - if node is CanvasItem: - return {"property": "material"} - if node is GPUParticles3D or node is GPUParticles2D or node is CPUParticles3D or node is CPUParticles2D: - return {"property": "material_override"} if node is GeometryInstance3D else {"property": "material"} - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Slot 'override' not supported on %s" % node.get_class() - ) - if slot == "canvas": - if node is CanvasItem: - return {"property": "material"} - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Slot 'canvas' requires a CanvasItem (got %s)" % node.get_class() - ) - if slot == "process": - if node is GPUParticles3D or node is GPUParticles2D: - return {"property": "process_material"} - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Slot 'process' requires a GPUParticles2D/3D (got %s)" % node.get_class() - ) - if slot.begins_with("surface_"): - if not (node is MeshInstance3D): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Slot '%s' requires a MeshInstance3D (got %s)" % [slot, node.get_class()] - ) - var idx_str := slot.substr(len("surface_")) - if not idx_str.is_valid_int(): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid surface slot: %s" % slot) - var idx := int(idx_str) - var mi := node as MeshInstance3D - var surf_count := mi.mesh.get_surface_count() if mi.mesh != null else 0 - if idx < 0 or idx >= surf_count: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Surface index %d out of range (mesh has %d surfaces)" % [idx, surf_count] - ) - return {"property": "surface_material_override/%d" % idx} - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown slot '%s'. Valid: override, canvas, process, surface_N" % slot - ) - - -## Apply one property to an in-memory material instance; returns null on -## success or an error dict on failure. -func _apply_one_param_on_instance(mat: Material, property: String, raw_value: Variant) -> Variant: - var prop_type: int = TYPE_NIL - var property_exists := false - for prop in mat.get_property_list(): - if prop.name == property: - property_exists = true - prop_type = prop.get("type", TYPE_NIL) - break - if not property_exists: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(mat, property) - ) - var coerced := MaterialValues.coerce_material_value(property, raw_value, prop_type) - if not coerced.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerced.error)) - mat.set(property, coerced.value) - return null - - -## Inspect a shader to get the Variant type of a uniform. Returns TYPE_NIL if -## the uniform is not declared. -static func _shader_uniform_type(shader: Shader, name: String) -> int: - if shader == null: - return TYPE_NIL - for u in shader.get_shader_uniform_list(): - if u.get("name", "") == name: - return int(u.get("type", TYPE_NIL)) - return TYPE_NIL diff --git a/addons/godot_ai/handlers/material_handler.gd.uid b/addons/godot_ai/handlers/material_handler.gd.uid deleted file mode 100644 index fdf789a..0000000 --- a/addons/godot_ai/handlers/material_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blh4norn3rjga diff --git a/addons/godot_ai/handlers/material_presets.gd b/addons/godot_ai/handlers/material_presets.gd deleted file mode 100644 index db48036..0000000 --- a/addons/godot_ai/handlers/material_presets.gd +++ /dev/null @@ -1,92 +0,0 @@ -@tool -extends RefCounted - -## Curated material preset blueprints. -## -## Each preset returns {type, params}. Handler applies them through the -## normal material build path so they get undo + validation for free. - - -const _PRESETS := { - "metal": { - "type": "orm", - "params": { - "metallic": 1.0, - "roughness": 0.25, - "albedo_color": {"r": 0.85, "g": 0.85, "b": 0.88, "a": 1.0}, - }, - }, - "glass": { - "type": "standard", - "params": { - "transparency": "alpha", - "albedo_color": {"r": 0.9, "g": 0.95, "b": 1.0, "a": 0.3}, - "metallic": 0.0, - "metallic_specular": 0.5, - "roughness": 0.05, - "refraction_enabled": true, - "refraction_scale": 0.05, - }, - }, - "emissive": { - "type": "standard", - "params": { - "emission_enabled": true, - "emission_energy_multiplier": 3.0, - "emission": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, - "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, - }, - }, - "unlit": { - "type": "standard", - "params": { - "shading_mode": "unshaded", - "albedo_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}, - }, - }, - "matte": { - "type": "standard", - "params": { - "roughness": 1.0, - "metallic": 0.0, - "albedo_color": {"r": 0.7, "g": 0.7, "b": 0.7, "a": 1.0}, - }, - }, - "ceramic": { - "type": "standard", - "params": { - "roughness": 0.4, - "metallic": 0.0, - "clearcoat_enabled": true, - "clearcoat": 0.7, - "clearcoat_roughness": 0.15, - "albedo_color": {"r": 0.95, "g": 0.95, "b": 0.95, "a": 1.0}, - }, - }, -} - - -static func list() -> Array: - return _PRESETS.keys() - - -static func has(preset_name: String) -> bool: - return _PRESETS.has(preset_name) - - -## Returns a deep-copied {type, params} blueprint for the named preset, or -## null if the preset is unknown. Overrides are merged into params. -static func build(preset_name: String, overrides: Dictionary) -> Variant: - if not _PRESETS.has(preset_name): - return null - var entry: Dictionary = _PRESETS[preset_name].duplicate(true) - var params: Dictionary = entry.get("params", {}) - # Allow overrides to change type, too. - if overrides.has("type"): - entry["type"] = overrides["type"] - for key in overrides: - if key == "type": - continue - params[key] = overrides[key] - entry["params"] = params - return entry diff --git a/addons/godot_ai/handlers/material_presets.gd.uid b/addons/godot_ai/handlers/material_presets.gd.uid deleted file mode 100644 index 2f46051..0000000 --- a/addons/godot_ai/handlers/material_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bnuwye1r8ow7g diff --git a/addons/godot_ai/handlers/material_values.gd b/addons/godot_ai/handlers/material_values.gd deleted file mode 100644 index d6a7f1d..0000000 --- a/addons/godot_ai/handlers/material_values.gd +++ /dev/null @@ -1,194 +0,0 @@ -@tool -extends RefCounted - -## Value coercion helpers for material authoring. -## -## Extends node_handler._coerce_value with material-specific cases: -## - enum-by-name (transparency="alpha" → TRANSPARENCY_ALPHA) -## - texture path → Texture2D -## - {r,g,b,a} dict → Color (also handled by node coerce, but we want it inline) - - -const _ENUM_TABLES := { - "transparency": { - "disabled": BaseMaterial3D.TRANSPARENCY_DISABLED, - "alpha": BaseMaterial3D.TRANSPARENCY_ALPHA, - "alpha_scissor": BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR, - "alpha_hash": BaseMaterial3D.TRANSPARENCY_ALPHA_HASH, - "alpha_depth_pre_pass": BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS, - }, - "shading_mode": { - "unshaded": BaseMaterial3D.SHADING_MODE_UNSHADED, - "per_pixel": BaseMaterial3D.SHADING_MODE_PER_PIXEL, - "per_vertex": BaseMaterial3D.SHADING_MODE_PER_VERTEX, - }, - "blend_mode": { - "mix": BaseMaterial3D.BLEND_MODE_MIX, - "add": BaseMaterial3D.BLEND_MODE_ADD, - "sub": BaseMaterial3D.BLEND_MODE_SUB, - "mul": BaseMaterial3D.BLEND_MODE_MUL, - }, - "cull_mode": { - "back": BaseMaterial3D.CULL_BACK, - "front": BaseMaterial3D.CULL_FRONT, - "disabled": BaseMaterial3D.CULL_DISABLED, - }, - "depth_draw_mode": { - "opaque_only": BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY, - "always": BaseMaterial3D.DEPTH_DRAW_ALWAYS, - "disabled": BaseMaterial3D.DEPTH_DRAW_DISABLED, - }, - "diffuse_mode": { - "burley": BaseMaterial3D.DIFFUSE_BURLEY, - "lambert": BaseMaterial3D.DIFFUSE_LAMBERT, - "lambert_wrap": BaseMaterial3D.DIFFUSE_LAMBERT_WRAP, - "toon": BaseMaterial3D.DIFFUSE_TOON, - }, - "specular_mode": { - "schlick_ggx": BaseMaterial3D.SPECULAR_SCHLICK_GGX, - "toon": BaseMaterial3D.SPECULAR_TOON, - "disabled": BaseMaterial3D.SPECULAR_DISABLED, - }, - "billboard_mode": { - "disabled": BaseMaterial3D.BILLBOARD_DISABLED, - "enabled": BaseMaterial3D.BILLBOARD_ENABLED, - "fixed_y": BaseMaterial3D.BILLBOARD_FIXED_Y, - "particles": BaseMaterial3D.BILLBOARD_PARTICLES, - }, - "texture_filter": { - "nearest": BaseMaterial3D.TEXTURE_FILTER_NEAREST, - "linear": BaseMaterial3D.TEXTURE_FILTER_LINEAR, - "nearest_mipmap": BaseMaterial3D.TEXTURE_FILTER_NEAREST_WITH_MIPMAPS, - "linear_mipmap": BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS, - }, -} - - -## Return the enum int for (property, string_name), or null if not a known enum string. -static func resolve_enum(property: String, value: Variant) -> Variant: - if not (value is String): - return null - if not _ENUM_TABLES.has(property): - return null - var table: Dictionary = _ENUM_TABLES[property] - var key: String = String(value).to_lower() - if table.has(key): - return table[key] - return null - - -## Parse a color from Color, "#rrggbb(aa)", named string, {r,g,b[,a]} dict, -## or [r,g,b(,a)] array. Delegates to the canonical parser (#714); returns -## null if the input cannot be parsed. -static func parse_color(value: Variant) -> Variant: - return McpJsonValues.parse_color(value) - - -static func parse_vector3(value: Variant) -> Variant: - return McpJsonValues.parse_vector3(value) - - -static func parse_vector2(value: Variant) -> Variant: - return McpJsonValues.parse_vector2(value) - - -## Load a Texture2D from a res:// / uid:// / user:// path (validate_loadable_path). -## Returns null on failure (including a path that fails confinement / traversal). -static func load_texture(path: String) -> Texture2D: - if not McpPathValidator.validate_loadable_path(path).is_empty(): - return null - if not ResourceLoader.exists(path): - return null - var res := ResourceLoader.load(path) - if res is Texture2D: - return res - return null - - -## Coerce a JSON-shaped value for a material property. -## Returns a dict {ok: true, value: ...} on success, or {ok: false, error: "..."} on failure. -## For properties the coercer doesn't have special logic for, falls back to target_type. -static func coerce_material_value(property: String, value: Variant, target_type: int) -> Dictionary: - # Enum-by-name: must match before generic TYPE_INT coercion. - if _ENUM_TABLES.has(property): - if value is String: - var enum_val = resolve_enum(property, value) - if enum_val == null: - return { - "ok": false, - "error": "Invalid %s value: '%s'. Valid: %s" % [ - property, value, ", ".join(_ENUM_TABLES[property].keys()) - ], - } - return {"ok": true, "value": int(enum_val)} - if value is int or value is float: - return {"ok": true, "value": int(value)} - - match target_type: - TYPE_COLOR: - var c = parse_color(value) - if c == null: - return {"ok": false, "error": "Invalid color for %s: %s" % [property, value]} - return {"ok": true, "value": c} - TYPE_VECTOR3: - var v3 = parse_vector3(value) - if v3 == null: - return {"ok": false, "error": "Invalid vector3 for %s: %s" % [property, value]} - return {"ok": true, "value": v3} - TYPE_VECTOR2: - var v2 = parse_vector2(value) - if v2 == null: - return {"ok": false, "error": "Invalid vector2 for %s: %s" % [property, value]} - return {"ok": true, "value": v2} - TYPE_BOOL: - if value is bool: - return {"ok": true, "value": value} - if value is int or value is float: - return {"ok": true, "value": bool(value)} - return {"ok": false, "error": "Expected bool for %s" % property} - TYPE_INT: - if value is int: - return {"ok": true, "value": value} - if value is float: - return {"ok": true, "value": int(value)} - return {"ok": false, "error": "Expected int for %s" % property} - TYPE_FLOAT: - if value is float: - return {"ok": true, "value": value} - if value is int: - return {"ok": true, "value": float(value)} - return {"ok": false, "error": "Expected number for %s" % property} - TYPE_OBJECT: - if value == null: - return {"ok": true, "value": null} - if value is Object: - return {"ok": true, "value": value} - if value is String: - var tex := load_texture(value) - if tex == null: - return {"ok": false, "error": "Resource not found or wrong type: %s" % value} - return {"ok": true, "value": tex} - return {"ok": false, "error": "Expected resource path (string) for %s" % property} - TYPE_STRING: - return {"ok": true, "value": String(value)} - - # Unknown target type — pass through. - return {"ok": true, "value": value} - - -## Serialize a Variant into JSON-friendly shape for responses. -static func serialize_value(value: Variant) -> Variant: - if value == null: - return null - if value is Color: - return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} - if value is Vector3: - return {"x": value.x, "y": value.y, "z": value.z} - if value is Vector2: - return {"x": value.x, "y": value.y} - if value is Resource: - var path := (value as Resource).resource_path - if path.is_empty(): - return {"type": value.get_class(), "path": ""} - return {"type": value.get_class(), "path": path} - return value diff --git a/addons/godot_ai/handlers/material_values.gd.uid b/addons/godot_ai/handlers/material_values.gd.uid deleted file mode 100644 index 7f8bbe9..0000000 --- a/addons/godot_ai/handlers/material_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://daqgjkflia8nk diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd deleted file mode 100644 index 6d7e1ff..0000000 --- a/addons/godot_ai/handlers/node_handler.gd +++ /dev/null @@ -1,1390 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") - -## Handles node creation and manipulation with undo/redo support. - -const ResourceHandler := preload("res://addons/godot_ai/handlers/resource_handler.gd") - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -func create_node(params: Dictionary) -> Dictionary: - var node_type: String = params.get("type", "") - var node_name: String = params.get("name", "") - var parent_path: String = params.get("parent_path", "") - var scene_path: String = params.get("scene_path", "") - - var scene_check := McpScenePath.require_edited_scene(params.get("scene_file", "")) - if scene_check.has("error"): - return scene_check - var scene_root: Node = scene_check.node - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var new_node: Node - - if not scene_path.is_empty(): - # Scene instancing path — load and instantiate a PackedScene. - # GEN_EDIT_STATE_INSTANCE makes the editor treat the result as a real - # scene instance (foldout icon, the .tscn stores a reference instead of - # an exploded subtree). Descendants remain owned by their sub-scene; - # setting their owner to our scene_root would break the instance link. - var scene_path_err = McpPathValidator.loadable_error(scene_path, "scene_path") - if scene_path_err != null: - return scene_path_err - if not ResourceLoader.exists(scene_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % scene_path) - var packed_scene = ResourceLoader.load(scene_path) - if packed_scene == null or not packed_scene is PackedScene: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a PackedScene" % scene_path) - new_node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE) - if new_node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate scene: %s" % scene_path) - else: - # ClassDB path — create by type. - if node_type.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type (or provide scene_path)") - if not ClassDB.class_exists(node_type): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % node_type) - if not ClassDB.is_parent_class(node_type, "Node"): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) - new_node = ClassDB.instantiate(node_type) - if new_node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) - - if not node_name.is_empty(): - new_node.name = node_name - - _undo_redo.create_action("MCP: Create %s" % new_node.name) - _undo_redo.add_do_method(parent, "add_child", new_node, true) - _undo_redo.add_do_method(new_node, "set_owner", scene_root) - _undo_redo.add_do_reference(new_node) - _undo_redo.add_undo_method(parent, "remove_child", new_node) - _undo_redo.commit_action() - - var response := { - "name": new_node.name, - "type": new_node.get_class(), - "path": McpScenePath.from_node(new_node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "undoable": true, - } - if not scene_path.is_empty(): - response["scene_path"] = scene_path - return {"data": response} - - -func delete_node(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var root_err := _reject_if_scene_root(node, scene_root, "delete") - if root_err != null: - return root_err - - var parent := node.get_parent() - var idx := node.get_index() - - _undo_redo.create_action("MCP: Delete %s" % node.name) - _undo_redo.add_do_method(parent, "remove_child", node) - _undo_redo.add_undo_method(parent, "add_child", node, true) - _undo_redo.add_undo_method(parent, "move_child", node, idx) - _undo_redo.add_undo_method(node, "set_owner", scene_root) - _undo_redo.add_undo_reference(node) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "undoable": true, - } - } - - -func reparent_node(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var new_parent_path: String = params.get("new_parent", "") - if new_parent_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_parent") - - var new_parent := McpScenePath.resolve(new_parent_path, scene_root) - if new_parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(new_parent_path, scene_root)) - - var root_err := _reject_if_scene_root(node, scene_root, "reparent") - if root_err != null: - return root_err - - # Prevent reparenting a node to itself or to one of its own descendants. - # Godot's `A.is_ancestor_of(B)` returns true iff B is a descendant of A, so - # the direction here matters: we want `node.is_ancestor_of(new_parent)` to - # catch "new_parent is below node in the tree" and thus would create a - # cycle. The previous direction (`new_parent.is_ancestor_of(node)`) asked - # the opposite question — whether we were trying to move a node to one of - # its own ancestors — which is a perfectly valid operation. See issue #121. - if node == new_parent or node.is_ancestor_of(new_parent): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot reparent a node to itself or its descendant") - - var old_parent := node.get_parent() - var old_idx := node.get_index() - - _undo_redo.create_action("MCP: Reparent %s" % node.name) - _undo_redo.add_do_method(old_parent, "remove_child", node) - _undo_redo.add_do_method(new_parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - _undo_redo.add_undo_method(new_parent, "remove_child", node) - _undo_redo.add_undo_method(old_parent, "add_child", node, true) - _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) - _undo_redo.add_undo_method(node, "set_owner", scene_root) - _undo_redo.add_undo_reference(node) - _undo_redo.commit_action() - - # Re-set owner for all descendants (reparent can break ownership chain) - _set_owner_recursive(node, scene_root) - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "old_parent": McpScenePath.from_node(old_parent, scene_root), - "new_parent": McpScenePath.from_node(new_parent, scene_root), - "undoable": true, - } - } - - -func set_property(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var property: String = params.get("property", "") - if property.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") - - if not "value" in params: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") - - var value = params.get("value") - - var found := false - var prop_type: int = TYPE_NIL - for prop in node.get_property_list(): - if prop.name == property: - found = true - prop_type = prop.get("type", TYPE_NIL) - break - if not found: - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) - - var old_value = node.get(property) - # Prefer declared property type; fall back to runtime type for dynamic props - # (scripted @export vars can report TYPE_NIL in the property list). - var target_type: int = prop_type if prop_type != TYPE_NIL else typeof(old_value) - - var instantiated_resource := false - - # Some MCP clients (Cline) stringify the documented {"__class__": "BoxMesh", ...} - # value before sending. Promote that string back to a Dictionary here so the - # `__class__` branch below handles it, instead of the next branch treating - # the JSON blob as a res:// path and emitting "Resource not found: {...}". - # See #206. - if target_type == TYPE_OBJECT and value is String and value.begins_with("{"): - var json := JSON.new() - if json.parse(value) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): - value = json.data - - var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://"))) - var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string) - if resource_string_value: - if value == "": - value = null - else: - var value_path_err = McpPathValidator.loadable_error(value, "value") - if value_path_err != null: - return value_path_err - if not ResourceLoader.exists(value): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) - var loaded := ResourceLoader.load(value) - if loaded == null: - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % value) - value = loaded - elif target_type == TYPE_OBJECT and value is Dictionary and value.has("__class__"): - # Shortcut: {"__class__": "BoxMesh", "size": {...}} instantiates a - # fresh Resource subclass and applies the remaining keys as - # properties. Mirrors resource_create's inline-assign path but - # avoids a separate tool call for the common case. - var type_str: String = value.get("__class__", "") - var made := ResourceHandler._instantiate_resource(type_str) - if made is Dictionary: - return made - var res: Resource = made - var remaining: Dictionary = (value as Dictionary).duplicate() - remaining.erase("__class__") - if not remaining.is_empty(): - var apply_err := ResourceHandler._apply_resource_properties(res, remaining) - if apply_err != null: - return apply_err - value = res - instantiated_resource = true - elif target_type == TYPE_ARRAY and old_value is Array and (old_value as Array).is_typed(): - ## Typed Array[T] slot (#612): the generic TYPE_ARRAY passthrough - ## hands an untyped Array to Godot's typed setter, which rejects it - ## wholesale and leaves the slot at its default — with success still - ## reported. Route through the element-aware coercer instead; errors - ## name the offending element index. - var typed_out: Variant = _coerce_typed_array(value, old_value) - if typed_out is Dictionary: - return typed_out - value = typed_out - elif ( - target_type == TYPE_DICTIONARY - and old_value is Dictionary - and (old_value as Dictionary).is_typed() - ): - ## Typed Dictionary[K, V] slot (#612 stage 3) — same silent-drop - ## family as typed arrays. A successful result is always a TYPED - ## Dictionary (a cleared duplicate of the slot), while the error - ## envelope is an untyped {"error": ...} — that's the discriminator - ## (a legit payload could contain an "error" key; typedness can't lie). - var typed_dict_out: Dictionary = _coerce_typed_dictionary(value, old_value) - if not typed_dict_out.is_typed(): - return typed_dict_out - value = typed_dict_out - else: - value = _coerce_value(value, target_type) - ## Refuse any value that didn't land as the target compound Variant - ## — wrong-shape dict (#123) or non-dict input like list / JSON string - ## that used to silently default-construct Vector3.ZERO (#191). - var coerce_err := _check_coerced(value, target_type) - if coerce_err != null: - return coerce_err - - _undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) - _undo_redo.add_do_property(node, property, value) - _undo_redo.add_undo_property(node, property, old_value) - if instantiated_resource: - _undo_redo.add_do_reference(value) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "property": property, - "value": _serialize_value(node.get(property)), - "old_value": _serialize_value(old_value), - "undoable": true, - } - } - - -func rename_node(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var new_name: String = params.get("new_name", "") - if new_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_name") - - ## The scene root's name is baked into the .tscn serialization and is - ## referenced by every NodePath that starts with `/` (AnimationPlayer - ## tracks, RemoteTransform3D targets, exported NodePath @vars, etc.). - ## Renaming it silently breaks those references. The MCP tool's docstring - ## has always promised "Cannot rename the scene root" — enforce it. #122 - if node == scene_root: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot rename the scene root") - - if new_name.validate_node_name() != new_name: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid characters in name: %s" % new_name) - - var old_name := String(node.name) - if old_name == new_name: - return { - "data": { - "path": node_path, - "name": new_name, - "old_name": old_name, - "unchanged": true, - "undoable": false, - "reason": "Name unchanged", - } - } - - var parent := node.get_parent() - for sibling in parent.get_children(): - if sibling != node and String(sibling.name) == new_name: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "A sibling already has the name '%s'" % new_name) - - _undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) - _undo_redo.add_do_property(node, "name", new_name) - _undo_redo.add_undo_property(node, "name", old_name) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "old_path": node_path, - "name": String(node.name), - "old_name": old_name, - "undoable": true, - } - } - - -func duplicate_node(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var root_err := _reject_if_scene_root(node, scene_root, "duplicate") - if root_err != null: - return root_err - - var parent := node.get_parent() - var dup: Node = node.duplicate() - if dup == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to duplicate node") - - # Apply optional name - var new_name: String = params.get("name", "") - if not new_name.is_empty(): - dup.name = new_name - - _undo_redo.create_action("MCP: Duplicate %s" % node.name) - _undo_redo.add_do_method(parent, "add_child", dup, true) - _undo_redo.add_do_method(dup, "set_owner", scene_root) - _undo_redo.add_do_reference(dup) - _undo_redo.add_undo_method(parent, "remove_child", dup) - _undo_redo.commit_action() - - # Set owner for all descendants of the duplicate - _set_owner_recursive(dup, scene_root) - - return { - "data": { - "path": McpScenePath.from_node(dup, scene_root), - "original_path": node_path, - "name": dup.name, - "type": dup.get_class(), - "undoable": true, - } - } - - -func move_node(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var root_err := _reject_if_scene_root(node, scene_root, "reorder") - if root_err != null: - return root_err - - if not "index" in params: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: index") - - var new_index: int = params.get("index", 0) - var parent := node.get_parent() - var old_index := node.get_index() - var sibling_count := parent.get_child_count() - - if new_index < 0 or new_index >= sibling_count: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Index %d out of range (0..%d)" % [new_index, sibling_count - 1]) - - _undo_redo.create_action("MCP: Move %s to index %d" % [node.name, new_index]) - _undo_redo.add_do_method(parent, "move_child", node, new_index) - _undo_redo.add_undo_method(parent, "move_child", node, old_index) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "old_index": old_index, - "new_index": new_index, - "undoable": true, - } - } - - -func add_to_group(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var group_value: Variant = params.get("group", "") - var type_err := McpParamValidators.require_string("group", group_value) - if type_err != null: - return type_err - var group := String(group_value) - if group.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") - - if node.is_in_group(group): - return {"data": {"path": node_path, "group": group, "already_member": true, "undoable": false, "reason": "No change made"}} - - _undo_redo.create_action("MCP: Add %s to group %s" % [node.name, group]) - _undo_redo.add_do_method(node, "add_to_group", group, true) - _undo_redo.add_undo_method(node, "remove_from_group", group) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "group": group, - "undoable": true, - } - } - - -func remove_from_group(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var group_value: Variant = params.get("group", "") - var type_err := McpParamValidators.require_string("group", group_value) - if type_err != null: - return type_err - var group := String(group_value) - if group.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: group") - - if not node.is_in_group(group): - return {"data": {"path": node_path, "group": group, "not_member": true, "undoable": false, "reason": "Node not in group"}} - - _undo_redo.create_action("MCP: Remove %s from group %s" % [node.name, group]) - _undo_redo.add_do_method(node, "remove_from_group", group) - _undo_redo.add_undo_method(node, "add_to_group", group, true) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "group": group, - "undoable": true, - } - } - - -func set_selection(params: Dictionary) -> Dictionary: - var paths: Array = params.get("paths", []) - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var selection := EditorInterface.get_selection() - selection.clear() - - var selected: Array[String] = [] - var not_found: Array[String] = [] - for path_variant in paths: - var path: String = str(path_variant) - var node := McpScenePath.resolve(path, scene_root) - if node: - selection.add_node(node) - selected.append(path) - else: - not_found.append(path) - - return { - "data": { - "selected": selected, - "not_found": not_found, - "count": selected.size(), - "undoable": false, - "reason": "Selection changes are not tracked in undo history", - } - } - - -func _set_owner_recursive(node: Node, owner: Node) -> void: - for child in node.get_children(): - child.set_owner(owner) - _set_owner_recursive(child, owner) - - -## Canonical dict-key sets for dict→Variant coercion. Alpha on `COLOR_KEYS` -## is optional — the coercer defaults it to 1.0 when absent. -const VECTOR2_KEYS: Array[String] = ["x", "y"] -const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] -const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"] -const COLOR_KEYS: Array[String] = ["r", "g", "b"] - - -## End-to-end coerce check for compound JSON-shaped targets -## (Vector2/Vector3/Color). Returns a full `make(...)`-shaped error dict -## if `value` didn't land as the target Variant after `_coerce_value`, -## else null. Wrong-shape dicts get the `_check_dict_coerce_failed` -## message (expected-vs-got keys); non-dict inputs (Array, String, -## primitive) name the received type and a JSON shape hint. No-op for -## non-compound targets — Godot's setter handles those. -## -## Used by set_property, resource_handler, and validation handlers -## (curve, texture). Issue #191 — passing a list, JSON string, or -## anything else to a Vector3 property used to silently store -## Vector3.ZERO; this gates that path. -static func _check_coerced(value: Variant, target_type: int, prefix: String = "") -> Variant: - var ok := false - match target_type: - TYPE_VECTOR2: - ok = value is Vector2 - TYPE_VECTOR3: - ok = value is Vector3 - TYPE_COLOR: - ok = value is Color - TYPE_PACKED_VECTOR2_ARRAY: - ok = value is PackedVector2Array - TYPE_PACKED_VECTOR3_ARRAY: - ok = value is PackedVector3Array - TYPE_PACKED_VECTOR4_ARRAY: - ok = value is PackedVector4Array - TYPE_PACKED_COLOR_ARRAY: - ok = value is PackedColorArray - TYPE_PACKED_INT32_ARRAY: - ok = value is PackedInt32Array - TYPE_PACKED_INT64_ARRAY: - ok = value is PackedInt64Array - TYPE_PACKED_FLOAT32_ARRAY: - ok = value is PackedFloat32Array - TYPE_PACKED_FLOAT64_ARRAY: - ok = value is PackedFloat64Array - TYPE_PACKED_STRING_ARRAY: - ok = value is PackedStringArray - TYPE_VECTOR2I: ok = value is Vector2i - TYPE_VECTOR3I: ok = value is Vector3i - TYPE_VECTOR4: ok = value is Vector4 - TYPE_VECTOR4I: ok = value is Vector4i - TYPE_QUATERNION: ok = value is Quaternion - TYPE_RECT2: ok = value is Rect2 - TYPE_RECT2I: ok = value is Rect2i - TYPE_AABB: ok = value is AABB - TYPE_PLANE: ok = value is Plane - TYPE_BASIS: ok = value is Basis - TYPE_TRANSFORM2D: ok = value is Transform2D - TYPE_TRANSFORM3D: ok = value is Transform3D - TYPE_PROJECTION: ok = value is Projection - _: - # null / untyped-TYPE_NIL / already-correct-type are handled by - # Godot's setter; anything else would silently no-op, so error. - if value == null or target_type == TYPE_NIL or typeof(value) == target_type: - return null - var unsupported := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot write %s to a %s property; godot-ai has no coercion for that type" % [ - type_string(typeof(value)), type_string(target_type), - ], - ) - return ErrorCodes.prefix_message(unsupported, prefix) - if ok: - return null - var dict_err := _check_dict_coerce_failed(value, target_type) - if dict_err != null: - return ErrorCodes.prefix_message(dict_err, prefix) - ## Wording stays neutral on shape — `_shape_hint` already produces a - ## dict-shaped string for Vector2/3/Color and a list-shaped one for - ## the Packed*Array slots. The old "expected a dict like [...]" phrasing - ## read self-contradictory for packed targets (PR #424 review). - var err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot coerce %s to %s; expected %s" % [ - type_string(typeof(value)), type_string(target_type), _shape_hint(target_type), - ], - ) - return ErrorCodes.prefix_message(err, prefix) - - -## Build a "{\"x\":1,...}" hint string from the canonical key constants -## so adding a key (e.g. Vector4) only touches VECTORN_KEYS. Packed*Array -## targets short-circuit to a literal list-shaped hint. -static func _shape_hint(target_type: int) -> String: - match target_type: - TYPE_PACKED_VECTOR2_ARRAY: - return "[{\"x\":0,\"y\":0}, ...]" - TYPE_PACKED_VECTOR3_ARRAY: - return "[{\"x\":0,\"y\":0,\"z\":0}, ...]" - TYPE_PACKED_VECTOR4_ARRAY: - return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]" - TYPE_PACKED_COLOR_ARRAY: - return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]" - TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: - return "[int, ...]" - TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: - return "[float, ...]" - TYPE_PACKED_STRING_ARRAY: - return "[\"...\", ...]" - TYPE_VECTOR2I: - return "{\"x\":0,\"y\":0}" - TYPE_VECTOR3I: - return "{\"x\":0,\"y\":0,\"z\":0}" - TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION: - return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}" - TYPE_RECT2, TYPE_RECT2I, TYPE_AABB: - return "{\"position\":{...},\"size\":{...}}" - TYPE_PLANE: - return "{\"normal\":{...},\"d\":0}" - TYPE_BASIS: - return "{\"x\":{...},\"y\":{...},\"z\":{...}}" - TYPE_TRANSFORM2D: - return "{\"x\":{...},\"y\":{...},\"origin\":{...}}" - TYPE_TRANSFORM3D: - return "{\"basis\":{...},\"origin\":{...}}" - TYPE_PROJECTION: - return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}" - var keys: Array[String] = [] - match target_type: - TYPE_VECTOR2: keys = VECTOR2_KEYS - TYPE_VECTOR3: keys = VECTOR3_KEYS - TYPE_COLOR: keys = COLOR_KEYS - var pairs: Array[String] = [] - for k in keys: - pairs.append("\"%s\":0" % k) - return "{" + ",".join(pairs) + "}" - - -## Detect a failed dict→typed-Variant coercion. Returns an INVALID_PARAMS -## error dict if `value` is still a Dictionary after a coercion attempt -## targeting a Vector2/Vector3/Color slot, else null. Message names the -## expected keys and the keys actually received so agents self-correct -## on the next retry. -static func _check_dict_coerce_failed(value: Variant, target_type: int) -> Variant: - if not (value is Dictionary): - return null - var expected: Array[String] = [] - var type_name := "" - match target_type: - TYPE_VECTOR2: - expected = VECTOR2_KEYS - type_name = "Vector2" - TYPE_VECTOR3: - expected = VECTOR3_KEYS - type_name = "Vector3" - TYPE_COLOR: - expected = COLOR_KEYS - type_name = "Color" - _: - return null - var got_keys: Array = (value as Dictionary).keys() - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot coerce dict to %s: expected keys %s; got %s" % [type_name, str(expected), str(got_keys)] - ) - - -## Coerce JSON-shaped values into Godot Variants when the target property -## type is known. Returns the coerced value on success, or the input -## unchanged on failure — callers detect the type mismatch via an -## `is ` check (curve_handler, texture_handler) or via the -## `_check_dict_coerce_failed` helper (set_property, resource_handler). -## -## Dictionary→Vector2/Vector3/Color cases REQUIRE all canonical keys; -## wrong-shape dicts flow through unchanged. See issue #123 — previous -## `dict.get(key, 0)` defaults silently zero-filled missing axes. -static func _coerce_value(value: Variant, target_type: int) -> Variant: - match target_type: - ## Vector2/Vector3/Color route through the canonical strict parser - ## (#714): same dict/array/string shapes as every other handler, and - ## non-numeric components fall through (returning the original - ## value) so _check_coerced flags them instead of crashing a typed - ## constructor or silently writing black/zeros. - TYPE_VECTOR2: - var v2 = McpJsonValues.parse_vector2(value) - if v2 != null: - return v2 - TYPE_VECTOR3: - var v3 = McpJsonValues.parse_vector3(value) - if v3 != null: - return v3 - TYPE_COLOR: - var col = McpJsonValues.parse_color(value) - if col != null: - return col - TYPE_BOOL: - if value is float or value is int: - return bool(value) - TYPE_INT: - if value is float: - return int(value) - TYPE_FLOAT: - if value is int: - return float(value) - TYPE_STRING_NAME: - if value is String: - return StringName(value) - TYPE_NODE_PATH: - if value is String: - return NodePath(value) - if value == null: - return NodePath() - TYPE_OBJECT: - # Resource loading is handled in set_property so we can return a - # typed error; here we only pass through cleared values. - if value == null: - return null - TYPE_ARRAY: - if value is Array: - return value - TYPE_DICTIONARY: - if value is Dictionary: - return value - TYPE_PACKED_VECTOR2_ARRAY: - if value is Array: - var out := PackedVector2Array() - for item in value: - if item is Vector2: - out.append(item) - elif item is Dictionary and item.has_all(VECTOR2_KEYS): - out.append(Vector2(item["x"], item["y"])) - else: - return value # leave for _check_coerced to flag - return out - TYPE_PACKED_VECTOR3_ARRAY: - if value is Array: - var out := PackedVector3Array() - for item in value: - if item is Vector3: - out.append(item) - elif item is Dictionary and item.has_all(VECTOR3_KEYS): - out.append(Vector3(item["x"], item["y"], item["z"])) - else: - return value - return out - TYPE_PACKED_VECTOR4_ARRAY: - if value is Array: - var out := PackedVector4Array() - for item in value: - if item is Vector4: - out.append(item) - elif item is Dictionary and item.has_all(VECTOR4_KEYS): - out.append(Vector4(item["x"], item["y"], item["z"], item["w"])) - else: - return value - return out - TYPE_PACKED_COLOR_ARRAY: - if value is Array: - var out := PackedColorArray() - for item in value: - if item is Color: - out.append(item) - elif item is Dictionary and item.has_all(COLOR_KEYS): - out.append(Color(item["r"], item["g"], item["b"], item.get("a", 1.0))) - elif item is String: - out.append(Color(item)) - else: - return value - return out - TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY: - if value is Array: - var out: Variant = PackedInt32Array() if target_type == TYPE_PACKED_INT32_ARRAY else PackedInt64Array() - for item in value: - if item is int or item is float: - out.append(int(item)) - else: - return value - return out - TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY: - if value is Array: - var out: Variant = PackedFloat32Array() if target_type == TYPE_PACKED_FLOAT32_ARRAY else PackedFloat64Array() - for item in value: - if item is float or item is int: - out.append(float(item)) - else: - return value - return out - TYPE_PACKED_STRING_ARRAY: - if value is Array: - var out := PackedStringArray() - for item in value: - if item is String: - out.append(item) - else: - return value - return out - TYPE_VECTOR2I: - if value is Dictionary and value.has_all(VECTOR2_KEYS): - return Vector2i(int(value["x"]), int(value["y"])) - TYPE_VECTOR3I: - if value is Dictionary and value.has_all(VECTOR3_KEYS): - return Vector3i(int(value["x"]), int(value["y"]), int(value["z"])) - TYPE_VECTOR4: - if value is Dictionary and value.has_all(VECTOR4_KEYS): - return Vector4(value["x"], value["y"], value["z"], value["w"]) - TYPE_VECTOR4I: - if value is Dictionary and value.has_all(VECTOR4_KEYS): - return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"])) - TYPE_QUATERNION: - if value is Dictionary and value.has_all(VECTOR4_KEYS): - return Quaternion(value["x"], value["y"], value["z"], value["w"]) - TYPE_RECT2: - if value is Dictionary and value.has("position") and value.has("size"): - var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2) - var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2) - if p is Vector2 and s is Vector2: - return Rect2(p, s) - TYPE_RECT2I: - if value is Dictionary and value.has("position") and value.has("size"): - var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I) - var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I) - if p is Vector2i and s is Vector2i: - return Rect2i(p, s) - TYPE_AABB: - if value is Dictionary and value.has("position") and value.has("size"): - var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3) - var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3) - if p is Vector3 and s is Vector3: - return AABB(p, s) - TYPE_PLANE: - if value is Dictionary and value.has("normal") and value.has("d"): - var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3) - if n is Vector3: - return Plane(n, float(value["d"])) - TYPE_BASIS: - if value is Dictionary and value.has_all(["x", "y", "z"]): - var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3) - var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3) - var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3) - if bx is Vector3 and by is Vector3 and bz is Vector3: - return Basis(bx, by, bz) - TYPE_TRANSFORM2D: - if value is Dictionary and value.has_all(["x", "y", "origin"]): - var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2) - var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2) - var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2) - if tx is Vector2 and ty is Vector2 and to_ is Vector2: - return Transform2D(tx, ty, to_) - TYPE_TRANSFORM3D: - if value is Dictionary and value.has("basis") and value.has("origin"): - var b: Variant = _coerce_value(value["basis"], TYPE_BASIS) - var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3) - if b is Basis and o is Vector3: - return Transform3D(b, o) - TYPE_PROJECTION: - if value is Dictionary and value.has_all(VECTOR4_KEYS): - var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4) - var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4) - var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4) - var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4) - if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4: - return Projection(px, py, pz, pw) - # PackedByteArray intentionally unhandled — needs design decision - # (base64 string vs. raw int list); JSON has no native byte type. - return value - - -## Fill a typed `Array[T]` slot from a JSON list (#612 stage 1: value-element -## types). `slot_value` is the property's current typed Array — Godot's getter -## returns the (possibly empty) typed container, which carries the element -## type, so no PROPERTY_HINT_TYPE_STRING parsing is needed. Elements coerce -## one at a time through the existing `_coerce_value` / `_check_coerced` -## pair, then bulk-move via `Array.assign()` with a post-assign size check, -## so a wrong element can never silently drop the write: it errors naming -## the element index. Returns the filled typed Array on success, or a -## `make(...)`-shaped error Dictionary (callers discriminate on -## `result is Dictionary` — a successful result is always an Array). -## -## Object elements (Array[Texture2D], Array[MyResource], ...) coerce per -## element through `_coerce_object_element` (#612 stage 2), mirroring the -## single-slot TYPE_OBJECT paths: res:// strings load, {"__class__": ...} -## instantiates, and each landed element is conformance-checked against the -## slot's element class/script so a wrong-class Resource errors naming the -## index instead of being rejected wholesale by `assign`. -static func _coerce_typed_array(value: Variant, slot_value: Array, prefix: String = "") -> Variant: - var elem_label := _typed_array_element_label(slot_value) - if not (value is Array): - var err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot write %s to a typed Array[%s] property; expected a list" % [ - type_string(typeof(value)), elem_label, - ], - ) - return ErrorCodes.prefix_message(err, prefix) - var elem_type := slot_value.get_typed_builtin() - var staging: Array = [] - var in_list: Array = value - for i in in_list.size(): - var elem_prefix := ("element %d" % i) if prefix.is_empty() else "%s element %d" % [prefix, i] - var coerced: Variant - if elem_type == TYPE_OBJECT: - coerced = _coerce_object_element(in_list[i], elem_prefix) - if coerced is Dictionary: - ## Object elements are never legit Dictionaries (a dict input - ## is either {"__class__"} — consumed above — or an error), so - ## a Dictionary return is unambiguously the error envelope. - return coerced - if coerced != null and not _object_element_conforms(coerced, slot_value): - var conform_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "element is %s, which is not a %s" % [ - (coerced as Object).get_class(), elem_label, - ], - ) - return ErrorCodes.prefix_message(conform_err, elem_prefix) - else: - coerced = _coerce_value(in_list[i], elem_type) - var elem_err := _check_coerced(coerced, elem_type, elem_prefix) - if elem_err != null: - return elem_err - if coerced == null: - ## Prefix with `elem_prefix` (which already folds in `prefix`), - ## not `prefix` again — the latter double-stamped the property - ## context (PR #682 review). Object arrays allow null entries - ## (Godot typed object arrays store null); value-type arrays - ## don't. - var null_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "cannot store null in Array[%s]" % elem_label, - ) - return ErrorCodes.prefix_message(null_err, elem_prefix) - staging.append(coerced) - var out := slot_value.duplicate() - out.clear() - out.assign(staging) - if out.size() != staging.size(): - ## Backstop for element shapes `_check_coerced` waves through but the - ## typed container still rejects — `assign` loud-rejects and leaves a - ## short array, which without this check would be a partial write. - var assign_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Array[%s] element conversion failed during assign (%d of %d elements landed)" % [ - elem_label, out.size(), staging.size(), - ], - ) - return ErrorCodes.prefix_message(assign_err, prefix) - return out - - -## "int" / "Vector3" / "Texture2D" / "MyItemData" — element-type name of a -## typed Array slot, for error messages. -static func _typed_array_element_label(slot_value: Array) -> String: - if slot_value.get_typed_builtin() == TYPE_OBJECT: - return _object_type_label(slot_value.get_typed_class_name(), slot_value.get_typed_script()) - return type_string(slot_value.get_typed_builtin()) - - -## Coerce one element of an object-typed Array (#612 stage 2). Mirrors the -## single-slot TYPE_OBJECT paths in set_property: a res:// path string loads -## the Resource; {"__class__": "X", ...} (including the #206 stringified -## form) instantiates via ResourceHandler and applies the remaining keys; -## "" / null store a null entry (typed object arrays allow them). Returns -## the Object (or null), or a make(...)-shaped error Dictionary with -## `elem_prefix` already folded in. -static func _coerce_object_element(elem: Variant, elem_prefix: String) -> Variant: - if elem == null: - return null - if elem is Object: - return elem - if elem is String and (elem as String).begins_with("{"): - var json := JSON.new() - if json.parse(elem) == OK and json.data is Dictionary and (json.data as Dictionary).has("__class__"): - elem = json.data - if elem is String: - if String(elem).is_empty(): - return null - var path_err = McpPathValidator.loadable_error(elem, "value") - if path_err != null: - return ErrorCodes.prefix_message(path_err, elem_prefix) - if not ResourceLoader.exists(elem): - return ErrorCodes.prefix_message( - ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem), - elem_prefix, - ) - var loaded := ResourceLoader.load(elem) - if loaded == null: - return ErrorCodes.prefix_message( - ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % elem), - elem_prefix, - ) - return loaded - if elem is Dictionary and (elem as Dictionary).has("__class__"): - var type_str: String = (elem as Dictionary).get("__class__", "") - var made := ResourceHandler._instantiate_resource(type_str) - if made is Dictionary: - return ErrorCodes.prefix_message(made, elem_prefix) - var res: Resource = made - var remaining: Dictionary = (elem as Dictionary).duplicate() - remaining.erase("__class__") - if not remaining.is_empty(): - var apply_err: Variant = ResourceHandler._apply_resource_properties(res, remaining) - if apply_err != null: - return ErrorCodes.prefix_message(apply_err, elem_prefix) - return res - return ErrorCodes.prefix_message( - ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - 'cannot convert %s to an Object element; pass a res:// path or {"__class__": ...}' - % type_string(typeof(elem)), - ), - elem_prefix, - ) - - -## True when `elem` satisfies the object-typed slot's element constraint — -## script type when the slot is Array[MyScriptClass], native class -## otherwise. Checked per element so a wrong-class Resource errors naming -## the index instead of being rejected wholesale by `Array.assign()`. -static func _object_element_conforms(elem: Object, slot_value: Array) -> bool: - return _object_conforms(elem, slot_value.get_typed_class_name(), slot_value.get_typed_script()) - - -## Shared class/script conformance predicate for typed Array elements and -## typed Dictionary values (#612 stages 2–3). -static func _object_conforms(elem: Object, cls_name: StringName, script: Variant) -> bool: - if script is Script: - return is_instance_of(elem, script) - var cls := String(cls_name) - return cls.is_empty() or elem.is_class(cls) - - -## Fill a typed `Dictionary[K, V]` slot from a JSON object (#612 stage 3). -## `slot_value` is the property's current typed Dictionary — the getter -## returns the (possibly empty) typed container carrying both constraint -## sides. Keys coerce via `_coerce_typed_dict_key` (JSON object keys are -## always Strings, so int/float/StringName key slots parse the string and -## fail closed on anything inexact); values mirror the typed-array element -## rules — object values through `_coerce_object_element` + conformance, -## everything else through `_coerce_value`/`_check_coerced`. Never partial: -## any bad key or value errors naming the key and nothing is written. -## -## Returns the filled TYPED Dictionary on success or an UNTYPED -## `make(...)`-shaped error Dictionary — callers discriminate on -## `is_typed()`, since a success result is always a duplicate of the typed -## slot and error envelopes are plain dicts. -static func _coerce_typed_dictionary( - value: Variant, slot_value: Dictionary, prefix: String = "" -) -> Dictionary: - var label := _typed_dictionary_label(slot_value) - if not (value is Dictionary): - var err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot write %s to a typed %s property; expected an object" % [ - type_string(typeof(value)), label, - ], - ) - return ErrorCodes.prefix_message(err, prefix) - var key_type := slot_value.get_typed_key_builtin() if slot_value.is_typed_key() else TYPE_NIL - var value_type := ( - slot_value.get_typed_value_builtin() if slot_value.is_typed_value() else TYPE_NIL - ) - var out := slot_value.duplicate() - out.clear() - var in_dict: Dictionary = value - for raw_key in in_dict.keys(): - var key_prefix := ( - 'key "%s"' % str(raw_key) if prefix.is_empty() - else '%s key "%s"' % [prefix, str(raw_key)] - ) - var key: Variant = _coerce_typed_dict_key(raw_key, key_type, label, key_prefix) - if key is Dictionary: - ## Scalar-only key coercion never returns a legit Dictionary key, - ## so a Dictionary here is unambiguously the error envelope. - return key - var raw_value: Variant = in_dict[raw_key] - var coerced: Variant - if value_type == TYPE_NIL: - ## Untyped value side (e.g. Dictionary[String, Variant]). - coerced = raw_value - elif value_type == TYPE_OBJECT: - coerced = _coerce_object_element(raw_value, key_prefix) - if coerced is Dictionary: - return coerced - if coerced != null and not _object_conforms( - coerced, - slot_value.get_typed_value_class_name(), - slot_value.get_typed_value_script(), - ): - var conform_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "value is %s, which is not a %s" % [ - (coerced as Object).get_class(), - _object_type_label( - slot_value.get_typed_value_class_name(), - slot_value.get_typed_value_script(), - ), - ], - ) - return ErrorCodes.prefix_message(conform_err, key_prefix) - else: - coerced = _coerce_value(raw_value, value_type) - var value_err := _check_coerced(coerced, value_type, key_prefix) - if value_err != null: - return value_err - if coerced == null: - var null_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "cannot store null as a %s value in %s" % [type_string(value_type), label], - ) - return ErrorCodes.prefix_message(null_err, key_prefix) - out[key] = coerced - if out.size() != in_dict.size(): - ## Two input keys collapsing onto one coerced key ("1" and "01" both - ## parse to int 1) would silently lose an entry — refuse instead. - var collide_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s keys collide after coercion (%d of %d entries landed)" % [ - label, out.size(), in_dict.size(), - ], - ) - return ErrorCodes.prefix_message(collide_err, prefix) - return out - - -## Coerce one JSON-object key onto a typed Dictionary's key slot. JSON keys -## are always Strings, so int/float/StringName key types accept exactly the -## strings that parse cleanly; everything else fails closed naming the key. -## Object/compound key types are unreachable from JSON and refuse loudly. -static func _coerce_typed_dict_key( - raw_key: Variant, key_type: int, label: String, key_prefix: String -) -> Variant: - if key_type == TYPE_NIL or typeof(raw_key) == key_type: - return raw_key - if raw_key is String: - var key_str := raw_key as String - match key_type: - TYPE_STRING_NAME: - return StringName(key_str) - TYPE_INT: - if key_str.is_valid_int(): - return int(key_str) - TYPE_FLOAT: - if key_str.is_valid_float(): - return float(key_str) - ## String key that didn't parse: JSON object keys are ALWAYS strings, - ## so blaming the String-ness would imply the caller could somehow - ## send a non-string key — name the expected key type instead. - var parse_err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "key does not parse as %s (the %s key type)" % [type_string(key_type), label], - ) - return ErrorCodes.prefix_message(parse_err, key_prefix) - if raw_key is float and key_type == TYPE_INT and is_equal_approx(raw_key, roundf(raw_key)): - ## Whole JSON numbers arrive as floats through some non-JSON callers. - return int(raw_key) - var err := ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "cannot use %s as a %s key" % [type_string(typeof(raw_key)), label], - ) - return ErrorCodes.prefix_message(err, key_prefix) - - -## "Dictionary[String, int]" / "Dictionary[int, Texture2D]" — for error -## messages. Untyped sides read as Variant. -static func _typed_dictionary_label(slot_value: Dictionary) -> String: - var key_label := "Variant" - if slot_value.is_typed_key(): - key_label = ( - _object_type_label( - slot_value.get_typed_key_class_name(), slot_value.get_typed_key_script() - ) - if slot_value.get_typed_key_builtin() == TYPE_OBJECT - else type_string(slot_value.get_typed_key_builtin()) - ) - var value_label := "Variant" - if slot_value.is_typed_value(): - value_label = ( - _object_type_label( - slot_value.get_typed_value_class_name(), slot_value.get_typed_value_script() - ) - if slot_value.get_typed_value_builtin() == TYPE_OBJECT - else type_string(slot_value.get_typed_value_builtin()) - ) - return "Dictionary[%s, %s]" % [key_label, value_label] - - -## Class/script display name for an object-typed constraint side. -static func _object_type_label(cls_name: StringName, script: Variant) -> String: - var cls := String(cls_name) - if script is Script and not String((script as Script).get_global_name()).is_empty(): - cls = String((script as Script).get_global_name()) - return cls if not cls.is_empty() else "Object" - - -func get_node_properties(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - # Optional token-reducing filter: `fields` restricts the response to a - # named subset. Defaults off (empty), so existing callers see the full - # dump unchanged. The MCP tool types this as a list, but batch_execute and - # raw callers bypass that, so validate the shape here before iterating. - var fields_param: Variant = params.get("fields", []) - if fields_param == null: - fields_param = [] - if not (fields_param is Array): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "'fields' must be an array of property names, got %s (%s)" % [ - type_string(typeof(fields_param)), str(fields_param), - ], - ) - var field_filter := {} - for f in fields_param: - ## Property names are strings on the wire; anything else is a - ## malformed filter (e.g. [123] or [["fov"]]) — reject rather than - ## silently stringify into a filter that matches nothing (#123/#126: - ## strict within the accepted shape). StringName is allowed for - ## editor-side callers. - if not (f is String or f is StringName): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "'fields' elements must be property-name strings, got %s in %s" % [ - type_string(typeof(f)), str(fields_param), - ], - ) - field_filter[str(f)] = true - var use_field_filter := not field_filter.is_empty() - - var properties: Array[Dictionary] = [] - var editor_property_count := 0 - var matched_fields := {} - for prop in node.get_property_list(): - var usage: int = prop.get("usage", 0) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - editor_property_count += 1 - if use_field_filter: - if not field_filter.has(prop.name): - continue - matched_fields[prop.name] = true - # Null reads are values, not omissions: `script` on an unscripted node - # and unset Resource slots (mesh, material, …) read back null and must - # appear as "value": null with their declared type, so callers can tell - # "Object-typed, currently unset" from "doesn't exist" (#771). - properties.append({ - "name": prop.name, - "type": type_string(prop.type), - "value": _serialize_value(node.get(prop.name)), - }) - # Requested names that matched no editor-visible property — distinguishes - # "you asked for something that doesn't exist" from "exists and is null". - var unknown_fields: Array[String] = [] - for f in field_filter: - if not matched_fields.has(f): - unknown_fields.append(f) - return { - "data": { - "path": node_path, - "node_type": node.get_class(), - "properties": properties, - "count": properties.size(), - # Total editor-visible properties before field filtering, so a - # caller that passed `fields` knows how many were withheld. - # Invariant: an unfiltered call returns every editor-visible - # property, so count == total_count; only the `fields` filter - # can make count < total_count. - "total_count": editor_property_count, - "unknown_fields": unknown_fields, - } - } - - -func get_children(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var scene_root: Node = resolved.scene_root - - var children: Array[Dictionary] = [] - for child in node.get_children(): - children.append({ - "name": child.name, - "type": child.get_class(), - "path": McpScenePath.from_node(child, scene_root), - "children_count": child.get_child_count(), - }) - return { - "data": { - "parent_path": node_path, - "children": children, - "count": children.size(), - } - } - - -func get_groups(params: Dictionary) -> Dictionary: - var resolved := _resolve_node(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var groups: Array[String] = [] - for group in node.get_groups(): - # Skip internal groups (start with underscore) - if not str(group).begins_with("_"): - groups.append(str(group)) - return { - "data": { - "path": node_path, - "groups": groups, - "count": groups.size(), - } - } - - -## Validate path param, resolve to node. Returns dict with node/path/scene_root -## on success, or an error dict (has "error" key) on failure. Thin wrapper -## around the shared `McpNodeValidator.resolve_or_error` helper (audit-v2 #20). -func _resolve_node(params: Dictionary) -> Dictionary: - return McpNodeValidator.resolve_or_error( - params.get("path", ""), "path", params.get("scene_file", ""), - ) - - -## Reject operations targeting the scene root. Returns an INVALID_PARAMS error -## dict with "Cannot the scene root", or null if `node` is not the root. -static func _reject_if_scene_root(node: Node, scene_root: Node, op: String) -> Variant: - if node == scene_root: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Cannot %s the scene root" % op) - return null - - -## Convert a Godot Variant to a JSON-safe value. Compound geometry types -## (AABB, Rect2, Transforms, …) and packed arrays serialize as structured -## dicts/arrays so agents can inspect fields instead of parsing Godot's -## debug repr — see issue #214. -static func _serialize_value(value: Variant) -> Variant: - return VariantSerializer.serialize(value) diff --git a/addons/godot_ai/handlers/node_handler.gd.uid b/addons/godot_ai/handlers/node_handler.gd.uid deleted file mode 100644 index 86149b4..0000000 --- a/addons/godot_ai/handlers/node_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://qhhd5mm5awym diff --git a/addons/godot_ai/handlers/particle_handler.gd b/addons/godot_ai/handlers/particle_handler.gd deleted file mode 100644 index 5604355..0000000 --- a/addons/godot_ai/handlers/particle_handler.gd +++ /dev/null @@ -1,860 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles particle emitter authoring (GPU + CPU, 2D + 3D). -## -## All write operations bundle node creation and sub-resource spawns -## (ParticleProcessMaterial, default QuadMesh) in a single create_action -## so Ctrl-Z rolls back the whole effect atomically. - -const ParticleValues := preload("res://addons/godot_ai/handlers/particle_values.gd") -const ParticlePresets := preload("res://addons/godot_ai/handlers/particle_presets.gd") - -const _VALID_TYPES := { - "gpu_3d": "GPUParticles3D", - "gpu_2d": "GPUParticles2D", - "cpu_3d": "CPUParticles3D", - "cpu_2d": "CPUParticles2D", -} - -const _MAIN_KEYS := [ - "amount", - "lifetime", - "one_shot", - "explosiveness", - "preprocess", - "speed_scale", - "randomness", - "fixed_fps", - "emitting", - "local_coords", - "interp_to_end", -] - - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -# ============================================================================ -# particle_create -# ============================================================================ - -func create_particle(params: Dictionary) -> Dictionary: - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "Particles") - var type_str: String = params.get("type", "gpu_3d") - - if not _VALID_TYPES.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] - ) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var node := _instantiate_particle(type_str) - if node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate particle node") - if not node_name.is_empty(): - node.name = node_name - - var process_mat: ParticleProcessMaterial = null - var process_material_created := false - var draw_mesh: Mesh = null - var draw_material: StandardMaterial3D = null - var draw_pass_mesh_created := false - var draw_material_created := false - - if type_str == "gpu_3d" or type_str == "gpu_2d": - process_mat = ParticleProcessMaterial.new() - process_material_created = true - if type_str == "gpu_3d": - draw_mesh = QuadMesh.new() - (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) - # Without a material, the mesh renders flat white — ignoring - # ParticleProcessMaterial.color_ramp entirely. Give it the standard - # billboard + vertex-color-as-albedo setup so color_ramp works. - draw_material = ParticleValues.build_draw_material({}).material - (draw_mesh as QuadMesh).material = draw_material - draw_pass_mesh_created = true - draw_material_created = true - - _undo_redo.create_action("MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name]) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - if process_mat != null: - _undo_redo.add_do_property(node, "process_material", process_mat) - _undo_redo.add_do_reference(process_mat) - if draw_mesh != null: - _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh) - _undo_redo.add_do_reference(draw_mesh) - if draw_material != null: - _undo_redo.add_do_reference(draw_material) - _undo_redo.add_do_reference(node) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": String(node.name), - "type": type_str, - "class": _VALID_TYPES[type_str], - "process_material_created": process_material_created, - "draw_pass_mesh_created": draw_pass_mesh_created, - "draw_material_created": draw_material_created, - "undoable": true, - } - } - - -# ============================================================================ -# particle_set_main -# ============================================================================ - -func set_main(params: Dictionary) -> Dictionary: - var resolved := _resolve_particle(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var properties: Dictionary = params.get("properties", {}) - if properties.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") - - var coerced: Dictionary = {} - var old_values: Dictionary = {} - for property in properties: - var prop_name: String = String(property) - if not (prop_name in _MAIN_KEYS): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Unknown main property '%s'. Valid: %s" % [prop_name, ", ".join(_MAIN_KEYS)] - ) - var prop_type := _node_property_type(node, prop_name) - if prop_type == TYPE_NIL: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' not present on %s" % [prop_name, node.get_class()] - ) - var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type) - if not coerce_result.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - coerced[prop_name] = coerce_result.value - old_values[prop_name] = node.get(prop_name) - - _undo_redo.create_action("MCP: Set particle main on %s" % node.name) - for prop_name in coerced: - _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) - _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) - _undo_redo.commit_action() - - var applied: Array[String] = [] - var serialized_values: Dictionary = {} - for prop_name in coerced: - applied.append(prop_name) - serialized_values[prop_name] = ParticleValues.serialize(coerced[prop_name]) - - return { - "data": { - "path": node_path, - "applied": applied, - "values": serialized_values, - "undoable": true, - } - } - - -# ============================================================================ -# particle_set_process -# ============================================================================ - -func set_process(params: Dictionary) -> Dictionary: - var resolved := _resolve_particle(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var properties: Dictionary = params.get("properties", {}) - if properties.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") - - # GPU: work through process_material; CPU: properties live on node directly. - if node is GPUParticles3D or node is GPUParticles2D: - return _set_process_gpu(node, node_path, properties) - return _set_process_cpu(node, node_path, properties) - - -func _set_process_gpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary: - var existing_mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial - var process_material_created := false - var mat: ParticleProcessMaterial = existing_mat - if mat == null: - mat = ParticleProcessMaterial.new() - process_material_created = true - - var coerced: Dictionary = {} - for property in properties: - var prop_name: String = String(property) - var prop_type := _object_property_type(mat, prop_name) - if prop_type == TYPE_NIL: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' not present on ParticleProcessMaterial" % prop_name - ) - var coerce_result := ParticleValues.coerce(prop_name, properties[prop_name], prop_type) - if not coerce_result.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - coerced[prop_name] = coerce_result.value - - _undo_redo.create_action("MCP: Set particle process on %s" % node.name) - if process_material_created: - _undo_redo.add_do_property(node, "process_material", mat) - _undo_redo.add_undo_property(node, "process_material", null) - _undo_redo.add_do_reference(mat) - # Apply new values directly on the (newly created) material. No old values to restore. - for prop_name in coerced: - mat.set(prop_name, coerced[prop_name]) - else: - # Use the reusable apply/restore pattern for existing material. - var old_values: Dictionary = {} - for prop_name in coerced: - old_values[prop_name] = mat.get(prop_name) - for prop_name in coerced: - _undo_redo.add_do_property(mat, prop_name, coerced[prop_name]) - _undo_redo.add_undo_property(mat, prop_name, old_values[prop_name]) - _undo_redo.commit_action() - - var applied: Array[String] = [] - var serialized: Dictionary = {} - for prop_name in coerced: - applied.append(prop_name) - serialized[prop_name] = ParticleValues.serialize(mat.get(prop_name)) - - return { - "data": { - "path": node_path, - "applied": applied, - "values": serialized, - "process_material_created": process_material_created, - "undoable": true, - } - } - - -func _set_process_cpu(node: Node, node_path: String, properties: Dictionary) -> Dictionary: - # CPU particles expose the same property vocabulary directly on the node, - # so property names pass through unchanged. - var coerced: Dictionary = {} - var old_values: Dictionary = {} - - for property in properties: - var prop_name: String = String(property) - var prop_type := _node_property_type(node, prop_name) - if prop_type == TYPE_NIL: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' not present on %s" % [prop_name, node.get_class()] - ) - var coerce_result := ParticleValues.coerce(prop_name, properties[property], prop_type) - if not coerce_result.ok: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - coerced[prop_name] = coerce_result.value - old_values[prop_name] = node.get(prop_name) - - _undo_redo.create_action("MCP: Set particle process on %s" % node.name) - for prop_name in coerced: - _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) - _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) - _undo_redo.commit_action() - - var applied: Array[String] = [] - var serialized: Dictionary = {} - for prop_name in coerced: - applied.append(prop_name) - serialized[prop_name] = ParticleValues.serialize(coerced[prop_name]) - - return { - "data": { - "path": node_path, - "applied": applied, - "values": serialized, - "process_material_created": false, - "undoable": true, - } - } - - -# ============================================================================ -# particle_set_draw_pass -# ============================================================================ - -func set_draw_pass(params: Dictionary) -> Dictionary: - var resolved := _resolve_particle(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var pass_idx: int = int(params.get("pass", 1)) - var mesh_path: String = params.get("mesh", "") - var texture_path: String = params.get("texture", "") - var material_path: String = params.get("material", "") - - if node is GPUParticles3D: - return _set_draw_pass_gpu_3d(node, node_path, pass_idx, mesh_path, material_path) - if node is CPUParticles3D: - return _set_draw_pass_cpu_3d(node, node_path, mesh_path, material_path) - if node is GPUParticles2D or node is CPUParticles2D: - return _set_draw_pass_2d(node, node_path, texture_path) - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Node %s is not a particle node" % node.get_class()) - - -func _set_draw_pass_gpu_3d(node: GPUParticles3D, node_path: String, pass_idx: int, mesh_path: String, material_path: String) -> Dictionary: - if pass_idx < 1 or pass_idx > 4: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "pass must be 1..4 (got %d)" % pass_idx) - - var mesh: Mesh = null - var mesh_created := false - var property_name := "draw_pass_%d" % pass_idx - # draw_pass_N is only a live property when draw_passes >= N. Probe via - # get_property_list so we don't read a ghost value. - var existing_mesh: Mesh = null - if int(node.draw_passes) >= pass_idx: - existing_mesh = node.get(property_name) as Mesh - if not mesh_path.is_empty(): - var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path") - if mesh_path_err != null: - return mesh_path_err - if not ResourceLoader.exists(mesh_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path) - var loaded := ResourceLoader.load(mesh_path) - if not (loaded is Mesh): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path) - mesh = loaded - else: - if existing_mesh == null: - mesh = QuadMesh.new() - (mesh as QuadMesh).size = Vector2(0.25, 0.25) - mesh_created = true - else: - mesh = existing_mesh - - var material: Material = null - if not material_path.is_empty(): - var material_path_err = McpPathValidator.loadable_error(material_path, "material_path") - if material_path_err != null: - return material_path_err - if not ResourceLoader.exists(material_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path) - var loaded_mat := ResourceLoader.load(material_path) - if not (loaded_mat is Material): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path) - material = loaded_mat - - var old_draw_passes: int = int(node.draw_passes) - var new_draw_passes: int = max(old_draw_passes, pass_idx) - var old_value = existing_mesh # Null if draw_passes < pass_idx - var old_material: Material = null - if material != null: - old_material = node.material_override - - _undo_redo.create_action("MCP: Set %s.draw_pass_%d" % [node.name, pass_idx]) - # Grow draw_passes first so draw_pass_N property exists before we set it. - if new_draw_passes != old_draw_passes: - _undo_redo.add_do_property(node, "draw_passes", new_draw_passes) - _undo_redo.add_undo_property(node, "draw_passes", old_draw_passes) - if not mesh_path.is_empty() or mesh_created: - _undo_redo.add_do_property(node, property_name, mesh) - _undo_redo.add_undo_property(node, property_name, old_value) - if mesh_created: - _undo_redo.add_do_reference(mesh) - if material != null: - _undo_redo.add_do_property(node, "material_override", material) - _undo_redo.add_undo_property(node, "material_override", old_material) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "pass": pass_idx, - "mesh_path": mesh_path, - "mesh_class": mesh.get_class() if mesh else "", - "material_path": material_path, - "draw_pass_mesh_created": mesh_created, - "draw_passes_grown": new_draw_passes != old_draw_passes, - "undoable": true, - } - } - - -func _set_draw_pass_cpu_3d(node: CPUParticles3D, node_path: String, mesh_path: String, material_path: String) -> Dictionary: - if mesh_path.is_empty() and material_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "CPUParticles3D requires mesh or material param") - - var mesh: Mesh = node.mesh - var old_mesh: Mesh = mesh - if not mesh_path.is_empty(): - var mesh_path_err = McpPathValidator.loadable_error(mesh_path, "mesh_path") - if mesh_path_err != null: - return mesh_path_err - if not ResourceLoader.exists(mesh_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Mesh not found: %s" % mesh_path) - var loaded := ResourceLoader.load(mesh_path) - if not (loaded is Mesh): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Mesh" % mesh_path) - mesh = loaded - - var material: Material = null - var old_material: Material = node.material_override - if not material_path.is_empty(): - var material_path_err = McpPathValidator.loadable_error(material_path, "material_path") - if material_path_err != null: - return material_path_err - if not ResourceLoader.exists(material_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Material not found: %s" % material_path) - var loaded_mat := ResourceLoader.load(material_path) - if not (loaded_mat is Material): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Material" % material_path) - material = loaded_mat - - _undo_redo.create_action("MCP: Set CPU particle draw on %s" % node.name) - if not mesh_path.is_empty(): - _undo_redo.add_do_property(node, "mesh", mesh) - _undo_redo.add_undo_property(node, "mesh", old_mesh) - if material != null: - _undo_redo.add_do_property(node, "material_override", material) - _undo_redo.add_undo_property(node, "material_override", old_material) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "mesh_path": mesh_path, - "material_path": material_path, - "draw_pass_mesh_created": false, - "undoable": true, - } - } - - -func _set_draw_pass_2d(node: Node, node_path: String, texture_path: String) -> Dictionary: - if texture_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "2D particles require texture param") - var texture_path_err = McpPathValidator.loadable_error(texture_path, "texture_path") - if texture_path_err != null: - return texture_path_err - if not ResourceLoader.exists(texture_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path) - var tex := ResourceLoader.load(texture_path) - if not (tex is Texture2D): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path) - - var old_texture: Texture2D = node.get("texture") - - _undo_redo.create_action("MCP: Set 2D particle texture on %s" % node.name) - _undo_redo.add_do_property(node, "texture", tex) - _undo_redo.add_undo_property(node, "texture", old_texture) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "texture_path": texture_path, - "undoable": true, - } - } - - -# ============================================================================ -# particle_restart -# ============================================================================ - -func restart_particle(params: Dictionary) -> Dictionary: - var resolved := _resolve_particle(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - if node.has_method("restart"): - node.restart() - return { - "data": { - "path": node_path, - "undoable": false, - "reason": "Restart is a runtime operation, not tracked in undo history", - } - } - - -# ============================================================================ -# particle_get -# ============================================================================ - -func get_particle(params: Dictionary) -> Dictionary: - var resolved := _resolve_particle(params) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - - var type_str := "" - for key in _VALID_TYPES: - if node.get_class() == _VALID_TYPES[key]: - type_str = key - break - - var main_values: Dictionary = {} - var node_prop_names := _property_names(node) - for key in _MAIN_KEYS: - if node_prop_names.has(key): - main_values[key] = ParticleValues.serialize(node.get(key)) - - var process_data: Dictionary = {} - if node is GPUParticles3D or node is GPUParticles2D: - var mat: ParticleProcessMaterial = node.process_material as ParticleProcessMaterial - if mat != null: - var process_props: Dictionary = {} - for prop in mat.get_property_list(): - var usage: int = prop.get("usage", 0) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - var v = mat.get(prop.name) - if v == null: - continue - process_props[prop.name] = ParticleValues.serialize(v) - process_data = { - "class": "ParticleProcessMaterial", - "properties": process_props, - } - - var draw_passes: Array[Dictionary] = [] - if node is GPUParticles3D: - var active_draw_pass_count: int = min(int(node.draw_passes), 4) - for i in range(1, active_draw_pass_count + 1): - var prop_name := "draw_pass_%d" % i - var m: Mesh = node.get(prop_name) as Mesh - draw_passes.append({ - "pass": i, - "mesh_class": m.get_class() if m != null else "", - }) - - var texture_path := "" - if node is GPUParticles2D or node is CPUParticles2D: - var t: Texture2D = node.get("texture") - if t != null: - texture_path = t.resource_path - - return { - "data": { - "path": node_path, - "type": type_str, - "class": node.get_class(), - "main": main_values, - "process": process_data, - "draw_passes": draw_passes, - "texture_path": texture_path, - } - } - - -# ============================================================================ -# particle_apply_preset -# ============================================================================ - -func apply_preset(params: Dictionary) -> Dictionary: - var preset_name: String = params.get("preset", "") - if preset_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") - - var overrides: Dictionary = params.get("overrides", {}) - var blueprint = ParticlePresets.build(preset_name, overrides) - if blueprint == null: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(ParticlePresets.list())] - ) - - var parent_path: String = params.get("parent_path", "") - var node_name: String = params.get("name", "") - var type_str: String = params.get("type", "gpu_3d") - if node_name.is_empty(): - node_name = preset_name.capitalize() - if not _VALID_TYPES.has(type_str): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid particle type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] - ) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent: Node = scene_root - if not parent_path.is_empty(): - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - var node := _instantiate_particle(type_str) - node.name = node_name - - var is_gpu := type_str == "gpu_3d" or type_str == "gpu_2d" - var is_3d := type_str == "gpu_3d" or type_str == "cpu_3d" - - var process_mat: ParticleProcessMaterial = null - var process_material_created := false - if is_gpu: - process_mat = ParticleProcessMaterial.new() - process_material_created = true - - # User-supplied override keys per group. Preset-blueprint keys may skip - # silently on types they don't apply to (presets are cross-type by - # design); user-requested overrides must apply or error (#770). - var user_keys: Dictionary = blueprint.get("user_keys", {}) - var user_main: Dictionary = user_keys.get("main", {}) - var user_process: Dictionary = user_keys.get("process", {}) - var user_draw: Dictionary = user_keys.get("draw", {}) - - var draw_mesh: Mesh = null - var draw_material: StandardMaterial3D = null - var draw_pass_mesh_created := false - var draw_material_created := false - var applied_draw: Array[String] = [] - var draw_config: Dictionary = blueprint.get("draw", {}) - if type_str == "gpu_3d": - var draw_result := ParticleValues.build_draw_material(draw_config) - if not draw_result.ok: - node.free() - var msg := String(draw_result.error) - if draw_result.get("unknown_key", false): - msg = _draw_key_unsupported_message(String(draw_result.key), type_str) - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) - draw_mesh = QuadMesh.new() - (draw_mesh as QuadMesh).size = Vector2(0.25, 0.25) - draw_material = draw_result.material - (draw_mesh as QuadMesh).material = draw_material - draw_pass_mesh_created = true - draw_material_created = true - for applied_key in draw_result.applied: - applied_draw.append(String(applied_key)) - elif type_str == "gpu_2d": - # GPUParticles2D has no draw-pass material; the one draw override it - # supports is draw.texture → the node's texture property. - for key in user_draw: - if String(key) != "texture": - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - _draw_key_unsupported_message(String(key), type_str) - ) - if user_draw.has("texture"): - var tex_result := _load_draw_texture(draw_config.get("texture")) - if tex_result.has("error"): - node.free() - return tex_result - node.set("texture", tex_result.texture) - applied_draw.append("texture") - else: - # cpu_3d / cpu_2d: no draw support — reject user draw overrides - # instead of dropping them. - for key in user_draw: - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - _draw_key_unsupported_message(String(key), type_str) - ) - - # Pre-apply preset values to in-memory targets (no undo needed; nodes not in tree yet). - var main_values: Dictionary = blueprint.get("main", {}) - var process_values: Dictionary = blueprint.get("process", {}) - var applied_main: Array[String] = [] - var applied_process: Array[String] = [] - - for prop in main_values: - var prop_name := String(prop) - var prop_type := _object_property_type(node, prop_name) - if prop_type == TYPE_NIL: - if user_main.has(prop_name): - var node_class := node.get_class() - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Override main.%s does not apply to type '%s' (no such property on %s)" % [ - prop_name, type_str, node_class - ] - ) - continue # Blueprint key: not all main keys apply to all types. - var coerce_result := ParticleValues.coerce(prop_name, main_values[prop_name], prop_type) - if not coerce_result.ok: - node.free() - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - node.set(prop_name, coerce_result.value) - applied_main.append(prop_name) - - # Apply process: GPU targets the ParticleProcessMaterial; CPU targets the node. - var process_target: Object = process_mat if is_gpu else node - for prop in process_values: - var prop_name := String(prop) - var prop_type := _object_property_type(process_target, prop_name) - if prop_type == TYPE_NIL: - if user_process.has(prop_name): - var target_class := process_target.get_class() - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Override process.%s does not apply to type '%s' (no such property on %s)" % [ - prop_name, type_str, target_class - ] - ) - continue # Blueprint key: preset property doesn't apply to this variant. - var coerce_result := ParticleValues.coerce(prop_name, process_values[prop_name], prop_type) - if not coerce_result.ok: - node.free() - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) - process_target.set(prop_name, coerce_result.value) - applied_process.append(prop_name) - - _undo_redo.create_action("MCP: Apply preset %s" % preset_name) - _undo_redo.add_do_method(parent, "add_child", node, true) - _undo_redo.add_do_method(node, "set_owner", scene_root) - _undo_redo.add_do_reference(node) - if process_mat != null: - _undo_redo.add_do_property(node, "process_material", process_mat) - _undo_redo.add_do_reference(process_mat) - if draw_mesh != null: - _undo_redo.add_do_property(node, "draw_pass_1", draw_mesh) - _undo_redo.add_do_reference(draw_mesh) - if draw_material != null: - _undo_redo.add_do_reference(draw_material) - _undo_redo.add_undo_method(parent, "remove_child", node) - _undo_redo.commit_action() - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "parent_path": McpScenePath.from_node(parent, scene_root), - "name": node_name, - "preset": preset_name, - "type": type_str, - "class": _VALID_TYPES[type_str], - "applied_main": applied_main, - "applied_process": applied_process, - "applied_draw": applied_draw, - "process_material_created": process_material_created, - "draw_pass_mesh_created": draw_pass_mesh_created, - "draw_material_created": draw_material_created, - "is_3d": is_3d, - "undoable": true, - } - } - - -# ============================================================================ -# Helpers -# ============================================================================ - -## Actionable rejection for a user draw override that can't apply to the -## selected particle type: name the key and where it IS supported. -static func _draw_key_unsupported_message(key: String, type_str: String) -> String: - if key == "texture": - return "draw.texture is only supported for gpu_2d (got type '%s')" % type_str - var probe := StandardMaterial3D.new() - if _object_property_type(probe, key) != TYPE_NIL: - return "draw.%s is only supported for gpu_3d (got type '%s')" % [key, type_str] - return ( - "Unknown draw key '%s' (draw overrides configure the gpu_3d " - + "draw-pass StandardMaterial3D; gpu_2d supports only draw.texture)" - ) % key - - -## Load a Texture2D for the gpu_2d draw.texture override. Returns -## {texture: Texture2D} or an error dict (same validation as set_draw_pass). -static func _load_draw_texture(value: Variant) -> Dictionary: - if not (value is String) or String(value).is_empty(): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "draw.texture must be a non-empty res:// path string (got %s)" % type_string(typeof(value)) - ) - var texture_path := String(value) - var texture_path_err = McpPathValidator.loadable_error(texture_path, "draw.texture") - if texture_path_err != null: - return texture_path_err - if not ResourceLoader.exists(texture_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Texture not found: %s" % texture_path) - var tex := ResourceLoader.load(texture_path) - if not (tex is Texture2D): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Texture2D" % texture_path) - return {"texture": tex} - - -static func _instantiate_particle(type_str: String) -> Node: - match type_str: - "gpu_3d": - return GPUParticles3D.new() - "gpu_2d": - return GPUParticles2D.new() - "cpu_3d": - return CPUParticles3D.new() - "cpu_2d": - return CPUParticles2D.new() - return null - - -func _resolve_particle(params: Dictionary) -> Dictionary: - var resolved := McpNodeValidator.resolve_or_error( - params.get("node_path", ""), "node_path", - ) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - var node_path: String = resolved.path - var is_particle := node is GPUParticles3D or node is GPUParticles2D \ - or node is CPUParticles3D or node is CPUParticles2D - if not is_particle: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a particle node (got %s)" % [node_path, node.get_class()] - ) - return {"node": node, "path": node_path} - - -static func _node_property_type(node: Object, name: String) -> int: - return _object_property_type(node, name) - - -static func _object_property_type(obj: Object, name: String) -> int: - if obj == null: - return TYPE_NIL - for prop in obj.get_property_list(): - if prop.name == name: - return int(prop.get("type", TYPE_NIL)) - return TYPE_NIL - - -static func _property_names(obj: Object) -> Dictionary: - var out: Dictionary = {} - if obj == null: - return out - for prop in obj.get_property_list(): - out[prop.name] = true - return out diff --git a/addons/godot_ai/handlers/particle_handler.gd.uid b/addons/godot_ai/handlers/particle_handler.gd.uid deleted file mode 100644 index ead3722..0000000 --- a/addons/godot_ai/handlers/particle_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://byfc0pnyb5qww diff --git a/addons/godot_ai/handlers/particle_presets.gd b/addons/godot_ai/handlers/particle_presets.gd deleted file mode 100644 index 3bbf1f4..0000000 --- a/addons/godot_ai/handlers/particle_presets.gd +++ /dev/null @@ -1,293 +0,0 @@ -@tool -extends RefCounted - -## Curated particle effect blueprints. -## -## Each preset returns {main, process, draw}. The handler applies them -## through the normal write path (one undo action wraps all spawns). - - -## Each preset has {main, process, draw}. `draw` configures the StandardMaterial3D -## attached to the auto-created QuadMesh in draw_pass_1 (GPU 3D only); if -## omitted, the handler falls back to a sensible billboard-particles default. -## `blend_mode: "add"` is what makes fire/magic/explosion glow — without -## additive blending, additively-layered particles just stack to gray. -const _PRESETS := { - "fire": { - "main": { - "amount": 80, - "lifetime": 1.2, - "one_shot": false, - "explosiveness": 0.0, - "preprocess": 0.5, - "local_coords": false, - }, - "process": { - "emission_shape": "sphere", - "emission_sphere_radius": 0.3, - "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, - "spread": 15.0, - "initial_velocity_min": 2.0, - "initial_velocity_max": 4.0, - "gravity": {"x": 0.0, "y": 1.0, "z": 0.0}, # buoyancy - "scale_min": 0.4, - "scale_max": 0.8, - "color_ramp": { - "stops": [ - {"time": 0.0, "color": [1.0, 1.0, 0.9, 1.0]}, - {"time": 0.3, "color": [1.0, 0.6, 0.1, 1.0]}, - {"time": 0.7, "color": [0.8, 0.1, 0.05, 0.7]}, - {"time": 1.0, "color": [0.2, 0.05, 0.05, 0.0]}, - ] - }, - }, - "draw": {"blend_mode": "add"}, - }, - "smoke": { - "main": { - "amount": 40, - "lifetime": 3.0, - "one_shot": false, - "explosiveness": 0.0, - "local_coords": false, - }, - "process": { - "emission_shape": "sphere", - "emission_sphere_radius": 0.4, - "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, - "spread": 20.0, - "initial_velocity_min": 0.5, - "initial_velocity_max": 1.5, - "gravity": {"x": 0.0, "y": 0.2, "z": 0.0}, - "scale_min": 0.6, - "scale_max": 1.4, - "color_ramp": { - "stops": [ - {"time": 0.0, "color": [0.3, 0.3, 0.3, 0.0]}, - {"time": 0.25, "color": [0.35, 0.35, 0.35, 0.7]}, - {"time": 0.75, "color": [0.2, 0.2, 0.2, 0.5]}, - {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, - ] - }, - }, - # Smoke uses regular alpha blending so it darkens the background. - "draw": {"blend_mode": "mix"}, - }, - "spark_burst": { - "main": { - "amount": 60, - "lifetime": 0.8, - "one_shot": true, - "explosiveness": 1.0, - "local_coords": false, - }, - "process": { - "emission_shape": "point", - "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, - "spread": 180.0, - "initial_velocity_min": 5.0, - "initial_velocity_max": 12.0, - "gravity": {"x": 0.0, "y": -9.8, "z": 0.0}, - "scale_min": 0.05, - "scale_max": 0.12, - "color": {"r": 1.0, "g": 0.9, "b": 0.2, "a": 1.0}, - }, - "draw": { - "blend_mode": "add", - "emission_enabled": true, - "emission": {"r": 1.0, "g": 0.8, "b": 0.2, "a": 1.0}, - "emission_energy_multiplier": 2.0, - }, - }, - "magic_swirl": { - "main": { - "amount": 120, - "lifetime": 2.0, - "one_shot": false, - "explosiveness": 0.0, - "local_coords": false, - }, - "process": { - "emission_shape": "ring", - "emission_ring_radius": 0.8, - "emission_ring_inner_radius": 0.6, - "emission_ring_height": 0.0, - "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, - "spread": 30.0, - "initial_velocity_min": 1.0, - "initial_velocity_max": 2.0, - "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, - "angular_velocity_min": 90.0, - "angular_velocity_max": 180.0, - "scale_min": 0.1, - "scale_max": 0.2, - "color_ramp": { - "stops": [ - {"time": 0.0, "color": [0.4, 0.9, 1.0, 0.0]}, - {"time": 0.3, "color": [0.5, 0.7, 1.0, 1.0]}, - {"time": 0.7, "color": [1.0, 0.4, 0.9, 1.0]}, - {"time": 1.0, "color": [0.8, 0.2, 0.7, 0.0]}, - ] - }, - }, - "draw": {"blend_mode": "add"}, - }, - "rain": { - "main": { - "amount": 500, - "lifetime": 1.5, - "one_shot": false, - "explosiveness": 0.0, - "local_coords": false, - }, - "process": { - "emission_shape": "box", - "emission_box_extents": {"x": 10.0, "y": 0.1, "z": 10.0}, - "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, - "spread": 2.0, - "initial_velocity_min": 15.0, - "initial_velocity_max": 18.0, - "gravity": {"x": 0.0, "y": -2.0, "z": 0.0}, - "scale_min": 0.02, - "scale_max": 0.04, - "color": {"r": 0.7, "g": 0.85, "b": 1.0, "a": 0.5}, - }, - # Rain drops render as streaks; fixed_y aligns them vertically. - "draw": {"billboard_mode": "fixed_y", "blend_mode": "mix"}, - }, - "explosion": { - "main": { - "amount": 200, - "lifetime": 1.5, - "one_shot": true, - "explosiveness": 1.0, - "local_coords": false, - }, - "process": { - "emission_shape": "sphere", - "emission_sphere_radius": 0.1, - "direction": {"x": 0.0, "y": 1.0, "z": 0.0}, - "spread": 180.0, - "initial_velocity_min": 6.0, - "initial_velocity_max": 10.0, - "gravity": {"x": 0.0, "y": -4.0, "z": 0.0}, - "scale_min": 0.3, - "scale_max": 0.7, - "color_ramp": { - "stops": [ - {"time": 0.0, "color": [1.0, 0.95, 0.5, 1.0]}, - {"time": 0.2, "color": [1.0, 0.4, 0.1, 1.0]}, - {"time": 0.7, "color": [0.3, 0.15, 0.1, 0.7]}, - {"time": 1.0, "color": [0.1, 0.1, 0.1, 0.0]}, - ] - }, - }, - "draw": { - "blend_mode": "add", - "emission_enabled": true, - "emission": {"r": 1.0, "g": 0.5, "b": 0.1, "a": 1.0}, - "emission_energy_multiplier": 1.5, - }, - }, - "lightning": { - # Short, bright, electric-blue spark burst. One-shot — call - # particle_restart to re-trigger. Pairs well with a scene-wide flash. - "main": { - "amount": 40, - "lifetime": 0.35, - "one_shot": true, - "explosiveness": 1.0, - "local_coords": false, - }, - "process": { - "emission_shape": "box", - "emission_box_extents": {"x": 0.1, "y": 1.5, "z": 0.1}, - "direction": {"x": 0.0, "y": -1.0, "z": 0.0}, - "spread": 8.0, - "initial_velocity_min": 18.0, - "initial_velocity_max": 28.0, - "gravity": {"x": 0.0, "y": 0.0, "z": 0.0}, - "scale_min": 0.08, - "scale_max": 0.18, - "color_ramp": { - "stops": [ - {"time": 0.0, "color": [1.0, 1.0, 1.0, 1.0]}, - {"time": 0.2, "color": [0.6, 0.85, 1.0, 1.0]}, - {"time": 0.6, "color": [0.3, 0.5, 1.0, 0.9]}, - {"time": 1.0, "color": [0.1, 0.2, 0.7, 0.0]}, - ] - }, - }, - "draw": { - "blend_mode": "add", - "emission_enabled": true, - "emission": {"r": 0.5, "g": 0.8, "b": 1.0, "a": 1.0}, - "emission_energy_multiplier": 4.0, - }, - }, -} - - -static func list() -> Array: - return _PRESETS.keys() - - -static func has(preset_name: String) -> bool: - return _PRESETS.has(preset_name) - - -## Return deep-copied {main, process, draw} blueprint with overrides merged in, -## plus "user_keys" ({main/process/draw: {key: true}}) recording which keys the -## caller supplied. The handler needs that distinction: preset-blueprint keys -## may skip silently on types they don't apply to (presets are cross-type by -## design), but user-requested overrides must apply or error (#770). -## Overrides may include top-level "main" / "process" / "draw" dicts, or bare -## keys routed to main (_MAIN_KEYS) or process — draw keys must be nested. -static func build(preset_name: String, overrides: Dictionary) -> Variant: - if not _PRESETS.has(preset_name): - return null - var entry: Dictionary = _PRESETS[preset_name].duplicate(true) - var main: Dictionary = entry.get("main", {}) - var process: Dictionary = entry.get("process", {}) - var draw: Dictionary = entry.get("draw", {}) - var user_keys := {"main": {}, "process": {}, "draw": {}} - for key in overrides: - var val = overrides[key] - if key == "main" and val is Dictionary: - for k in val: - main[k] = val[k] - user_keys.main[String(k)] = true - elif key == "process" and val is Dictionary: - for k in val: - process[k] = val[k] - user_keys.process[String(k)] = true - elif key == "draw" and val is Dictionary: - for k in val: - draw[k] = val[k] - user_keys.draw[String(k)] = true - elif _MAIN_KEYS.has(key): - main[key] = val - user_keys.main[String(key)] = true - else: - process[key] = val - user_keys.process[String(key)] = true - entry["main"] = main - entry["process"] = process - entry["draw"] = draw - entry["user_keys"] = user_keys - return entry - - -const _MAIN_KEYS := { - "amount": true, - "lifetime": true, - "one_shot": true, - "explosiveness": true, - "preprocess": true, - "speed_scale": true, - "randomness": true, - "fixed_fps": true, - "emitting": true, - "local_coords": true, - "interp_to_end": true, -} diff --git a/addons/godot_ai/handlers/particle_presets.gd.uid b/addons/godot_ai/handlers/particle_presets.gd.uid deleted file mode 100644 index 7c1aac9..0000000 --- a/addons/godot_ai/handlers/particle_presets.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bss2ccpmsxo4p diff --git a/addons/godot_ai/handlers/particle_values.gd b/addons/godot_ai/handlers/particle_values.gd deleted file mode 100644 index 66782f6..0000000 --- a/addons/godot_ai/handlers/particle_values.gd +++ /dev/null @@ -1,246 +0,0 @@ -@tool -extends RefCounted - -## Value coercion + gradient/curve builders for particle properties. - -const MaterialValues := preload("res://addons/godot_ai/handlers/material_values.gd") - -const _EMISSION_SHAPES := { - "point": ParticleProcessMaterial.EMISSION_SHAPE_POINT, - "sphere": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, - "sphere_surface": ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE, - "box": ParticleProcessMaterial.EMISSION_SHAPE_BOX, - "points": ParticleProcessMaterial.EMISSION_SHAPE_POINTS, - "directed_points": ParticleProcessMaterial.EMISSION_SHAPE_DIRECTED_POINTS, - "ring": ParticleProcessMaterial.EMISSION_SHAPE_RING, -} - - -## Resolve a shape name to the int enum, or return null. -static func resolve_emission_shape(value: Variant) -> Variant: - if value is int: - return value - if value is float: - return int(value) - if value is String: - var key := String(value).to_lower() - if _EMISSION_SHAPES.has(key): - return _EMISSION_SHAPES[key] - return null - - -static func emission_shape_names() -> Array: - return _EMISSION_SHAPES.keys() - - -## Build a Gradient from {stops: [{time, color}]} dict. -static func build_gradient(value: Variant) -> Variant: - if value is Gradient: - return value - if value is GradientTexture1D: - return (value as GradientTexture1D).gradient - if not (value is Dictionary): - return null - var d: Dictionary = value - if not d.has("stops"): - return null - var stops_array = d.get("stops") - if not (stops_array is Array): - return null - var offsets := PackedFloat32Array() - var colors := PackedColorArray() - for stop in stops_array: - if not (stop is Dictionary): - return null - offsets.append(float(stop.get("time", 0.0))) - var c = MaterialValues.parse_color(stop.get("color")) - if c == null: - return null - colors.append(c) - var grad := Gradient.new() - grad.offsets = offsets - grad.colors = colors - return grad - - -## Build a GradientTexture1D wrapping a Gradient (what ParticleProcessMaterial.color_ramp wants). -static func build_gradient_texture(value: Variant) -> Variant: - if value is GradientTexture1D: - return value - var grad = build_gradient(value) - if grad == null: - return null - var tex := GradientTexture1D.new() - tex.gradient = grad - return tex - - -## Build a Curve from [{time, value}] or {points: [...]} (float-over-time). -static func build_curve(value: Variant) -> Variant: - if value is Curve: - return value - if value is CurveTexture: - return (value as CurveTexture).curve - var points_array: Variant = null - if value is Array: - points_array = value - elif value is Dictionary and value.has("points"): - points_array = value["points"] - if not (points_array is Array): - return null - var curve := Curve.new() - for pt in points_array: - if not (pt is Dictionary): - return null - var t := float(pt.get("time", 0.0)) - var v := float(pt.get("value", 0.0)) - curve.add_point(Vector2(t, v)) - return curve - - -static func build_curve_texture(value: Variant) -> Variant: - if value is CurveTexture: - return value - var curve = build_curve(value) - if curve == null: - return null - var tex := CurveTexture.new() - tex.curve = curve - return tex - - -## Coerce a particle property value to the appropriate type. -## Handles: Vector3/gravity/direction, Color, float, int, bool, enum strings. -## For color_ramp returns a GradientTexture1D; for *_curve returns CurveTexture. -static func coerce(property: String, value: Variant, target_type: int) -> Dictionary: - # Special-cased properties. - if property == "emission_shape": - var shape = resolve_emission_shape(value) - if shape == null: - return { - "ok": false, - "error": "Invalid emission_shape '%s'. Valid: %s" % [ - value, ", ".join(emission_shape_names()) - ], - } - return {"ok": true, "value": int(shape)} - - if property == "color_ramp" or property == "color_initial_ramp": - var tex = build_gradient_texture(value) - if tex == null: - return {"ok": false, "error": "Invalid gradient for %s (expected {stops: [{time, color}]})" % property} - return {"ok": true, "value": tex} - - if property == "color" and value is Dictionary and not (value as Dictionary).has("stops"): - # color is a single Color, not a ramp. - var c = MaterialValues.parse_color(value) - if c == null: - return {"ok": false, "error": "Invalid color"} - return {"ok": true, "value": c} - - if property.ends_with("_curve"): - var tex = build_curve_texture(value) - if tex == null: - return {"ok": false, "error": "Invalid curve for %s (expected [{time, value}])" % property} - return {"ok": true, "value": tex} - - # Fall through to the material coercer (handles Color/Vec3/Vec2/float/int/bool/enum). - return MaterialValues.coerce_material_value(property, value, target_type) - - -## Build a StandardMaterial3D suitable for GPUParticles3D draw-pass rendering. -## -## Godot's default Mesh has no material, which means ParticleProcessMaterial's -## color_ramp (which drives the COLOR varying) gets ignored and particles -## render as flat white squares that don't face the camera. A correct default -## must have vertex_color_use_as_albedo=true, billboard=particles, unshaded, -## and alpha transparency so the gradient actually modulates the pixels. -## -## Config is an optional dict that overrides individual properties. Supported -## keys match BaseMaterial3D properties (plus enum-by-name via MaterialValues): -## blend_mode: "mix" | "add" | "sub" | "mul" -## transparency: "disabled" | "alpha" | "alpha_scissor" | "alpha_hash" | "alpha_depth_pre_pass" -## shading_mode: "unshaded" | "per_pixel" | "per_vertex" -## billboard_mode: "disabled" | "enabled" | "fixed_y" | "particles" -## vertex_color_use_as_albedo: bool -## emission_enabled: bool -## emission: Color -## emission_energy_multiplier: float -## albedo_color: Color -## albedo_texture: res:// path -## (anything else accepted by BaseMaterial3D.set()) -## -## Returns {ok: true, material: StandardMaterial3D, applied: Array[String]}, -## or {ok: false, key, error, unknown_key} when a config key is not a -## StandardMaterial3D property or its value fails coercion — draw config must -## not disappear silently (#770). -static func build_draw_material(config: Dictionary) -> Dictionary: - var mat := StandardMaterial3D.new() - # Sensible defaults for particle draw-pass rendering. - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.vertex_color_use_as_albedo = true - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES - mat.billboard_keep_scale = true - # Configure from dict overrides. - var applied: Array[String] = [] - for key in config: - var prop_name := String(key) - var prop_type := _object_property_type(mat, prop_name) - if prop_type == TYPE_NIL: - return { - "ok": false, - "key": prop_name, - "unknown_key": true, - "error": "Unknown draw key '%s' (not a StandardMaterial3D property)" % prop_name, - } - var coerce_result := MaterialValues.coerce_material_value( - prop_name, config[prop_name], prop_type - ) - if not coerce_result.ok: - return { - "ok": false, - "key": prop_name, - "unknown_key": false, - "error": "draw.%s: %s" % [prop_name, coerce_result.error], - } - mat.set(prop_name, coerce_result.value) - applied.append(prop_name) - return {"ok": true, "material": mat, "applied": applied} - - -static func _object_property_type(obj: Object, name: String) -> int: - if obj == null: - return TYPE_NIL - for prop in obj.get_property_list(): - if prop.name == name: - return int(prop.get("type", TYPE_NIL)) - return TYPE_NIL - - -## Serialize for response. -static func serialize(value: Variant) -> Variant: - if value == null: - return null - if value is GradientTexture1D: - var grad := (value as GradientTexture1D).gradient - if grad == null: - return {"type": "GradientTexture1D", "stops": []} - var stops: Array = [] - for i in grad.offsets.size(): - var c: Color = grad.colors[i] - stops.append({ - "time": grad.offsets[i], - "color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a}, - }) - return {"type": "GradientTexture1D", "stops": stops} - if value is CurveTexture: - var curve := (value as CurveTexture).curve - if curve == null: - return {"type": "CurveTexture", "points": []} - var points: Array = [] - for i in curve.get_point_count(): - var p := curve.get_point_position(i) - points.append({"time": p.x, "value": p.y}) - return {"type": "CurveTexture", "points": points} - return MaterialValues.serialize_value(value) diff --git a/addons/godot_ai/handlers/particle_values.gd.uid b/addons/godot_ai/handlers/particle_values.gd.uid deleted file mode 100644 index 71f6adc..0000000 --- a/addons/godot_ai/handlers/particle_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bnnnjq06dmclc diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd deleted file mode 100644 index dc5156c..0000000 --- a/addons/godot_ai/handlers/physics_shape_handler.gd +++ /dev/null @@ -1,338 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Sizes a CollisionShape2D/CollisionShape3D to match a visual sibling's -## bounds. Auto-creates the concrete Shape subclass when the slot is empty -## or the requested type differs — bundling creation and sizing in a single -## undo action. -## -## Shape type defaults: Box for 3D, Rectangle for 2D. - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -const _SHAPE_3D_CLASSES := { - "box": "BoxShape3D", - "sphere": "SphereShape3D", - "capsule": "CapsuleShape3D", - "cylinder": "CylinderShape3D", -} - -const _SHAPE_2D_CLASSES := { - "rectangle": "RectangleShape2D", - "circle": "CircleShape2D", - "capsule": "CapsuleShape2D", -} - - -func autofit(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var scene_root: Node = _resolved.scene_root - - var is_3d := node is CollisionShape3D - var is_2d := node is CollisionShape2D - if not (is_3d or is_2d): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node at %s is %s — must be CollisionShape3D or CollisionShape2D" % [node_path, node.get_class()] - ) - - var source_path: String = params.get("source_path", "") - var source: Node = null - if source_path.is_empty(): - var search := _find_bounds_visual(node, is_3d, scene_root) - if search.has("error"): - return search.error - source = search.source - else: - source = McpScenePath.resolve(source_path, scene_root) - if source == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, - "source_path: %s" % McpScenePath.format_node_error(source_path, scene_root)) - - var shape_type: String = params.get("shape_type", "box" if is_3d else "rectangle") - var type_map := _SHAPE_3D_CLASSES if is_3d else _SHAPE_2D_CLASSES - # Accept either the short form ("box") or the matching Godot class name - # ("BoxShape3D") — every other tool in the server takes class names, and - # resource_get_info(type="Shape3D") surfaces concrete_subclasses by class. - if not type_map.has(shape_type): - for short_form in type_map: - if type_map[short_form] == shape_type: - shape_type = short_form - break - if not type_map.has(shape_type): - var valid_pairs: Array[String] = [] - for short_form in type_map: - valid_pairs.append("%s (%s)" % [short_form, type_map[short_form]]) - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid shape_type '%s' for %s. Valid: %s" % [shape_type, node.get_class(), ", ".join(valid_pairs)] - ) - var shape_class: String = type_map[shape_type] - - # Measure the visual. - var bounds := _measure_bounds(source, is_3d) - if bounds.has("error"): - return bounds.error - - # Reuse the existing shape if it already matches the requested class; - # otherwise create a fresh one of the right type in the same undo action. - var existing_shape: Shape3D = null - var existing_shape_2d: Shape2D = null - if is_3d: - existing_shape = node.shape - else: - existing_shape_2d = node.shape - - var needs_new_shape := false - if is_3d: - needs_new_shape = existing_shape == null or existing_shape.get_class() != shape_class - else: - needs_new_shape = existing_shape_2d == null or existing_shape_2d.get_class() != shape_class - - var target_shape: Resource - if needs_new_shape: - var instance := ClassDB.instantiate(shape_class) - if instance == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % shape_class) - target_shape = instance - else: - target_shape = existing_shape if is_3d else existing_shape_2d - - # Compute and apply size. - var size_info := _apply_shape_size(target_shape, shape_type, bounds, is_3d) - var old_shape = existing_shape if is_3d else existing_shape_2d - - _undo_redo.create_action("MCP: Autofit %s on %s" % [shape_class, node.name]) - if needs_new_shape: - _undo_redo.add_do_property(node, "shape", target_shape) - _undo_redo.add_undo_property(node, "shape", old_shape) - _undo_redo.add_do_reference(target_shape) - else: - # Existing shape stays, but its size changes — snapshot size for undo. - for key in size_info.applied: - var new_val = target_shape.get(key) - var old_val = size_info.previous.get(key) - _undo_redo.add_do_property(target_shape, key, new_val) - _undo_redo.add_undo_property(target_shape, key, old_val) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "source_path": McpScenePath.from_node(source, scene_root) if source_path.is_empty() else source_path, - "shape_type": shape_type, - "shape_class": shape_class, - "shape_created": needs_new_shape, - "size": size_info.size_response, - "undoable": true, - } - } - - -## Returns `{source: Node}` on success, `{error: }` on failure. -## Ambiguous tier-2 matches put candidate scene paths in -## `error.data.candidates` so callers can pick one explicitly. -static func _find_bounds_visual(collision_node: Node, is_3d: bool, scene_root: Node) -> Dictionary: - var parent := collision_node.get_parent() - if parent == null: - return {"error": _no_visual_error(is_3d)} - - # Tier 1: direct siblings of the collision shape. Uses the broad - # VisualInstance3D filter for backwards compatibility — callers who put - # the visual directly next to the collision picked it on purpose. - var siblings := _measurable_visuals(parent.get_children(), collision_node, is_3d, false) - if not siblings.is_empty(): - return {"source": siblings[0]} - - # Tier 2: parent siblings (uncles). Tighten the filter to - # GeometryInstance3D so we don't auto-pick a Light3D / DirectionalLight3D - # as a collision source. Auto-pick only when unambiguous; surface - # multiple candidates so the agent chooses. - var grandparent := parent.get_parent() - if grandparent == null: - return {"error": _no_visual_error(is_3d)} - var uncles := _measurable_visuals(grandparent.get_children(), parent, is_3d, true) - if uncles.size() == 1: - return {"source": uncles[0]} - if uncles.size() > 1: - var paths: Array[String] = [] - for n in uncles: - paths.append(McpScenePath.from_node(n, scene_root)) - var msg := "Multiple visual candidates near %s — pass source_path explicitly. Candidates: %s" % [ - McpScenePath.from_node(collision_node, scene_root), - ", ".join(paths), - ] - var err := ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) - err["error"]["data"] = {"candidates": paths} - return {"error": err} - return {"error": _no_visual_error(is_3d)} - - -## Filter `nodes` for ones we can measure as a collision source. When -## `strict` is true (tier 2 / uncles) only GeometryInstance3D counts in 3D — -## avoids picking up lights as accidental sources. 2D filter is already -## narrow enough that strictness doesn't change behavior. -static func _measurable_visuals(nodes: Array, exclude: Node, is_3d: bool, strict: bool) -> Array[Node]: - var out: Array[Node] = [] - for n in nodes: - if n == exclude: - continue - if is_3d: - if strict: - if n is GeometryInstance3D: - out.append(n) - elif n is VisualInstance3D: - out.append(n) - elif n is Sprite2D or n is TextureRect: - out.append(n) - return out - - -static func _no_visual_error(is_3d: bool) -> Dictionary: - var hint := "MeshInstance3D" if is_3d else "Sprite2D" - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "No visual found near collision shape — searched siblings and parent-siblings. Pass source_path explicitly (e.g. a %s)" % hint, - ) - - -## Measure the visual bounds of `source`. Returns {aabb: AABB} for 3D or -## {rect: Rect2} for 2D on success, or {error: ...} on failure. -## Bounds are returned in world-ish size (local extents scaled by the source -## node's own transform scale) so a MeshInstance3D at scale=(2,2,2) gives an -## 8× volume collider, not a unit collider. -static func _measure_bounds(source: Node, is_3d: bool) -> Dictionary: - if is_3d: - if source is VisualInstance3D: - var aabb: AABB = (source as VisualInstance3D).get_aabb() - # get_aabb() is local-space; pre-multiply by the source's scale - # so the collider tracks what you actually see in the viewport. - var scale_3d: Vector3 = (source as Node3D).transform.basis.get_scale() - aabb.position = aabb.position * scale_3d - aabb.size = aabb.size * scale_3d - return {"aabb": aabb} - return {"error": ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Source %s has no measurable 3D bounds (must be VisualInstance3D subclass)" % source.get_class() - )} - # 2D - if source is Sprite2D: - var s: Sprite2D = source - var srect: Rect2 = s.get_rect() - # get_rect() reports the local texture rect and ignores scale. - srect.position = srect.position * s.scale - srect.size = srect.size * s.scale - return {"rect": srect} - if source is TextureRect: - var tr: TextureRect = source - # tr.size is the Control's laid-out size, which is Vector2.ZERO - # before the first layout pass (e.g. just after the node was created - # via MCP). Fall back to the texture's own size when that happens, - # so autofit doesn't silently produce a zero-sized shape. - var tr_size: Vector2 = tr.size - if tr_size.is_zero_approx(): - if tr.texture != null: - tr_size = tr.texture.get_size() * tr.scale - else: - return {"error": ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "TextureRect at %s has zero layout size and no texture to fall back to — autofit would produce a zero-sized shape" % source.name - )} - return {"rect": Rect2(Vector2.ZERO, tr_size)} - return {"error": ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Source %s has no measurable 2D bounds (must be Sprite2D or TextureRect)" % source.get_class() - )} - - -## Apply size to `shape` based on `bounds` and the requested shape_type. -## Returns {applied: [property_names], previous: {name: old_value}, size_response: dict}. -static func _apply_shape_size(shape: Resource, shape_type: String, bounds: Dictionary, is_3d: bool) -> Dictionary: - var applied: Array[String] = [] - var previous := {} - var size_response := {} - - if is_3d: - var aabb: AABB = bounds.aabb - var size_v: Vector3 = aabb.size - match shape_type: - "box": - previous["size"] = shape.get("size") - (shape as BoxShape3D).size = size_v - applied.append("size") - size_response = {"x": size_v.x, "y": size_v.y, "z": size_v.z} - "sphere": - var r := maxf(maxf(size_v.x, size_v.y), size_v.z) * 0.5 - previous["radius"] = shape.get("radius") - (shape as SphereShape3D).radius = r - applied.append("radius") - size_response = {"radius": r} - "capsule": - var cap := shape as CapsuleShape3D - var r2 := maxf(size_v.x, size_v.z) * 0.5 - var h := size_v.y - previous["radius"] = cap.radius - previous["height"] = cap.height - # CapsuleShape3D enforces height >= 2*radius and silently - # clamps setters that would violate it. Read back the - # stored values so the response reflects reality. - cap.radius = r2 - cap.height = h - applied.append("radius") - applied.append("height") - size_response = {"radius": cap.radius, "height": cap.height} - "cylinder": - var cyl := shape as CylinderShape3D - var r3 := maxf(size_v.x, size_v.z) * 0.5 - var ch := size_v.y - previous["radius"] = cyl.radius - previous["height"] = cyl.height - cyl.radius = r3 - cyl.height = ch - applied.append("radius") - applied.append("height") - size_response = {"radius": cyl.radius, "height": cyl.height} - else: - var rect: Rect2 = bounds.rect - var sz: Vector2 = rect.size - match shape_type: - "rectangle": - previous["size"] = shape.get("size") - (shape as RectangleShape2D).size = sz - applied.append("size") - size_response = {"x": sz.x, "y": sz.y} - "circle": - var cr := maxf(sz.x, sz.y) * 0.5 - previous["radius"] = shape.get("radius") - (shape as CircleShape2D).radius = cr - applied.append("radius") - size_response = {"radius": cr} - "capsule": - var cap2 := shape as CapsuleShape2D - var cr2 := sz.x * 0.5 - var ch2 := sz.y - previous["radius"] = cap2.radius - previous["height"] = cap2.height - # CapsuleShape2D has the same height >= 2*radius invariant - # as its 3D counterpart; read back what Godot actually kept. - cap2.radius = cr2 - cap2.height = ch2 - applied.append("radius") - applied.append("height") - size_response = {"radius": cap2.radius, "height": cap2.height} - - return {"applied": applied, "previous": previous, "size_response": size_response} diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd.uid b/addons/godot_ai/handlers/physics_shape_handler.gd.uid deleted file mode 100644 index 09acb4a..0000000 --- a/addons/godot_ai/handlers/physics_shape_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cdg8kthqla1cj diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd deleted file mode 100644 index eb7d36f..0000000 --- a/addons/godot_ai/handlers/project_handler.gd +++ /dev/null @@ -1,572 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles project settings and filesystem search commands. - -const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") -const RUN_READY_WAIT_SEC := 3.0 - -## ProjectSettings keys that decide what the engine EXECUTES at project -## startup. `McpPathValidator._reject_sensitive_write` already refuses direct -## writes to `res://project.godot`, `res://override.cfg` and `res://.godot/` -## for exactly this reason — but `set_project_setting` writes that same -## manifest through the settings API, so without this list the guard is -## trivially side-steppable: -## -## settings_set key="autoload/Boot" value="*res://../../evil.gd" -## -## would land arbitrary code on the next project open, and a `project.godot` -## diff is easy to miss in review. Autoloads have a validated route of their -## own (`autoload_handler.add_autoload`, which runs the path through -## `McpPathValidator`); the rest are refused outright because there is no -## legitimate agent workflow for repointing the engine's startup execution. -## -## Deliberately NARROW. A denylist that over-blocks turns settings_set into a -## tool agents can't use, so only keys that actually carry code (or a command -## line) are listed — not their whole section. `application/run/` is an exact -## entry for `main_scene` precisely because its siblings (`max_fps`, -## `low_processor_mode`, …) are inert and must stay writable; likewise -## `application/boot_splash/image` is data, not code, and is NOT blocked. -## -## Prefix entries match the key and anything beneath it, and are used only -## where every key under the prefix is code-bearing. Exact entries match only -## themselves. Comparison is case-folded — ProjectSettings keys are -## case-sensitive, but a case variant that Godot would reject is still a -## clearer error coming from here than from a half-applied save. -const STARTUP_EXECUTION_KEY_PREFIXES: Array[String] = [ - "autoload/", ## every key under it is a script path - "editor_plugins/", ## enabled-plugin list; each entry is loaded as code -] -const STARTUP_EXECUTION_KEYS_EXACT: Array[String] = [ - "application/run/main_scene", ## the scene the game boots into - "editor/script/templates_search_path", ## where the editor loads templates from - "editor/run/main_run_args", ## command line for the run -] - - -## Returns "" when `key` may be written via set_project_setting, or a -## human-readable refusal reason otherwise. Static so it is unit-testable -## without instancing the handler. -static func startup_execution_key_refusal(key: String) -> String: - var lowered := key.strip_edges().to_lower() - for prefix in STARTUP_EXECUTION_KEY_PREFIXES: - if lowered.begins_with(prefix): - if prefix == "autoload/": - return ( - "Refusing to set '%s' — autoloads run code at project startup. " % key - + "Use autoload_manage(op='add'), which validates the script path." - ) - return ( - "Refusing to set '%s' — keys under '%s' are loaded as code " % [key, prefix] - + "at project startup." - ) - for exact in STARTUP_EXECUTION_KEYS_EXACT: - if lowered == exact: - return ( - "Refusing to set '%s' — this key controls what the engine loads " % key - + "or executes at project startup." - ) - return "" - -var _connection: McpConnection -var _debugger_plugin -var _editor_log_buffer - - -func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void: - _connection = connection - _debugger_plugin = debugger_plugin - _editor_log_buffer = editor_log_buffer - - -func get_project_setting(params: Dictionary) -> Dictionary: - var key: String = params.get("key", "") - if key.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") - - if not ProjectSettings.has_setting(key): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Setting not found: %s" % key) - - var value = ProjectSettings.get_setting(key) - return { - "data": { - "key": key, - "value": NodeHandler._serialize_value(value), - "type": type_string(typeof(value)), - } - } - - -func set_project_setting(params: Dictionary) -> Dictionary: - var key: String = params.get("key", "") - if key.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: key") - - if not params.has("value"): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") - - ## Refuse the startup-execution surface before touching ProjectSettings — - ## see STARTUP_EXECUTION_KEY_PREFIXES for why this guard exists here and - ## not only in McpPathValidator. - var refusal := startup_execution_key_refusal(key) - if not refusal.is_empty(): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, refusal) - - var value = params.get("value") - var had_setting := ProjectSettings.has_setting(key) - var old_value = ProjectSettings.get_setting(key) if had_setting else null - # JSON has no distinct int type: Godot parses `1920` as float. If the - # existing setting is TYPE_INT, coerce whole-number floats back to int so - # we don't silently flip typed-int settings (viewport_width, etc.) to - # floats on disk. See issue #31. - if had_setting and typeof(old_value) == TYPE_INT and typeof(value) == TYPE_FLOAT and float(int(value)) == value: - value = int(value) - ProjectSettings.set_setting(key, value) - var err := ProjectSettings.save() - if err != OK: - if had_setting: - ProjectSettings.set_setting(key, old_value) - else: - ProjectSettings.clear(key) - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save project settings (error %d)" % err) - - return { - "data": { - "key": key, - "value": NodeHandler._serialize_value(value), - "old_value": NodeHandler._serialize_value(old_value), - "type": type_string(typeof(value)), - "undoable": false, - "reason": "ProjectSettings changes are saved to disk", - } - } - - -func run_project(params: Dictionary) -> Dictionary: - var mode: String = params.get("mode", "main") - var autosave: bool = params.get("autosave", true) - # Idempotent: a project that's already running satisfies the caller's intent. - # Returning INVALID_PARAMS here punished agents that legitimately called run - # to ensure the project is playing (87+ installs/day hit the matching - # stop-not-running case in telemetry). Surface state via was_already_running - # so a caller wanting a *different* scene can detect and stop+restart. - if EditorInterface.is_playing_scene(): - return _run_project_current_liveness_response( - _run_project_base_data( - mode, - str(params.get("scene", "")), - autosave, - true, - "Project was already running; no action taken" - ) - ) - - var validation_error: Variant = null - if mode == "custom": - var custom_scene: String = params.get("scene", "") - if custom_scene.is_empty(): - validation_error = ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: scene (required when mode='custom')") - else: - ## play_custom_scene() was the last path-taking op in the plugin - ## with no containment check; every sibling that accepts a scene - ## path validates it (scene_handler.open_scene, - ## node_handler.create_node's scene_path). - validation_error = McpPathValidator.loadable_error(custom_scene, "scene") - elif mode != "main" and mode != "current": - validation_error = ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid mode '%s' — use 'main', 'current', or 'custom'" % mode) - if validation_error != null: - return validation_error - - # play_*_scene internally triggers try_autosave() → _save_scene_with_preview() - # which renders a preview thumbnail and calls frame processing. If our - # WebSocket connection's _process() re-enters during that render, the - # engine crashes (SIGABRT in _save_scene_with_preview). Pause processing - # around the play call — same pattern as SceneHandler.save_scene. - if _connection: - _connection.pause_processing = true - - # try_autosave() reads run/auto_save/save_before_running every call, so - # toggling it off around the play call suppresses the save without - # touching the user's persisted preference. Issue #81. - var autosave_key := "run/auto_save/save_before_running" - var editor_settings: EditorSettings = null - if not autosave: - editor_settings = EditorInterface.get_editor_settings() - var prior_autosave: bool = true - var restore_setting := false - if editor_settings != null and editor_settings.has_setting(autosave_key): - prior_autosave = bool(editor_settings.get_setting(autosave_key)) - editor_settings.set_setting(autosave_key, false) - restore_setting = true - - if _debugger_plugin != null: - _debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected()) - - match mode: - "main": - EditorInterface.play_main_scene() - "current": - EditorInterface.play_current_scene() - "custom": - var scene_path: String = params.get("scene", "") - EditorInterface.play_custom_scene(scene_path) - - if restore_setting: - editor_settings.set_setting(autosave_key, prior_autosave) - - if _connection: - _connection.pause_processing = false - - var base_data := _run_project_base_data( - mode, - str(params.get("scene", "")), - autosave, - false, - "Play/stop is a runtime action" - ) - var request_id: String = params.get("_request_id", "") - if _connection != null and _debugger_plugin != null and not request_id.is_empty(): - _finish_run_project_deferred(request_id, base_data, _connection, _debugger_plugin) - return McpDispatcher.DEFERRED_RESPONSE - - return _run_project_current_liveness_response(base_data) - - -func _editor_log_cursor() -> int: - return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0 - - -func _game_helper_autoload_expected() -> bool: - return ProjectSettings.has_setting("autoload/_mcp_game_helper") - - -static func _run_project_base_data( - mode: String, - scene: String, - autosave: bool, - was_already_running: bool, - reason: String -) -> Dictionary: - return { - "mode": mode, - "scene": scene, - "autosave": autosave, - "was_already_running": was_already_running, - "undoable": false, - "reason": reason, - } - - -func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary: - if _debugger_plugin == null: - return {"data": base_data} - var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) - ## One-shot read — force a Debugger-tab scan so boot errors that landed - ## after the last gated scan are in this response (#641). - var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true) - return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info)) - - -## `static` is load-bearing (#712, same rationale as the script/filesystem -## handlers and editor_handler._do_reload_plugin): this coroutine awaits -## across frames, and a plugin reload frees this RefCounted handler -## mid-await — resuming an instance coroutine on a freed object errors. -## `connection` and `debugger_plugin` are parameterized explicitly and -## re-validated after every await; the response is dropped silently when -## either died (the server's command timeout surfaces the failure). -static func _finish_run_project_deferred( - request_id: String, base_data: Dictionary, connection, debugger_plugin -) -> void: - var tree: SceneTree = connection.get_tree() - while true: - await tree.process_frame - if not is_instance_valid(connection) or not is_instance_valid(debugger_plugin): - return - var pre_status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) - if ( - not EditorInterface.is_playing_scene() - and int(pre_status.get("elapsed_msec", 0)) > 100 - and str(pre_status.get("status", "stopped")) == "launching" - ): - debugger_plugin.end_game_run() - var status: Dictionary = debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC) - var errors_info: Dictionary = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0))) - var decision := _run_project_liveness_decision(status, errors_info) - if not bool(decision.get("resolve", false)): - continue - ## #641: the loop above polls with gated (cheap) scans; boot parse - ## errors can land in the Errors tab in the same frames the run goes - ## live. Re-gather once with a forced scan before replying so the - ## response reports them instead of leaving them to a later - ## logs_read. Rebuilding the decision with strictly-more errors can - ## only keep it resolved (errors never un-resolve a decision). - errors_info = debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)), true) - decision = _run_project_liveness_decision(status, errors_info) - connection.send_deferred_response(request_id, _run_project_response(base_data, decision)) - return - - -static func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary: - var data := base_data.duplicate(true) - var game_status: Dictionary = decision.get("game_status", {}) - data["game_status"] = game_status - data["helper_live"] = bool(game_status.get("helper_live", false)) - data["session_active"] = bool(game_status.get("session_active", false)) - if bool(data.get("was_already_running", false)): - data["reason"] = _run_project_already_running_message(decision) - else: - data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action")) - data["recent_errors"] = decision.get("recent_errors", []) - data["recent_errors_scope"] = decision.get("recent_errors_scope", "none") - data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false) - data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false) - data.merge(McpDebuggerPlugin.split_errors_by_scope(data["recent_errors"], data["recent_errors_scope"]), true) - return {"data": data} - - -static func _run_project_already_running_message(decision: Dictionary) -> String: - var state := str(decision.get("liveness_status", "unknown")) - match state: - "live": - var live_errors: Array = decision.get("recent_errors", []) - if not live_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run": - return ( - "Project was already running; the Godot AI game helper is live, but %d editor error%s surfaced during this run (first: %s). Check logs_read(source='editor', include_details=true)." - % [live_errors.size(), "s" if live_errors.size() != 1 else "", _format_editor_error_summary(live_errors[0])] - ) - return "Project was already running; the Godot AI game helper is live." - "not_live": - var errors: Array = decision.get("recent_errors", []) - var scope := str(decision.get("recent_errors_scope", "none")) - if not errors.is_empty() and scope == "run": - return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0]) - if not errors.is_empty(): - return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0]) - return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state." - "break": - var break_errors: Array = decision.get("recent_errors", []) - if not break_errors.is_empty() and str(decision.get("recent_errors_scope", "none")) == "run": - return "Project was already running but the game is parked at a debugger break: %s. Call project_manage(op='stop') to end the run, fix the error, and relaunch." % _format_editor_error_summary(break_errors[0]) - if not break_errors.is_empty(): - return "Project was already running but the game is parked at a debugger break. A recent editor error may be related, but may predate this run: %s. Call project_manage(op='stop') to end the run." % _format_editor_error_summary(break_errors[0]) - return "Project was already running but the game is parked at a debugger break. Call project_manage(op='stop') to end the run; the break reason is in the editor's Debugger panel." - "no_helper": - return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness." - "launching": - return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly." - "stopped": - return "Project was already marked playing by the editor, but no active game liveness run exists." - _: - return "Project was already running; current liveness status is %s." % state - - -## Static (with the rest of the deferred-finisher chain) so the #712 -## load-bearing-static coroutines above can call it after their owner -## handler was freed. Uses no instance state. -static func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary: - var enriched_status := McpDebuggerPlugin.with_liveness_flags(status) - var state := str(status.get("status", "stopped")) - var recent_errors: Array = errors_info.get("errors", []) - var errors_scope := str(errors_info.get("scope", "none")) - var truncated := bool(errors_info.get("truncated", false)) - ## Clear errors that predate this run's window — they don't belong in - ## a successful launch response and only add noise. They remain - ## reachable via logs_read(source='editor') and the retained buffer so - ## failed-run debugging is unaffected. - ## #635 tradeoff: a genuine in-run error whose Errors-tab row carries - ## an empty or byte-identical time text can be misclassified as - ## retained_recent and will be dropped here. Still reachable via - ## logs_read. - if state == "live" and errors_scope == "retained_recent": - recent_errors = [] - errors_scope = "none" - var correlated_error := not recent_errors.is_empty() and errors_scope == "run" - var elapsed_msec := int(status.get("elapsed_msec", 0)) - var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0))) - var decision := { - "resolve": false, - "game_status": enriched_status, - "liveness_status": state, - "recent_errors": recent_errors, - "recent_errors_scope": errors_scope, - "recent_errors_may_predate_run": errors_scope == "retained_recent", - "recent_errors_truncated": truncated, - "message": "", - } - if state == "live": - decision["resolve"] = true - if correlated_error: - ## #641: "live" only means the helper autoload registered — scripts - ## can still have failed to parse or load during boot (a broken - ## node script does not stop the game from running). Surface those - ## errors in the success message so agents don't read a clean - ## launch into a run that silently lost scripts. - decision["message"] = ( - "Game launched and the Godot AI game helper is live, but %d editor error%s surfaced during startup (first: %s) — likely a script that failed to parse or load. Check logs_read(source='editor', include_details=true)." - % [recent_errors.size(), "s" if recent_errors.size() != 1 else "", _format_editor_error_summary(recent_errors[0])] - ) - if truncated: - decision["message"] += " Editor logs since this run may be truncated; showing retained errors." - else: - decision["message"] = "Game launched and the Godot AI game helper is live." - elif state == "break": - ## #645: the game process is parked in a remote-debugger break. A - ## boot-time parse error (GDScriptLanguage::debug_break_parse) produces - ## no Errors-tab row, no Logger entry, and no game-log line — the - ## synthesized break record is the only evidence, and it lands a - ## moment after the break signal (stack frames arrive async). Wait for - ## it (correlated_error) before resolving; the ready window is the - ## fallback if synthesis never lands. - var break_info: Dictionary = status.get("break", {}) - var break_reason := str(break_info.get("reason", "")) - if bool(break_info.get("pre_live", true)): - decision["resolve"] = correlated_error or elapsed_msec >= ready_wait_msec - var summary := break_reason - if correlated_error: - summary = _format_editor_error_summary(recent_errors[0]) - if summary.is_empty(): - summary = "script parse/load error (reason not captured)" - decision["message"] = "Game hit a script error during startup and is frozen at a debugger break before the Godot AI game helper registered: %s. The run cannot continue; call project_manage(op='stop'), fix the error, and relaunch. Check logs_read(source='editor', include_details=true)." % summary - else: - var reason_suffix := (": %s" % break_reason) if not break_reason.is_empty() else "" - decision["resolve"] = true - decision["message"] = "Game is paused at a debugger break%s. Resume it from the editor's Debugger panel or call project_manage(op='stop')." % reason_suffix - elif correlated_error: - decision["resolve"] = true - decision["liveness_status"] = "not_live" - decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) - if truncated: - decision["message"] += " Editor logs since this run may be truncated; showing retained errors." - elif state == "not_live": - decision["resolve"] = true - if not recent_errors.is_empty(): - decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0]) - else: - decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state." - elif state == "no_helper": - decision["resolve"] = true - decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable." - elif state == "stopped": - decision["resolve"] = true - decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live." - elif state == "launching" and elapsed_msec >= ready_wait_msec: - decision["resolve"] = true - decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0) - return decision - - -static func _format_editor_error_summary(entry: Dictionary) -> String: - return McpSurfacedErrorTracker.format_editor_error_summary(entry) - - -func stop_project(params: Dictionary) -> Dictionary: - # Idempotent: a project that's already stopped satisfies the caller's intent. - # Returning INVALID_PARAMS here was the largest single source of fleet-wide - # project_manage failures (87 installs/24h). was_running=false lets callers - # distinguish a no-op stop from one that actually halted a running session. - if not EditorInterface.is_playing_scene(): - return { - "data": { - "stopped": true, - "was_running": false, - "undoable": false, - "reason": "Project was not running; no action taken", - } - } - - if _debugger_plugin != null: - _debugger_plugin.end_game_run() - EditorInterface.stop_playing_scene() - - # stop_playing_scene() is async — is_playing_scene() only flips to false on - # the next frame, and readiness_changed follows in _process. Defer the - # response so we can reply with authoritative readiness instead of letting - # the server poll for the event. Issue #29. - var request_id: String = params.get("_request_id", "") - if _connection != null and not request_id.is_empty(): - _finish_stop_project_deferred(request_id, _connection) - return McpDispatcher.DEFERRED_RESPONSE - - # Fallback for contexts without a connection (e.g. batch_execute via - # dispatch_direct, or unit tests that instantiate the handler with null). - return { - "data": { - "stopped": true, - "was_running": true, - "undoable": false, - "reason": "Play/stop is a runtime action", - } - } - - -# Wait two frames so Godot can tick the stop-play state change. After this -# is_playing_scene() reflects truth and get_readiness() is authoritative. -# If the plugin tears down (_exit_tree frees _connection) during the await, -# is_instance_valid() goes false and we drop the response silently — the -# server's 5s request timeout will surface the failure to the caller. -# `static` is load-bearing (#712): see _finish_run_project_deferred. -static func _finish_stop_project_deferred(request_id: String, connection) -> void: - var tree: SceneTree = connection.get_tree() - await tree.process_frame - await tree.process_frame - if not is_instance_valid(connection): - return - connection.send_deferred_response(request_id, { - "data": { - "stopped": true, - "was_running": true, - "undoable": false, - "reason": "Play/stop is a runtime action", - "readiness_after": McpConnection.get_readiness(), - } - }) - - -func search_filesystem(params: Dictionary) -> Dictionary: - var name_filter: String = params.get("name", "") - var type_filter: String = params.get("type", "") - var path_filter: String = params.get("path", "") - - if name_filter.is_empty() and type_filter.is_empty() and path_filter.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, path) is required") - - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var results: Array[Dictionary] = [] - _scan_directory(efs.get_filesystem(), name_filter, type_filter, path_filter, results) - return {"data": {"files": results, "count": results.size()}} - - -func _scan_directory(dir: EditorFileSystemDirectory, name_filter: String, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: - for i in dir.get_file_count(): - var file_path := dir.get_file_path(i) - var file_type := dir.get_file_type(i) - - var matches := true - - if not name_filter.is_empty(): - if file_path.get_file().to_lower().find(name_filter.to_lower()) == -1: - matches = false - - if matches and not type_filter.is_empty(): - if file_type != type_filter: - matches = false - - if matches and not path_filter.is_empty(): - if file_path.to_lower().find(path_filter.to_lower()) == -1: - matches = false - - if matches: - out.append({ - "path": file_path, - "type": file_type, - }) - - for i in dir.get_subdir_count(): - _scan_directory(dir.get_subdir(i), name_filter, type_filter, path_filter, out) diff --git a/addons/godot_ai/handlers/project_handler.gd.uid b/addons/godot_ai/handlers/project_handler.gd.uid deleted file mode 100644 index ec2b5d7..0000000 --- a/addons/godot_ai/handlers/project_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://brf8u32hvha68 diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd deleted file mode 100644 index 553ed9b..0000000 --- a/addons/godot_ai/handlers/resource_handler.gd +++ /dev/null @@ -1,591 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const ClassIntrospection := preload("res://addons/godot_ai/utils/class_introspection.gd") - -## Handles resource search, inspection, and assignment to nodes. - -const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -func search_resources(params: Dictionary) -> Dictionary: - var type_filter: String = params.get("type", "") - var path_filter: String = params.get("path", "") - - if type_filter.is_empty() and path_filter.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (type, path) is required") - - var efs := EditorInterface.get_resource_filesystem() - if efs == null: - return ErrorCodes.make_not_ready( - ErrorCodes.SUB_EDITOR_UNAVAILABLE, - "EditorFileSystem not available", false) - - var results: Array[Dictionary] = [] - _scan_resources(efs.get_filesystem(), type_filter, path_filter, results) - return {"data": {"resources": results, "count": results.size()}} - - -func _scan_resources(dir: EditorFileSystemDirectory, type_filter: String, path_filter: String, out: Array[Dictionary]) -> void: - for i in dir.get_file_count(): - var file_path := dir.get_file_path(i) - var file_type := dir.get_file_type(i) - - var matches := true - - if not type_filter.is_empty(): - # Check if the file type matches or is a subclass of the requested type - if file_type != type_filter and not ClassDB.is_parent_class(file_type, type_filter): - matches = false - - if matches and not path_filter.is_empty(): - if file_path.to_lower().find(path_filter.to_lower()) == -1: - matches = false - - if matches: - out.append({ - "path": file_path, - "type": file_type, - }) - - for i in dir.get_subdir_count(): - _scan_resources(dir.get_subdir(i), type_filter, path_filter, out) - - -func load_resource(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var path_err = McpPathValidator.loadable_error(path, "path") - if path_err != null: - return path_err - - if not ResourceLoader.exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % path) - - var res: Resource = load(path) - if res == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % path) - - var properties: Array[Dictionary] = [] - for prop in res.get_property_list(): - var usage: int = prop.get("usage", 0) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - var value = res.get(prop.name) - if value == null and prop.type != TYPE_NIL: - continue - properties.append({ - "name": prop.name, - "type": type_string(prop.type), - "value": NodeHandler._serialize_value(value), - }) - - return { - "data": { - "path": path, - "type": res.get_class(), - "properties": properties, - "property_count": properties.size(), - } - } - - -func assign_resource(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - var property: String = params.get("property", "") - var resource_path: String = params.get("resource_path", "") - - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - if property.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: property") - - if resource_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: resource_path") - - var rpath_err = McpPathValidator.loadable_error(resource_path, "resource_path") - if rpath_err != null: - return rpath_err - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - # Verify property exists - var found := false - for prop in node.get_property_list(): - if prop.name == property: - found = true - break - if not found: - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property)) - - if not ResourceLoader.exists(resource_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found: %s" % resource_path) - - var res: Resource = load(resource_path) - if res == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load resource: %s" % resource_path) - - var old_value = node.get(property) - - _undo_redo.create_action("MCP: Assign %s to %s.%s" % [resource_path.get_file(), node.name, property]) - _undo_redo.add_do_property(node, property, res) - _undo_redo.add_undo_property(node, property, old_value) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "property": property, - "resource_path": resource_path, - "resource_type": res.get_class(), - "undoable": true, - } - } - - -## Instantiate a built-in Resource subclass, optionally apply `properties`, -## and either assign it to a node slot (undoable) or save it to a .tres file -## (not undoable — mirrors material_create). Exactly one home is required; -## a resource with no home would be GC'd after the handler returns. -func create_resource(params: Dictionary) -> Dictionary: - var type_str: String = params.get("type", "") - if type_str.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") - - var properties: Dictionary = params.get("properties", {}) - var node_path: String = params.get("path", "") - var property: String = params.get("property", "") - var resource_path: String = params.get("resource_path", "") - var overwrite: bool = params.get("overwrite", false) - - var home_err := McpResourceIO.validate_home(params) - if home_err != null: - return home_err - var has_file_target := not resource_path.is_empty() - - var made := _instantiate_resource(type_str) - if made is Dictionary: - return made - var res: Resource = made - - if not properties.is_empty(): - var apply_err := _apply_resource_properties(res, properties) - if apply_err != null: - return apply_err - - if has_file_target: - return _save_created_resource(res, type_str, resource_path, overwrite, properties.size()) - return _assign_created_resource(res, type_str, node_path, property, properties.size()) - - -## Validate that `type_str` names a concrete Resource subclass that we can -## instantiate. Returns an error dict on failure, or null on success. -static func _validate_resource_class(type_str: String) -> Variant: - if not ClassDB.class_exists(type_str): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str) - if ClassDB.is_parent_class(type_str, "Node"): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s is a Node type, not a Resource — use node_create instead" % type_str - ) - if not ClassDB.is_parent_class(type_str, "Resource"): - var parent := ClassDB.get_parent_class(type_str) - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s is not a Resource type (extends %s)" % [type_str, parent] - ) - if not ClassDB.can_instantiate(type_str): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s is abstract and cannot be instantiated — use a concrete subclass (e.g. BoxMesh, BoxShape3D, StyleBoxFlat)" % type_str - ) - return null - - -## Build the "Unknown resource type" error with a steer toward op="scan". A type -## that reaches here is neither an engine built-in (ClassDB) nor a registered -## project class (the global script-class registry). In an agent-driven workflow -## the most common cause is a `class_name` script just made via script_create -## that isn't registered yet — the global class table only rebuilds on a -## filesystem scan (normally an editor-focus event). Point the caller at the one -## cheap call that fixes that, so it doesn't fall back to a full plugin reload. -## See #614 for the headless scan op. -static func _unknown_resource_type_error(type_str: String) -> Dictionary: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - ( - "Unknown resource type: %s — not an engine built-in or a registered project class. " - + "If you just created it with script_create, the global class table is stale until a " - + "scan: call filesystem_manage(op=\"scan\"), then retry. Otherwise check the spelling." - ) % type_str - ) - - -## Resolve a resource type name to a fresh instance. Handles engine built-ins -## (ClassDB) and project `class_name` Resources (the global script-class -## registry). Returns a Resource on success, or an error dict on failure. -static func _instantiate_resource(type_str: String) -> Variant: - if ClassDB.class_exists(type_str): - var class_err: Variant = _validate_resource_class(type_str) - if class_err != null: - return class_err - var built_in := ClassDB.instantiate(type_str) - if built_in == null or not (built_in is Resource): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str) - return built_in - for entry in ProjectSettings.get_global_class_list(): - if entry.get("class", "") == type_str: - var script_path: String = entry.get("path", "") - var scr: Variant = load(script_path) - # Reject non-Resource script classes BEFORE constructing them: - # scr.new() runs _init(), and an @tool class_name extending a - # non-RefCounted type (e.g. Node) would otherwise build — and leak — - # an orphan instance this path never frees. get_instance_base_type() - # resolves to the native base, so multi-level custom Resource - # hierarchies (B extends A extends Resource) still pass. - var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path) - if base_or_err is Dictionary: - return base_or_err - var base_type: StringName = base_or_err - if not ClassDB.is_parent_class(base_type, "Resource"): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type]) - if not scr.can_instantiate(): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str) - # Reject scripts whose _init() requires arguments BEFORE scr.new(): - # scr.new() passes no args, so a required-arg _init raises and aborts - # this handler mid-call, null-cascading into a generic "malformed - # result" error instead of a clean rejection. get_script_method_list() - # reports the effective (incl. inherited) _init; required args = - # args - default_args. Statically detectable only — a _init that runs - # but throws still falls through to scr.new() and the dispatcher catch. - for method in scr.get_script_method_list(): - if method.get("name", "") == "_init": - var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size() - if required_args > 0: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str) - break - var made: Variant = scr.new() - if made == null or not (made is Resource): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str) - return made - return _unknown_resource_type_error(type_str) - - -## Maximum nesting depth for the {"__class__": ...} sub-resource shortcut. -## Caller-supplied dicts recurse through _apply_resource_properties; without a -## cap a deeply nested payload overflows the GDScript call stack and crashes -## the editor (#536). 32 is far beyond any legitimate sub-resource chain. -const MAX_NESTED_RESOURCE_DEPTH := 32 - - -## Apply a dict of property values to a freshly-instantiated Resource, -## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed. -## Returns null on success or an error dict on failure. -## `depth` is internal recursion bookkeeping for the nested {"__class__": ...} -## shortcut — external callers use the default of 0. -static func _apply_resource_properties(res: Resource, properties: Dictionary, depth: int = 0) -> Variant: - if depth > MAX_NESTED_RESOURCE_DEPTH: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Nested resource properties exceed the maximum depth of %d — flatten the {\"__class__\": ...} nesting or create the deep sub-resources in separate calls" % MAX_NESTED_RESOURCE_DEPTH - ) - var prop_types := {} - for prop in res.get_property_list(): - prop_types[prop.name] = prop.get("type", TYPE_NIL) - for key in properties.keys(): - if not prop_types.has(key): - var valid: Array[String] = [] - for prop in res.get_property_list(): - if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR: - valid.append(prop.name) - valid.sort() - # Name the script's class_name (e.g. MyTestResource) rather than the - # native base (Resource) so the hint names the type the agent created, - # and point at the real MCP verb — resource_manage(op="get_info") now - # answers for project class_name Resources too. - var type_label := res.get_class() - var res_script: Variant = res.get_script() - if res_script is Script and not String(res_script.get_global_name()).is_empty(): - type_label = String(res_script.get_global_name()) - var err := ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label] - ) - err["error"]["data"] = {"valid_properties": valid} - return err - var target_type: int = prop_types[key] - if target_type == TYPE_NIL: - target_type = typeof(res.get(key)) - var v = properties[key] - if target_type == TYPE_OBJECT and v is String: - if v == "": - v = null - else: - var vpath_err = McpPathValidator.loadable_error(v, "property '%s'" % key) - if vpath_err != null: - return vpath_err - var loaded := ResourceLoader.load(v) - if loaded == null: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Resource not found at path '%s' for property '%s'" % [v, key] - ) - v = loaded - elif target_type == TYPE_OBJECT and v is Dictionary and v.has("__class__"): - # Nested shortcut: the same {"__class__": "X", ...} form that - # node_handler.set_property accepts, now also supported here so - # resource_create/environment_create callers can populate - # sub-resource slots (ShaderMaterial.shader, etc.) in one shot. - var sub_type: String = v.get("__class__", "") - # Resolve via the shared helper so the nested shortcut accepts both - # engine built-ins (ClassDB) and project `class_name` Resources, - # exactly like the top-level resource_create path. - var sub_made := _instantiate_resource(sub_type) - if sub_made is Dictionary: - # Preserve the property-slot context the inline path used to add. - sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key] - return sub_made - var sub_res: Resource = sub_made - var remaining: Dictionary = (v as Dictionary).duplicate() - remaining.erase("__class__") - if not remaining.is_empty(): - var nested_err := _apply_resource_properties(sub_res, remaining, depth + 1) - if nested_err != null: - return nested_err - v = sub_res - else: - var slot_value: Variant = res.get(key) - if target_type == TYPE_ARRAY and slot_value is Array and (slot_value as Array).is_typed(): - ## Typed Array[T] slot (#612): mirror set_property's dispatch — - ## the generic passthrough would hand an untyped Array to the - ## typed setter, which drops it silently while we report success. - var typed_out: Variant = NodeHandler._coerce_typed_array( - v, slot_value, "Property '%s'" % key - ) - if typed_out is Dictionary: - return typed_out - v = typed_out - elif ( - target_type == TYPE_DICTIONARY - and slot_value is Dictionary - and (slot_value as Dictionary).is_typed() - ): - ## Typed Dictionary[K, V] slot (#612 stage 3): success is a - ## typed duplicate of the slot; the error envelope is untyped. - var typed_dict_out: Dictionary = NodeHandler._coerce_typed_dictionary( - v, slot_value, "Property '%s'" % key - ) - if not typed_dict_out.is_typed(): - return typed_dict_out - v = typed_dict_out - else: - v = NodeHandler._coerce_value(v, target_type) - ## Mirror set_property's coerce check: wrong-shape dicts (#123) and - ## non-dict inputs that don't land as the target compound Variant - ## (#191) both error here instead of writing zero-filled Variants. - var coerce_err := NodeHandler._check_coerced(v, target_type, "Property '%s'" % key) - if coerce_err != null: - return coerce_err - res.set(key, v) - return null - - -func _assign_created_resource(res: Resource, type_str: String, node_path: String, property: String, applied_count: int) -> Dictionary: - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - var found := false - var prop_type: int = TYPE_NIL - for prop in node.get_property_list(): - if prop.name == property: - found = true - prop_type = prop.get("type", TYPE_NIL) - break - if not found: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(node, property) - ) - if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' on %s is not an Object slot (type %s)" % [property, node.get_class(), type_string(prop_type)] - ) - - var old_value = node.get(property) - - _undo_redo.create_action("MCP: Create %s for %s.%s" % [type_str, node.name, property]) - _undo_redo.add_do_property(node, property, res) - _undo_redo.add_undo_property(node, property, old_value) - _undo_redo.add_do_reference(res) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "property": property, - "type": type_str, - "resource_class": res.get_class(), - "properties_applied": applied_count, - "undoable": true, - } - } - - -func _save_created_resource(res: Resource, type_str: String, resource_path: String, overwrite: bool, applied_count: int) -> Dictionary: - return McpResourceIO.save_to_disk(res, resource_path, overwrite, "Resource", { - "type": type_str, - "resource_class": res.get_class(), - "properties_applied": applied_count, - }, _connection) - - -## Introspect a Resource class — return its editor-visible properties, parent, -## whether it's abstract, and (for abstract bases) the list of concrete -## subclasses that resource_create can instantiate. Read-only. -func get_resource_info(params: Dictionary) -> Dictionary: - var type_str: String = params.get("type", "") - if type_str.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type") - - if not ClassDB.class_exists(type_str): - # Project class_name Resources aren't in ClassDB; resolve them through the - # global script-class registry so get_info answers for the same custom - # types resource_create can make. Read-only — never instantiates. - var custom_info: Variant = _custom_resource_info(type_str) - if custom_info != null: - return custom_info - return _unknown_resource_type_error(type_str) - if ClassDB.is_parent_class(type_str, "Node"): - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s is a Node type, not a Resource — use node_* tools for node introspection" % type_str - ) - if not ClassDB.is_parent_class(type_str, "Resource") and type_str != "Resource": - var parent := ClassDB.get_parent_class(type_str) - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "%s is not a Resource type (extends %s)" % [type_str, parent] - ) - - var can_instantiate: bool = ClassDB.can_instantiate(type_str) - var class_info := ClassIntrospection.build(type_str, { - "sections": ["properties"], - "include_inherited": true, - "include_inheritors": not can_instantiate, - "limit": 0, - }) - var data: Dictionary = { - "type": type_str, - "parent_class": class_info.parent_class, - "can_instantiate": can_instantiate, - "is_abstract": not can_instantiate, - "properties": class_info.properties, - "property_count": class_info.property_count, - } - - # For abstract bases (Shape3D, Material, Texture, StyleBox, ...) surface - # the concrete Resource subclasses an agent could try next. - if not can_instantiate: - data["concrete_subclasses"] = class_info.concrete_inheritors - - return {"data": data} - - -## Resolve a loaded global-class script to its native base type, or an error if -## the script failed to load (not a Script) or to compile (empty base type). -## Shared by the create and get_info custom-Resource paths so both report a -## compile failure rather than a misleading "is not a Resource type (extends )". -static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant: - if not (scr is Script): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path]) - var base_type: StringName = scr.get_instance_base_type() - if String(base_type).is_empty(): - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path]) - return base_type - - -## get_info for a project `class_name` Resource (not in ClassDB). Returns an info -## dict, an error dict (for a class_name whose native base is not a Resource), or -## null if `type_str` is not a registered global class. Read-only: resolves -## properties from the script + its native base WITHOUT instantiating (no _init()). -static func _custom_resource_info(type_str: String) -> Variant: - for entry in ProjectSettings.get_global_class_list(): - if entry.get("class", "") != type_str: - continue - var script_path: String = entry.get("path", "") - var scr: Variant = load(script_path) - var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path) - if base_or_err is Dictionary: - return base_or_err - var base_type: StringName = base_or_err - if not ClassDB.is_parent_class(base_type, "Resource"): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type]) - var can_instantiate: bool = scr.can_instantiate() - # Inherited (native) properties come from the engine base via ClassDB... - var class_info := ClassIntrospection.build(String(base_type), { - "sections": ["properties"], - "include_inherited": true, - "limit": 0, - }) - var props: Array = [] - for native_prop in class_info.properties: - props.append(native_prop) - # ...and the script's own (and inherited script) exported properties come - # from the Script itself, so we never construct the resource. A real - # default isn't available without instantiating, so script props carry an - # explicit null — keeping one uniform key set across the array (native - # props carry their real default). - for raw_prop in scr.get_script_property_list(): - var prop: Dictionary = raw_prop - var usage := int(prop.get("usage", 0)) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - props.append({ - "name": str(prop.get("name", "")), - "type": type_string(int(prop.get("type", TYPE_NIL))), - "class_name": str(prop.get("class_name", "")), - "hint": int(prop.get("hint", PROPERTY_HINT_NONE)), - "hint_string": str(prop.get("hint_string", "")), - "usage": usage, - "default": null, - }) - props.sort_custom(func(a, b): return a.name < b.name) - # parent_class is the immediate script parent when there is one (so a - # multi-level chain B -> A -> Resource reports A), else the native base. - var parent_name := String(base_type) - var base_script: Variant = scr.get_base_script() - if base_script is Script and not String(base_script.get_global_name()).is_empty(): - parent_name = String(base_script.get_global_name()) - return {"data": { - "type": type_str, - "parent_class": parent_name, - "can_instantiate": can_instantiate, - # is_abstract reflects real abstractness (the @abstract annotation), - # NOT editor-instantiability — a non-@tool concrete Resource has - # can_instantiate()==false in-editor but is not abstract. - "is_abstract": scr.is_abstract(), - "properties": props, - "property_count": props.size(), - }} - return null diff --git a/addons/godot_ai/handlers/resource_handler.gd.uid b/addons/godot_ai/handlers/resource_handler.gd.uid deleted file mode 100644 index d79d3cf..0000000 --- a/addons/godot_ai/handlers/resource_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwwd0n3c56ir diff --git a/addons/godot_ai/handlers/scene_handler.gd b/addons/godot_ai/handlers/scene_handler.gd deleted file mode 100644 index 1c1f2b4..0000000 --- a/addons/godot_ai/handlers/scene_handler.gd +++ /dev/null @@ -1,420 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles scene tree reading and node search. - -var _connection: McpConnection -var _save_scene_callable: Callable = Callable() -var _save_scene_as_callable: Callable = Callable() - - -func _init(connection: McpConnection = null) -> void: - _connection = connection - - -func get_scene_tree(params: Dictionary) -> Dictionary: - var max_depth: int = params.get("depth", 10) - var offset: int = maxi(0, int(params.get("offset", 0))) - # limit <= 0 means "no limit" (the hierarchy resource reads the whole tree); - # the scene_get_hierarchy tool passes an explicit positive limit. Paginating - # here — rather than walking + serializing the full tree and slicing on the - # Python side — means only the requested window builds node dicts and clean - # scene paths, and only the window crosses the WebSocket. - var limit: int = int(params.get("limit", 0)) - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return {"data": { - "nodes": [], - "total_count": 0, - "offset": offset, - "limit": limit, - "has_more": false, - "message": "No scene open", - }} - - var nodes: Array[Dictionary] = [] - # index_ref[0] is the running DFS index shared across the recursion (Arrays - # pass by reference in GDScript). The walk still visits every node to get an - # accurate total_count, but only materializes those inside the window. - var index_ref: Array[int] = [0] - # _walk_tree self-seeds the root's path for full reads; pass "" explicitly. - _walk_tree(scene_root, nodes, 0, max_depth, scene_root, offset, limit, index_ref, "") - var total: int = index_ref[0] - return {"data": { - "nodes": nodes, - "total_count": total, - "offset": offset, - "limit": limit, - "has_more": limit > 0 and offset + limit < total, - }} - - -func get_open_scenes(_params: Dictionary) -> Dictionary: - var scene_paths := EditorInterface.get_open_scenes() - var scene_root := EditorInterface.get_edited_scene_root() - var current := scene_root.scene_file_path if scene_root else "" - return { - "data": { - "scenes": scene_paths, - "current_scene": current, - "count": scene_paths.size(), - } - } - - -func find_nodes(params: Dictionary) -> Dictionary: - var name_filter: String = params.get("name", "") - var type_filter: String = params.get("type", "") - var group_filter: String = params.get("group", "") - - if name_filter.is_empty() and type_filter.is_empty() and group_filter.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "At least one filter (name, type, group) is required") - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var results: Array[Dictionary] = [] - _find_recursive(scene_root, scene_root, name_filter, type_filter, group_filter, results) - return {"data": {"nodes": results, "count": results.size()}} - - -func _find_recursive(node: Node, scene_root: Node, name_filter: String, type_filter: String, group_filter: String, out: Array[Dictionary]) -> void: - var matches := true - - if not name_filter.is_empty(): - if node.name.to_lower().find(name_filter.to_lower()) == -1: - matches = false - - if matches and not type_filter.is_empty(): - if node.get_class() != type_filter: - matches = false - - if matches and not group_filter.is_empty(): - if not node.is_in_group(group_filter): - matches = false - - if matches: - out.append({ - "name": node.name, - "type": node.get_class(), - "path": McpScenePath.from_node(node, scene_root), - }) - - for child in node.get_children(): - _find_recursive(child, scene_root, name_filter, type_filter, group_filter, out) - - -## Create a new scene with the given root node type, save to disk, and open it. -func create_scene(params: Dictionary) -> Dictionary: - var root_type: String = params.get("root_type", "Node3D") - var path: String = params.get("path", "") - - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var path_err = McpPathValidator.path_error(path, "path", true) - if path_err != null: - return path_err - - if not path.ends_with(".tscn") and not path.ends_with(".scn"): - path += ".tscn" - - if not ClassDB.class_exists(root_type): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown node type: %s" % root_type) - if not ClassDB.is_parent_class(root_type, "Node"): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % root_type) - - # Ensure parent directory exists - var dir_path := path.get_base_dir() - if not DirAccess.dir_exists_absolute(dir_path): - var err := DirAccess.make_dir_recursive_absolute(dir_path) - if err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) - - var root: Node = ClassDB.instantiate(root_type) - if root == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % root_type) - - var root_name: String = params.get("root_name", "") - if root_name.is_empty(): - root_name = path.get_file().get_basename() - root.name = root_name - - if _connection: - _connection.pause_processing = true - var err := _pack_and_save_with_uid(root, path) - if err == OK: - EditorInterface.open_scene_from_path(path) - if _connection: - _connection.pause_processing = false - - if err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err)) - - return { - "data": { - "path": path, - "root_type": root_type, - "root_name": root_name, - "undoable": false, - "reason": "Scene creation involves file system operations", - } - } - - -## Pack `root` and save it to `path`, embedding a fresh uid or preserving the -## one `path` already had — the exact save sequence `create_scene` runs, -## minus the `pause_processing` guard (the caller owns that, since it also -## needs to bracket `open_scene_from_path`) and minus opening the scene -## (switching the editor's active scene isn't safe inside the shared test -## runner, so tests call this directly instead of going through -## `create_scene` end-to-end). Frees `root`. Returns `OK`, or the first -## `Error` encountered. -func _pack_and_save_with_uid(root: Node, path: String) -> Error: - var packed := PackedScene.new() - packed.pack(root) - root.free() - - # Captured BEFORE the save below overwrites the file — see - # McpResourceIO.ensure_uid's doc comment. - var prior_uid := ResourceLoader.get_resource_uid(path) if FileAccess.file_exists(path) else ResourceUID.INVALID_ID - - var err := ResourceSaver.save(packed, path) - if err == OK: - err = McpResourceIO.ensure_uid(path, prior_uid) - return err - - -## How long open_scene waits for the editor to actually switch to the -## requested scene before replying switched=false. Tab switches normally land -## within a few frames; keep this under the dispatcher's 4500 ms deferred -## default so the coroutine always answers before DEFERRED_TIMEOUT fires. -const _OPEN_SETTLE_MAX_MSEC := 3000 - - -## Open an existing scene by file path. -func open_scene(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var force_reload: bool = params.get("force_reload", false) - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var path_err = McpPathValidator.loadable_error(path, "path") - if path_err != null: - return path_err - - if not ResourceLoader.exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Scene not found: %s" % path) - - var scene_root := EditorInterface.get_edited_scene_root() - var current_path := scene_root.scene_file_path if scene_root else "" - ## Instance id of the root at call time. A completed open OR reload always - ## replaces the edited-scene root with a NEW instance, so this is the - ## reliable completion signal — unlike scene_file_path, which is unchanged - ## across a force_reload of the already-open scene (#633 review). - var prev_root_id := scene_root.get_instance_id() if scene_root else 0 - var payload := { - "path": path, - "force_reload": force_reload, - "reloaded_from_disk": false, - "previous_scene_path": current_path, - "undoable": false, - "reason": "Scene navigation cannot be undone via editor undo", - } - - if current_path == path and not force_reload: - ## Already the edited scene — nothing switches, reply immediately. - payload["switched"] = true - payload["settle"] = "already_current" - return {"data": payload} - - if force_reload and current_path == path: - EditorInterface.reload_scene_from_path(path) - payload["reloaded_from_disk"] = true - else: - EditorInterface.open_scene_from_path(path) - - ## The tab switch completes asynchronously; replying now lets an immediate - ## follow-up write land on the PREVIOUS scene (#633 — a scene_save issued - ## right after open_scene saved the old scene). Defer the reply until the - ## edited scene actually is `path` AND its root is a fresh instance, so - ## success means "the editor is now editing the (re)loaded scene". - var request_id: String = params.get("_request_id", "") - if _connection != null and not request_id.is_empty(): - _finish_open_scene_deferred(_connection, request_id, path, prev_root_id, payload) - return McpDispatcher.DEFERRED_RESPONSE - - ## Synchronous fallback (batch_execute and unit-test contexts can't await): - ## preserve the old reply-immediately behavior, flagged as not waited on. - payload["switched"] = false - payload["settle"] = "not_waited" - return {"data": payload} - - -## `static` is load-bearing (same reason as FilesystemHandler's deferred scan -## finish): the coroutine must outlive this RefCounted handler, which can be -## freed mid-await by an editor_reload_plugin. Parameterise everything; -## reference no instance state. -static func _finish_open_scene_deferred( - connection: McpConnection, - request_id: String, - path: String, - prev_root_id: int, - payload: Dictionary, -) -> void: - if not is_instance_valid(connection): - return - var tree := connection.get_tree() - if tree == null: - return - # Hand back a frame so _dispatch() registers this request as deferred - # before the coroutine can push a reply. - await tree.process_frame - var deadline_ms := Time.get_ticks_msec() + _OPEN_SETTLE_MAX_MSEC - while Time.get_ticks_msec() < deadline_ms: - var root := EditorInterface.get_edited_scene_root() - # Require BOTH the target path AND a fresh root instance: a - # force_reload keeps scene_file_path == path across the reload, so the - # instance swap is what proves the (re)load actually completed rather - # than the coroutine settling on the stale pre-reload root. - if root != null and root.scene_file_path == path and root.get_instance_id() != prev_root_id: - if not is_instance_valid(connection): - return - payload["switched"] = true - payload["settle"] = "settled" - connection.send_deferred_response(request_id, {"data": payload}) - return - await tree.process_frame - if not is_instance_valid(connection): - return - payload["switched"] = false - payload["settle"] = "timeout" - connection.send_deferred_response(request_id, {"data": payload}) - - -## Save the currently edited scene. -## Pauses WebSocket processing during save to prevent re-entrant _process() -## calls during EditorNode::_save_scene_with_preview's thumbnail render. -func save_scene(_params: Dictionary) -> Dictionary: - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var path := scene_root.scene_file_path - if path.is_empty(): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Current scene has never been saved; call scene_manage(op='save_as') with a res://... path ending in .tscn or .scn." - ) - - if _connection: - _connection.pause_processing = true - var err := _save_current_scene() - if _connection: - _connection.pause_processing = false - - if err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to save scene: %s" % error_string(err)) - - return { - "data": { - "path": path, - "undoable": false, - "reason": "File save cannot be undone via editor undo", - } - } - - -## Save the currently edited scene to a new file path. -func save_scene_as(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var path_err = McpPathValidator.path_error(path, "path", true) - if path_err != null: - return path_err - - if not path.ends_with(".tscn") and not path.ends_with(".scn"): - path += ".tscn" - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - # Ensure parent directory exists - var dir_path := path.get_base_dir() - if not DirAccess.dir_exists_absolute(dir_path): - var err := DirAccess.make_dir_recursive_absolute(dir_path) - if err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) - - if _connection: - _connection.pause_processing = true - _save_current_scene_as(path) - if _connection: - _connection.pause_processing = false - - return { - "data": { - "path": path, - "undoable": false, - "reason": "File save cannot be undone via editor undo", - } - } - - -func _save_current_scene() -> int: - if _save_scene_callable.is_valid(): - return int(_save_scene_callable.call()) - return EditorInterface.save_scene() - - -func _save_current_scene_as(path: String) -> void: - if _save_scene_as_callable.is_valid(): - _save_scene_as_callable.call(path) - return - EditorInterface.save_scene_as(path) - - -func _walk_tree(node: Node, out: Array[Dictionary], depth: int, max_depth: int, scene_root: Node, offset: int, limit: int, index_ref: Array[int], node_path: String) -> void: - if depth > max_depth: - return - var idx: int = index_ref[0] - index_ref[0] = idx + 1 - # Materialize only nodes inside the [offset, offset+limit) window. Outside - # it we still recurse (to count total_count) but skip the per-node dict. - # - # Path build strategy depends on the read shape (identical output either way): - # * A whole-tree read (offset == 0 and limit <= 0 — the resource-style read - # backing godot://scene/hierarchy) threads the parent's clean path down the - # DFS: each node's path is one O(1) concat reusing the descent, instead of - # McpScenePath.from_node's two native walks back up (is_ancestor_of + - # get_path_to). Benchmarked ~1.8x faster on a ~1.5k-node tree, up to ~5x on - # deep chains. - # * Any windowed read (limit > 0, or an offset > 0 skip) keeps from_node for - # just the emitted nodes: threading would concatenate a path for every node - # visited for total_count, which benchmarks ~20% slower for a small window. - # - # `node_path` is self-seeded at the scene root below, so a caller cannot leave - # a full read unseeded (it has no default — pass "" for windowed reads). - var incremental := limit <= 0 and offset == 0 - if incremental and node == scene_root: - node_path = "/" + String(scene_root.name) - var in_window := idx >= offset and (limit <= 0 or idx < offset + limit) - if in_window: - out.append({ - "name": node.name, - "type": node.get_class(), - "path": node_path if incremental else McpScenePath.from_node(node, scene_root), - "children_count": node.get_child_count(), - }) - for child in node.get_children(): - var child_path := (node_path + "/" + String(child.name)) if incremental else "" - _walk_tree(child, out, depth + 1, max_depth, scene_root, offset, limit, index_ref, child_path) diff --git a/addons/godot_ai/handlers/scene_handler.gd.uid b/addons/godot_ai/handlers/scene_handler.gd.uid deleted file mode 100644 index af3711f..0000000 --- a/addons/godot_ai/handlers/scene_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://7ms40gm6t2r4 diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd deleted file mode 100644 index 698b462..0000000 --- a/addons/godot_ai/handlers/script_handler.gd +++ /dev/null @@ -1,501 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const DiagnosticsCapture := preload("res://addons/godot_ai/utils/diagnostics_capture.gd") -const ValidationLogger := preload("res://addons/godot_ai/runtime/validation_logger.gd") - -## Handles script creation, reading, attaching, detaching, and symbol inspection. - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - -# The bounded import-settle window and the deferred completion coroutine -# live on McpResourceIO since #714 — write_file's fresh-`.gd` path shares -# them, so create_script and write_file can't drift apart again (#261). - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -func create_script(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var content: String = params.get("content", "") - - var path_err = McpPathValidator.path_error(path, "path", true) - if path_err != null: - return path_err - - if not path.ends_with(".gd"): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd") - - var existed_before := FileAccess.file_exists(path) - - # Shared write path (#714): parent mkdir + write/flush + explicit error - # check live on McpResourceIO so write_file can't drift from this again. - var write_failure: Variant = McpResourceIO.write_text_to_disk(path, content) - if write_failure != null: - return write_failure - - var data := { - "path": path, - "size": content.length(), - "committed": true, - "import_settled": existed_before, - "import_settle": "already_known" if existed_before else "not_waited", - "undoable": false, - "reason": "File system operations cannot be undone via editor undo", - } - _attach_gdscript_diagnostics(data, path, content) - - # A freshly-declared `class_name` is NOT in the global class table until a - # filesystem scan runs — update_file() below registers the file with the - # resource pipeline but not the class registry (see the scan() comment). - # Surface that precisely (only when the class isn't already registered) so a - # headless caller knows to follow up with filesystem_manage(op="scan") - # instead of hitting a confusing "Unknown type" / "Unknown resource type" on - # the very next call. We don't scan here — a scan() per create is the exact - # SIGABRT race documented below; the explicit op is single-flight. - # Skip the hint when the script failed to parse: a scan won't register a - # class from a broken script, so pointing at op="scan" would steer the caller - # away from the real fix (the parse error already attached above). - var declared_class := _extract_class_name(content) - if ( - not declared_class.is_empty() - and not _script_has_error_diagnostics(data) - and not _class_name_registered(declared_class) - ): - data["class_name"] = declared_class - data["class_registration"] = "scan_required" - data["class_registration_hint"] = ( - "New class_name '%s' isn't in the global class table yet. " % declared_class - + "Call filesystem_manage(op=\"scan\") if it won't resolve on the next " - + "call (e.g. resource_manage op=\"create\", or used as a type in another " - + "script). The editor also registers it on its next filesystem scan or " - + "when its window regains focus." - ) - - # Register just this file with the editor instead of a full recursive - # scan(). A scan() per write stacks `update_scripts_classes` / - # `update_script_paths_documentation` WorkerThreadPool tasks under concurrent - # script creation ("Task ... already exists" / "!tasks.has(p_task)"), which - # races the global-class registry and can SIGABRT in - # ScriptServer::remove_global_class_by_path (see dsarno/godot#6). - # update_file() is the single-file path the rest of the plugin already uses. - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - # `.gd.uid` is the sidecar Godot generates on scan; list both so the caller - # can rm the full set in one go. - McpResourceIO.attach_cleanup_hint(data, existed_before, [path, path + ".uid"]) - - # scan() is async — ResourceLoader.exists(path) returns false until Godot's - # filesystem pipeline finishes. If we reply now, an immediate attach_script - # races and 404s (#261). Defer the response until the resource is visible - # (or a bounded timeout elapses). For freshly-created files we wait; on - # overwrite the resource was already known to ResourceLoader, so reply now. - var request_id: String = params.get("_request_id", "") - if not existed_before and _connection != null and not request_id.is_empty(): - McpResourceIO.finish_text_write_deferred(_connection, request_id, path, data) - return McpDispatcher.DEFERRED_RESPONSE - - # Synchronous fallback: batch_execute (no request_id) and unit-test contexts - # (no connection) get the immediate reply that the previous behaviour gave. - return {"data": data} - - -## Extract the `class_name` a script declares, or "" if none. A cheap line scan -## (no full parse) for create_script's "scan_required" hint. Stops at the first -## space/tab or comma so all three valid forms yield just the name: -## `class_name Foo`, `class_name Foo extends Bar`, and the icon form -## `class_name Foo, "res://icon.svg"`. -static func _extract_class_name(content: String) -> String: - for raw_line in content.split("\n"): - var line := raw_line.strip_edges() - if line.begins_with("class_name "): - var rest := line.substr(11).strip_edges() - var cut := rest.length() - for i in rest.length(): - var ch := rest[i] - if ch == " " or ch == "\t" or ch == ",": - cut = i - break - return rest.substr(0, cut) - return "" - - -## True if create_script's diagnostics captured a parse error for this script. -## Used to suppress the "scan_required" hint when the class can't register -## anyway — see create_script. -static func _script_has_error_diagnostics(data: Dictionary) -> bool: - for diag in data.get("diagnostics", []): - if diag is Dictionary and diag.get("level", "") == "error": - return true - return false - - -## True if `cn` is already usable as a type — an engine built-in (ClassDB) or an -## already-registered project global class. A brand-new class_name returns false -## until a filesystem scan registers it. -static func _class_name_registered(cn: String) -> bool: - if ClassDB.class_exists(cn): - return true - for entry in ProjectSettings.get_global_class_list(): - if entry.get("class", "") == cn: - return true - return false - - -func read_script(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - - var path_err = McpPathValidator.path_error(path, "path") - if path_err != null: - return path_err - - if not FileAccess.file_exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) - - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) - - var content := file.get_as_text() - file.close() - - return { - "data": { - "path": path, - "content": content, - "size": content.length(), - "line_count": content.count("\n") + (1 if not content.is_empty() else 0), - } - } - - -## Instance (not static) despite using no instance state: tests stub -## `_capture_gdscript_load_diagnostics` via subclass override, and static -## calls bind lexically — see test_script.gd. filesystem_handler shares -## this by instantiating a bare ScriptHandler (#714). -func _attach_gdscript_diagnostics(data: Dictionary, path: String, content: String) -> void: - var validation := _validate_gdscript_source(content) - var diagnostics: Array = [] - var diagnostics_detail := "none" - var diagnostics_status := "checked" - - if not validation.get("ok", true): - var capture := _capture_gdscript_load_diagnostics(path) - diagnostics = capture.get("diagnostics", []) - diagnostics_detail = capture.get("diagnostics_detail", "none") - diagnostics_status = capture.get("diagnostics_status", "checked") - if not validation.get("ok", true) and diagnostics.is_empty(): - diagnostics.append(_fallback_gdscript_diagnostic(path, validation.get("error_code", FAILED), content)) - diagnostics_detail = "fallback" - data["diagnostics"] = diagnostics - data["diagnostics_detail"] = diagnostics_detail - data["diagnostics_scope"] = "this_file" - data["diagnostics_status"] = diagnostics_status - - -static func _validate_gdscript_source(content: String) -> Dictionary: - var script := GDScript.new() - script.source_code = content - ## Keep validation off the live cached resource: assigning resource_path to - ## this ephemeral Script can collide with loaded instances. reload() still - ## performs normal GDScript analysis, including static initializer work, so - ## this check is intentionally scoped to `.gd` writes where the editor would - ## compile the file on scan anyway. - var err := script.reload() - return { - "ok": err == OK, - "error_code": err, - } - - -func _capture_gdscript_load_diagnostics(path: String) -> Dictionary: - var buffer := McpEditorLogBuffer.new() - var logger := ValidationLogger.new(buffer) - var capture := DiagnosticsCapture.capture_this_file(buffer, path, func() -> Dictionary: - OS.add_logger(logger) - # ResourceLoader.load() reports parse failure instead of throwing, and - # a failed GDScript parse does not execute user code; remove immediately - # after the synchronous load to keep the private capture window tiny. - ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) - OS.remove_logger(logger) - return {} - ) - return capture - - -static func _fallback_gdscript_diagnostic(path: String, error_code: int, content: String) -> Dictionary: - var line := _fallback_gdscript_error_line(content) - return { - "source": "editor", - "level": "error", - "text": "GDScript reload failed with error code %d." % error_code, - "path": path, - "line": line, - "function": "GDScript::reload", - "details": { - "code": "gdscript_reload_failed", - "error_code": error_code, - "fallback_line": true, - "source": { - "path": path, - "line": line, - }, - }, - } - - -static func _fallback_gdscript_error_line(content: String) -> int: - var lines := content.split("\n") - for i in range(lines.size() - 1, -1, -1): - if not str(lines[i]).strip_edges().is_empty(): - return i + 1 - return 1 - - -func patch_script(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var old_text: String = params.get("old_text", "") - var new_text: String = params.get("new_text", "") - var replace_all: bool = params.get("replace_all", false) - - var path_err = McpPathValidator.path_error(path, "path", true) - if path_err != null: - return path_err - if not "old_text" in params: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: old_text") - if not "new_text" in params: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: new_text") - if not path.ends_with(".gd"): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Path must end with .gd (use filesystem_write_text for other text files)") - if old_text.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "old_text must not be empty") - - var read := FileAccess.open(path, FileAccess.READ) - if read == null: - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found or unreadable: %s" % path) - var content := read.get_as_text() - read.close() - - var match_count := content.count(old_text) - if match_count == 0: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "old_text not found in %s" % path) - if match_count > 1 and not replace_all: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "old_text matches %d times; pass replace_all=true or provide a more specific snippet" % match_count, - ) - - var new_content: String - var replacements: int - if replace_all: - new_content = content.replace(old_text, new_text) - replacements = match_count - else: - var idx := content.find(old_text) - new_content = content.substr(0, idx) + new_text + content.substr(idx + old_text.length()) - replacements = 1 - - # Shared write path (#714). No import-settle deferral here: the file - # already exists, so ResourceLoader knows it and there is no scan to wait - # for — same rationale as create_script's overwrite arm. - var write_failure: Variant = McpResourceIO.write_text_to_disk(path, new_content) - if write_failure != null: - return write_failure - - var data := { - "path": path, - "replacements": replacements, - "size": new_content.length(), - "old_size": content.length(), - "undoable": false, - "reason": "File system operations cannot be undone via editor undo", - } - _attach_gdscript_diagnostics(data, path, new_content) - - # Single-file register, not a full scan() — see create_script (dsarno/godot#6). - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - return {"data": data} - - -func attach_script(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - var script_path: String = params.get("script_path", "") - - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - if script_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: script_path") - - var spath_err = McpPathValidator.loadable_error(script_path, "script_path") - if spath_err != null: - return spath_err - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - if not ResourceLoader.exists(script_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Script not found: %s" % script_path) - - var script: Script = load(script_path) - if script == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script: %s" % script_path) - - var old_script: Script = node.get_script() - - _undo_redo.create_action("MCP: Attach script to %s" % node.name) - _undo_redo.add_do_method(node, "set_script", script) - _undo_redo.add_undo_method(node, "set_script", old_script) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "script_path": script_path, - "had_previous_script": old_script != null, - "undoable": true, - } - } - - -func detach_script(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - var old_script: Script = node.get_script() - if old_script == null: - return {"data": {"path": node_path, "had_script": false, "undoable": false, "reason": "No script attached"}} - - _undo_redo.create_action("MCP: Detach script from %s" % node.name) - _undo_redo.add_do_method(node, "set_script", null) - _undo_redo.add_undo_method(node, "set_script", old_script) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "removed_script": old_script.resource_path if old_script.resource_path else "(inline)", - "undoable": true, - } - } - - -func find_symbols(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - - var path_err = McpPathValidator.path_error(path, "path") - if path_err != null: - return path_err - - if not FileAccess.file_exists(path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "File not found: %s" % path) - - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file: %s" % path) - - var content := file.get_as_text() - file.close() - - var functions: Array[Dictionary] = [] - var signals_list: Array[String] = [] - var exports: Array[Dictionary] = [] - var class_name_str := "" - var extends_str := "" - - var lines := content.split("\n") - for i in lines.size(): - var line := lines[i].strip_edges() - - # class_name — same cut logic as _extract_class_name so the - # `extends Bar` / icon-form tails don't leak into the symbol name. - if line.begins_with("class_name "): - var cn_rest := line.substr(11).strip_edges() - var cn_cut := cn_rest.length() - for ci in cn_rest.length(): - var cn_ch := cn_rest[ci] - if cn_ch == " " or cn_ch == "\t" or cn_ch == ",": - cn_cut = ci - break - class_name_str = cn_rest.substr(0, cn_cut) - - # extends - if line.begins_with("extends "): - extends_str = line.substr(8).strip_edges() - - # signal - if line.begins_with("signal "): - var sig_text := line.substr(7).strip_edges() - # Strip any parameters for the name - var paren_idx := sig_text.find("(") - if paren_idx >= 0: - signals_list.append(sig_text.substr(0, paren_idx).strip_edges()) - else: - signals_list.append(sig_text) - - # func (including `static func` — strip the leading `static ` first) - var func_line := line.substr(7).strip_edges() if line.begins_with("static func ") else line - if func_line.begins_with("func "): - var func_text := func_line.substr(5).strip_edges() - var paren_idx := func_text.find("(") - if paren_idx >= 0: - functions.append({ - "name": func_text.substr(0, paren_idx).strip_edges(), - "line": i + 1, - }) - - # @export - if line.begins_with("@export"): - # Next non-empty line should have the var declaration - # But often export and var are on the same logical flow - # Try to find "var" on the same line or the next line - var var_line := line - if var_line.find("var ") == -1 and i + 1 < lines.size(): - var_line = lines[i + 1].strip_edges() - var var_idx := var_line.find("var ") - if var_idx >= 0: - var rest := var_line.substr(var_idx + 4).strip_edges() - # Extract variable name (up to : or = or end) - var end_idx := rest.length() - for ch_idx in rest.length(): - if rest[ch_idx] == ":" or rest[ch_idx] == "=" or rest[ch_idx] == " ": - end_idx = ch_idx - break - exports.append({ - "name": rest.substr(0, end_idx), - "line": i + 1, - }) - - return { - "data": { - "path": path, - "class_name": class_name_str, - "extends": extends_str, - "functions": functions, - "signals": signals_list, - "exports": exports, - "function_count": functions.size(), - "signal_count": signals_list.size(), - "export_count": exports.size(), - } - } diff --git a/addons/godot_ai/handlers/script_handler.gd.uid b/addons/godot_ai/handlers/script_handler.gd.uid deleted file mode 100644 index 6be1ac4..0000000 --- a/addons/godot_ai/handlers/script_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dhub87454jxb3 diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd deleted file mode 100644 index ad85c7d..0000000 --- a/addons/godot_ai/handlers/signal_handler.gd +++ /dev/null @@ -1,274 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles signal listing, connecting, and disconnecting on scene nodes. - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -func list_signals(params: Dictionary) -> Dictionary: - var path_value: Variant = params.get("path", "") - var path_type_err = McpParamValidators.require_string("path", path_value) - if path_type_err != null: - return path_type_err - ## String(...) conversion: require_string accepts StringName too, and - ## a bare typed assignment from StringName would defeat the guard. - var path: String = String(path_value) - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var _resolved := McpNodeValidator.resolve_or_error(path, "path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var scene_root: Node = _resolved.scene_root - - ## Default: hide editor-internal connections (SceneTreeEditor observers - ## live on every scene node and would otherwise dominate the response). - ## Pass include_editor=true to see them. See #213. - var include_editor: bool = params.get("include_editor", false) - - var signals: Array[Dictionary] = [] - for sig in node.get_signal_list(): - var args: Array[Dictionary] = [] - for arg in sig.get("args", []): - args.append({"name": arg.get("name", ""), "type": type_string(arg.get("type", 0))}) - signals.append({ - "name": sig.get("name", ""), - "args": args, - }) - - var connections: Array[Dictionary] = [] - var editor_connection_count := 0 - for sig in signals: - for conn in node.get_signal_connection_list(sig.name): - var callable: Callable = conn.get("callable", Callable()) - var target := callable.get_object() - if target == null: - continue # skip connections to freed objects - if not include_editor and _is_editor_internal_target(target, scene_root): - editor_connection_count += 1 - continue - connections.append({ - "signal": sig.name, - "target": _format_target_path(target, scene_root), - "method": callable.get_method(), - }) - - return { - "data": { - "path": McpScenePath.from_node(node, scene_root), - "signals": signals, - "signal_count": signals.size(), - "connections": connections, - "connection_count": connections.size(), - "editor_connection_count": editor_connection_count, - } - } - - -## A target is "editor-internal" when it's a Node sitting outside the edited -## scene tree AND not anywhere under a declared autoload — typical case is -## the SceneTreeEditor dock listening for visibility/script/state changes on -## every scene node. Connections to autoloads (declared under ``autoload/*`` -## in ProjectSettings) are user-authored even though they live under -## ``/root/`` rather than under the edited scene root, so the autoload -## root *and* any descendant of it stay visible. Non-Node targets -## (anonymous Callables, RefCounted listeners etc.) also stay visible — we -## can't reliably classify them. -func _is_editor_internal_target(target: Object, scene_root: Node) -> bool: - if not (target is Node): - return false - var node_target: Node = target - if node_target == scene_root: - return false - if scene_root.is_ancestor_of(node_target): - return false - if _is_under_autoload(node_target): - return false - return true - - -## True if `node` is a declared autoload root or sits anywhere under one. -## When the node is in the SceneTree we read its absolute path -## (``/root//...``) and check the first segment after ``/root/``; -## this covers connections to deep descendants of editor-instanced -## autoloads (e.g. ``/root/MyAutoload/Foo/Bar``). When the node isn't in -## the tree (test fixtures often construct nodes in isolation), we walk -## the parent chain and match each ancestor's ``name`` against the -## autoload key as a best-effort fallback. -static func _is_under_autoload(node: Node) -> bool: - if node.is_inside_tree(): - var path := str(node.get_path()) - if not path.begins_with("/root/"): - return false - var first_segment := path.substr(6).split("/", true, 1)[0] - return ProjectSettings.has_setting("autoload/" + first_segment) - var cursor: Node = node - while cursor != null: - if ProjectSettings.has_setting("autoload/" + str(cursor.name)): - return true - cursor = cursor.get_parent() - return false - - -## Serialize a connection's target path. Descendants of (or equal to) the -## edited scene root render as the usual scene-relative form -## (``/Main/Camera3D``). Non-descendants — autoload subtrees in particular -## — render as their canonical absolute SceneTree path -## (``/root/MyAutoload/Child``) instead of a scene-relative path full of -## ``..`` segments, which agents can't navigate back to. Non-Node targets -## (anonymous Callables, etc.) fall back to their string representation. -static func _format_target_path(target: Object, scene_root: Node) -> String: - if not (target is Node): - return str(target) - var node_target: Node = target - if node_target == scene_root or scene_root.is_ancestor_of(node_target): - return McpScenePath.from_node(node_target, scene_root) - if node_target.is_inside_tree(): - return str(node_target.get_path()) - return McpScenePath.from_node(node_target, scene_root) - - -func connect_signal(params: Dictionary) -> Dictionary: - var resolved := _resolve_signal_params(params) - if resolved.has("error"): - return resolved - - var source: Node = resolved.source - var target: Node = resolved.target - var signal_name: String = resolved.signal_name - var method: String = resolved.method - var scene_root: Node = resolved.scene_root - - if not source.has_signal(signal_name): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Signal '%s' not found on %s" % [signal_name, params.path]) - - if not target.has_method(method): - return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, "Method '%s' not found on %s" % [method, params.target]) - - var callable := Callable(target, method) - if source.is_connected(signal_name, callable): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method]) - - _undo_redo.create_action("MCP: Connect signal %s" % signal_name) - _undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST) - _undo_redo.add_undo_method(source, "disconnect", signal_name, callable) - _undo_redo.commit_action() - - return {"data": _signal_response(source, signal_name, target, method, scene_root)} - - -func disconnect_signal(params: Dictionary) -> Dictionary: - var resolved := _resolve_signal_params(params) - if resolved.has("error"): - return resolved - - var source: Node = resolved.source - var target: Node = resolved.target - var signal_name: String = resolved.signal_name - var method: String = resolved.method - var scene_root: Node = resolved.scene_root - - var callable := Callable(target, method) - if not source.is_connected(signal_name, callable): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method]) - - # Capture the connection's current flags so undo restores it exactly as it - # was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would - # silently promote a runtime-only connection into one that serializes on the - # next save. (The connection still exists at this point — checked above.) - var reconnect_flags := 0 - for conn in source.get_signal_connection_list(signal_name): - if conn.get("callable", Callable()) == callable: - reconnect_flags = int(conn.get("flags", 0)) - break - - _undo_redo.create_action("MCP: Disconnect signal %s" % signal_name) - _undo_redo.add_do_method(source, "disconnect", signal_name, callable) - _undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags) - _undo_redo.commit_action() - - return {"data": _signal_response(source, signal_name, target, method, scene_root)} - - -func _resolve_signal_params(params: Dictionary) -> Dictionary: - for key in ["path", "signal", "target", "method"]: - ## Type-check before calling .is_empty(): a non-string value (e.g. an - ## int or dict) has no is_empty() and would crash the handler, which - ## the dispatcher only reports as an opaque "malformed result" (#210). - var value = params.get(key, "") - var type_err = McpParamValidators.require_string(key, value) - if type_err != null: - return type_err - if value.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % key) - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var source_result := _resolve_node_or_autoload(params.path, scene_root, "Source") - if source_result.has("error"): - return source_result - var source: Node = source_result.node - - var target_result := _resolve_node_or_autoload(params.target, scene_root, "Target") - if target_result.has("error"): - return target_result - var target: Node = target_result.node - - return { - "source": source, - "target": target, - "signal_name": params.signal, - "method": params.method, - "scene_root": scene_root, - } - - -## Resolve a path to a Node, with three distinct outcomes: -## 1. Found in the edited scene tree → returns {node} -## 2. Declared as an autoload AND instantiated at edit time → returns {node} -## 3. Declared as an autoload but NOT instantiated at edit time → returns -## INVALID_PARAMS with guidance. Most autoloads are runtime-only, so a -## silent "not found" hides the real reason the connection can't be made. -## 4. Not in scene and not a declared autoload → returns INVALID_PARAMS. -func _resolve_node_or_autoload(path: String, scene_root: Node, role: String) -> Dictionary: - var node := McpScenePath.resolve(path, scene_root) - if node != null: - return {"node": node} - - var name := path.trim_prefix("/") - if ProjectSettings.has_setting("autoload/" + name): - # Autoload is declared — see if the editor has it instanced. - var tree := Engine.get_main_loop() - if tree is SceneTree: - var live := (tree as SceneTree).root.get_node_or_null(name) - if live != null: - return {"node": live} - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "%s '%s' is a declared autoload but isn't instantiated in the editor. " % [role, name] + - "Most autoloads are runtime-only; edit-time signal connection isn't supported for them. " + - "Connect it from a script attached to the scene using @onready + connect(), " + - "or enable editor-instancing for this autoload in Project Settings > Autoload.") - - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, - "%s node not found: %s (not in scene tree or autoloads)" % [role, path]) - - -func _signal_response(source: Node, signal_name: String, target: Node, method: String, scene_root: Node) -> Dictionary: - return { - "source": McpScenePath.from_node(source, scene_root), - "signal": signal_name, - "target": McpScenePath.from_node(target, scene_root), - "method": method, - "undoable": true, - } diff --git a/addons/godot_ai/handlers/signal_handler.gd.uid b/addons/godot_ai/handlers/signal_handler.gd.uid deleted file mode 100644 index d95e1d3..0000000 --- a/addons/godot_ai/handlers/signal_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b4n8byjeqeddm diff --git a/addons/godot_ai/handlers/test_handler.gd b/addons/godot_ai/handlers/test_handler.gd deleted file mode 100644 index 2c0f4ea..0000000 --- a/addons/godot_ai/handlers/test_handler.gd +++ /dev/null @@ -1,349 +0,0 @@ -@tool -extends RefCounted - -## Discovers and runs McpTestSuite scripts from res://tests/. -## Exposes run_tests and get_test_results as MCP commands. -## -## Live MCP runs service the WebSocket transport between tests -## (McpConnection.service_transport_during_exclusive_run) so a long suite -## can no longer starve the server's keepalive, and abort at a per-call -## ceiling derived from the server-provided time budget. Direct callers, -## unit-test fixtures, and batch contexts (no request id / no connection) -## keep the legacy fully-synchronous behavior with no ceiling. -## See docs/test-run-transport-starvation-plan.md. - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Clamp bounds for the server-provided ``timeout_budget_sec`` param. The -## floor is purely defensive (a malformed or buggy server value must not -## abort every run instantly); the param is not user-facing. -const BUDGET_MIN_SEC := 30.0 -const BUDGET_MAX_SEC := 3600.0 -## Conservative default when the server sent no (or an invalid) budget: an -## old server's own test_run timeout is 120s, and the plugin must abort -## and reply before that future expires. -const BUDGET_DEFAULT_SEC := 110.0 -## Abort this long before the server would time the call out, so the -## partial-results reply beats the server-side timeout. -const CEILING_MARGIN_SEC := 10.0 - -var _runner: McpTestRunner -var _undo_redo: EditorUndoRedoManager -var _log_buffer: McpLogBuffer -## Live plugin dispatcher, exposed to suites via ctx so tests can prove the -## lazy handler registrations (#736) materialize with their real ctor args. -## Optional third arg keeps old two-arg fixtures working; untyped because -## the dispatcher constructs this handler (avoids a load-time type cycle). -var _dispatcher -## Live connection for exclusive-run transport servicing. Null in unit-test -## fixtures and batch contexts, which keep the legacy synchronous path. -var _connection: McpConnection - - -func _init( - undo_redo: EditorUndoRedoManager, - log_buffer: McpLogBuffer, - dispatcher = null, - connection: McpConnection = null, -) -> void: - _runner = McpTestRunner.new() - _undo_redo = undo_redo - _log_buffer = log_buffer - _dispatcher = dispatcher - _connection = connection - - -func run_tests(params: Dictionary) -> Dictionary: - var suite_filter: String = params.get("suite", "") - var test_filter: String = params.get("test_name", "") - var exclude_test_filter: String = params.get("exclude_test_name", "") - var verbose: bool = params.get("verbose", false) - - var request_id: String = params.get("_request_id", "") - var live := _connection != null and not request_id.is_empty() - var service_cb := Callable() - var deadline_ticks_ms := 0 - var budget_sec := 0.0 - var started_ms := Time.get_ticks_msec() - var run_state := {} - if live: - budget_sec = _validated_budget_sec(params) - service_cb = Callable(_connection, "service_transport_during_exclusive_run") - deadline_ticks_ms = started_ms + int((budget_sec - CEILING_MARGIN_SEC) * 1000.0) - - ## Clear the previous run's results BEFORE discovery so an abort at any - ## later point can never expose a stale prior run via get_test_results. - _runner.clear() - - var discovery := _discover_suites(service_cb, deadline_ticks_ms, run_state) - var discovery_outcome: String = discovery.get("outcome", "") - if not discovery_outcome.is_empty(): - ## Aborted during discovery: no suite has begun, so there is no - ## suite teardown to run. Same outcome mapping as the run itself. - var empty_results: Dictionary = _runner.get_results(verbose) - if not discovery.errors.is_empty(): - empty_results["load_errors"] = discovery.errors - return _map_outcome( - discovery_outcome, "discovery", empty_results, 0, started_ms, budget_sec - ) - - var suites: Array = discovery.suites - ## #882: an explicit `suite` filter that matches nothing must say so, not - ## return {"total": 0} — a caller cannot otherwise distinguish "suite has - ## no tests" from "suite was never registered" (truncated discovery). - ## Checked BEFORE the no-suites branch so an empty discovery still names - ## the unmatched filter (with suites_available: []). - var unknown := unknown_suite_error(suite_filter, suites) - if not unknown.is_empty(): - if not discovery.errors.is_empty(): - unknown["error"]["data"]["load_errors"] = discovery.errors - return unknown - - if suites.is_empty(): - var msg := "No test suites found in res://tests/" - if not discovery.errors.is_empty(): - msg += " (%d script(s) failed to load: %s)" % [ - discovery.errors.size(), - ", ".join(discovery.errors), - ] - var no_suites := {"error": msg, "total": 0, "load_errors": discovery.errors} - ## Keep the edited_scene annotation on the no-suites error payload too, - ## so the response contract is consistent across every return path. - _annotate_edited_scene(no_suites) - return {"data": no_suites} - - var ctx := { - "undo_redo": _undo_redo, - "log_buffer": _log_buffer, - "dispatcher": _dispatcher, - } - - var run: Dictionary = _runner.run_suites_serviced( - suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter, - service_cb, deadline_ticks_ms, run_state - ) - var results: Dictionary = run["results"] - if not discovery.errors.is_empty(): - results["load_errors"] = discovery.errors - return _map_outcome( - run["outcome"], "run", results, run["tests_not_run"], started_ms, budget_sec - ) - - -## Empty when `suite_filter` is empty or names a discovered suite; otherwise -## the INVALID_PARAMS protocol error run_tests returns (same ErrorCodes.make -## shape as the paused/timeout aborts — never an error inside a success -## envelope). Static + pure so the suite can pin it without calling -## run_tests() end-to-end (which would recursively re-run the whole corpus -## from inside itself — see test_serviced_test_run.gd). -static func unknown_suite_error(suite_filter: String, suites: Array) -> Dictionary: - if suite_filter.is_empty(): - return {} - var names := PackedStringArray() - for suite: McpTestSuite in suites: - names.append(suite.suite_name()) - if names.has(suite_filter): - return {} - var err := ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - ( - "No suite named '%s' is registered (%d discovered)." - + " If it should exist, discovery may be truncated —" - + " re-import and restart the editor (#882)." - ) % [suite_filter, names.size()], - ) - err["error"]["data"] = { - "suite": suite_filter, - "suites_available": Array(names), - } - return err - - -## Map a runner/discovery outcome onto the response envelope. Ownership is -## deliberately here, not in the runner: the runner reports WHAT happened, -## the handler decides how it goes over the wire (plan D2). -func _map_outcome( - outcome: String, - phase: String, - results: Dictionary, - tests_not_run: int, - started_ms: int, - budget_sec: float, -) -> Dictionary: - var elapsed_ms := Time.get_ticks_msec() - started_ms - match outcome: - "completed": - _annotate_edited_scene(results) - return {"data": results} - "transport_lost": - ## The peer is gone (or flood-closed); the send will fail against - ## the dead socket regardless, but a sync handler must return an - ## envelope. Partials stay retrievable via get_test_results after - ## the plugin reconnects. - results["aborted"] = "transport_lost" - results["tests_not_run"] = tests_not_run - _annotate_edited_scene(results) - return {"data": results} - "paused": - var depth := _connection.pause_depth() if _connection != null else 0 - if _log_buffer != null: - _log_buffer.log( - "[error] test run aborted in %s: transport paused at checkpoint (depth %d)" - % [phase, depth] - ) - var paused_err := ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - ( - "Test run aborted: the MCP transport was paused at a between-test " - + "checkpoint (pause depth %d) — a paused transport cannot service " - + "the WebSocket heartbeat, so continuing would starve the session. " - + "Partial results: test_manage(op=\"results_get\")." - ) % depth - ) - paused_err["error"]["data"] = _abort_data( - phase, results, tests_not_run, elapsed_ms, budget_sec, {"pause_depth": depth} - ) - return paused_err - "timeout": - var timeout_err := ErrorCodes.make( - ErrorCodes.TEST_RUN_TIMEOUT, - ( - "Test run hit its abort ceiling after %.1fs (budget %.0fs, ceiling = " - + "budget - %.0fs): %d passed, %d failed, %d of the selected tests " - + "never ran. Narrow the run with suite=/test_name= filters, or fetch " - + "the partial results with test_manage(op=\"results_get\")." - ) % [ - elapsed_ms / 1000.0, budget_sec, CEILING_MARGIN_SEC, - int(results.get("passed", 0)), int(results.get("failed", 0)), - tests_not_run, - ] - ) - timeout_err["error"]["data"] = _abort_data( - phase, results, tests_not_run, elapsed_ms, budget_sec, {} - ) - return timeout_err - ## Unknown outcome is a runner bug — surface it loudly. - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, "Unknown test run outcome '%s'" % outcome - ) - - -func _abort_data( - phase: String, - results: Dictionary, - tests_not_run: int, - elapsed_ms: int, - budget_sec: float, - extra: Dictionary, -) -> Dictionary: - var data := { - "phase": phase, - "elapsed_ms": elapsed_ms, - "budget_sec": budget_sec, - "passed": int(results.get("passed", 0)), - "failed": int(results.get("failed", 0)), - "skipped": int(results.get("skipped", 0)), - "total": int(results.get("total", 0)), - "tests_not_run": tests_not_run, - } - data.merge(extra) - return data - - -## Strict validation of the server-provided per-call budget: numeric, -## finite, positive, then clamped to [BUDGET_MIN_SEC, BUDGET_MAX_SEC]. -## Everything else (missing, wrong type, NaN/inf, non-positive) falls back -## to BUDGET_DEFAULT_SEC. typeof() so bool never sneaks through as int. -func _validated_budget_sec(params: Dictionary) -> float: - var raw: Variant = params.get("timeout_budget_sec", null) - var t := typeof(raw) - if t == TYPE_FLOAT or t == TYPE_INT: - var v := float(raw) - if is_finite(v) and v > 0.0: - return clampf(v, BUDGET_MIN_SEC, BUDGET_MAX_SEC) - return BUDGET_DEFAULT_SEC - - -## Many suites assume the project's main scene is the edited scene (they read -## /Main/... nodes directly). Running with another scene open produces a flood -## of phantom failures that look like real regressions. Surface the edited -## scene and a warning when it differs from run/main_scene so the failures are -## attributable at a glance instead of costing a debugging round (#635). -func _annotate_edited_scene(results: Dictionary) -> void: - var scene_root := EditorInterface.get_edited_scene_root() - var edited := scene_root.scene_file_path if scene_root else "" - results["edited_scene"] = edited - var main_scene := str(ProjectSettings.get_setting("application/run/main_scene", "")) - if main_scene.is_empty() or edited == main_scene: - return - if int(results.get("failed", 0)) <= 0: - return - results["scene_warning"] = ( - "Edited scene is '%s' but the project main scene is '%s'. Many suites " - % [edited if not edited.is_empty() else "", main_scene] - + "assume the main scene is open and will report phantom failures " - + "otherwise. If these failures are unexpected, scene_open('%s') and re-run." % main_scene - ) - - -func get_test_results(params: Dictionary) -> Dictionary: - var verbose: bool = params.get("verbose", false) - return {"data": _runner.get_results(verbose)} - - -## Returns {"suites": Array, "errors": Array[String], "outcome": String}. -## Resilient: a broken script doesn't kill discovery of the rest. A -## non-empty outcome ("timeout" / "transport_lost" / "paused") means a -## between-load checkpoint aborted discovery — script loading is itself an -## atomic phase, and a directory of heavy scripts must neither starve the -## heartbeat nor escape the run budget. -func _discover_suites( - service_cb: Callable = Callable(), - deadline_ticks_ms: int = 0, - run_state: Dictionary = {}, -) -> Dictionary: - var suites := [] - var errors: Array[String] = [] - var dir := DirAccess.open("res://tests") - if dir == null: - return { - "suites": suites, - "errors": ["DirAccess.open('res://tests') returned null — directory may not exist"], - "outcome": "", - } - - dir.list_dir_begin() - var file_name := dir.get_next() - while not file_name.is_empty(): - if file_name.begins_with("test_") and file_name.ends_with(".gd"): - var stop := _discovery_checkpoint(service_cb, deadline_ticks_ms, run_state) - if not stop.is_empty(): - return {"suites": suites, "errors": errors, "outcome": stop} - var path := "res://tests/" + file_name - var script = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) - if script == null: - errors.append("%s (load failed — check for parse errors or duplicate methods)" % file_name) - elif script.can_instantiate(): - var instance = script.new() - if instance is McpTestSuite: - suites.append(instance) - else: - errors.append("%s (not a McpTestSuite subclass)" % file_name) - else: - errors.append("%s (cannot instantiate — abstract or broken)" % file_name) - file_name = dir.get_next() - - ## Sort by suite name for deterministic order. - suites.sort_custom(func(a, b) -> bool: - return a.suite_name() < b.suite_name() - ) - return {"suites": suites, "errors": errors, "outcome": ""} - - -## Discovery-phase twin of McpTestRunner._checkpoint. Both delegate to the -## shared McpConnection.exclusive_run_checkpoint so the outcome mapping -## cannot drift between the discovery and between-test paths. -func _discovery_checkpoint( - service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary -) -> String: - return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state) diff --git a/addons/godot_ai/handlers/test_handler.gd.uid b/addons/godot_ai/handlers/test_handler.gd.uid deleted file mode 100644 index e56fce1..0000000 --- a/addons/godot_ai/handlers/test_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bfg3c6iinhwmx diff --git a/addons/godot_ai/handlers/texture_handler.gd b/addons/godot_ai/handlers/texture_handler.gd deleted file mode 100644 index 4e90d18..0000000 --- a/addons/godot_ai/handlers/texture_handler.gd +++ /dev/null @@ -1,199 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Creates procedural textures — GradientTexture2D (wrapping a Gradient) -## and NoiseTexture2D (wrapping a FastNoiseLite). Assigns to a node slot -## (undoable, bundles sub-resources) or saves to a .tres file. - -const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd") - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -const _FILL_MODES := { - "linear": GradientTexture2D.FILL_LINEAR, - "radial": GradientTexture2D.FILL_RADIAL, - "square": GradientTexture2D.FILL_SQUARE, -} - -const _NOISE_TYPES := { - "simplex": FastNoiseLite.TYPE_SIMPLEX, - "simplex_smooth": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, - "perlin": FastNoiseLite.TYPE_PERLIN, - "cellular": FastNoiseLite.TYPE_CELLULAR, - "value": FastNoiseLite.TYPE_VALUE, - "value_cubic": FastNoiseLite.TYPE_VALUE_CUBIC, -} - - -# ============================================================================ -# gradient_texture_create -# ============================================================================ - -func create_gradient_texture(params: Dictionary) -> Dictionary: - var stops: Array = params.get("stops", []) - var width: int = params.get("width", 256) - var height: int = params.get("height", 1) - var fill: String = params.get("fill", "linear") - - if stops.size() < 2: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "gradient_texture_create requires at least 2 stops, got %d" % stops.size() - ) - if not _FILL_MODES.has(fill): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid fill '%s'. Valid: %s" % [fill, ", ".join(_FILL_MODES.keys())] - ) - - var home_err := McpResourceIO.validate_home(params) - if home_err != null: - return home_err - - var gradient := Gradient.new() - var offsets := PackedFloat32Array() - var colors := PackedColorArray() - for i in range(stops.size()): - var stop = stops[i] - if not stop is Dictionary: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "stops[%d] must be a dict with 'offset' and 'color' keys" % i - ) - if not stop.has("offset") or not stop.has("color"): - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "stops[%d] missing 'offset' or 'color' key" % i - ) - offsets.append(float(stop["offset"])) - var color_value = NodeHandler._coerce_value(stop["color"], TYPE_COLOR) - var color_err := NodeHandler._check_coerced(color_value, TYPE_COLOR, "stops[%d].color" % i) - if color_err != null: - return color_err - colors.append(color_value) - gradient.offsets = offsets - gradient.colors = colors - - var tex := GradientTexture2D.new() - tex.gradient = gradient - tex.width = width - tex.height = height - tex.fill = _FILL_MODES[fill] - - return _finalize(tex, [gradient], params, "Gradient texture", { - "texture_class": "GradientTexture2D", - "gradient_class": "Gradient", - "stop_count": stops.size(), - "fill": fill, - }) - - -# ============================================================================ -# noise_texture_create -# ============================================================================ - -func create_noise_texture(params: Dictionary) -> Dictionary: - var noise_type: String = params.get("noise_type", "simplex_smooth") - var width: int = params.get("width", 512) - var height: int = params.get("height", 512) - var frequency: float = params.get("frequency", 0.01) - var seed_value: int = params.get("seed", 0) - var fractal_octaves: int = params.get("fractal_octaves", 0) # 0 = leave default - - if not _NOISE_TYPES.has(noise_type): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid noise_type '%s'. Valid: %s" % [noise_type, ", ".join(_NOISE_TYPES.keys())] - ) - - var home_err := McpResourceIO.validate_home(params) - if home_err != null: - return home_err - - var noise := FastNoiseLite.new() - noise.noise_type = _NOISE_TYPES[noise_type] - noise.frequency = frequency - noise.seed = seed_value - if fractal_octaves > 0: - noise.fractal_octaves = fractal_octaves - - var tex := NoiseTexture2D.new() - tex.noise = noise - tex.width = width - tex.height = height - - return _finalize(tex, [noise], params, "Noise texture", { - "texture_class": "NoiseTexture2D", - "noise_class": "FastNoiseLite", - "noise_type": noise_type, - }) - - -# ============================================================================ -# shared helpers -# ============================================================================ - -func _finalize(tex: Resource, sub_resources: Array, params: Dictionary, label: String, extra: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - var property: String = params.get("property", "") - var resource_path: String = params.get("resource_path", "") - var overwrite: bool = params.get("overwrite", false) - - if not resource_path.is_empty(): - return McpResourceIO.save_to_disk(tex, resource_path, overwrite, label, extra, _connection) - return _assign_texture(tex, sub_resources, node_path, property, label, extra) - - -func _assign_texture(tex: Resource, sub_resources: Array, node_path: String, property: String, label: String, extra: Dictionary) -> Dictionary: - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - - var found := false - var prop_type: int = TYPE_NIL - for prop in node.get_property_list(): - if prop.name == property: - found = true - prop_type = prop.get("type", TYPE_NIL) - break - if not found: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(node, property) - ) - if prop_type != TYPE_NIL and prop_type != TYPE_OBJECT: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Property '%s' on %s is not an Object slot" % [property, node.get_class()] - ) - - var old_value = node.get(property) - - _undo_redo.create_action("MCP: Create %s for %s.%s" % [label, node.name, property]) - _undo_redo.add_do_property(node, property, tex) - _undo_redo.add_undo_property(node, property, old_value) - _undo_redo.add_do_reference(tex) - for sub in sub_resources: - _undo_redo.add_do_reference(sub) - _undo_redo.commit_action() - - var data := { - "path": node_path, - "property": property, - "undoable": true, - } - data.merge(extra) - return {"data": data} - - diff --git a/addons/godot_ai/handlers/texture_handler.gd.uid b/addons/godot_ai/handlers/texture_handler.gd.uid deleted file mode 100644 index a0a0f73..0000000 --- a/addons/godot_ai/handlers/texture_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmloikhre8lhe diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd deleted file mode 100644 index a71c745..0000000 --- a/addons/godot_ai/handlers/theme_handler.gd +++ /dev/null @@ -1,476 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles Theme resource authoring: creating, modifying color/constant/font-size/ -## stylebox slots, and applying a theme to a Control subtree. -## -## Themes are Godot's equivalent of USS: a Theme holds (class, name) -> value -## entries (colors, constants, fonts, font_sizes, styleboxes, icons) which -## cascade down a Control subtree when the theme is assigned at any ancestor. -## One well-authored theme replaces hundreds of per-node property sets. - -const _COLOR_HINT := "expected hex #rrggbb, named color, or {r,g,b,a} dict" - -var _undo_redo: EditorUndoRedoManager -var _connection: McpConnection - - -func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null) -> void: - _undo_redo = undo_redo - _connection = connection - - -# ============================================================================ -# theme_create -# ============================================================================ - -func create_theme(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var overwrite: bool = params.get("overwrite", false) - - var err := _validate_res_path(path, ".tres", "path", true) - if err != null: - return err - - # Capture whether the file was already there BEFORE the save so we can - # report `overwritten` accurately (after save the file always exists). - var existed_before := FileAccess.file_exists(path) - if existed_before and not overwrite: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Theme already exists at %s (pass overwrite=true to replace)" % path - ) - - # Ensure parent directory exists. make_dir_recursive is idempotent — - # no need to check dir_exists first (avoids TOCTOU race). - var dir_path := path.get_base_dir() - var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) - if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to create directory: %s (error %d)" % [dir_path, mkdir_err] - ) - - var theme := Theme.new() - var save_err := McpResourceIO.guarded_save(theme, path, _connection) - if save_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to save theme to %s: %s (error %d)" % [path, error_string(save_err), save_err] - ) - - # Make sure the editor's filesystem picks up the new file. - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(path) - - return { - "data": { - "path": path, - "overwritten": existed_before, - "undoable": false, - "reason": "File creation is persistent; delete the file manually to revert", - } - } - - -# ============================================================================ -# theme_set_color / theme_set_constant / theme_set_font_size -# ============================================================================ - -func set_color(params: Dictionary) -> Dictionary: - return _set_scalar(params, "color", func(theme, name, cls): return theme.get_color(name, cls), - func(theme, name, cls, val): theme.set_color(name, cls, val), - func(theme, name, cls): theme.clear_color(name, cls), - func(theme, name, cls): return theme.has_color(name, cls), - func(v): return _parse_color(v)) - - -# constant / font_size parsers validate before coercing: int("abc")/int({})/int([]) -# all return 0 in GDScript (never null), so a bare `int(v)` would silently store -# garbage as 0 and report success. Returning null for non-numeric input lets -# _set_scalar's null guard surface a VALUE_OUT_OF_RANGE error, matching the -# color path's contract. -func set_constant(params: Dictionary) -> Dictionary: - return _set_scalar(params, "constant", func(theme, name, cls): return theme.get_constant(name, cls), - func(theme, name, cls, val): theme.set_constant(name, cls, int(val)), - func(theme, name, cls): theme.clear_constant(name, cls), - func(theme, name, cls): return theme.has_constant(name, cls), - func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) - - -func set_font_size(params: Dictionary) -> Dictionary: - return _set_scalar(params, "font_size", func(theme, name, cls): return theme.get_font_size(name, cls), - func(theme, name, cls, val): theme.set_font_size(name, cls, int(val)), - func(theme, name, cls): theme.clear_font_size(name, cls), - func(theme, name, cls): return theme.has_font_size(name, cls), - func(v): return int(v) if (v is int or v is float or (v is String and v.is_valid_int())) else null) - - -# Shared implementation for scalar Theme slots (color, constant, font_size). -# Captures old value, applies new value, saves to disk, registers undo that -# restores the old value and saves again. -func _set_scalar( - params: Dictionary, - kind: String, - getter: Callable, - setter: Callable, - clearer: Callable, - has_fn: Callable, - parser: Callable, -) -> Dictionary: - var load_result := _load_theme_from_params(params) - if load_result.has("error"): - return load_result - var theme: Theme = load_result.theme - var theme_path: String = load_result.path - - var class_name_param: String = params.get("class_name", "") - if class_name_param.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name") - - var name: String = params.get("name", "") - if name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - - if not "value" in params: - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: value") - - var raw_value = params.get("value") - if raw_value == null: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind - ) - var parsed = parser.call(raw_value) - if parsed == null: - ## color slots want a color hint; constant/font_size are integer slots. - var hint := _COLOR_HINT if kind == "color" else "expected an integer" - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, - "Invalid %s value: %s (%s)" % [kind, raw_value, hint]) - - var had_before: bool = has_fn.call(theme, name, class_name_param) - var before_value = getter.call(theme, name, class_name_param) if had_before else null - - _undo_redo.create_action("MCP: Theme set %s %s/%s" % [kind, class_name_param, name]) - _undo_redo.add_do_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, parsed) - if had_before: - _undo_redo.add_undo_method(self, "_apply_scalar", theme_path, setter, name, class_name_param, before_value) - else: - _undo_redo.add_undo_method(self, "_clear_scalar", theme_path, clearer, name, class_name_param) - _undo_redo.commit_action() - - return { - "data": { - "path": theme_path, - "kind": kind, - "class_name": class_name_param, - "name": name, - "value": _serialize_value(parsed), - "previous_value": _serialize_value(before_value) if had_before else null, - "undoable": true, - } - } - - -func _apply_scalar(theme_path: String, setter: Callable, name: String, class_name_param: String, value: Variant) -> void: - var theme: Theme = ResourceLoader.load(theme_path) - if theme == null: - push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) - return - setter.call(theme, name, class_name_param, value) - McpResourceIO.guarded_save(theme, theme_path, _connection) - - -func _clear_scalar(theme_path: String, clearer: Callable, name: String, class_name_param: String) -> void: - var theme: Theme = ResourceLoader.load(theme_path) - if theme == null: - push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) - return - clearer.call(theme, name, class_name_param) - McpResourceIO.guarded_save(theme, theme_path, _connection) - - -# ============================================================================ -# theme_set_stylebox_flat -# ============================================================================ - -## Compose a StyleBoxFlat and assign it to a theme slot. -## -## Parameters (beyond theme_path / class_name / name): -## bg_color (Color, "#rrggbb", "#rrggbbaa", or {r,g,b,a}) -## border_color (Color) -## border {all|top|bottom|left|right: int} — side keys override `all` -## corners {all|top_left|top_right|bottom_left|bottom_right: int} -## margins {all|top|bottom|left|right: float} -## shadow {color, size: int, offset_x: float, offset_y: float} -## anti_aliasing (bool) -## -## Unknown keys inside any nested dict are rejected with INVALID_PARAMS so -## typos fail loudly instead of silently being ignored. -func set_stylebox_flat(params: Dictionary) -> Dictionary: - var load_result := _load_theme_from_params(params) - if load_result.has("error"): - return load_result - var theme: Theme = load_result.theme - var theme_path: String = load_result.path - - var class_name_param: String = params.get("class_name", "") - if class_name_param.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: class_name") - - var name: String = params.get("name", "") - if name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: name") - - var sb := StyleBoxFlat.new() - if params.has("bg_color"): - var bg := _parse_color(params.bg_color) - if bg == null: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid bg_color: %s (%s)" % [str(params.bg_color), _COLOR_HINT]) - sb.bg_color = bg - if params.has("border_color"): - var bc := _parse_color(params.border_color) - if bc == null: - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid border_color: %s (%s)" % [str(params.border_color), _COLOR_HINT]) - sb.border_color = bc - - # border: {all, top, bottom, left, right} — int widths - if params.has("border"): - var err := _apply_sides(sb, params.border, "border", - ["top", "bottom", "left", "right"], - "border_width_", - TYPE_INT) - if err != "": - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err) - - # corners: {all, top_left, top_right, bottom_left, bottom_right} — int radii - if params.has("corners"): - var err2 := _apply_sides(sb, params.corners, "corners", - ["top_left", "top_right", "bottom_left", "bottom_right"], - "corner_radius_", - TYPE_INT) - if err2 != "": - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err2) - - # margins: {all, top, bottom, left, right} — float padding - if params.has("margins"): - var err3 := _apply_sides(sb, params.margins, "margins", - ["top", "bottom", "left", "right"], - "content_margin_", - TYPE_FLOAT) - if err3 != "": - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, err3) - - # shadow: {color, size, offset_x, offset_y} - if params.has("shadow"): - if typeof(params.shadow) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "'shadow' must be a dict with color/size/offset_x/offset_y") - var shadow: Dictionary = params.shadow - var allowed_shadow_keys := {"color": true, "size": true, "offset_x": true, "offset_y": true} - for k in shadow.keys(): - if not allowed_shadow_keys.has(k): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Unknown key in 'shadow': %s (valid: color, size, offset_x, offset_y)" % k) - if shadow.has("color"): - var sc := _parse_color(shadow.color) - if sc == null: - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, - "Invalid shadow.color: %s (%s)" % [str(shadow.color), _COLOR_HINT]) - sb.shadow_color = sc - if shadow.has("size"): - sb.shadow_size = int(shadow.size) - if shadow.has("offset_x") or shadow.has("offset_y"): - sb.shadow_offset = Vector2( - float(shadow.get("offset_x", 0)), - float(shadow.get("offset_y", 0)), - ) - - if params.has("anti_aliasing"): - sb.anti_aliasing = bool(params.anti_aliasing) - - var had_before := theme.has_stylebox(name, class_name_param) - var before_sb: StyleBox = theme.get_stylebox(name, class_name_param) if had_before else null - - _undo_redo.create_action("MCP: Theme set stylebox %s/%s" % [class_name_param, name]) - _undo_redo.add_do_method(self, "_apply_stylebox", theme_path, name, class_name_param, sb) - if had_before: - _undo_redo.add_undo_method(self, "_apply_stylebox", theme_path, name, class_name_param, before_sb) - else: - _undo_redo.add_undo_method(self, "_clear_stylebox", theme_path, name, class_name_param) - _undo_redo.commit_action() - - return { - "data": { - "path": theme_path, - "class_name": class_name_param, - "name": name, - "stylebox_class": "StyleBoxFlat", - "bg_color": _serialize_value(sb.bg_color), - "border": { - "top": sb.border_width_top, - "bottom": sb.border_width_bottom, - "left": sb.border_width_left, - "right": sb.border_width_right, - }, - "corners": { - "top_left": sb.corner_radius_top_left, - "top_right": sb.corner_radius_top_right, - "bottom_left": sb.corner_radius_bottom_left, - "bottom_right": sb.corner_radius_bottom_right, - }, - "margins": { - "top": sb.content_margin_top, - "bottom": sb.content_margin_bottom, - "left": sb.content_margin_left, - "right": sb.content_margin_right, - }, - "undoable": true, - } - } - - -## Parse a {all, , , ...} dict and apply it to StyleBoxFlat via -## its set_ properties. Returns "" on success, an error -## message on failure. Validates that only known keys are present. -func _apply_sides(sb: StyleBoxFlat, sides_dict: Variant, dict_name: String, - side_names: Array, prop_prefix: String, value_type: int) -> String: - if typeof(sides_dict) != TYPE_DICTIONARY: - return "'%s' must be a dict with 'all' and/or side-specific keys" % dict_name - var valid_keys := {"all": true} - for s in side_names: - valid_keys[s] = true - for k in sides_dict.keys(): - if not valid_keys.has(k): - return "Unknown key in '%s': %s (valid: all, %s)" % [ - dict_name, k, ", ".join(side_names) - ] - # Apply `all` first, then override with side-specific keys. - if sides_dict.has("all"): - var all_val: Variant = sides_dict.all - for s in side_names: - var v: Variant = int(all_val) if value_type == TYPE_INT else float(all_val) - sb.set(prop_prefix + s, v) - for s in side_names: - if sides_dict.has(s): - var v2: Variant = int(sides_dict[s]) if value_type == TYPE_INT else float(sides_dict[s]) - sb.set(prop_prefix + s, v2) - return "" - - -func _apply_stylebox(theme_path: String, name: String, class_name_param: String, sb: StyleBox) -> void: - var theme: Theme = ResourceLoader.load(theme_path) - if theme == null: - push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) - return - theme.set_stylebox(name, class_name_param, sb) - McpResourceIO.guarded_save(theme, theme_path, _connection) - - -func _clear_stylebox(theme_path: String, name: String, class_name_param: String) -> void: - var theme: Theme = ResourceLoader.load(theme_path) - if theme == null: - push_warning("MCP: Failed to load theme for undo/redo: %s" % theme_path) - return - theme.clear_stylebox(name, class_name_param) - McpResourceIO.guarded_save(theme, theme_path, _connection) - - -# ============================================================================ -# theme_apply — assign a theme to a Control -# ============================================================================ - -func apply_theme(params: Dictionary) -> Dictionary: - var node_path: String = params.get("node_path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: node_path") - - var theme_path: String = params.get("theme_path", "") - var theme: Theme = null - if not theme_path.is_empty(): - var path_err := _validate_res_path(theme_path, ".tres") - if path_err != null: - return path_err - if not ResourceLoader.exists(theme_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) - theme = ResourceLoader.load(theme_path) - if theme == null or not theme is Theme: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path) - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var _scene_root: Node = _resolved.scene_root - if not node is Control and not node is Window: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a Control or Window (got %s)" % [node_path, node.get_class()] - ) - - var before_theme: Theme = node.theme - _undo_redo.create_action("MCP: Apply theme to %s" % node.name) - _undo_redo.add_do_property(node, "theme", theme) - _undo_redo.add_undo_property(node, "theme", before_theme) - _undo_redo.commit_action() - - return { - "data": { - "node_path": node_path, - "theme_path": theme_path if theme != null else "", - "cleared": theme == null, - "undoable": true, - } - } - - -# ============================================================================ -# Helpers -# ============================================================================ - -func _load_theme_from_params(params: Dictionary) -> Dictionary: - var theme_path: String = params.get("theme_path", "") - var err := _validate_res_path(theme_path, ".tres", "theme_path", true) - if err != null: - return err - if not ResourceLoader.exists(theme_path): - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) - var theme: Theme = ResourceLoader.load(theme_path) - if theme == null or not theme is Theme: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "Resource at %s is not a Theme" % theme_path) - return {"theme": theme, "path": theme_path} - - -static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path", for_write: bool = false) -> Variant: - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) - var path_err := McpPathValidator.validate_resource_path(path, for_write) - if not path_err.is_empty(): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, path_err]) - if not path.ends_with(required_suffix): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "%s must end with %s (got %s)" % [param_name, required_suffix, path] - ) - return null - - -## Parse a color from Color, "#rrggbb", "#rrggbbaa", named (red/blue/...) or dict. -## Returns null if the input cannot be parsed. -## Delegates to the canonical parser (#714) — gains [r,g,b(,a)] array -## support and strict key/component checking, same shapes as every other -## color-accepting handler. -static func _parse_color(value: Variant) -> Variant: - return McpJsonValues.parse_color(value) - - -static func _serialize_value(value: Variant) -> Variant: - if value == null: - return null - if value is Color: - return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} - if value is Vector2: - return {"x": value.x, "y": value.y} - return value diff --git a/addons/godot_ai/handlers/theme_handler.gd.uid b/addons/godot_ai/handlers/theme_handler.gd.uid deleted file mode 100644 index b77af13..0000000 --- a/addons/godot_ai/handlers/theme_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gjyldaddj7mu diff --git a/addons/godot_ai/handlers/tilemap_handler.gd b/addons/godot_ai/handlers/tilemap_handler.gd deleted file mode 100644 index ac68e34..0000000 --- a/addons/godot_ai/handlers/tilemap_handler.gd +++ /dev/null @@ -1,163 +0,0 @@ -@tool -extends RefCounted - -## TileMap / TileMapLayer authoring — set, fill, clear, and read tile cells -## directly in the editor scene with full undo/redo support. -## -## All ops target TileMapLayer nodes in the currently edited scene by -## scene-relative path (e.g. "/LavaLake20x20/Ground"). - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -const MAX_RECT_FILL_CELLS := 4096 - -var _undo_redo: EditorUndoRedoManager - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -## Set a single tile cell. -## params: {path, source_id, atlas_col, atlas_row, map_x, map_y} -## Returns: {map_x, map_y, source_id, atlas_col, atlas_row} -func set_cell(params: Dictionary) -> Dictionary: - var layer := _resolve_layer(params) - if layer.has("error"): return layer - var node: TileMapLayer = layer.node - var pos := Vector2i(params.get("map_x", 0), params.get("map_y", 0)) - var src := int(params.get("source_id", 0)) - var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0)) - var prev := _capture_cell_state(node, pos) - _undo_redo.create_action("MCP: TileMap set_cell") - _undo_redo.add_do_method(node, "set_cell", pos, src, atlas) - _undo_redo.add_undo_method(self, "_restore_cell_state", node, pos, prev) - _undo_redo.commit_action() - return {"data": {"map_x": pos.x, "map_y": pos.y, "source_id": src, - "atlas_col": atlas.x, "atlas_row": atlas.y, "undoable": true}} - - -## Fill a rectangular region with one tile type in a single undo action. -## params: {path, source_id, atlas_col, atlas_row, rect_x, rect_y, rect_w, rect_h} -## Returns: {cells_filled, rect: {x, y, w, h}} -func set_cells_rect(params: Dictionary) -> Dictionary: - var layer := _resolve_layer(params) - if layer.has("error"): return layer - var node: TileMapLayer = layer.node - var src := int(params.get("source_id", 0)) - var atlas := Vector2i(params.get("atlas_col", 0), params.get("atlas_row", 0)) - var rx := int(params.get("rect_x", 0)); var ry := int(params.get("rect_y", 0)) - var rw := int(params.get("rect_w", 1)); var rh := int(params.get("rect_h", 1)) - if rw <= 0 or rh <= 0: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "rect_w and rect_h must be > 0 (got %d x %d)" % [rw, rh] - ) - var cell_count := rw * rh - if cell_count > MAX_RECT_FILL_CELLS: - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Rect too large: %d cells exceeds max %d" % [cell_count, MAX_RECT_FILL_CELLS] - ) - var cells: Array[Vector2i] = [] - var snapshot: Array[Dictionary] = [] - for x in range(rx, rx + rw): - for y in range(ry, ry + rh): - var pos := Vector2i(x, y) - cells.append(pos) - snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) - _undo_redo.create_action("MCP: TileMap set_cells_rect %dx%d" % [rw, rh]) - for pos in cells: - _undo_redo.add_do_method(node, "set_cell", pos, src, atlas) - _undo_redo.add_undo_method(self, "_restore_rect_snapshot", node, snapshot) - _undo_redo.commit_action() - return {"data": {"cells_filled": cells.size(), - "rect": {"x": rx, "y": ry, "w": rw, "h": rh}, "undoable": true}} - - -## Remove all tiles from a TileMapLayer. -## params: {path} -## Returns: {cleared: true} -func clear_layer(params: Dictionary) -> Dictionary: - var layer := _resolve_layer(params) - if layer.has("error"): return layer - var node: TileMapLayer = layer.node - var snapshot := _capture_used_cells_snapshot(node) - _undo_redo.create_action("MCP: TileMap clear") - _undo_redo.add_do_method(node, "clear") - _undo_redo.add_undo_method(self, "_restore_cells_snapshot", node, snapshot) - _undo_redo.commit_action() - return {"data": {"cleared": true, "undoable": true}} - - -## Return all used cell coordinates. -## params: {path} -## Returns: {cells: [{x, y}, ...], count: int} -func get_used_cells(params: Dictionary) -> Dictionary: - var layer := _resolve_layer(params) - if layer.has("error"): return layer - var node: TileMapLayer = layer.node - var cells := node.get_used_cells() - var result: Array = [] - for c in cells: - result.append({"x": c.x, "y": c.y}) - return {"data": {"cells": result, "count": result.size()}} - - -## Resolve a TileMapLayer node from params["path"] in the currently edited -## scene. Returns {"node": TileMapLayer} on success, or an error dict. -func _resolve_layer(params: Dictionary) -> Dictionary: - var path: String = params.get("path", "") - var scene_file: String = params.get("scene_file", "") - var resolved := McpNodeValidator.resolve_or_error(path, "path", scene_file) - if resolved.has("error"): - return resolved - var node: Node = resolved.node - if not node is TileMapLayer: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, - "Node is not a TileMapLayer: %s" % path) - return {"node": node} - - -func _capture_cell_state(node: TileMapLayer, pos: Vector2i) -> Dictionary: - var source_id := node.get_cell_source_id(pos) - if source_id == -1: - return {"has_tile": false} - var atlas: Vector2i = node.get_cell_atlas_coords(pos) - var alternative := node.get_cell_alternative_tile(pos) - return { - "has_tile": true, - "source_id": source_id, - "atlas_col": atlas.x, - "atlas_row": atlas.y, - "alternative": alternative, - } - - -func _capture_used_cells_snapshot(node: TileMapLayer) -> Array[Dictionary]: - var snapshot: Array[Dictionary] = [] - for pos in node.get_used_cells(): - snapshot.append({"pos": pos, "state": _capture_cell_state(node, pos)}) - return snapshot - - -func _restore_cells_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void: - node.clear() - for entry in snapshot: - _restore_cell_state(node, entry.pos, entry.state) - - -func _restore_rect_snapshot(node: TileMapLayer, snapshot: Array[Dictionary]) -> void: - for entry in snapshot: - _restore_cell_state(node, entry.pos, entry.state) - - -func _restore_cell_state(node: TileMapLayer, pos: Vector2i, state: Dictionary) -> void: - if not state.get("has_tile", false): - node.erase_cell(pos) - return - node.set_cell( - pos, - int(state.get("source_id", -1)), - Vector2i(int(state.get("atlas_col", -1)), int(state.get("atlas_row", -1))), - int(state.get("alternative", 0)) - ) diff --git a/addons/godot_ai/handlers/tilemap_handler.gd.uid b/addons/godot_ai/handlers/tilemap_handler.gd.uid deleted file mode 100644 index 8cbed53..0000000 --- a/addons/godot_ai/handlers/tilemap_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cm8s7a0ey2q6k diff --git a/addons/godot_ai/handlers/tileset_handler.gd b/addons/godot_ai/handlers/tileset_handler.gd deleted file mode 100644 index a443b01..0000000 --- a/addons/godot_ai/handlers/tileset_handler.gd +++ /dev/null @@ -1,178 +0,0 @@ -@tool -extends RefCounted - -## TileSet management — atlas inspection helpers. - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - - -func _init() -> void: - pass - - -## Query all occupied atlas tile positions for a single source. -## -## params: -## tileset_path — res:// path to the TileSet resource (required, non-empty) -## source_id — raw TileSet source id (required) -## -## Returns: -## {"data": {"tiles": [{"col": int, "row": int}, ...], "count": int}} -## on success (including empty sources, where tiles=[] and count=0) -## ErrorCodes.make(code, message) on any validation or load failure -## -## Error codes: -## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent -## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false -## WRONG_TYPE — loaded resource is not a TileSet, or source is -## not a TileSetAtlasSource -## VALUE_OUT_OF_RANGE — source_id not present in TileSet -## -## This method is read-only: it never calls ResourceSaver or modifies any resource. -func get_atlas_tiles(params: Dictionary) -> Dictionary: - var resolved := _resolve_atlas_source(params) - if resolved.has("error"): - return resolved - var src: TileSetAtlasSource = resolved.src - - var tiles: Array = [] - for i in range(src.get_tiles_count()): - var v: Vector2i = src.get_tile_id(i) - tiles.append({"col": v.x, "row": v.y}) - - return {"data": {"tiles": tiles, "count": tiles.size()}} - - -## Return the atlas texture of a TileSetAtlasSource as a Base64-encoded PNG. -## -## params: -## tileset_path — res:// path to the TileSet resource (required, non-empty) -## source_id — raw TileSet source id (required) -## max_size — optional int; if > 0, the image is scaled so its longest -## edge is at most max_size pixels (default 0 = full res) -## -## Returns: -## {"data": {"image_base64": String, "width": int, "height": int, -## "original_width": int, "original_height": int, "format": "png"}} -## on success -## ErrorCodes.make(code, message) on any validation or load failure -## -## Error codes: -## MISSING_REQUIRED_PARAM — tileset_path absent/empty, or source_id absent -## RESOURCE_NOT_FOUND — ResourceLoader.exists(tileset_path) is false -## WRONG_TYPE — loaded resource is not a TileSet, or source is -## not a TileSetAtlasSource, or texture is null -## VALUE_OUT_OF_RANGE — source_id not present in TileSet -## -## This method is read-only: it never calls ResourceSaver or modifies anything. -func get_atlas_image(params: Dictionary) -> Dictionary: - var resolved := _resolve_atlas_source(params) - if resolved.has("error"): - return resolved - var source_id: int = resolved.source_id - var src: TileSetAtlasSource = resolved.src - - var tex: Texture2D = src.texture - if tex == null: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Source %d has no texture assigned" % source_id - ) - - var img: Image = tex.get_image() - if img == null: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Could not retrieve image data from texture of source %d" % source_id - ) - if img.is_compressed(): - var decompress_err := img.decompress() - if decompress_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Could not decompress texture of source %d: %s" % [source_id, error_string(decompress_err)] - ) - - var original_width: int = img.get_width() - var original_height: int = img.get_height() - - var max_size: int = params.get("max_size", 0) - if max_size > 0: - var longest_edge: int = max(original_width, original_height) - if longest_edge > max_size: - var scale: float = float(max_size) / float(longest_edge) - var new_w: int = max(1, int(original_width * scale)) - var new_h: int = max(1, int(original_height * scale)) - img.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) - - var png_bytes: PackedByteArray = img.save_png_to_buffer() - if png_bytes.is_empty(): - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "PNG encoding produced empty output for source %d" % source_id - ) - var b64: String = Marshalls.raw_to_base64(png_bytes) - - return { - "data": { - "image_base64": b64, - "width": img.get_width(), - "height": img.get_height(), - "original_width": original_width, - "original_height": original_height, - "format": "png", - } - } - - -func _resolve_atlas_source(params: Dictionary) -> Dictionary: - var tileset_path: String = params.get("tileset_path", "") - if tileset_path.is_empty(): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "'tileset_path' parameter is required and must not be empty" - ) - - if not params.has("source_id"): - return ErrorCodes.make( - ErrorCodes.MISSING_REQUIRED_PARAM, - "'source_id' parameter is required" - ) - - var tileset_path_err = McpPathValidator.loadable_error(tileset_path, "tileset_path") - if tileset_path_err != null: - return tileset_path_err - - if not ResourceLoader.exists(tileset_path): - return ErrorCodes.make( - ErrorCodes.RESOURCE_NOT_FOUND, - "TileSet resource not found: %s" % tileset_path - ) - - var ts = load(tileset_path) - if not ts is TileSet: - var loaded_type := "null" if ts == null else ts.get_class() - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Resource at '%s' is not a TileSet (got %s)" % [tileset_path, loaded_type] - ) - - var source_id: int = int(params.get("source_id", -999)) - if source_id < 0 or not ts.has_source(source_id): - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "source_id %d does not exist in TileSet" % source_id - ) - - var src = ts.get_source(source_id) - if not src is TileSetAtlasSource: - var source_type: String = "null" if src == null else src.get_class() - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Source %d is not a TileSetAtlasSource (got %s)" % [source_id, source_type] - ) - - return { - "source_id": source_id, - "src": src, - } diff --git a/addons/godot_ai/handlers/tileset_handler.gd.uid b/addons/godot_ai/handlers/tileset_handler.gd.uid deleted file mode 100644 index 643cfd3..0000000 --- a/addons/godot_ai/handlers/tileset_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://de3v4m1pnk7tr diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd deleted file mode 100644 index 9795097..0000000 --- a/addons/godot_ai/handlers/ui_handler.gd +++ /dev/null @@ -1,525 +0,0 @@ -@tool -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Handles UI-specific (Control) layout helpers: anchor presets, etc. -## -## Anchors/offsets are the worst part of Control layout to set one-property-at-a-time. -## This handler wraps Godot's built-in presets (FULL_RECT, CENTER, TOP_LEFT, ...) so -## callers can set a whole layout with one command, with proper undo. - -var _undo_redo: EditorUndoRedoManager - - -const _PRESETS := { - "top_left": Control.PRESET_TOP_LEFT, - "top_right": Control.PRESET_TOP_RIGHT, - "bottom_left": Control.PRESET_BOTTOM_LEFT, - "bottom_right": Control.PRESET_BOTTOM_RIGHT, - "center_left": Control.PRESET_CENTER_LEFT, - "center_top": Control.PRESET_CENTER_TOP, - "center_right": Control.PRESET_CENTER_RIGHT, - "center_bottom": Control.PRESET_CENTER_BOTTOM, - "center": Control.PRESET_CENTER, - "left_wide": Control.PRESET_LEFT_WIDE, - "top_wide": Control.PRESET_TOP_WIDE, - "right_wide": Control.PRESET_RIGHT_WIDE, - "bottom_wide": Control.PRESET_BOTTOM_WIDE, - "vcenter_wide": Control.PRESET_VCENTER_WIDE, - "hcenter_wide": Control.PRESET_HCENTER_WIDE, - "full_rect": Control.PRESET_FULL_RECT, -} - -const _RESIZE_MODES := { - "minsize": Control.PRESET_MODE_MINSIZE, - "keep_width": Control.PRESET_MODE_KEEP_WIDTH, - "keep_height": Control.PRESET_MODE_KEEP_HEIGHT, - "keep_size": Control.PRESET_MODE_KEEP_SIZE, -} - -const _ANCHOR_OFFSET_PROPS := [ - "anchor_left", "anchor_top", "anchor_right", "anchor_bottom", - "offset_left", "offset_top", "offset_right", "offset_bottom", -] - - -func _init(undo_redo: EditorUndoRedoManager) -> void: - _undo_redo = undo_redo - - -## Apply a Control layout preset (anchors + offsets) to a UI node. -## -## Params: -## path - scene path to a Control node (required) -## preset - preset name: full_rect, center, top_left, ... (required) -## resize_mode - minsize | keep_width | keep_height | keep_size (default: minsize) -## margin - integer margin in pixels from the anchor edges (default: 0) -func set_anchor_preset(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - var preset_name: String = str(params.get("preset", "")).to_lower() - if preset_name.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") - if not _PRESETS.has(preset_name): - var names := _PRESETS.keys() - names.sort() - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(names)] - ) - - var resize_mode_name: String = str(params.get("resize_mode", "minsize")).to_lower() - if not _RESIZE_MODES.has(resize_mode_name): - var names := _RESIZE_MODES.keys() - names.sort() - return ErrorCodes.make( - ErrorCodes.VALUE_OUT_OF_RANGE, - "Unknown resize_mode '%s'. Valid: %s" % [resize_mode_name, ", ".join(names)] - ) - - var margin: int = int(params.get("margin", 0)) - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var scene_root: Node = _resolved.scene_root - if not node is Control: - var got_class: String = node.get_class() - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a Control (got %s)%s" % [ - node_path, got_class, _canvas_layer_overlay_hint(got_class) - ] - ) - - var control := node as Control - var preset_value: int = _PRESETS[preset_name] - var resize_mode_value: int = _RESIZE_MODES[resize_mode_name] - - # Snapshot before so we can undo every property the preset may have touched. - var before: Dictionary = {} - for prop in _ANCHOR_OFFSET_PROPS: - before[prop] = control.get(prop) - - _undo_redo.create_action("MCP: Set %s anchor preset %s" % [control.name, preset_name]) - _undo_redo.add_do_method( - control, "set_anchors_and_offsets_preset", preset_value, resize_mode_value, margin - ) - for prop in _ANCHOR_OFFSET_PROPS: - _undo_redo.add_undo_property(control, prop, before[prop]) - _undo_redo.commit_action() - - var after: Dictionary = {} - for prop in _ANCHOR_OFFSET_PROPS: - after[prop] = control.get(prop) - - return { - "data": { - "path": node_path, - "preset": preset_name, - "resize_mode": resize_mode_name, - "margin": margin, - "anchors": { - "left": after.anchor_left, - "top": after.anchor_top, - "right": after.anchor_right, - "bottom": after.anchor_bottom, - }, - "offsets": { - "left": after.offset_left, - "top": after.offset_top, - "right": after.offset_right, - "bottom": after.offset_bottom, - }, - "undoable": true, - } - } - - -## Set the visible `text` property on a UI Control (Label, Button + subclasses, -## LineEdit, TextEdit, RichTextLabel, LinkButton). Undoable. -func set_text(params: Dictionary) -> Dictionary: - var node_path: String = params.get("path", "") - if node_path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: path") - - if not params.has("text"): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: text") - var text_value: Variant = params["text"] - if typeof(text_value) != TYPE_STRING: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "text must be a string") - - var _resolved := McpNodeValidator.resolve_or_error(node_path, "node_path") - if _resolved.has("error"): - return _resolved - var node: Node = _resolved.node - var scene_root: Node = _resolved.scene_root - var node_type := node.get_class() - if not node is Control: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Node %s is not a Control (got %s)" % [node_path, node_type] - ) - # Scan get_property_list() (matches set_property / _apply_property in this - # repo) so we can both confirm `text` exists and that it's actually a String - # — guards against a custom Control whose `text` happens to be some other - # type, where set()-ing a String would silently mis-coerce. - var text_prop_type := TYPE_NIL - var has_text := false - for prop in node.get_property_list(): - if prop.get("name", "") == "text": - has_text = true - text_prop_type = prop.get("type", TYPE_NIL) - break - if not has_text: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Control %s has no 'text' property (got %s)" % [node_path, node_type] - ) - if text_prop_type != TYPE_STRING: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - "Control %s has a non-string 'text' property (got %s)" % [node_path, node_type] - ) - - var old_value: String = node.get("text") - - _undo_redo.create_action("MCP: Set %s text" % node.name) - _undo_redo.add_do_property(node, "text", text_value) - _undo_redo.add_undo_property(node, "text", old_value) - _undo_redo.commit_action() - - return { - "data": { - "path": node_path, - "text": text_value, - "old_text": old_value, - "node_type": node_type, - "undoable": true, - } - } - - -# ============================================================================ -# build_layout — declarative nested-dict → Control tree in one undo action -# ============================================================================ - -## Build a tree of Control nodes atomically. -## -## Params: -## tree - Dictionary describing the root node. Required fields: "type". -## Optional: "name", "properties" (dict), "anchor_preset", -## "anchor_margin", "theme" (res://, uid:// or user:// path), "children" (array). -## parent_path - Parent scene path. Empty or "/" = scene root. -## -## Validation is done before any scene mutation: class names, property -## existence, and res:// paths are all checked up-front. If anything is -## invalid, no node is created. -func build_layout(params: Dictionary) -> Dictionary: - var tree = params.get("tree") - if not params.has("tree"): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: tree") - if typeof(tree) != TYPE_DICTIONARY: - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "tree must be a dictionary") - - var _scene_check := McpNodeValidator.require_scene_or_error() - if _scene_check.has("error"): - return _scene_check - var scene_root: Node = _scene_check.scene_root - - var parent_path: String = params.get("parent_path", "") - var parent: Node = scene_root - if not parent_path.is_empty() and parent_path != "/": - parent = McpScenePath.resolve(parent_path, scene_root) - if parent == null: - return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) - - # Validate + build in memory first; if anything fails, free and bail. - var built := _build_subtree(tree) - if built.has("error"): - return built - var root_node: Node = built.node - var created: Array[Node] = built.created - - _undo_redo.create_action("MCP: Build UI layout (%d nodes)" % created.size()) - _undo_redo.add_do_method(parent, "add_child", root_node, true) - _undo_redo.add_do_method(root_node, "set_owner", scene_root) - for n in created: - _undo_redo.add_do_method(n, "set_owner", scene_root) - _undo_redo.add_do_reference(n) - _undo_redo.add_undo_method(parent, "remove_child", root_node) - _undo_redo.commit_action() - - return { - "data": { - "root_path": McpScenePath.from_node(root_node, scene_root), - "node_count": created.size(), - "undoable": true, - } - } - - -## Recursively instantiate + configure a node and its children in memory. -## Returns {"node": root, "created": [all descendants incl. root]} or {"error": ...}. -func _build_subtree(spec: Dictionary) -> Dictionary: - var node_type: String = spec.get("type", "") - if node_type.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Every layout node requires a 'type'") - if not ClassDB.class_exists(node_type): - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown type: %s" % node_type) - if not ClassDB.is_parent_class(node_type, "Node"): - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Node type" % node_type) - - var node: Node = ClassDB.instantiate(node_type) - if node == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % node_type) - - var node_name: String = spec.get("name", "") - if not node_name.is_empty(): - node.name = node_name - - # Properties. - if spec.has("properties"): - var props = spec.get("properties") - if typeof(props) != TYPE_DICTIONARY: - node.free() - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "properties must be a dictionary") - for key in props: - var value = props[key] - var apply_err := _apply_property(node, str(key), value) - if apply_err != null: - node.free() - return apply_err - - # Theme (res:// / uid:// / user:// path -> Resource). - if spec.has("theme"): - var theme_path: String = str(spec.get("theme", "")) - if not theme_path.is_empty(): - var theme_path_err = McpPathValidator.loadable_error(theme_path, "theme") - if theme_path_err != null: - node.free() - return theme_path_err - if not ResourceLoader.exists(theme_path): - node.free() - return ErrorCodes.make(ErrorCodes.RESOURCE_NOT_FOUND, "Theme not found: %s" % theme_path) - var theme_res: Resource = ResourceLoader.load(theme_path) - if theme_res == null or not theme_res is Theme: - node.free() - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "theme path must point to a Theme resource: %s" % theme_path) - if not node is Control and not node is Window: - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "theme can only be set on Control / Window (got %s)%s" % [ - node_type, _canvas_layer_overlay_hint(node_type) - ] - ) - node.theme = theme_res as Theme - - # Anchor preset — applied before children so children inherit sensible anchors. - if spec.has("anchor_preset"): - var preset_name: String = str(spec.get("anchor_preset", "")).to_lower() - if not _PRESETS.has(preset_name): - node.free() - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown anchor_preset: %s" % preset_name) - if not node is Control: - node.free() - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "anchor_preset requires a Control (got %s)%s" % [ - node_type, _canvas_layer_overlay_hint(node_type) - ] - ) - var preset_value: int = _PRESETS[preset_name] - var margin: int = int(spec.get("anchor_margin", 0)) - (node as Control).set_anchors_and_offsets_preset(preset_value, Control.PRESET_MODE_MINSIZE, margin) - - var created: Array[Node] = [node] - if spec.has("children"): - var children = spec.get("children") - if typeof(children) != TYPE_ARRAY: - node.free() - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "children must be an array") - for child_spec in children: - if typeof(child_spec) != TYPE_DICTIONARY: - node.free() - return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "each child must be a dictionary") - var child_result := _build_subtree(child_spec) - if child_result.has("error"): - node.free() - return child_result - var child_node: Node = child_result.node - node.add_child(child_node) - for n in child_result.created: - created.append(n) - return {"node": node, "created": created} - - -## Mapping from theme_override_* property prefixes to their add/remove methods. -const _THEME_OVERRIDE_MAP := { - "theme_override_colors/": { - "add": "add_theme_color_override", - "remove": "remove_theme_color_override", - "coerce_type": TYPE_COLOR, - }, - "theme_override_constants/": { - "add": "add_theme_constant_override", - "remove": "remove_theme_constant_override", - "coerce_type": TYPE_INT, - }, - "theme_override_font_sizes/": { - "add": "add_theme_font_size_override", - "remove": "remove_theme_font_size_override", - "coerce_type": TYPE_INT, - }, - "theme_override_styles/": { - "add": "add_theme_stylebox_override", - "remove": "remove_theme_stylebox_override", - "coerce_type": TYPE_OBJECT, - }, -} - - -## Apply a property to a newly-instantiated node. Handles Color/Vector2/NodePath -## coercion from JSON-friendly forms. Returns null on success, error dict on failure. -func _apply_property(node: Node, prop: String, value: Variant) -> Variant: - # Handle theme_override_* pseudo-properties before the regular property scan. - for prefix in _THEME_OVERRIDE_MAP: - if prop.begins_with(prefix): - if not node is Control: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "theme_override_* requires a Control node (got %s)" % node.get_class() - ) - var override_name := prop.substr(prefix.length()) - var info: Dictionary = _THEME_OVERRIDE_MAP[prefix] - var coerce_type: int = info.coerce_type - - # For stylebox overrides, load from a res:// / uid:// / user:// path. - if coerce_type == TYPE_OBJECT: - if value is String and (value.begins_with("res://") or value.begins_with("uid://") or value.begins_with("user://")): - var style_path_err = McpPathValidator.loadable_error(value, "stylebox") - if style_path_err != null: - return style_path_err - var res := ResourceLoader.load(value) - if res == null or not res is StyleBox: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "Style resource not found or not a StyleBox: %s" % value - ) - node.call(info.add, override_name, res) - else: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "theme_override_styles/ expects a res:// / uid:// / user:// path to a StyleBox" - ) - else: - var coercion := _coerce_for_type(value, coerce_type) - if not coercion.ok: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Cannot coerce '%s' for %s" % [value, prop] - ) - node.call(info.add, override_name, coercion.value) - return null - - var found := false - var prop_type := TYPE_NIL - for p in node.get_property_list(): - if p.name == prop: - found = true - prop_type = p.get("type", TYPE_NIL) - break - if not found: - return ErrorCodes.make( - ErrorCodes.PROPERTY_NOT_ON_CLASS, - McpPropertyErrors.build_message(node, prop) - ) - - var coercion := _coerce_for_type(value, prop_type) - if not coercion.ok: - return ErrorCodes.make( - ErrorCodes.WRONG_TYPE, - "Property '%s' on %s expects type %s (cannot coerce %s)" % [ - prop, node.get_class(), type_string(prop_type), value - ] - ) - node.set(prop, coercion.value) - return null - - -## Coerce a JSON-friendly value to the target Godot type. Returns -## {"ok": true, "value": coerced} on success, {"ok": false} on failure. -## For types we don't explicitly coerce, the value is returned as-is -## (Godot will typecheck at set() time and fail loudly if it disagrees). -static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary: - match prop_type: - TYPE_COLOR: - ## Canonical parser (#714): adds [r,g,b(,a)] array support and - ## strict key/component checking, same shapes everywhere. - var parsed_color = McpJsonValues.parse_color(value) - if parsed_color != null: - return {"ok": true, "value": parsed_color} - return {"ok": false} - TYPE_VECTOR2: - ## Same canonical parser as TYPE_COLOR (CodeRabbit review): - ## keeping the inline copy here would re-introduce exactly the - ## permissive-vs-strict drift this PR removes elsewhere. - var parsed_v2 = McpJsonValues.parse_vector2(value) - if parsed_v2 != null: - return {"ok": true, "value": parsed_v2} - return {"ok": false} - TYPE_VECTOR2I: - if value is Vector2i: - return {"ok": true, "value": value} - if value is Dictionary and value.has("x") and value.has("y"): - return {"ok": true, "value": Vector2i(int(value.x), int(value.y))} - if value is Array and value.size() == 2: - return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))} - return {"ok": false} - TYPE_RECT2: - if value is Rect2: - return {"ok": true, "value": value} - if value is Array and value.size() == 4: - return { - "ok": true, - "value": - Rect2(float(value[0]), float(value[1]), float(value[2]), float(value[3])), - } - if value is Dictionary: - if value.has("x") and value.has("y") and value.has("w") and value.has("h"): - return { - "ok": true, - "value": - Rect2(float(value.x), float(value.y), float(value.w), float(value.h)), - } - if value.has("position") and value.has("size"): - var pos := _coerce_for_type(value.position, TYPE_VECTOR2) - var sz := _coerce_for_type(value.size, TYPE_VECTOR2) - if pos.ok and sz.ok: - return {"ok": true, "value": Rect2(pos.value, sz.value)} - return {"ok": false} - TYPE_NODE_PATH: - if value is NodePath: - return {"ok": true, "value": value} - if value is String: - return {"ok": true, "value": NodePath(value)} - return {"ok": false} - return {"ok": true, "value": value} - - -# CanvasLayer is the canonical HUD parent but isn't a Control, so applying -# Control-only properties (theme, anchor_preset) to it is a common mistake. -# The recovery shape is always the same: nest a Control child under the layer. -static func _canvas_layer_overlay_hint(node_class: String) -> String: - if node_class != "CanvasLayer": - return "" - return ( - ". CanvasLayer is not a Control — add a Control (e.g. Panel or Control " - + "with anchor_preset=full_rect) as its child and apply theme / " - + "anchor_preset to that overlay." - ) diff --git a/addons/godot_ai/handlers/ui_handler.gd.uid b/addons/godot_ai/handlers/ui_handler.gd.uid deleted file mode 100644 index f48f841..0000000 --- a/addons/godot_ai/handlers/ui_handler.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckm6f1objpgvw diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd deleted file mode 100644 index f598837..0000000 --- a/addons/godot_ai/mcp_dock.gd +++ /dev/null @@ -1,3559 +0,0 @@ -@tool -class_name McpDock -extends VBoxContainer - -## Editor dock panel showing MCP connection status, client config, and command log. -## -## Audit-v2 #360 partial extraction. Two cohesive subpanels live in -## res://addons/godot_ai/dock_panels/: -## - log_viewer.gd: MCP request/response log (dev-mode only). -## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel. -## -## The audit also called for ServerStatusPanel and ClientRowController -## extractions; those were *deliberately deferred*. Their UI scatters across -## the dock layout (status icon at top, crash panel mid, setup section lower; -## client rows + drift banner + scroll grid spread similarly), so a clean -## extract-by-panel needs either visible UI reorganization or a coordinator- -## Node pattern with property-accessor façades on McpDock that re-tangle the -## very state they claim to move. -## -## A future refactor probably wants extract-by-concern instead — e.g. -## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING → -## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions -## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning -## the per-row Configure/Remove worker pool. The dock would keep UI -## construction and lose the state-machine ownership. See issue #360. - -const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") -const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd") -const Telemetry := preload("res://addons/godot_ai/telemetry.gd") -const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd") -const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_state.gd") -const Client := preload("res://addons/godot_ai/clients/_base.gd") -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") -const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd") -const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd") -const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd") -const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") -const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd") -const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd") -const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd") -const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") - -const DEV_MODE_SETTING := "godot_ai/dev_mode" -## "Change the port + reconfigure your clients" guide. Surfaced from the crash -## panel when a foreign process holds the HTTP port — the one piece of recovery -## (per-client config rewrite) that doesn't fit in the inline crash body. -## Resolved against the installed plugin version at click time (see -## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped, -## not tip-of-main, which may have drifted from that build's UI. -const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md" -const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob" -## Opened by the "How to install uv" button. See _on_install_uv for why the -## dock links here instead of running an installer itself. -const UV_INSTALL_DOCS_URL := "https://docs.astral.sh/uv/getting-started/installation/" -const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 -const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 -const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000 -static var COLOR_MUTED := Color(0.7, 0.7, 0.7) -static var COLOR_HEADER := Color(0.95, 0.95, 0.95) -## Used for "in-progress" / "stale, action needed" UI: the startup-grace -## status icon, the spawn-failure suggested-port hint, the drift banner, -## and the per-row mismatch dot. One constant so a future palette tweak -## doesn't have to find every literal. -static var COLOR_AMBER := Color(1.0, 0.75, 0.25) - -var _connection -var _log_buffer -var _plugin: EditorPlugin - -# Always visible -var _redock_btn: Button -var _status_icon: ColorRect -var _status_label: Label -var _body_scroll: ScrollContainer -var _body: VBoxContainer -var _client_grid: VBoxContainer -var _client_configure_all_btn: Button -var _client_empty_cta_btn: Button -var _clients_summary_label: Label -var _clients_window: Window -var _dev_mode_toggle: CheckButton -var _install_label: Label - -# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients -# that cap total tool count (Antigravity: 100). Pending set is mutated by -# checkbox clicks; saved set reflects what the spawned server actually -# sees. `Apply and Restart Server` writes pending → setting and triggers a -# plugin reload so the new server comes up with the trimmed list. -var _tools_pending_excluded: PackedStringArray = PackedStringArray() -var _tools_saved_excluded: PackedStringArray = PackedStringArray() -## Custom (addon-registered) tools list — rebuilt live on registry -## tools_changed; per-tool checkboxes apply immediately (no restart). -var _custom_tools_list: VBoxContainer -var _custom_tools_count_label: Label -var _tools_domain_checkboxes: Dictionary = {} -var _tools_count_label: Label -var _tools_apply_btn: Button -var _tools_reset_btn: Button -var _tools_dirty_warning: Label -var _tools_close_confirm: ConfirmationDialog -var _telemetry_toggle: CheckButton -var _telemetry_pending_enabled: bool = true -var _telemetry_saved_enabled: bool = true - -# Settings tab (secondary window, Tab 3) — Vision Routing section plus the -# LAN opt-in (#507): "Allow remote hosts (CIDR)" behind a collapsed -# "Remote access (advanced)" disclosure (auto-expands when a non-empty -# allowlist is configured). The value feeds `--allow-host` at server spawn -# (see plugin.gd::_build_server_flags). The LineEdit's live text is the -# pending state; `_allow_hosts_saved` mirrors the persisted EditorSetting, -# same pending/saved shape as the Tools tab above. -var _allow_hosts_section: VBoxContainer -var _allow_hosts_fold: FoldableContainer -var _allow_hosts_edit: LineEdit -var _allow_hosts_hint: Label -var _allow_hosts_apply_btn: Button -var _allow_hosts_saved: String = "" - -## Per-client UI handles, keyed by client id. Each entry holds the row's -## status dot, configure/remove buttons, config-file buttons, and manual panel. -var _client_rows: Dictionary = {} - -# Drift banner — surfaced near the Clients section when one or more clients -# have a stored entry whose URL no longer matches `http_url()` (typical after -# the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing: -# cached row dots/banner remain visible while a background worker performs the -# potentially blocking config/CLI probes, then the main thread applies results. -# Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps -# during tab-away/tab-back churn. See #166 and #226. -var _drift_banner: VBoxContainer -var _drift_label: Label -## Set when the user clicks "How to install uv"; consumed by the next -## application focus-in so the uv row is re-probed after the user has had a -## chance to install, not immediately. See _on_install_uv and _notification. -## (Deliberately spelled without the focus-in constant name: the guard in -## tests/unit/test_editor_focus_refocus.py locates the notification handler -## by first occurrence of that token.) -var _uv_recheck_pending := false -## Handles for the Setup section's "Server" row. `_update_status` keeps -## the label text/color in sync with `McpConnection.server_version` so the -## dock reports the TRUE running server version, not the plugin's -## expected version. See #174 follow-up — a plugin upgrade via self- -## update can leave the plugin connected to an older adopted server -## (foreign-port branch never sets `_server_pid`, so `_stop_server` -## can't kill it); the line has to show the mismatch honestly. -var _setup_server_label: Label -## Last rendered server-version string. `_update_status` runs every -## frame; early-outs text repaint when nothing changed. Empty means -## "no line rendered yet" (dev-checkout branch doesn't render a -## user-mode Server line). -var _last_rendered_server_text: String = "" -## Restart-server button shown next to the Setup container when -## `McpConnection.server_version` drifts from the plugin version. Hidden -## in the match case so the UI stays calm. -var _version_restart_btn: Button -var _server_restart_in_progress := false -## Sorted snapshot of the most recent mismatched-client set. Powers two things: -## (a) the Reconfigure button reuses this list instead of re-running -## `check_status` per row (saves ~18 filesystem reads per click), and -## (b) `_refresh_drift_banner` early-returns when the set is unchanged so -## repeated explicit refreshes don't repaint identical text. Mirrors the -## `_last_server_status` pattern used by the crash panel. -var _last_mismatched_ids: Array[String] = [] -## One-shot post-self-update auto-repin (armed by plugin.gd via -## `notify_self_update_success`). After an update, every client configured -## through this plugin still pins the OLD server version — the drift banner -## names them, but until they're rewritten each attach-bridge (re)start -## launches the outdated backend. The first completed status sweep with a -## healthy server consumes this flag and runs the same reconfigure the -## banner button would, so the user isn't left owing a manual click for a -## state the update itself created. Sweeps that land while the server is -## still INCOMPATIBLE (stale-occupant recovery in flight) keep it pending — -## their rows read ERROR, not CONFIGURED_MISMATCH. -var _pending_post_update_repin: bool = false -## The version the completed update replaced — consumed with the flag -## above; the pin-only gate renders each entry against it. -var _post_update_from_version: String = "" -var _client_status_refresh_thread: Thread -## Single source of truth for the refresh-sweep state machine. See -## `ClientRefreshStateScript` for the transition table. Replaces the -## previously scattered booleans (`_in_flight`, `_timed_out`, -## `_deferred_until_filesystem_ready`, `_shutdown_requested`). -var _refresh_state: int = ClientRefreshStateScript.IDLE -## Pending-request flags. Kept separate from `_refresh_state` because -## they're "what should the next refresh look like" — not state of -## any current refresh. A pending request is queued when a refresh -## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by -## `_apply_client_status_refresh_results` once the in-flight worker -## drains. `_pending_force` also captures forced retries deferred via -## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait. -var _client_status_refresh_pending: bool = false -var _client_status_refresh_pending_force: bool = false -var _client_status_refresh_pending_initial: bool = false -var _last_client_status_refresh_completed_msec: int = 0 -var _client_status_refresh_started_msec: int = 0 -var _client_status_refresh_generation: int = 0 -## Owns the self-update slice: GitHub Releases poll, ZIP download, install -## orchestration, and the install-in-flight gate. Dock keeps banner UI -## only and consults the gate via `_is_self_update_in_progress()`. -var _update_manager -static var _orphaned_client_status_refresh_threads: Array[Thread] = [] - -## Per-row worker state for Configure / Remove. Issue #239: shelling out -## to a hung CLI on main hangs the editor. We dispatch each click to its -## own thread (one slot per client), then `_process` reaps completed workers -## and applies returned payloads on main. The buttons stay disabled while -## the slot is busy so the user can't queue a re-click on the same row. -## -## Per-client (not single-slot) so Configure-all can fan out — the -## workers are independent, only the row UI is shared, and McpCliExec -## bounds the wall-clock for each. -## -## A watchdog can abandon a slot when a worker fails to report completion. -## The thread object is retained in `_orphaned_client_action_threads` until -## it finishes so GDScript does not destroy a live Thread object. -var _client_action_threads: Dictionary = {} -var _client_action_generations: Dictionary = {} -var _client_action_started_msec: Dictionary = {} -var _client_action_names: Dictionary = {} -## Timed-out Configure/Remove workers are abandoned but retained here until -## they finish, so GDScript does not destroy a live Thread object. -static var _orphaned_client_action_threads: Array[Thread] = [] - -# Dev-mode only -var _dev_section: VBoxContainer -var _server_label: Label -var _reload_btn: Button -var _setup_section: VBoxContainer -var _setup_container: VBoxContainer -## Primary dev-section button — always (re)starts a `--reload` dev server. -## Same-version Python edits get adopted as compatible by the lifecycle, so -## neither the drift nor the crash Restart button surfaces; this is the -## unconditional kick contributors need to pick up source changes without -## a version bump. -var _dev_primary_btn: Button -## Small "✕" affordance next to the primary — stops the dev server without -## spawning a replacement. Disabled when no dev server is running. -var _dev_stop_btn: Button -var _log_viewer: LogViewerScript -## Vision Routing (optional) - set by plugin.gd; builds the "Vision Routing" -## tab in Clients & Tools and the quick toggle under Developer mode. -var vision_routing: VisionRoutingScript = null - -var _last_connected := false -var _last_status_text := "" -var _last_status_tooltip := "" -var _startup_grace_until_msec: int = 0 - -# Spawn-failure panel — rendered when `get_server_status` reports a -# non-OK `state`. One panel, one body paragraph per state, no cascading -# booleans. See `_crash_body_for_state`. -var _crash_panel: VBoxContainer -var _crash_output: RichTextLabel -var _crash_restart_btn: Button -var _crash_reload_btn: Button -## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE -## case (no `can_recover_incompatible` proof). The inline body names a free -## port; this button carries the per-client reconfigure steps that don't fit -## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`. -var _crash_docs_btn: Button -## Port-picker escape hatch — visible inside the crash panel when the root -## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes -## the EditorSetting and reloads the plugin in response to the panel's -## `port_apply_requested` signal. -var _port_picker_panel: PortPickerPanelScript -## Last status Dict rendered into the panel — used to skip re-population -## when nothing changed, which would otherwise reset the user's scroll -## position on every frame. GDScript Dicts compare by value with `==`. -var _last_server_status: Dictionary = {} - -# First-run grace: uvx installs 60+ Python packages on first run (can take -# 10-30s on a slow connection). Don't scare users with "Disconnected" during -# that window — show "Starting server…" instead. After this expires, fall -# back to the normal disconnect UI. -const STARTUP_GRACE_MSEC := 60 * 1000 - -# Update banner — visible UI only. Releases polling, ZIP download, and -# the install pipeline live on `_update_manager`. -var _update_banner: VBoxContainer -var _update_label: Label -var _update_btn: Button - -# Mixed-state banner — surfaces when `addons/godot_ai/` contains -# `*.update_backup` files left by a self-update whose rollback failed -# (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner -# the user sees "plugin won't start" with no actionable context, re-runs -# the update, and compounds the mismatch (issue #354 / audit-v2 #10). -var _mixed_state_banner: VBoxContainer -var _mixed_state_label: Label -var _mixed_state_files: RichTextLabel -var _mixed_state_rescan_btn: Button - - -func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void: - _connection = connection - _log_buffer = log_buffer - _plugin = plugin - _startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC - - -func _ready() -> void: - _build_ui() - - -func _process(_delta: float) -> void: - _prune_orphaned_client_status_refresh_threads() - _prune_orphaned_client_action_threads() - _poll_completed_client_status_refresh_thread() - _poll_completed_client_action_threads() - _check_client_status_refresh_timeout() - _check_client_action_timeouts() - if _connection == null: - return - _retry_deferred_client_status_refresh() - _update_status() - if _log_viewer != null and _log_viewer.visible: - _log_viewer.tick() - - -func _exit_tree() -> void: - ## Block on any in-flight refresh worker before letting the dock leave the - ## tree. The plugin disable path (editor_reload_plugin, Project Settings - ## toggle) reloads the McpDock script class — which wipes the static - ## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid- - ## execution, and triggers `~Thread … destroyed without its completion - ## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds - ## errors, intermittent SIGSEGV). Probes finish in well under a second - ## under normal conditions; if a CLI probe genuinely hung, the runtime - ## timeout path (`_abandon_client_status_refresh_thread`) has already - ## moved that thread into the orphan list, so we drain it here too. - ## - ## `wait_to_finish` is unbounded by design: GDScript's Thread API has no - ## timeout, and a polling/abandon fallback would just re-introduce the - ## GC-mid-execution crash this fix exists to prevent. Blocking the editor - ## briefly on plugin-reload is strictly better than the SIGSEGV. - _refresh_state = ClientRefreshStateScript.SHUTTING_DOWN - _drain_client_status_refresh_workers() - _drain_client_action_workers() - - -## Public drain entry consulted by `McpUpdateManager._install_zip` before -## any disk write. Pairs both worker pools so the manager doesn't reach -## into private dock methods. `_exit_tree` still calls the two underlying -## drains directly because it has additional state-machine work -## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. -func prepare_for_self_update_drain() -> void: - _poll_completed_client_status_refresh_thread() - _poll_completed_client_action_threads() - _drain_client_status_refresh_workers() - _drain_client_action_workers() - - -func _drain_client_status_refresh_workers() -> void: - ## Block until any in-flight refresh worker (and any orphaned workers from - ## a prior timeout) finish, then clear refresh state. Same blocking - ## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree` - ## (dock teardown) and `McpUpdateManager._install_zip` (before extract - ## overwrites plugin scripts on disk). - _client_status_refresh_generation += 1 - if _client_status_refresh_thread != null: - _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - for thread in _orphaned_client_status_refresh_threads: - if thread != null: - thread.wait_to_finish() - _orphaned_client_status_refresh_threads.clear() - ## Don't transition out of SHUTTING_DOWN — the drain is called from - ## `_exit_tree` (sticky shutdown) and from - ## `McpUpdateManager._install_zip`'s post-drain reset, which writes - ## the state explicitly. - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - - -func _drain_client_action_workers() -> void: - ## Same drain semantics as the refresh worker (see comment above): the - ## plugin disable / install-update path reloads our script class, so any - ## live Thread must finish before its slot is GC'd or we hit - ## `~Thread … destroyed without its completion having been realized` → - ## VM corruption. Normal UI recovery is handled by the per-row watchdog; - ## teardown still blocks because GDScript's Thread API has no kill/timeout - ## primitive and destroying a live Thread corrupts the VM. - ## - ## Generation-bumped per-row so any result from a worker that finished - ## after we started draining detects the generation mismatch and - ## short-circuits without touching freed UI state. - ## - ## After draining, restore the row UI for any in-flight rows: bare - ## `_client_action_threads.clear()` would leave the dock stuck showing - ## "Configuring…" / "Removing…" with disabled buttons forever — a - ## user-visible failure mode for the install-update bail-out branch - ## (zip extract failure on the manager clears `_install_in_flight` and - ## the dock stays alive). - for client_id in _client_action_threads.keys(): - var t: Thread = _client_action_threads[client_id] - if t != null: - t.wait_to_finish() - _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(String(client_id)) - var row: Dictionary = _client_rows.get(String(client_id), {}) - if not row.is_empty(): - _apply_row_status( - String(client_id), - row.get("status", Client.Status.NOT_CONFIGURED), - "" - ) - _client_action_threads.clear() - for thread in _orphaned_client_action_threads: - if thread != null: - thread.wait_to_finish() - _orphaned_client_action_threads.clear() - _client_action_started_msec.clear() - _client_action_names.clear() - - -func _check_client_action_timeouts() -> void: - var now := Time.get_ticks_msec() - for client_id in _client_action_threads.keys(): - if not _client_action_started_msec.has(client_id): - continue - var started := int(_client_action_started_msec.get(client_id, 0)) - if now - started >= CLIENT_ACTION_TIMEOUT_MSEC: - _abandon_client_action_thread(String(client_id)) - - -func _abandon_client_action_thread(client_id: String) -> void: - if not _client_action_threads.has(client_id): - return - var thread: Thread = _client_action_threads[client_id] - var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec())) - var worker_alive := thread != null and thread.is_alive() - if thread != null: - _orphaned_client_action_threads.append(thread) - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - var action := str(_client_action_names.get(client_id, "configure")) - _client_action_names.erase(client_id) - _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 - _finalize_action_buttons(client_id) - print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [ - client_id, - action, - elapsed, - str(worker_alive), - ]) - var label := "Remove" if action == "remove" else "Configure" - _apply_row_status( - client_id, - Client.Status.ERROR, - "%s did not report completion in time; refreshing current status." % label - ) - _refresh_clients_summary() - if is_inside_tree(): - _request_client_status_refresh(true) - - -func _prune_orphaned_client_action_threads() -> void: - var completed_orphan := false - for i in range(_orphaned_client_action_threads.size() - 1, -1, -1): - var thread := _orphaned_client_action_threads[i] - if thread == null: - _orphaned_client_action_threads.remove_at(i) - elif not thread.is_alive(): - thread.wait_to_finish() - _orphaned_client_action_threads.remove_at(i) - completed_orphan = true - if completed_orphan and is_inside_tree(): - _request_client_action_completion_refresh() - - -func _request_client_action_completion_refresh() -> void: - _request_client_status_refresh(true) - - -func _notification(what: int) -> void: - # Detect dock/undock by watching for reparenting events. - if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED: - _update_redock_visibility.call_deferred() - elif what == NOTIFICATION_APPLICATION_FOCUS_IN: - if _should_refresh_client_statuses_on_focus_in(): - _request_client_status_refresh(false) - ## Re-probe uv only when the user actually went off to install it - ## (see _on_install_uv). `check_uv_version()` is cached, so an - ## ungated refresh here would usually be free — but after the - ## button invalidated that cache it costs one blocking - ## `uvx --version`, and this notification must not grow a probe on - ## the common focus-in path. One-shot: clear before refreshing. - if _uv_recheck_pending: - _uv_recheck_pending = false - _refresh_setup_status.call_deferred() - - -func _should_refresh_client_statuses_on_focus_in() -> bool: - ## Focus-in is part of Godot/editor window activation. Keep automatic refresh, - ## but only through the async/cooldown-protected path; never run a blocking - ## client-status sweep directly from this notification. - return true - - -func _is_floating() -> bool: - var p := get_parent() - while p != null: - if p is Window: - return p != get_tree().root - p = p.get_parent() - return false - - -func _update_redock_visibility() -> void: - if _redock_btn == null: - return - var floating := _is_floating() - if _redock_btn.visible != floating: - _redock_btn.visible = floating - - -func _on_redock() -> void: - # When floating, our Window is NOT the editor root. Closing it triggers - # Godot's internal dock-return logic (same as clicking the window's X). - var win := get_window() - if win != null and win != get_tree().root: - win.close_requested.emit() - - -func _build_margin_container(margin: int = 12) -> MarginContainer: - var margin_container := MarginContainer.new() - margin_container.add_theme_constant_override("margin_left", margin) - margin_container.add_theme_constant_override("margin_right", margin) - margin_container.add_theme_constant_override("margin_top", margin) - margin_container.add_theme_constant_override("margin_bottom", margin) - return margin_container - - -func _build_ui() -> void: - add_theme_constant_override("separation", 8) - - # --- Top row: status indicator + redock button (when floating) --- - var status_row := HBoxContainer.new() - status_row.add_theme_constant_override("separation", 8) - - _status_icon = ColorRect.new() - _status_icon.custom_minimum_size = Vector2(14, 14) - # Amber on first paint — matches the "Starting server…" label text and - # distinguishes from a real disconnect (red). - _status_icon.color = COLOR_AMBER - var icon_center := CenterContainer.new() - icon_center.add_child(_status_icon) - status_row.add_child(icon_center) - - _status_label = Label.new() - # Start in grace state — _update_status will take over on the next frame - # once the connection is available. Never show bare "Disconnected" on - # first paint because that's misleading while the server is still - # spinning up. - _status_label.text = "Starting server…" - _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - status_row.add_child(_status_label) - - _redock_btn = Button.new() - _redock_btn.text = "Dock" - _redock_btn.tooltip_text = "Return this panel to the editor dock" - _redock_btn.visible = false - _redock_btn.pressed.connect(_on_redock) - status_row.add_child(_redock_btn) - - add_child(status_row) - - # Install-mode line — so a git-clone user doesn't press the yellow Update - # banner below and silently downgrade from main to the last release tag. - # See #144. - _install_label = Label.new() - _install_label.add_theme_color_override("font_color", COLOR_MUTED) - _install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _install_label.text = _install_mode_text() - _install_label.tooltip_text = _install_mode_tooltip() - _install_label.mouse_filter = Control.MOUSE_FILTER_STOP - add_child(_install_label) - - _body_scroll = ScrollContainer.new() - _body_scroll.name = "DockBodyScroll" - _body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - _body_scroll.custom_minimum_size = Vector2(0, 48) - _body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - add_child(_body_scroll) - - _body = VBoxContainer.new() - _body.name = "DockBody" - _body.add_theme_constant_override("separation", 8) - _body.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _body_scroll.add_child(_body) - - # --- Spawn-failure panel (shown when `_start_server` reports a non-OK - # state via `get_server_status`). One body paragraph + the matching - # action; the top status label already carries the state headline. - _crash_panel = VBoxContainer.new() - _crash_panel.add_theme_constant_override("separation", 6) - _crash_panel.visible = false - - _crash_output = RichTextLabel.new() - _crash_output.custom_minimum_size = Vector2(0, 60) - _crash_output.bbcode_enabled = false - _crash_output.selection_enabled = true - _crash_output.scroll_following = false - _crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _crash_output.fit_content = true - _crash_panel.add_child(_crash_output) - - _port_picker_panel = PortPickerPanelScript.new() - _port_picker_panel.setup() - _port_picker_panel.port_apply_requested.connect(_on_port_apply_requested) - _crash_panel.add_child(_port_picker_panel) - - _crash_restart_btn = Button.new() - _crash_restart_btn.text = "Restart Server" - _crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server" - _crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _crash_restart_btn.add_theme_color_override("font_color", Color.WHITE) - _crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE) - _crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE) - _crash_restart_btn.pressed.connect(_on_restart_stale_server) - _crash_restart_btn.visible = false - _crash_panel.add_child(_crash_restart_btn) - - _crash_reload_btn = Button.new() - _crash_reload_btn.text = "Reload Plugin" - _crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue" - _crash_reload_btn.pressed.connect(_on_reload_plugin) - _crash_panel.add_child(_crash_reload_btn) - - _crash_docs_btn = Button.new() - _crash_docs_btn.text = "How to change the port" - _crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients" - _crash_docs_btn.visible = false - _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url())) - _crash_panel.add_child(_crash_docs_btn) - - _crash_panel.add_child(HSeparator.new()) - _body.add_child(_crash_panel) - - _build_mixed_state_banner() - _refresh_mixed_state_banner() - - # --- Update banner (top of dock, hidden until check finds a newer version) --- - _update_banner = VBoxContainer.new() - _update_banner.add_theme_constant_override("separation", 4) - _update_banner.visible = false - - _update_label = Label.new() - _update_label.add_theme_font_size_override("font_size", 15) - _update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3)) - ## Wrap long banner text (e.g. the < 4.5 support-floor guidance) instead - ## of letting a single line stretch the whole dock wide. The dock is a - ## fixed-width side panel, so constrain horizontally and wrap. - _update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _update_label.size_flags_horizontal = Control.SIZE_FILL - _update_label.custom_minimum_size = Vector2(0, 0) - _update_banner.add_child(_update_label) - - var update_btn_row := HBoxContainer.new() - update_btn_row.add_theme_constant_override("separation", 6) - - _update_btn = Button.new() - _update_btn.text = "Update" - _update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _update_btn.pressed.connect(_on_update_pressed) - update_btn_row.add_child(_update_btn) - - var release_link := Button.new() - release_link.text = "Release notes" - release_link.pressed.connect(func(): OS.shell_open(UpdateManagerScript.RELEASES_PAGE)) - update_btn_row.add_child(release_link) - - _update_banner.add_child(update_btn_row) - _update_banner.add_child(HSeparator.new()) - - _body.add_child(_update_banner) - - if _update_manager == null: - _update_manager = UpdateManagerScript.new() - _update_manager.setup(_plugin, self) - _update_manager.update_check_completed.connect(_on_update_check_result) - _update_manager.install_state_changed.connect(_on_install_state_changed) - _body.add_child(_update_manager) - _update_manager.check_for_updates.call_deferred() - - # --- Dev-only connection extras (server label + reload button) --- - _dev_section = VBoxContainer.new() - _dev_section.add_theme_constant_override("separation", 6) - _body.add_child(_dev_section) - - _server_label = Label.new() - _server_label.add_theme_color_override("font_color", COLOR_MUTED) - _dev_section.add_child(_server_label) - _refresh_server_label() - - var btn_row := HBoxContainer.new() - btn_row.add_theme_constant_override("separation", 6) - - _reload_btn = Button.new() - _reload_btn.text = "Dev: Reload Plugin" - _reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server." - _reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _reload_btn.pressed.connect(_on_reload_plugin) - btn_row.add_child(_reload_btn) - - _dev_section.add_child(btn_row) - - # --- Setup section (dev-only or when uv missing) --- - _setup_section = VBoxContainer.new() - _setup_section.add_theme_constant_override("separation", 6) - _body.add_child(_setup_section) - - _setup_section.add_child(HSeparator.new()) - _setup_section.add_child(_make_header("Setup")) - _setup_container = VBoxContainer.new() - _setup_container.add_theme_constant_override("separation", 6) - _setup_section.add_child(_setup_container) - - _body.add_child(HSeparator.new()) - - # --- Clients --- - var clients_header_row := HBoxContainer.new() - clients_header_row.add_theme_constant_override("separation", 8) - - var clients_header := _make_header("Clients") - clients_header_row.add_child(clients_header) - - _clients_summary_label = Label.new() - _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED) - _clients_summary_label.clip_text = true - _clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - clients_header_row.add_child(_clients_summary_label) - - var clients_actions := HFlowContainer.new() - clients_actions.add_theme_constant_override("h_separation", 8) - clients_actions.add_theme_constant_override("v_separation", 4) - - var clients_refresh_btn := Button.new() - clients_refresh_btn.text = "Refresh" - clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run." - clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed) - clients_actions.add_child(clients_refresh_btn) - - var clients_open_btn := Button.new() - clients_open_btn.text = "Clients & Tools" - clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)." - clients_open_btn.pressed.connect(_on_open_clients_window) - clients_actions.add_child(clients_open_btn) - - _body.add_child(clients_header_row) - _body.add_child(clients_actions) - - _client_empty_cta_btn = Button.new() - _client_empty_cta_btn.text = "Configure an AI client ->" - _client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server." - _client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _client_empty_cta_btn.visible = false - _client_empty_cta_btn.pressed.connect(_on_open_clients_window) - _body.add_child(_client_empty_cta_btn) - - # Drift banner — hidden until a sweep finds at least one mismatched client. - _drift_banner = VBoxContainer.new() - _drift_banner.add_theme_constant_override("separation", 4) - _drift_banner.visible = false - _drift_label = Label.new() - _drift_label.add_theme_color_override("font_color", COLOR_AMBER) - _drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _drift_banner.add_child(_drift_label) - var drift_btn := Button.new() - drift_btn.text = "Reconfigure mismatched" - drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL." - drift_btn.pressed.connect(_on_reconfigure_mismatched) - _drift_banner.add_child(drift_btn) - _body.add_child(_drift_banner) - - _clients_window = Window.new() - _clients_window.title = "Godot AI Settings" - ## `Vector2i * float` yields Vector2; wrap the result back to Vector2i. - _clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale()) - _clients_window.visible = false - _clients_window.close_requested.connect(_on_clients_window_close_requested) - add_child(_clients_window) - - ## Tabbed secondary window: Clients (per-client rows), Tools (domain- - ## exclusion checkboxes for clients that cap total tool count, like - ## Antigravity at 100), and Settings (allow-host LAN opt-in, #507). - ## Adding another tab is one more _build_*_tab call — no surgery on the - ## rest of the window. - var tabs := TabContainer.new() - tabs.anchor_right = 1.0 - tabs.anchor_bottom = 1.0 - _clients_window.add_child(tabs) - - var clients_tab := VBoxContainer.new() - clients_tab.add_theme_constant_override("separation", 8) - var clients_margin := _build_margin_container() - clients_margin.name = "Clients" - clients_margin.add_child(clients_tab) - tabs.add_child(clients_margin) - - _client_configure_all_btn = Button.new() - _client_configure_all_btn.text = "Configure all" - _client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server" - _client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END - _client_configure_all_btn.pressed.connect(_on_configure_all_clients) - clients_tab.add_child(_client_configure_all_btn) - - var clients_scroll := ScrollContainer.new() - clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - clients_tab.add_child(clients_scroll) - - _client_grid = VBoxContainer.new() - _client_grid.add_theme_constant_override("separation", 4) - _client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL - clients_scroll.add_child(_client_grid) - - for client_id in ClientConfigurator.client_ids(): - _build_client_row(client_id) - - _build_tools_tab(tabs) - _build_settings_tab(tabs) - - _body.add_child(HSeparator.new()) - - # --- Dev mode toggle (always visible) --- - var dev_toggle_row := HBoxContainer.new() - var dev_toggle_label := Label.new() - dev_toggle_label.text = "Developer mode" - dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - dev_toggle_row.add_child(dev_toggle_label) - - _dev_mode_toggle = CheckButton.new() - _dev_mode_toggle.button_pressed = _load_dev_mode() - _dev_mode_toggle.toggled.connect(_on_dev_mode_toggled) - dev_toggle_row.add_child(_dev_mode_toggle) - _body.add_child(dev_toggle_row) - - # --- Log section (dev-only) --- - _log_viewer = LogViewerScript.new() - _log_viewer.setup(_log_buffer) - _log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed) - _body.add_child(_log_viewer) - - # Apply initial dev-mode visibility - _apply_dev_mode_visibility() - _refresh_setup_status.call_deferred() - _perform_initial_client_status_refresh() - - -## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)` -## without re-declaring identical helpers + COLOR_HEADER constants. -static func _make_header(text: String) -> Label: - var label := Label.new() - label.text = text - label.add_theme_font_size_override("font_size", 18) - label.add_theme_color_override("font_color", COLOR_HEADER) - return label - - -func _build_client_row(client_id: String) -> void: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - - var dot := ColorRect.new() - dot.custom_minimum_size = Vector2(10, 10) - dot.color = COLOR_MUTED - var dot_center := CenterContainer.new() - dot_center.add_child(dot) - row.add_child(dot_center) - - var name_label := Label.new() - name_label.text = ClientConfigurator.client_display_name(client_id) - name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - ## #838/#816 step 11: say which transport Configure will write — the - ## client-owned attach bridge or the client's native URL mode. - var transport_tag := Label.new() - transport_tag.text = _client_transport_tag(client_id) - transport_tag.add_theme_color_override("font_color", COLOR_MUTED) - transport_tag.tooltip_text = ( - "Configure writes a local `godot-ai attach` launch command for this client." - if transport_tag.text == "attach" - else "Configure writes this client's native URL entry." - ) - ## Long error messages from `_verify_post_state` (e.g. "reported remove ok - ## but verification still reads configured…") used to push the Retry / - ## Configure button off-screen — the row's Label wanted its full text - ## width as minimum size, so the buttons got squeezed out. Wrap onto - ## multiple lines instead so the row keeps its right edge stable and - ## the buttons remain visible; the user can also read the whole message - ## without resizing the window. - name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - row.add_child(name_label) - row.add_child(transport_tag) - - var configure_btn := Button.new() - configure_btn.text = "Configure" - configure_btn.pressed.connect(_on_configure_client.bind(client_id)) - row.add_child(configure_btn) - - var remove_btn := Button.new() - remove_btn.text = "Remove" - remove_btn.visible = false - remove_btn.pressed.connect(_on_remove_client.bind(client_id)) - row.add_child(remove_btn) - - # F-3-4: use the authoritative facade so Open/Reveal land on the same - # file `_check_status_merged` drives (last-wins across project tiers, - # matching the F2 status fix). - var config_path := ClientConfigurator.effective_authoritative_path(client_id) - var open_config_btn := Button.new() - _apply_editor_icon(open_config_btn, "ExternalLink", "Open") - open_config_btn.custom_minimum_size = Vector2(28, 28) - open_config_btn.visible = not config_path.is_empty() - open_config_btn.pressed.connect(_on_open_config_file.bind(client_id)) - row.add_child(open_config_btn) - - var reveal_btn := Button.new() - _apply_editor_icon(reveal_btn, "Folder", "Reveal") - reveal_btn.custom_minimum_size = Vector2(28, 28) - reveal_btn.visible = not config_path.is_empty() - reveal_btn.pressed.connect(_on_reveal_config_folder.bind(client_id)) - row.add_child(reveal_btn) - - _client_grid.add_child(row) - - var manual_panel := VBoxContainer.new() - manual_panel.add_theme_constant_override("separation", 4) - manual_panel.visible = false - - var manual_hint := Label.new() - manual_hint.text = "Run this manually:" - manual_hint.add_theme_color_override("font_color", COLOR_MUTED) - manual_panel.add_child(manual_hint) - - var manual_text := TextEdit.new() - manual_text.editable = false - manual_text.custom_minimum_size = Vector2(0, 60) - manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY - manual_panel.add_child(manual_text) - - var copy_btn := Button.new() - copy_btn.text = "Copy" - copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id)) - manual_panel.add_child(copy_btn) - - _client_grid.add_child(manual_panel) - - _client_rows[client_id] = { - "dot": dot, - "status": Client.Status.NOT_CONFIGURED, - "name_label": name_label, - "configure_btn": configure_btn, - "remove_btn": remove_btn, - "open_config_btn": open_config_btn, - "reveal_btn": reveal_btn, - "config_path": config_path, - "manual_panel": manual_panel, - "manual_text": manual_text, - } - _refresh_client_config_file_buttons(client_id) - - -func _apply_editor_icon(button: Button, icon_name: String, fallback_text: String) -> void: - if has_theme_icon(icon_name, "EditorIcons"): - button.icon = get_theme_icon(icon_name, "EditorIcons") - else: - button.text = fallback_text - - -# --- Status updates --- - -func _update_status() -> void: - var connected: bool = _connection != null and _connection.is_connected - ## Pull the connection's transport snapshot on this existing refresh tick. - ## `has_method` preserves the plugin self-update seam while an older - ## Connection instance is still alive under a hot-reloaded dock script. - var transport_status: Dictionary = ( - _connection.get_transport_status() - if _connection != null and _connection.has_method("get_transport_status") - else {} - ) - ## During plugin self-update there's a brief window where this dock - ## script is already the new version (Godot hot-reloads scripts on - ## file change) but `_plugin` is still the old `EditorPlugin` instance - ## (only `set_plugin_enabled(false, true)` re-instantiates that). When - ## the new dock calls a method the old plugin doesn't have, `_process` - ## errors every frame until `McpUpdateManager._reload_after_update` - ## lands. Guard every `_plugin.()` call with `has_method` - ## so that window stays silent. See #168. - var server_status: Dictionary = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if ServerStateScript.blocks_client_health(state): - connected = false - - ## One `match`/`elif` chain, one source of truth. Adding a new - ## spawn outcome = one `ServerStateScript` constant + one arm here + - ## one body string in `_crash_body_for_state`. - ## Default covers both a missing/old Connection instance and an unknown - ## future transport phase. Every recognized state below overrides it, so - ## startup grace and settled disconnect have one rendering path. - var inside_startup_grace := Time.get_ticks_msec() < _startup_grace_until_msec - var status_text := "Starting server…" if inside_startup_grace else "Disconnected" - var status_color := COLOR_AMBER if inside_startup_grace else Color.RED - if _server_restart_in_progress: - status_text = "Restarting server..." - status_color = COLOR_AMBER - elif connected: - status_text = _connected_status_text() - status_color = Color.GREEN - elif state == ServerStateScript.CRASHED: - var exit_ms: int = server_status.get("exit_ms", 0) - status_text = "Server exited after %.1fs" % (exit_ms / 1000.0) - status_color = Color.RED - elif state == ServerStateScript.PORT_EXCLUDED: - status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port() - status_color = Color.RED - elif state == ServerStateScript.INCOMPATIBLE: - status_text = "Incompatible server on port %d" % ClientConfigurator.http_port() - status_color = Color.RED - elif state == ServerStateScript.FOREIGN_PORT: - ## #647: the post-crash probe names the actual conflicting port - ## (HTTP or WS) — don't blame port 8000 when 9500 is the occupant. - var conflict_port: int = int(server_status.get("conflict_port", 0)) - if conflict_port <= 0: - conflict_port = ClientConfigurator.http_port() - status_text = "Port %d held by another process" % conflict_port - status_color = Color.RED - elif state == ServerStateScript.NO_COMMAND: - status_text = "No server command found" - status_color = Color.RED - elif not transport_status.is_empty(): - var transport_phase := str(transport_status.get("phase", "")) - if transport_phase == "connecting": - status_text = _transport_status_text(transport_status) - status_color = COLOR_AMBER - elif transport_phase == "retrying": - status_text = _transport_status_text(transport_status) - status_color = COLOR_AMBER - elif transport_phase == "closing": - status_text = _transport_status_text(transport_status) - status_color = COLOR_AMBER - elif transport_phase == "blocked": - ## Exact terminal labels come from lifecycle state above. This is a - ## generic fallback for a blocked connection without a diagnosis. - status_text = _transport_status_text(transport_status) - status_color = Color.RED - - ## keep_server_on_exit (#800): the reaper env opt-outs are staged at - ## spawn, so a mid-session toggle only lands on the next server start — - ## say so while the running server still carries the old behavior. - if connected and ClientConfigurator.keep_server_on_exit() != bool(server_status.get("keep_alive", false)): - status_text += " — keep-server-on-exit applies after Restart" - - _update_crash_panel(server_status) - _refresh_server_version_label(server_status) - _refresh_server_label(server_status) - - ## A transient disconnect reason remains in the transport snapshot until - ## handshake_ack. Once the dock renders the connection as OPEN, do not pair - ## its green label with the previous peer's recovery diagnostic. - var status_tooltip := "" if connected else str(transport_status.get("reason", "")) - var changed: bool = ( - connected != _last_connected - or status_text != _last_status_text - or status_tooltip != _last_status_tooltip - ) - if not changed: - return - var just_connected: bool = connected and not _last_connected - _last_connected = connected - _last_status_text = status_text - _last_status_tooltip = status_tooltip - _status_icon.color = status_color - _status_label.text = status_text - _status_label.tooltip_text = status_tooltip - if just_connected: - ## #739: the server just came up. If the startup uv probe failed - ## (the reporter's screenshot: green "Server connected" beside a - ## red "uv: not found" row), the failure was transient — re-probe - ## instead of pinning the red row for the whole session. Runs - ## AFTER the label writes above and via the deferred queue, so the - ## status-machine state is committed before the probe can block. - _schedule_uv_reprobe() - - ## Status transitions are exactly when "is the launch still settling?" - ## can change (Starting server… -> connected / Disconnected / terminal - ## diagnosis), so re-evaluate the Setup section's visibility here (#744). - ## Cheap: runs only on `changed`, and the uv probe result is cached. - _apply_dev_mode_visibility() - - _update_dev_section_buttons() - - -## Render the diagnostic panel body for a given spawn state. The top -## status label already names the problem; this answers "what do I do?". -## Panel shows for any non-OK state; picker shows only when moving the HTTP -## port alone is a valid recovery. Incompatible godot-ai servers commonly -## hold both HTTP and WS ports, so their message points to Editor Settings -## instead of offering the HTTP-only quick picker. -func _update_crash_panel(server_status: Dictionary) -> void: - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if not ServerStateScript.is_terminal_diagnosis(state): - if _crash_panel.visible: - _crash_panel.visible = false - _last_server_status = {} - return - if server_status == _last_server_status: - return - _last_server_status = server_status.duplicate() - _crash_panel.visible = true - _crash_output.clear() - _crash_output.add_text(_crash_body_for_state(state, server_status)) - var show_recovery_restart := ( - state == ServerStateScript.INCOMPATIBLE - and bool(server_status.get("can_recover_incompatible", false)) - ) - if _crash_restart_btn != null: - _crash_restart_btn.visible = show_recovery_restart - _crash_restart_btn.disabled = _server_restart_in_progress - _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" - if _crash_reload_btn != null: - _crash_reload_btn.visible = ( - not show_recovery_restart - and state != ServerStateScript.INCOMPATIBLE - ) - ## Docs link only for the genuinely-foreign occupant: a recoverable - ## (older godot-ai) server gets Restart Server instead, and the inline - ## body already names a free port — the link carries the per-client - ## reconfigure steps that don't fit inline. - if _crash_docs_btn != null: - _crash_docs_btn.visible = ( - state == ServerStateScript.INCOMPATIBLE - and not bool(server_status.get("can_recover_incompatible", false)) - ) - - ## #647: the quick picker only moves `godot_ai/http_port`, so hide it - ## when the diagnosed conflict is on the WebSocket port — the crash - ## body already points at `godot_ai/ws_port` in Editor Settings. - var conflict_port := int(server_status.get("conflict_port", 0)) - var http_conflict := conflict_port <= 0 or conflict_port == ClientConfigurator.http_port() - var port_picker_visible := ( - state == ServerStateScript.PORT_EXCLUDED - or (state == ServerStateScript.FOREIGN_PORT and http_conflict) - ) - _port_picker_panel.visible = port_picker_visible - if port_picker_visible: - ## Seed the spinbox with a suggested non-reserved port each time the - ## panel surfaces. Idempotent when the user already has a good - ## candidate queued up. - _port_picker_panel.seed_suggested_port() - - -static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String: - ## Single sentence per state. The top status label already names the - ## problem; don't repeat it here. This copy answers "what do I do?". - var port := ClientConfigurator.http_port() - match state: - ServerStateScript.PORT_EXCLUDED: - return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port - ServerStateScript.INCOMPATIBLE: - var message := str(server_status.get("message", "")) - if bool(server_status.get("can_recover_incompatible", false)): - var expected := str(server_status.get("expected_version", "")) - if expected.is_empty(): - expected = ClientConfigurator.get_plugin_version() - if not message.is_empty(): - return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected] - return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected] - ## Genuinely foreign occupant (no recovery proof). Name a concrete - ## free port so the user doesn't have to hunt for one, and let the - ## crash panel's "How to change the port" link carry the per-client - ## reconfigure steps. `suggest_free_port` already routes through the - ## Windows reservation table, so the named port won't itself fail - ## with WinError 10013. - var hint := _free_port_hint(port) - if not message.is_empty(): - return "%s %s" % [message, hint] - return "Port %d is occupied by an incompatible server. %s" % [port, hint] - ServerStateScript.FOREIGN_PORT: - ## #647: prefer the lifecycle's diagnosis (it names the right - ## port — HTTP vs WS — and the Editor Setting to change) over - ## the generic HTTP-port fallback. - var foreign_message := str(server_status.get("message", "")) - if not foreign_message.is_empty(): - return foreign_message - return "Another process is already bound to port %d. Pick a free port or stop the other process." % port - ServerStateScript.CRASHED: - ## #805: a specific crash diagnosis from the lifecycle (e.g. the - ## flapping-occupant latch) beats the generic launch-mode copy. - ## Generic crash paths clear the message, so stale text from an - ## earlier state can't leak in here. - var crash_message := str(server_status.get("message", "")) - if not crash_message.is_empty(): - return crash_message - ## Both spawn attempts failed on the uvx tier — stock releases: - ## PyPI lag. Local builds (version with +metadata): almost always the - ## dev venv was not found (unresolved junction/symlink) so uvx tried - ## a pin that may lack checkout-local extras. - if ClientConfigurator.get_server_launch_mode() == "uvx": - var version := ClientConfigurator.get_plugin_version() - var pin := ClientConfigurator._pypi_pin_version(version) - if pin != version: - ## `%` binds tighter than `+` in GDScript — format the fully - ## concatenated string, never the last fragment alone. - return ( - "The server exited before the WebSocket handshake. " - + "Local plugin version is %s (PEP 440 local build metadata) — uvx pins PyPI godot-ai==%s. " - + "If you need checkout-local server code, ensure addons/godot_ai resolves to your " - + "dev tree (symlink/junction) with a `.venv`, or set GODOT_AI_VENV_PYTHON to that " - + "venv's python binary, then Reload Plugin. Log should show 'MCP | using dev venv: ...'." - ) % [version, pin] - return ( - "The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. " - + "If this is a brand-new release, PyPI's index may still be propagating (~10 min). " - + "Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. " - + "Target: godot-ai==%s." - ) % pin - return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback." - ServerStateScript.NO_COMMAND: - return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`." - _: - return "" - - -## One sentence naming concrete free ports for the user to switch to. Names -## BOTH http and ws: this branch also fires for an incompatible godot-ai -## server we can't prove we own, which commonly holds both ports — moving only -## http would then leave the new server unable to bind ws. Both suggestions are -## routed through `suggest_free_port` so they clear Windows' winnat reservation -## table (no point suggesting a port that 10013s on bind). Only the http port -## reaches client configs; the ws port is server↔plugin, hence the wording. -## The per-client reconfigure steps live behind the crash panel's docs link. -static func _free_port_hint(port: int) -> String: - var free_http := ClientConfigurator.suggest_free_port(port + 1) - var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1) - return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws] - - -## URL for the port-conflict guide, pinned to the release tag that matches the -## installed plugin version (releases are tagged `v`). The crash-panel -## button only exists in builds that ship `docs/port-conflicts.md`, so the -## versioned ref always resolves — and a shipped build never points users at a -## tip-of-main guide that has drifted from its own UI. -static func _port_conflict_docs_url() -> String: - var version := ClientConfigurator.get_plugin_version() - var git_ref := ("v%s" % version) if not version.is_empty() else "main" - return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH] - - -## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` -## confirms `*.update_backup` files exist in the addons tree. Mirrors the -## issue #354 fix shape: structured, agent-readable diagnostic that survives -## a normal editor restart so the user can act on it instead of re-running -## the update. -func _build_mixed_state_banner() -> void: - _mixed_state_banner = VBoxContainer.new() - _mixed_state_banner.add_theme_constant_override("separation", 4) - _mixed_state_banner.visible = false - - _mixed_state_label = Label.new() - _mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _mixed_state_label.add_theme_color_override("font_color", Color.RED) - _mixed_state_banner.add_child(_mixed_state_label) - - _mixed_state_files = RichTextLabel.new() - _mixed_state_files.bbcode_enabled = false - _mixed_state_files.fit_content = true - _mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF - _mixed_state_files.selection_enabled = true - _mixed_state_files.scroll_active = true - _mixed_state_files.custom_minimum_size = Vector2(0, 90) - _mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER) - _mixed_state_banner.add_child(_mixed_state_files) - - _mixed_state_rescan_btn = Button.new() - _mixed_state_rescan_btn.text = "Re-scan" - _mixed_state_rescan_btn.tooltip_text = ( - "Scan addons/godot_ai/ for *.update_backup files again." - + " Click after restoring the addon manually to dismiss this banner." - ) - _mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true)) - _mixed_state_banner.add_child(_mixed_state_rescan_btn) - - _mixed_state_banner.add_child(HSeparator.new()) - _body.add_child(_mixed_state_banner) - - -func _refresh_mixed_state_banner(force: bool = false) -> void: - ## Re-scan button passes `force=true` to bypass the scanner's TTL - ## cache so a manual fix is reflected immediately. - _apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose( - UpdateMixedStateScript.ADDON_DIR, force - )) - - -## Render seam exposed for testing — the GDScript test suite drives this -## directly with synthetic diagnostics so dock banner contracts can be -## pinned without polluting the real `addons/godot_ai/` tree with backup -## files. Callers from production go through `_refresh_mixed_state_banner`. -func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void: - if _mixed_state_banner == null: - return - if diag.is_empty(): - _mixed_state_banner.visible = false - return - _mixed_state_banner.visible = true - ## `Dictionary.get(...)` returns Variant; Label.text is typed String. - ## Explicit cast keeps the type contract honest and dodges some Godot - ## 4.x point-release quirks around Variant→typed-property assignment. - _mixed_state_label.text = String(diag.get("message", "")) - _mixed_state_files.clear() - for path in diag.get("backup_files", []): - _mixed_state_files.add_text(String(path)) - _mixed_state_files.newline() - if bool(diag.get("truncated", false)): - _mixed_state_files.add_text( - "… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS - ) - _mixed_state_files.newline() - - -## Signal handler for the extracted LogViewer — the panel owns its own -## display visibility, the dock owns logging routing. Routes to BOTH the -## dispatcher (gates [recv]/[send] recording) and the log buffer's console -## echo — the connection logs [event]/[defer] lines directly to the buffer, -## bypassing the dispatcher, so gating only `mcp_logging` left the console -## spamming with the toggle off (#626). Ring recording is unaffected, so -## the dock's log panel keeps working while the console stays quiet. -func _on_log_logging_enabled_changed(enabled: bool) -> void: - if _connection and _connection.dispatcher: - _connection.dispatcher.mcp_logging = enabled - if _log_buffer != null: - _log_buffer.enabled = enabled - - -## Signal handler for the extracted PortPickerPanel — the panel range-validates -## the spinbox value before emitting, so we just write the EditorSetting and -## reload the plugin here. -func _on_port_apply_requested(new_port: int) -> void: - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port) - ## Every saved client config now points at the old port. Re-sweep so the - ## drift banner appears in the same frame the user committed the change — - ## the plugin reload below will run a second sweep on its own first paint, - ## but we want the banner up immediately rather than after the reload - ## handshake races to completion. See #166. - _refresh_all_client_statuses() - ## Reload after the setting is committed so `_start_server` reads the new - ## port on the re-enabled plugin instance. - _on_reload_plugin() - - -func _refresh_server_label(server_status: Dictionary = {}) -> void: - if _server_label == null: - return - var ws_port := ClientConfigurator.ws_port() - if _plugin != null and _plugin.has_method("get_resolved_ws_port"): - ws_port = int(_plugin.get_resolved_ws_port()) - var text := "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()] - if server_status.is_empty() and _plugin != null and _plugin.has_method("get_server_status"): - server_status = _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_pid"): - var ownership := _server_ownership_tag( - int(server_status.get("state", ServerStateScript.UNINITIALIZED)), - int(_plugin.get_server_pid()), - ) - if not ownership.is_empty(): - text += " · %s" % ownership - _server_label.text = text - - -## #838/#816 step 11: name which backend flavor the editor is riding. -## Diagnostic display only — never kill proof (external adoption clears PID -## authority, see server_lifecycle.gd::adopt_compatible_server / #669). -static func _server_ownership_tag(state: int, server_pid: int) -> String: - if state != ServerStateScript.READY: - return "" - return "plugin-managed backend" if server_pid > 0 else "externally adopted backend" - - -## "attach" when Configure writes a client-owned launch command for this -## client, "URL" when it writes the client's native URL entry. Derived from -## descriptor data so the tag can never disagree with what Configure does. -static func _client_transport_tag(client_id: String) -> String: - var client := ClientRegistry.get_by_id(client_id) - if client == null: - return "" - return "URL" if client.command_shape == Client.CommandShape.NONE else "attach" - - -# --- Telemetry setting persistence --- - - -## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set -## to a truthy value, false if either is set and non-truthy, null if neither -## env var is present at all. -func _is_telemetry_disabled_via_env() -> Variant: - if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")): - return null - return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY") - - -## Reads the telemetry preference, applying env-var override when present. -## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and -## sets the checkbox state + locked tooltip. Call after _telemetry_toggle -## has been created. -func _load_telemetry_setting() -> void: - var es := EditorInterface.get_editor_settings() - var env_disabled = _is_telemetry_disabled_via_env() - - var enabled: bool - if env_disabled != null: - ## Env var present: resolve and save to EditorSettings so future sessions without - ## the env var honour the last-set value. - enabled = not bool(env_disabled) - if es != null: - es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled) - else: - ## No env var: read (or create) the EditorSettings key. - if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED): - enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED)) - else: - enabled = true - if es != null: - es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, true) - - _telemetry_pending_enabled = enabled - _telemetry_saved_enabled = enabled - - if _telemetry_toggle == null: - return - _telemetry_toggle.set_pressed_no_signal(enabled) - if env_disabled != null: - _telemetry_toggle.disabled = true - _telemetry_toggle.tooltip_text = ( - "Telemetry is controlled by an environment variable " - + "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)." - ) - else: - _telemetry_toggle.disabled = false - _telemetry_toggle.tooltip_text = "" - - -func _on_telemetry_toggled(pressed: bool) -> void: - _telemetry_pending_enabled = pressed - _refresh_tools_ui_state() - - -# --- Dev mode persistence --- - - -func _load_dev_mode() -> bool: - # Default OFF for every install (including dev checkouts). Contributors - # who want the extra diagnostic UI (Reload Plugin, MCP log - # panel, Start/Stop Dev Server) can flip the toggle once — editor - # settings persist across sessions. - var es := EditorInterface.get_editor_settings() - if es == null: - return false - if not es.has_setting(DEV_MODE_SETTING): - es.set_setting(DEV_MODE_SETTING, false) - return false - return bool(es.get_setting(DEV_MODE_SETTING)) - - -func _on_dev_mode_toggled(enabled: bool) -> void: - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(DEV_MODE_SETTING, enabled) - _apply_dev_mode_visibility() - _refresh_setup_status() - - -func _apply_dev_mode_visibility() -> void: - if _dev_mode_toggle == null: - return ## dock UI not built yet (unit tests, teardown window) - var dev := _dev_mode_toggle.button_pressed - _dev_section.visible = dev - if _log_viewer != null: - _log_viewer.visible = dev - # Setup section: visible in dev mode, OR in user mode when uv is missing - # (so users can install uv from the dock) — but not while the server - # launch is still settling (#744): mid-launch a red "uv: not found" row - # is usually a transient probe failure (#739) or irrelevant because the - # launch is succeeding via the .venv or system tiers. `_update_status` - # re-applies visibility on every status transition, so the section - # appears the moment the launch outcome makes it relevant. - var is_dev := ClientConfigurator.is_dev_checkout() - var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty() - _setup_section.visible = _setup_section_should_show(dev, uv_missing, _server_launch_pending()) - - -## Pure visibility decision for the Setup section (#744). Split out so the -## truth table is unit-testable without faking the uv probe or a dev -## checkout: dev toggle always shows the section; a missing uv only shows -## it once the server launch has settled. -static func _setup_section_should_show( - dev_toggle: bool, uv_missing: bool, launch_pending: bool -) -> bool: - return dev_toggle or (uv_missing and not launch_pending) - - -## True while the server launch outcome is still unknown: not connected, -## no terminal diagnosis yet, and the startup grace window ("Starting -## server…" in the status row) is still running. Mirrors the status-label -## logic in `_update_status` so the Setup section and the amber status -## text agree on what "still launching" means. -func _server_launch_pending() -> bool: - if _last_connected: - return false - var server_status: Dictionary = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if ServerStateScript.is_terminal_diagnosis(state): - return false - return Time.get_ticks_msec() < _startup_grace_until_msec - - -# --- Button handlers --- - - -func _do_plugin_reload() -> void: - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -func _on_reload_plugin() -> void: - # Persist a pending plugin_reload telemetry event *before* the - # disable kills the live WebSocket — the new plugin's _enter_tree - # flushes it via `_telemetry.flush_pending_plugin_reload()`. - Telemetry.record_pending_plugin_reload("dock_button") - # Defer the toggle so any in-flight input event finishes propagating - # before the dock (and its Window children) leave the tree. Calling - # set_plugin_enabled synchronously from a button press frees the - # viewport mid-dispatch. - _do_plugin_reload.call_deferred() - - -## Setup-section "Server" row: always report the TRUE running server -## version (from the handshake_ack) rather than the plugin's expected -## version, and highlight the mismatch so self-update drift is visible -## at a glance instead of silently masked by a green label. -## -## Render states, keyed off live version metadata: -## - empty (pre-ack): show the expected version only as an unverified target -## - matches plugin: show it green, no Restart button -## - dev mismatch: show amber with an explicit dev marker -## - release mismatch: show actual vs expected; only surface Restart when the -## plugin has ownership proof for the process -func _refresh_server_version_label(server_status: Dictionary = {}) -> void: - if _setup_server_label == null: - return - var plugin_ver := ClientConfigurator.get_plugin_version() - if server_status.is_empty(): - ## Re-fetch only when called outside `_update_status`'s frame - ## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the - ## per-frame loop, the caller threads its cached snapshot through - ## so we don't allocate a fresh Dictionary every frame. - server_status = ( - _plugin.get_server_status() - if _plugin != null and _plugin.has_method("get_server_status") - else {} - ) - var server_ver: String = _connection.server_version if _connection != null else "" - if server_ver.is_empty(): - server_ver = str(server_status.get("actual_version", "")) - var expected_ver := str(server_status.get("expected_version", "")) - if expected_ver.is_empty(): - expected_ver = plugin_ver - var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED)) - if _server_restart_in_progress and ( - server_ver == expected_ver - or ( - ServerStateScript.is_terminal_diagnosis(state) - and state != ServerStateScript.INCOMPATIBLE - ) - ): - _server_restart_in_progress = false - var text: String - var color: Color - var show_restart := false - if _server_restart_in_progress: - text = "restarting server..." - color = COLOR_AMBER - show_restart = true - elif server_ver.is_empty(): - text = "checking live version (expected godot-ai == %s)" % expected_ver - color = COLOR_MUTED - elif server_ver == expected_ver: - text = "godot-ai == %s" % server_ver - color = Color.GREEN - else: - text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver] - var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE - color = Color.RED if is_incompatible else COLOR_AMBER - var has_managed_proof: bool = ( - _plugin != null - and _plugin.has_method("can_restart_managed_server") - and _plugin.can_restart_managed_server() - ) - var can_recover: bool = bool(server_status.get("can_recover_incompatible", false)) - show_restart = ( - (not is_incompatible and has_managed_proof) - ## Recoverable incompatible servers get the primary action in - ## the top error panel. Duplicating it in Setup made the UI - ## look like it had multiple restart paths. - or (is_incompatible and can_recover and _crash_restart_btn == null) - ) - if text == _last_rendered_server_text: - _setup_server_label.add_theme_color_override("font_color", color) - _update_restart_button(show_restart) - return - _last_rendered_server_text = text - _setup_server_label.text = text - _setup_server_label.add_theme_color_override("font_color", color) - _update_restart_button(show_restart) - - -func _update_restart_button(visible: bool) -> void: - if _version_restart_btn != null: - _version_restart_btn.visible = visible - _version_restart_btn.disabled = _server_restart_in_progress - _version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart" - if _crash_restart_btn != null: - _crash_restart_btn.disabled = _server_restart_in_progress - _crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server" - - -func _on_restart_stale_server() -> void: - if _plugin == null or _server_restart_in_progress: - return - _server_restart_in_progress = true - _last_rendered_server_text = "" - _refresh_server_version_label() - if not is_inside_tree(): - await _dispatch_stale_server_restart() - _server_restart_in_progress = false - _last_rendered_server_text = "" - _refresh_server_version_label() - return - call_deferred("_restart_stale_server_after_feedback") - - -func _restart_stale_server_after_feedback() -> void: - await get_tree().create_timer(0.15).timeout - if not await _dispatch_stale_server_restart(): - _server_restart_in_progress = false - _last_rendered_server_text = "" - _refresh_server_version_label() - - -func _dispatch_stale_server_restart() -> bool: - if _plugin == null: - return false - var status: Dictionary = ( - _plugin.get_server_status() - if _plugin.has_method("get_server_status") - else {} - ) - if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE: - if _plugin.has_method("recover_incompatible_server"): - ## Coroutine in production (#678): recovery reports success only - ## after the respawn walk completes and the connection unblocks. - return bool(await _plugin.recover_incompatible_server()) - elif _plugin.has_method("force_restart_server"): - _plugin.force_restart_server() - return true - return false - - -# --- Setup section --- - -## #739: a `uvx --version` probe that failed once at editor startup used -## to pin "uv: not found" for the whole session — the Install-uv click -## was the only invalidation path, so the fix users discovered was -## re-clicking Install on every launch. Re-probe on events that suggest -## the failure was transient (server-connect transition, manual Refresh). -## No-op once uv has been found, so this costs nothing in the healthy -## steady state; when uv is genuinely absent, the re-probe is a fast -## negative (CliFinder's well-known-dir walk plus one bounded `where`). -## Runs on the main thread like the initial probe — same wall-clock -## bound, and the triggering events are rare (once per connect / click). -## -## Callers go through _schedule_uv_reprobe() rather than calling this -## inline: the cache-miss probe shells out (bounded at 3s) on the -## calling thread, and both call sites sit mid-flow in UI handlers — -## the connect transition wants its status-label writes committed -## first, and the Refresh click wants the client sweep dispatched -## without waiting on the probe. Same deferred convention as -## _on_install_uv. (The deferred queue still flushes on the main -## thread, so a worst-case 3s probe delays that frame — acceptable for -## a rare, bounded event; a worker thread would be the heavier cure.) -func _schedule_uv_reprobe() -> void: - _reprobe_uv_if_negative.call_deferred() - - -func _reprobe_uv_if_negative() -> void: - if not ClientConfigurator.uv_probe_negative(): - return - ClientConfigurator.invalidate_uv_detection() - _refresh_setup_status() - _apply_dev_mode_visibility() - - -func _refresh_setup_status() -> void: - if _setup_container == null: - return - for child in _setup_container.get_children(): - child.queue_free() - _dev_primary_btn = null - _dev_stop_btn = null - - var is_dev := ClientConfigurator.is_dev_checkout() - if is_dev: - _setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN)) - - var btn_row := HBoxContainer.new() - btn_row.add_theme_constant_override("separation", 4) - btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - - _dev_primary_btn = Button.new() - _dev_primary_btn.text = "Restart Dev Server" - _dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _dev_primary_btn.pressed.connect(_on_dev_primary_pressed) - btn_row.add_child(_dev_primary_btn) - - _dev_stop_btn = Button.new() - _dev_stop_btn.text = "✕" - _dev_stop_btn.tooltip_text = "Stop the dev server without spawning a replacement." - _dev_stop_btn.pressed.connect(_on_dev_stop_pressed) - btn_row.add_child(_dev_stop_btn) - - _setup_container.add_child(btn_row) - _update_dev_section_buttons() - return - - # User mode — check for uv - var uv_version := ClientConfigurator.check_uv_version() - if not uv_version.is_empty(): - var compact_uv_version := _compact_uv_version_text(uv_version) - var uv_tooltip := uv_version if compact_uv_version != uv_version else "" - _setup_container.add_child(_make_status_row("uv", compact_uv_version, Color.GREEN, uv_tooltip)) - ## Build the Server row with a placeholder label we can update every - ## frame. `_refresh_server_version_label` replaces the text + color - ## once `McpConnection.server_version` lands via `handshake_ack`, and - ## flips to amber + "(plugin X)" on drift. Pre-ack we show the - ## plugin's expected version so the row isn't blank. - var server_row := HBoxContainer.new() - server_row.add_theme_constant_override("separation", 8) - var key_label := Label.new() - key_label.text = "Server" - key_label.add_theme_color_override("font_color", COLOR_MUTED) - key_label.custom_minimum_size = Vector2(60, 0) - server_row.add_child(key_label) - _setup_server_label = Label.new() - _setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - server_row.add_child(_setup_server_label) - _version_restart_btn = Button.new() - _version_restart_btn.text = "Restart" - _version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port() - _version_restart_btn.pressed.connect(_on_restart_stale_server) - _version_restart_btn.visible = false - server_row.add_child(_version_restart_btn) - _setup_container.add_child(server_row) - _last_rendered_server_text = "" - _refresh_server_version_label() - else: - _setup_container.add_child(_make_status_row("uv", "not found", Color.RED)) - var install_btn := Button.new() - install_btn.text = "How to install uv" - install_btn.tooltip_text = ( - "Opens the official uv installation docs. Godot AI deliberately does " - + "not run the installer for you — see _on_install_uv." - ) - install_btn.pressed.connect(_on_install_uv) - _setup_container.add_child(install_btn) - - -func _install_mode_text() -> String: - if ClientConfigurator.is_dev_checkout(): - return "Install: dev checkout — update via git pull" - return "Install: v%s" % ClientConfigurator.get_plugin_version() - - -func _install_mode_tooltip() -> String: - if not ClientConfigurator.is_dev_checkout(): - return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release." - var target := _resolve_plugin_symlink_target() - if target.is_empty(): - return "Plugin source tree resolved via local .venv — press Reload Plugin after editing." - return "Plugin source: %s\nPress Reload Plugin after editing." % target - - -func _resolve_plugin_symlink_target() -> String: - var logical := ProjectSettings.globalize_path("res://addons/godot_ai").rstrip("/").rstrip("\\") - var resolved := ClientConfigurator.resolve_addons_realpath() - if resolved.is_empty() or resolved == logical: - return "" - return resolved - - -static func _compact_uv_version_text(uv_version: String) -> String: - var text := uv_version.strip_edges() - if text.ends_with(")"): - var metadata_start := text.rfind(" (") - if metadata_start >= 0: - return text.substr(0, metadata_start).strip_edges() - return text - - -func _make_status_row( - label_text: String, - value_text: String, - value_color: Color, - tooltip_text: String = "" -) -> HBoxContainer: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - if not tooltip_text.is_empty(): - row.tooltip_text = tooltip_text - - var label := Label.new() - label.text = label_text - label.add_theme_color_override("font_color", COLOR_MUTED) - label.custom_minimum_size.x = 60 - if not tooltip_text.is_empty(): - label.tooltip_text = tooltip_text - row.add_child(label) - - var value := Label.new() - value.text = value_text - value.add_theme_color_override("font_color", value_color) - if not tooltip_text.is_empty(): - value.tooltip_text = tooltip_text - row.add_child(value) - - return row - - -## Pure helper for the primary "Restart Dev Server" button. Always enabled -## (clicking with nothing running just spawns fresh); tooltip adapts to -## whether a kill+respawn or fresh spawn is what'll happen. -static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary: - var port := ClientConfigurator.http_port() - if has_managed or dev_running: - return { - "text": "Restart Dev Server", - "tooltip": ( - "Kill the server on port %d and start a fresh --reload dev server. " - + "Use this to pick up Python source changes that don't bump the version." - ) % port, - } - return { - "text": "Start Dev Server", - "tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port, - } - - -## Pure helper for the small "✕" stop button — only enabled when a dev -## server is actually running. Stops without respawning; intentionally -## never targets a managed server (that's the lifecycle's responsibility). -static func _dev_stop_btn_state(dev_running: bool) -> Dictionary: - if dev_running: - return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."} - return {"enabled": false, "tooltip": "No --reload dev server to stop."} - - -func _on_dev_primary_pressed() -> void: - if _plugin == null or _server_restart_in_progress: - return - if not _plugin.has_method("force_restart_or_start_dev_server"): - return - if _plugin.has_method("record_dev_server_toggle"): - _plugin.record_dev_server_toggle("start") - _server_restart_in_progress = true - _update_dev_section_buttons() - if not is_inside_tree(): - ## Test path — no scene tree means no timer; run synchronously - ## so suite assertions see the dispatch without `await`. - _plugin.force_restart_or_start_dev_server() - _server_restart_in_progress = false - return - call_deferred("_perform_dev_restart_after_feedback") - - -func _on_dev_stop_pressed() -> void: - if _plugin == null: - return - if _plugin.has_method("stop_dev_server"): - _plugin.stop_dev_server() - if _plugin.has_method("record_dev_server_toggle"): - _plugin.record_dev_server_toggle("stop") - _update_dev_section_buttons.call_deferred() - - -func _perform_dev_restart_after_feedback() -> void: - ## Brief paint cycle so the user sees "Restarting..." before the - ## blocking _wait_for_port_free freezes the editor for up to 5s. - await get_tree().create_timer(0.15).timeout - ## Re-check has_method post-await — a self-update mixed-state window - ## could swap _plugin's script class while we were sleeping, leaving - ## the old reference pointing at a class that no longer carries the - ## new method. Same #168 guard pattern as _update_dev_section_buttons. - if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"): - _plugin.force_restart_or_start_dev_server() - ## start_dev_server's spawn happens via a 0.5s SceneTree timer; give - ## it time to land plus a buffer for the WS reconnect before clearing - ## the busy state. The unconditional clear matches sibling restart - ## buttons — overshoot is fine because subsequent _update_status calls - ## refresh the button against live plugin state. - await get_tree().create_timer(2.0).timeout - _server_restart_in_progress = false - _update_dev_section_buttons() - - -## Single-scan refresh of every dev-section button state. Both buttons -## key off the same `has_managed_server` / `is_dev_server_running` pair, -## and the latter scrapes lsof/ps — so doing the discovery once and -## applying to both avoids the duplicate subprocess fork on every -## connection-state transition. -func _update_dev_section_buttons() -> void: - if _plugin == null: - return - if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")): - return - var has_managed: bool = _plugin.has_managed_server() - var dev_running: bool = _plugin.is_dev_server_running() - if _dev_primary_btn != null: - if _server_restart_in_progress: - _dev_primary_btn.disabled = true - _dev_primary_btn.text = "Restarting..." - _dev_primary_btn.tooltip_text = "Killing the current server and respawning..." - else: - var primary_state := _dev_primary_btn_state(has_managed, dev_running) - _dev_primary_btn.disabled = false - _dev_primary_btn.text = primary_state["text"] - _dev_primary_btn.tooltip_text = primary_state["tooltip"] - if _dev_stop_btn != null: - var stop_state := _dev_stop_btn_state(dev_running) - _dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress - _dev_stop_btn.tooltip_text = stop_state["tooltip"] - - -func _client_status_refresh_has_completed() -> bool: - return _last_client_status_refresh_completed_msec > 0 - - -func _connected_status_text() -> String: - return "Server connected" - - -static func _transport_status_text(snapshot: Dictionary) -> String: - ## Total over the transport enum for isolated consumers/tests. The dock's - ## connected fast path renders `_connected_status_text()` before calling it. - var phase := str(snapshot.get("phase", "")) - var attempt := maxi(1, int(snapshot.get("attempt", 0))) - match phase: - "connected": - return "Server connected" - "connecting": - return "Connecting — attempt %d" % attempt - "retrying": - var retry_in_sec := ceili(maxf(0.0, float(snapshot.get("retry_in_sec", 0.0)))) - return "Retrying in %ds — attempt %d" % [retry_in_sec, attempt] - "closing": - return "Disconnecting…" - "blocked": - return "Connection blocked" - return "Disconnected" - - -## Open uv's official install documentation rather than executing an -## installer on the user's behalf. -## -## This used to shell out to `curl -LsSf https://astral.sh/uv/install.sh | sh` -## (and the PowerShell `irm … | iex` equivalent). That is arbitrary remote -## code execution as the editor user, one dock click deep, with no version -## pin, no checksum, and no signature — while this same plugin verifies its -## OWN updates with an RSA-4096 signature over a SHA-256 sidecar, pinned to a -## GitHub host and this repo's release-asset path. Holding a third-party -## installer to a weaker standard than our own payload is the wrong trade, -## and pinning a digest here would only cover the bootstrap script, not the -## uv binary it goes on to fetch. -## -## Opening the docs keeps the discovery value of the button (the user still -## learns uv is missing and how to get it) while leaving the decision to -## install — and the choice of install method — with the user. Mirrors the -## dock's existing "Run this manually" fallback for client CLIs. -func _on_install_uv() -> void: - OS.shell_open(UV_INSTALL_DOCS_URL) - ## Drop the cached uvx path AND the cached `uvx --version` so that once - ## the user has installed uv (in a terminal, from the docs we just - ## opened), the dock finds the new binary instead of replaying the - ## cached "not found" result for the rest of the session. - ## Routing through the configurator matters on Windows, where the - ## CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"` - ## would leave the cache stale and the dock would keep showing - ## "uv: not found" for the rest of the session. - ClientConfigurator.invalidate_uv_detection() - ## Deliberately do NOT refresh here. `OS.shell_open` returns as soon as - ## the browser is handed the URL, so an immediate refresh would run long - ## before the user could install anything and would simply re-cache - ## "not found" — undoing the invalidation above. (The old shell-out was - ## a blocking `OS.execute`, so refreshing straight after it was correct - ## then; it stopped being correct when the installer call went away.) - ## Re-probe when the editor regains focus instead — see _notification. - _uv_recheck_pending = true - - -# --- Client section --- - -func _on_configure_client(client_id: String) -> void: - if _server_blocks_client_health(): - _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - _dispatch_client_action(client_id, "configure") - - -func _on_remove_client(client_id: String) -> void: - _dispatch_client_action(client_id, "remove") - - -## Spawn a worker thread for Configure / Remove so a hung CLI can't lock -## the editor (issue #239). The action verbs are: "configure" → calls -## `ClientConfigurator.configure`; "remove" → calls -## `ClientConfigurator.remove`. Both routes shell out to the per-client -## CLI via `McpCliExec.run`, which is wall-clock-bounded. -## -## Per-row in-flight rules: -## - One worker at a time per client (the row's slot). -## - Both buttons disabled while the slot is busy — prevents a -## double-click queueing a stale Configure on top of a still-running -## Remove. -## - The dot turns amber and the row label gets a "Configuring…" / -## "Removing…" suffix so the user can see the click was registered. -func _dispatch_client_action(client_id: String, action: String) -> void: - if _is_self_update_in_progress(): - ## Same gate as the refresh worker — the install window overwrites - ## plugin scripts on disk, and a worker mid-call into them would - ## SIGABRT in `GDScriptFunction::call`. See `_update_manager`. - return - if _client_action_threads.has(client_id): - return - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - - _set_row_action_in_flight(client_id, action) - ## Snapshot `server_url` on main: `http_url()` reads - ## `EditorInterface.get_editor_settings()`, which is main-thread-only. - ## The status-refresh worker uses the same pattern — see - ## `_perform_initial_client_status_refresh` and - ## `_request_client_status_refresh`. - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - ## #691: refresh the env snapshot on main before this worker starts — - ## configure/remove resolve CLI + config paths off-thread and must not - ## race a concurrent spawn window's setenv/unsetenv. - ClientConfigurator.warm_env_snapshot() - var generation := int(_client_action_generations.get(client_id, 0)) + 1 - _client_action_generations[client_id] = generation - var thread := Thread.new() - _client_action_threads[client_id] = thread - _client_action_started_msec[client_id] = Time.get_ticks_msec() - _client_action_names[client_id] = action - var err := thread.start( - Callable(self, "_run_client_action_worker").bind( - client_id, action, server_url, launch_context, generation - ) - ) - if err != OK: - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(client_id) - _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") - _refresh_clients_summary() - - -func _run_client_action_worker( - client_id: String, - action: String, - server_url: String, - launch_context: Dictionary, - generation: int, -) -> Dictionary: - var result: Dictionary - if action == "remove": - result = ClientConfigurator.remove(client_id, server_url, launch_context) - else: - result = ClientConfigurator.configure(client_id, server_url, launch_context) - return { - "client_id": client_id, - "action": action, - "result": result, - "generation": generation, - } - - -func _poll_completed_client_action_threads() -> void: - for client_id in _client_action_threads.keys(): - var thread: Thread = _client_action_threads[client_id] - if thread == null or thread.is_alive(): - continue - var payload: Variant = thread.wait_to_finish() - _client_action_threads[client_id] = null - if payload is Dictionary: - var data := payload as Dictionary - var result: Dictionary = data.get("result", {}) - _apply_client_action_result( - String(data.get("client_id", client_id)), - String(data.get("action", _client_action_names.get(client_id, "configure"))), - result, - int(data.get("generation", _client_action_generations.get(client_id, 0))) - ) - else: - _apply_client_action_result( - String(client_id), - String(_client_action_names.get(client_id, "configure")), - {"status": "error", "message": "worker returned no result"}, - int(_client_action_generations.get(client_id, 0)) - ) - - -func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: - if int(_client_action_generations.get(client_id, 0)) != generation: - if _client_action_threads.get(client_id, null) == null: - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - return - if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: - return - if _client_action_threads.has(client_id): - var t: Thread = _client_action_threads[client_id] - if t != null: - t.wait_to_finish() - _client_action_threads.erase(client_id) - _client_action_started_msec.erase(client_id) - _client_action_names.erase(client_id) - _finalize_action_buttons(client_id) - if _server_blocks_client_health(): - _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - - var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED - if result.get("status") == "ok": - ## #877: Remove targets only the selected scope, so a configure is the - ## only action with an all-scope sweep to disclose. The manual panel - ## that lists those removes is shown on the failure path below, which - ## left the success path — where the sweep actually ran — silent. - var sweep_note := ( - ClientConfigurator.configure_sweep_note(client_id) if action == "configure" else "" - ) - _apply_row_status(client_id, success_status, sweep_note) - var row: Dictionary = _client_rows.get(client_id, {}) - if not row.is_empty(): - (row["manual_panel"] as VBoxContainer).visible = false - else: - _apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed"))) - if action == "configure": - _show_manual_command_for(client_id) - _refresh_clients_summary() - - -## In-flight visual: rewrite the verb onto the button the user just -## clicked ("Configuring…" / "Removing…") so the feedback lands where -## their attention already is. Don't pollute the row label — that'd -## clobber any drift hint ("URL out of date") still relevant to the row. -## The dot turns amber so the row reads as "busy" at a glance, not as -## green (premature success) or red (premature failure). Both buttons -## go disabled so a double-click or second action can't queue stale -## work behind the in-flight worker. -func _set_row_action_in_flight(client_id: String, action: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - var configure_btn: Button = row["configure_btn"] - var remove_btn: Button = row["remove_btn"] - configure_btn.disabled = true - remove_btn.disabled = true - if action == "remove": - remove_btn.text = "Removing…" - else: - configure_btn.text = "Configuring…" - (row["dot"] as ColorRect).color = COLOR_AMBER - - -## Re-enable both buttons and reset their text back to canonical labels. -## `_apply_row_status` sets `configure_btn.text` per the resulting -## Status (Configure / Reconfigure / Retry), so we only need to reset -## `remove_btn.text` here — its sibling visibility toggle already -## handles whether to show it at all. -func _finalize_action_buttons(client_id: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - (row["configure_btn"] as Button).disabled = false - var remove_btn: Button = row["remove_btn"] - remove_btn.disabled = false - remove_btn.text = "Remove" - - -func _on_refresh_clients_pressed() -> void: - ## Explicit user action — also give a failed uv probe another chance - ## (#739), mirroring how the same click already re-sweeps client CLIs. - _schedule_uv_reprobe() - _request_client_status_refresh(true) - - -func _on_configure_all_clients() -> void: - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): - return - for client_id in _client_rows: - var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) - if status == Client.Status.CONFIGURED: - continue - _on_configure_client(String(client_id)) - _refresh_clients_summary() - - -func _on_open_clients_window() -> void: - if _clients_window == null: - return - ## Re-sweep before the user has time to act on stale dot colors. The request - ## is async/stale-while-refreshing so the popup paints immediately with - ## last-known state; the fresh colors land when the background worker returns. - ## This is an explicit user action, so it bypasses the focus-in cooldown. - _request_client_status_refresh(true) - ## Also re-sync the Tools tab from the persisted setting — another - ## editor instance (or a hand-edit of editor_settings-4.tres) may have - ## changed the excluded list while the window was closed. - _reset_tools_pending_from_setting() - _refresh_tools_ui_state() - if vision_routing != null: - vision_routing.refresh_ui() - # popup_centered() with a minsize forces the window to that size and - # centers on the parent viewport. Setting .size on a hidden Window - # doesn't always take effect, so we force it at popup time here. - _clients_window.popup_centered(Vector2i(640, 600)) - - -func _settings_are_dirty() -> bool: - return ( - _tools_pending_excluded != _tools_saved_excluded - or _telemetry_pending_enabled != _telemetry_saved_enabled - or _allow_hosts_is_dirty() - ) - - -func _on_clients_window_close_requested() -> void: - if _clients_window == null: - return - ## If the user has unapplied settings, a close would silently throw the - ## pending state away. Prompt before discarding current options and if - ## they confirm, reset pending → saved so the window shows the persisted - ## state the next time they open it. - if _settings_are_dirty(): - _show_tools_close_confirm() - return - _clients_window.hide() - - -# --- Tools tab (domain exclusion) --- - -func _build_tools_tab(tabs: TabContainer) -> void: - ## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction. - ## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the - ## saved setting each time the window opens. - var tools_tab := VBoxContainer.new() - tools_tab.add_theme_constant_override("separation", 8) - var tools_margin := _build_margin_container() - tools_margin.name = "Tools" - tools_margin.add_child(tools_tab) - tabs.add_child(tools_margin) - - var intro := Label.new() - intro.text = ( - "Some MCP clients cap tools per connection (Antigravity: 100). " - + "Uncheck a domain to drop its non-core tools from this server. " - + "Core tools stay on. Changes require a server restart." - ) - intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - intro.add_theme_color_override("font_color", COLOR_MUTED) - intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL - tools_tab.add_child(intro) - - var count_row := HBoxContainer.new() - count_row.add_theme_constant_override("separation", 8) - var count_header := Label.new() - count_header.text = "Tools Enabled:" - count_header.add_theme_color_override("font_color", COLOR_MUTED) - count_row.add_child(count_header) - _tools_count_label = Label.new() - _tools_count_label.add_theme_font_size_override("font_size", 15) - count_row.add_child(_tools_count_label) - _tools_dirty_warning = Label.new() - _tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER) - _tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _tools_dirty_warning.visible = false - _tools_dirty_warning.text = "Unapplied changes" - count_row.add_child(_tools_dirty_warning) - tools_tab.add_child(count_row) - - tools_tab.add_child(HSeparator.new()) - - var scroll := ScrollContainer.new() - scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - tools_tab.add_child(scroll) - - var grid := VBoxContainer.new() - grid.add_theme_constant_override("separation", 4) - grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL - scroll.add_child(grid) - - ## Core pseudo-row — disabled checkbox, always checked. Shows the 5 - ## always-loaded tools as a single line item so the user can see where - ## their baseline tool budget goes without listing individual core names - ## inline (tooltip has them). - var core_row := HBoxContainer.new() - core_row.add_theme_constant_override("separation", 8) - var core_chk := CheckBox.new() - core_chk.button_pressed = true - core_chk.disabled = true - core_chk.focus_mode = Control.FOCUS_NONE - core_row.add_child(core_chk) - var core_label := Label.new() - core_label.text = "Core (always on)" - core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - core_row.add_child(core_label) - var core_count := Label.new() - core_count.text = "%d tools" % (ToolCatalog.CORE_TOOLS.size() + ToolCatalog.ALWAYS_ON_TOOLS.size()) - core_count.add_theme_color_override("font_color", COLOR_MUTED) - core_row.add_child(core_count) - core_row.tooltip_text = "%s · always on: %s" % [ - ", ".join(ToolCatalog.CORE_TOOLS), - ", ".join(ToolCatalog.ALWAYS_ON_TOOLS), - ] - grid.add_child(core_row) - - grid.add_child(HSeparator.new()) - - _tools_domain_checkboxes.clear() - for entry in ToolCatalog.DOMAINS: - _build_tools_domain_row(grid, entry) - - ## Custom (addon-registered) tools — unlike the domain rows above, - ## toggles apply LIVE: the registry re-pushes the filtered catalog to - ## the server on every change, no restart needed. - grid.add_child(HSeparator.new()) - var custom_header_row := HBoxContainer.new() - custom_header_row.add_theme_constant_override("separation", 8) - var custom_header := Label.new() - custom_header.text = "Custom tools (addon-registered)" - custom_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL - custom_header_row.add_child(custom_header) - _custom_tools_count_label = Label.new() - _custom_tools_count_label.add_theme_color_override("font_color", COLOR_MUTED) - custom_header_row.add_child(_custom_tools_count_label) - grid.add_child(custom_header_row) - var custom_hint := Label.new() - custom_hint.text = "Applies immediately — disabled tools are hidden from agents and rejected if called." - custom_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - custom_hint.add_theme_color_override("font_color", COLOR_MUTED) - custom_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL - grid.add_child(custom_hint) - _custom_tools_list = VBoxContainer.new() - _custom_tools_list.add_theme_constant_override("separation", 4) - _custom_tools_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL - grid.add_child(_custom_tools_list) - _connect_custom_tool_registry() - _refresh_custom_tools_rows() - - tools_tab.add_child(HSeparator.new()) - - var telemetry_row := HBoxContainer.new() - telemetry_row.add_theme_constant_override("separation", 8) - var telemetry_label := Label.new() - telemetry_label.text = "Telemetry" - telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - telemetry_row.add_child(telemetry_label) - _telemetry_toggle = CheckButton.new() - _telemetry_toggle.toggled.connect(_on_telemetry_toggled) - telemetry_row.add_child(_telemetry_toggle) - tools_tab.add_child(telemetry_row) - - tools_tab.add_child(HSeparator.new()) - - var footer := HBoxContainer.new() - footer.add_theme_constant_override("separation", 8) - - _tools_apply_btn = Button.new() - _tools_apply_btn.text = "Apply and Restart Server" - _tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains." - _tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _tools_apply_btn.pressed.connect(_on_tools_apply) - footer.add_child(_tools_apply_btn) - - _tools_reset_btn = Button.new() - _tools_reset_btn.text = "Reset to defaults" - _tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply." - _tools_reset_btn.pressed.connect(_on_tools_reset) - footer.add_child(_tools_reset_btn) - - tools_tab.add_child(footer) - - _tools_close_confirm = ConfirmationDialog.new() - _tools_close_confirm.title = "Discard unapplied changes?" - ## Generic wording: _settings_are_dirty() covers domain toggles, the - ## telemetry switch, AND the Settings tab's allow-host field (#507) — - ## the old "checked/unchecked domains" text misled non-domain edits. - _tools_close_confirm.dialog_text = ( - "You have unapplied changes in this window.\n" - + "Close it and discard those changes?" - ) - _tools_close_confirm.ok_button_text = "Discard" - _tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed) - add_child(_tools_close_confirm) - - _reset_tools_pending_from_setting() - _refresh_tools_ui_state() - - -## --- Custom (addon-registered) tools section --- - -func _connect_custom_tool_registry() -> void: - var registry := McpToolRegistry.get_instance() - if registry == null: - return - if not registry.tools_changed.is_connected(_on_custom_tool_registry_changed): - registry.tools_changed.connect(_on_custom_tool_registry_changed) - - -func _on_custom_tool_registry_changed() -> void: - ## Deferred: tools_changed can fire from inside a checkbox toggle in - ## this very list — rebuilding synchronously would free the emitting - ## control mid-signal. - _refresh_custom_tools_rows.call_deferred() - - -func _refresh_custom_tools_rows() -> void: - if _custom_tools_list == null or not is_instance_valid(_custom_tools_list): - return - for child in _custom_tools_list.get_children(): - child.queue_free() - var registry := McpToolRegistry.get_instance() - var specs: Array = [] if registry == null else registry.all() - specs.sort_custom(func(a, b): return String(a.name) < String(b.name)) - var enabled_count := 0 - for spec in specs: - if registry.is_tool_enabled(spec.name): - enabled_count += 1 - _custom_tools_list.add_child(_build_custom_tool_row(registry, spec)) - if _custom_tools_count_label != null and is_instance_valid(_custom_tools_count_label): - _custom_tools_count_label.text = "%d/%d enabled" % [enabled_count, specs.size()] - if specs.is_empty(): - var empty := Label.new() - empty.text = "None registered. Addons add tools via McpToolRegistry — see docs/plugin-architecture.md." - empty.add_theme_color_override("font_color", COLOR_MUTED) - empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _custom_tools_list.add_child(empty) - - -func _build_custom_tool_row(registry: McpToolRegistry, spec: McpCustomToolSpec) -> HBoxContainer: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - var chk := CheckBox.new() - chk.button_pressed = registry.is_tool_enabled(spec.name) - chk.toggled.connect(func(pressed: bool): registry.set_tool_enabled(spec.name, pressed)) - row.add_child(chk) - var name_label := Label.new() - name_label.text = spec.name + (" · promoted" if spec.promoted else "") - name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_child(name_label) - var source_label := Label.new() - source_label.text = spec.source if not spec.source.is_empty() else spec.source_path.get_base_dir().get_file() - source_label.add_theme_color_override("font_color", COLOR_MUTED) - row.add_child(source_label) - ## Tooltip = the agent-facing description plus provenance, so the user - ## can judge what they're enabling without leaving the dock. - row.tooltip_text = "%s\n\nsource: %s%s" % [ - spec.description, - spec.source_path, - "\npromoted: registers as first-class MCP tool custom_%s" % spec.name if spec.promoted else "", - ] - name_label.tooltip_text = row.tooltip_text - return row - - -func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - - var chk := CheckBox.new() - chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects - chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"]))) - row.add_child(chk) - - var name_label := Label.new() - name_label.text = String(entry["label"]) - name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_child(name_label) - - var count_label := Label.new() - count_label.text = "%d tools" % int(entry["count"]) - count_label.add_theme_color_override("font_color", COLOR_MUTED) - row.add_child(count_label) - - ## Hover tooltip = flat list of tool names in this domain. Lets the - ## user decide without leaving the dock (e.g. "I just want to drop - ## `animation_preset_*` — do I lose anything else?"). - var tools_list: Array = entry.get("tools", []) - row.tooltip_text = ", ".join(tools_list) - name_label.tooltip_text = row.tooltip_text - count_label.tooltip_text = row.tooltip_text - - parent.add_child(row) - _tools_domain_checkboxes[String(entry["id"])] = chk - - -func _reset_tools_pending_from_setting() -> void: - ## Read the saved setting → pending/saved arrays, then sync checkbox state. - ## Unknown domain names in the setting (e.g. from an older plugin - ## version) are dropped from the display here (only ids with a checkbox - ## survive). The startup path is protected separately: - ## `ClientConfigurator.excluded_domains()` filters unknown names before - ## they reach `--exclude-domains`, whose `parse_exclude_list` hard-fails - ## on them. - var saved_raw := ClientConfigurator.excluded_domains() - var saved := PackedStringArray() - if not saved_raw.is_empty(): - for part in saved_raw.split(","): - var t := part.strip_edges() - if t.is_empty(): - continue - if _tools_domain_checkboxes.has(t) and saved.find(t) == -1: - saved.append(t) - saved.sort() - _tools_saved_excluded = saved - _tools_pending_excluded = saved.duplicate() - for id in _tools_domain_checkboxes: - var chk: CheckBox = _tools_domain_checkboxes[id] - ## `set_pressed_no_signal` — mutating programmatically should not - ## fire the toggled handler, which would mutate pending back. - chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1) - ## Also reset telemetry pending state from the persisted setting. - if _telemetry_toggle != null: - _load_telemetry_setting() - ## And the Settings tab's allow-host field (#507) — same window-open / - ## discard-confirm re-sync contract as the tools checkboxes. - _reset_allow_hosts_from_setting() - - -func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void: - var idx := _tools_pending_excluded.find(domain_id) - if pressed and idx != -1: - _tools_pending_excluded.remove_at(idx) - elif not pressed and idx == -1: - _tools_pending_excluded.append(domain_id) - _tools_pending_excluded.sort() - _refresh_tools_ui_state() - - -func _refresh_tools_ui_state() -> void: - if _tools_count_label == null: - return - var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded) - var total := ToolCatalog.total_tool_count() - _tools_count_label.text = "%d / %d" % [enabled, total] - var dirty := _settings_are_dirty() - _tools_dirty_warning.visible = dirty - _tools_apply_btn.disabled = not dirty - ## Color the count when the user is over Antigravity's cap — a soft - ## signal that their selection still won't fit. 100 is the Antigravity - ## limit; other clients may cap higher, so this is advisory only. - if enabled > 100: - _tools_count_label.add_theme_color_override("font_color", COLOR_AMBER) - else: - _tools_count_label.remove_theme_color_override("font_color") - - -func _on_tools_apply() -> void: - var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded) - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded) - es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled) - _tools_saved_excluded = _tools_pending_excluded.duplicate() - _telemetry_saved_enabled = _telemetry_pending_enabled - _refresh_tools_ui_state() - ## Plugin reload respawns the server with the new `--exclude-domains` flag - ## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the - ## port-change Apply flow. - _on_reload_plugin() - - -func _on_tools_reset() -> void: - ## Resets only the tool-domain exclusions, not the telemetry toggle. - ## Telemetry is a privacy preference users typically want to set once - ## and have honored — flipping it back to "on" via a generic Reset - ## button would be a surprising privacy regression. The button label - ## is scoped to tools accordingly. - _tools_pending_excluded = PackedStringArray() - for id in _tools_domain_checkboxes: - var chk: CheckBox = _tools_domain_checkboxes[id] - chk.set_pressed_no_signal(true) - _refresh_tools_ui_state() - - -func _show_tools_close_confirm() -> void: - if _tools_close_confirm == null: - return - _tools_close_confirm.popup_centered() - - -func _on_tools_discard_confirmed() -> void: - _reset_tools_pending_from_setting() - _refresh_tools_ui_state() - if _clients_window != null: - _clients_window.hide() - - -# --- Settings tab (allow-host LAN opt-in, #507) --- - -func _build_settings_tab(tabs: TabContainer) -> void: - ## Tab 3 — settings-style controls that don't fit Clients or Tools: the - ## Vision Routing section plus the `--allow-host` LAN opt-in behind a - ## collapsed "Remote access (advanced)" disclosure, so its security - ## warning renders exactly at the point of configuration. Rendered once - ## on dock construction, mirroring `_build_tools_tab`; - ## `_reset_allow_hosts_from_setting()` and `vision_routing.refresh_ui()` - ## re-sync each time the window opens (via - ## `_reset_tools_pending_from_setting` / `_on_open_clients_window`). - var settings_tab := VBoxContainer.new() - settings_tab.add_theme_constant_override("separation", 8) - var settings_margin := _build_margin_container() - settings_margin.name = "Settings" - settings_margin.add_child(settings_tab) - tabs.add_child(settings_margin) - - ## Vision Routing is configuration, not status — it lives here rather - ## than in the dock. Not dev-gated: it is the Settings tab's primary - ## content and must work for every user. - if vision_routing != null: - vision_routing.build_section(settings_tab) - - ## Remote access (advanced): collapsed by default; auto-expands when a - ## non-empty CIDR allowlist is already configured so an active - ## off-loopback bind is never hidden behind a collapsed header. The - ## disclosure replaces the former developer-mode gate for this block. - _allow_hosts_fold = FoldableContainer.new() - _allow_hosts_fold.title = "Remote access (advanced)" - _allow_hosts_fold.folded = true - settings_tab.add_child(_allow_hosts_fold) - - _allow_hosts_section = VBoxContainer.new() - _allow_hosts_section.add_theme_constant_override("separation", 6) - _allow_hosts_fold.add_child(_allow_hosts_section) - - _allow_hosts_section.add_child(_make_header("Allow remote hosts (CIDR)")) - - var intro := Label.new() - intro.text = ( - "Comma-separated CIDRs or bare IPs (e.g. 192.168.1.0/24, 10.0.0.5). " - + "When non-empty, the server binds off loopback and accepts MCP " - + "connections from these ranges (--allow-host)." - ) - intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - intro.add_theme_color_override("font_color", COLOR_MUTED) - intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _allow_hosts_section.add_child(intro) - - ## Warning banner — the DNS-rebinding guard is widened to every machine - ## in the named ranges, so make the user name a network they trust - ## instead of offering a blanket "expose everything" toggle (#507). - var warning := Label.new() - warning.text = ( - "Warning: every machine in these ranges can drive this Godot editor, " - + "and the DNS-rebinding guard's Host allowlist is widened to match. " - + "Only name networks you trust. On untrusted or shared networks, " - + "prefer an SSH tunnel or Tailscale instead of exposing the port." - ) - warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - warning.add_theme_color_override("font_color", COLOR_AMBER) - warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _allow_hosts_section.add_child(warning) - - _allow_hosts_edit = LineEdit.new() - _allow_hosts_edit.placeholder_text = "e.g. 192.168.1.0/24, 10.0.0.5 — empty = loopback only" - _allow_hosts_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _allow_hosts_edit.text_changed.connect(_on_allow_hosts_text_changed) - _allow_hosts_section.add_child(_allow_hosts_edit) - - _allow_hosts_hint = Label.new() - _allow_hosts_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _allow_hosts_hint.add_theme_color_override("font_color", Color.RED) - _allow_hosts_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _allow_hosts_hint.visible = false - _allow_hosts_section.add_child(_allow_hosts_hint) - - _allow_hosts_apply_btn = Button.new() - _allow_hosts_apply_btn.text = "Apply and Restart Server" - _allow_hosts_apply_btn.tooltip_text = ( - "Save the allowlist to Editor Settings and reload the plugin so the " - + "server respawns with --allow-host. Clear the field and Apply to " - + "return to loopback-only." - ) - _allow_hosts_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _allow_hosts_apply_btn.pressed.connect(_on_allow_hosts_apply) - _allow_hosts_section.add_child(_allow_hosts_apply_btn) - - _reset_allow_hosts_from_setting() - - -func _reset_allow_hosts_from_setting() -> void: - _allow_hosts_saved = ClientConfigurator.allow_hosts() - _refresh_allow_hosts_fold_state() - if _allow_hosts_edit == null: - return - _allow_hosts_edit.text = _allow_hosts_saved - _refresh_allow_hosts_ui_state() - - -## Auto-expands the "Remote access (advanced)" disclosure whenever a -## non-empty allowlist is configured, so an active off-loopback bind is -## never hidden behind a collapsed header. -func _refresh_allow_hosts_fold_state() -> void: - if _allow_hosts_fold == null: - return - if ClientConfigurator.allow_hosts().is_empty(): - _allow_hosts_fold.fold() - else: - _allow_hosts_fold.expand() - - -func _allow_hosts_is_dirty() -> bool: - if _allow_hosts_edit == null: - return false - return McpAllowHosts.normalize(_allow_hosts_edit.text) != _allow_hosts_saved - - -func _on_allow_hosts_text_changed(_new_text: String) -> void: - _refresh_allow_hosts_ui_state() - - -func _refresh_allow_hosts_ui_state() -> void: - if _allow_hosts_edit == null or _allow_hosts_apply_btn == null: - return - var invalid := McpAllowHosts.invalid_tokens(_allow_hosts_edit.text) - if invalid.is_empty(): - _allow_hosts_hint.visible = false - else: - ## Name the accepted syntax in the hint — matches the server's - ## `parse_allow_hosts` (CIDR / bare IP, comma-separated). - _allow_hosts_hint.text = ( - "Invalid entries (must be a CIDR like 192.168.1.0/24 or a bare IP, comma-separated): %s" - % ", ".join(invalid) - ) - _allow_hosts_hint.visible = true - _allow_hosts_apply_btn.disabled = not _allow_hosts_is_dirty() or not invalid.is_empty() - - -func _on_allow_hosts_apply() -> void: - if _allow_hosts_edit == null: - return - var normalized := McpAllowHosts.normalize(_allow_hosts_edit.text) - if not McpAllowHosts.invalid_tokens(normalized).is_empty(): - return - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(McpSettings.SETTING_ALLOW_HOSTS, normalized) - _allow_hosts_saved = normalized - _allow_hosts_edit.text = normalized - _refresh_allow_hosts_ui_state() - ## Plugin reload respawns the server with the new `--allow-host` flag - ## (see `plugin.gd::_build_server_flags`). Mirrors the Tools-tab Apply - ## and port-change flows. - _on_reload_plugin() - - -func _refresh_clients_summary() -> void: - # Count from cached row status values — `_apply_row_status` is the single - # source of truth, and reading cached status avoids re-running - # filesystem/CLI-hitting checks on every refresh. The same cache re-derives - # the drift banner so per-row mutations (Configure/Reconfigure/Remove on a - # row in the Clients & Tools window) keep the dock-level banner in sync - # without an extra sweep. See #166 and #226. - if _clients_summary_label == null: - return - var configured := 0 - var mismatched_ids: Array[String] = [] - for client_id in _client_rows: - var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) - if status == Client.Status.CONFIGURED: - configured += 1 - elif status == Client.Status.CONFIGURED_MISMATCH: - mismatched_ids.append(client_id) - var text := "%d / %d configured" % [configured, _client_rows.size()] - if mismatched_ids.size() > 0: - text += " (%d stale)" % mismatched_ids.size() - if ClientRefreshStateScript.should_show_checking_badge(_refresh_state): - text += ( - " (checking...)" - if _refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT - else " (client probe still running)" - ) - _clients_summary_label.text = text - if _client_configure_all_btn != null: - _client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state) - if _client_empty_cta_btn != null: - _client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed() - _refresh_drift_banner(mismatched_ids) - _update_status() - - -func _show_manual_command_for(client_id: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - var cmd := ClientConfigurator.manual_command(client_id) - if cmd.is_empty(): - row["manual_panel"].visible = false - return - row["manual_text"].text = cmd - row["manual_panel"].visible = true - ## #680: for rows low in the list the panel materializes below the - ## visible scroll area and the Configure click looks like a no-op. - ## Deferred so the just-shown panel has a settled rect to scroll to. - _scroll_manual_panel_into_view.call_deferred(row["manual_panel"]) - - -func _scroll_manual_panel_into_view(panel: Control) -> void: - if panel == null or not panel.is_inside_tree(): - return - var ancestor := panel.get_parent() - while ancestor != null and not (ancestor is ScrollContainer): - ancestor = ancestor.get_parent() - if ancestor != null: - (ancestor as ScrollContainer).ensure_control_visible(panel) - - -func _on_copy_manual_command(client_id: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - DisplayServer.clipboard_set(row["manual_text"].text) - - -func _on_open_config_file(client_id: String) -> void: - var path := _client_config_path_for_row(client_id) - if path.is_empty(): - return - if FileAccess.file_exists(path): - OS.shell_open(path) - return - _reveal_config_folder(path) - - -func _on_reveal_config_folder(client_id: String) -> void: - var path := _client_config_path_for_row(client_id) - if path.is_empty(): - return - _reveal_config_folder(path) - - -func _client_config_path_for_row(client_id: String) -> String: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return "" - return String(row.get("config_path", "")) - - -func _reveal_config_folder(path: String) -> void: - var dir := path.get_base_dir() - if dir.is_empty(): - return - OS.shell_open(dir) - - -func _refresh_all_client_statuses() -> void: - ## Compatibility wrapper for older explicit call sites. Treat this as a manual - ## refresh: it bypasses focus-in cooldown but still runs probes off the editor - ## main thread. - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - _request_client_status_refresh(true) - - -func _is_client_status_refresh_in_cooldown() -> bool: - if _last_client_status_refresh_completed_msec <= 0: - return false - return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC - - -func _has_client_status_refresh_timed_out() -> bool: - if not ClientRefreshStateScript.has_worker_alive(_refresh_state): - return false - if _client_status_refresh_started_msec <= 0: - return false - return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC - - -func _check_client_status_refresh_timeout() -> void: - if not _has_client_status_refresh_timed_out(): - return - if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT: - return - _refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT - _refresh_clients_summary() - - -func _abandon_client_status_refresh_thread() -> void: - ## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a - ## worker. If a CLI probe hangs, orphan this run, bump the generation so any - ## late result becomes a no-op, and let a forced/manual refresh start a fresh - ## probe slot. Completed orphan threads are pruned from `_process`. - _client_status_refresh_generation += 1 - if _client_status_refresh_thread != null: - _orphaned_client_status_refresh_threads.append(_client_status_refresh_thread) - _client_status_refresh_thread = null - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - ## Reset the full pending-request triplet, not just the - ## focus-in / cooldown half. A timed-out worker has already - ## warmed bytecode, so any stale `_pending_initial` from an - ## earlier deferred-during-busy startup is no longer load-bearing - ## — leaving it set would cause `_retry_deferred_*` to dispatch - ## `_perform_initial_*` a second time after this abandon - ## (which would then no-op because no fresh worker is needed - ## but still re-warm bytecode and walk the row set redundantly). - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - _client_status_refresh_started_msec = 0 - _refresh_clients_summary() - - -func _prune_orphaned_client_status_refresh_threads() -> void: - for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1): - var thread := _orphaned_client_status_refresh_threads[i] - if thread == null: - _orphaned_client_status_refresh_threads.remove_at(i) - elif not thread.is_alive(): - thread.wait_to_finish() - _orphaned_client_status_refresh_threads.remove_at(i) - - -func _perform_initial_client_status_refresh() -> void: - ## Pre-warm strategy bytecode on main, then hand every client probe - ## (JSON / TOML / CLI alike) to the worker. - ## - ## Godot's GDScript hot-reload of overwritten plugin files is lazy: the - ## bytecode swap happens on first dereference, not at `set_plugin_enabled` - ## time. A worker thread spawned from a fresh `_build_ui` walks into - ## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while - ## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on - ## main first forces the swap to complete here; the worker then finds - ## stable bytecode. Filesystem signals don't bracket the swap window - ## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on - ## in-place plugin reload because the editor stays focused — so neither - ## works as a gate. See #233 / #235. - ## - ## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode` - ## call invokes a pure-memory helper on each strategy script — - ## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus - ## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk, - ## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot` - ## per client adds the `installed` flag and (for CLI clients) a cached - ## CLI path to each probe. - ## - ## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the - ## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON - ## parses for the ~17 non-CLI clients now happen off the main thread, - ## so the dock paints immediately on cold open instead of stalling - ## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls. - ## - ## No-op outside the tree — GDScript tests instantiate via `new()`. - if not is_inside_tree(): - return - if _client_rows.is_empty(): - return - if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): - return - if _is_self_update_in_progress(): - return - if _is_editor_filesystem_busy(): - _defer_initial_client_status_refresh_until_filesystem_ready() - return - if ClientRefreshStateScript.has_worker_alive(_refresh_state): - return - - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return - - _warm_strategy_bytecode() - - var generation := _begin_client_status_refresh_run() - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - var all_probes: Array[Dictionary] = [] - - for client_id in _client_rows: - var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id)) - if probe.is_empty(): - continue - all_probes.append(probe) - _refresh_clients_summary() - - if all_probes.is_empty(): - _finalize_completed_refresh() - return - - _client_status_refresh_thread = Thread.new() - var err := _client_status_refresh_thread.start( - Callable(self, "_run_client_status_refresh_worker").bind( - all_probes, server_url, launch_context, generation - ) - ) - if err != OK: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_thread = null - _refresh_clients_summary() - - -## Force GDScript's lazy bytecode swap to complete for every script the -## worker thread will reach into. Each call is pure-memory — no disk, no -## network, no `OS.execute` — so it only costs the bytecode dereference -## itself. See `_perform_initial_client_status_refresh` for context and -## #233 / #235 for the SIGABRT this exists to prevent. -func _warm_strategy_bytecode() -> void: - var ids := ClientConfigurator.client_ids() - if ids.is_empty(): - return - var any_client := ClientRegistry.get_by_id(String(ids[0])) - if any_client != null: - JsonStrategy.verify_entry(any_client, {}, "") - TomlStrategy.format_body(PackedStringArray(), "") - CliStrategy.format_args(PackedStringArray(), "", "") - ## #691: refresh the env snapshot on main before the worker starts, so - ## its config-path expansions read the snapshot instead of racing a - ## concurrent spawn window's setenv/unsetenv. - ClientConfigurator.warm_env_snapshot() - - -func _begin_client_status_refresh_run() -> int: - ## Marks a refresh as starting and returns the new generation token. - ## Generation is bumped here (not at completion) so that a worker result - ## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree` - ## fires can be detected as stale via generation mismatch. - _refresh_state = ClientRefreshStateScript.RUNNING - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _client_status_refresh_started_msec = Time.get_ticks_msec() - _client_status_refresh_generation += 1 - _refresh_clients_summary() - return _client_status_refresh_generation - - -func _finalize_completed_refresh() -> void: - ## Stamps cooldown and clears in-flight state. Called at the end of every - ## refresh that successfully applied results — the worker reaping path - ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. - _last_client_status_refresh_completed_msec = Time.get_ticks_msec() - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - _refresh_state = ClientRefreshStateScript.IDLE - _refresh_clients_summary() - ## After the summary pass so `_last_mismatched_ids` reflects the sweep - ## that just completed. - _maybe_auto_repin_after_update() - - -func _request_client_status_refresh(force: bool = false) -> bool: - ## Stale-while-refreshing: do not clear dots, summary, or the drift banner - ## when a refresh is requested. The existing UI remains visible until the - ## background worker's result is applied on the main thread. - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _refresh_clients_summary() - return false - if _is_self_update_in_progress(): - ## Self-update is overwriting plugin scripts on disk; spawning a worker - ## now would crash it inside `GDScriptFunction::call` once the bytecode - ## swap reaches a script the worker is mid-call into. Focus-in / - ## manual button / cooldown timer all funnel through here, so one - ## gate covers every spawn path during the install window. The flag - ## lives on `_update_manager` and dies with the dock instance during - ## `set_plugin_enabled(false)`. - return false - if ClientRefreshStateScript.has_worker_alive(_refresh_state): - if force and _has_client_status_refresh_timed_out(): - _abandon_client_status_refresh_thread() - else: - _client_status_refresh_pending = true - _client_status_refresh_pending_force = _client_status_refresh_pending_force or force - _refresh_clients_summary() - return false - if ClientRefreshStateScript.is_blocked_for_spawn(_refresh_state): - return false - if not force and _is_client_status_refresh_in_cooldown(): - return false - if _client_rows.is_empty(): - return false - if _is_editor_filesystem_busy(): - if force: - _defer_client_status_refresh_until_filesystem_ready(force) - return false - - ## Manual refresh (any `force=true` path: button click, popup open, - ## external API caller) implies "may have installed a CLI since the - ## last sweep" — flush CliFinder so freshly-installed binaries get - ## re-detected. Focus-in (`force=false`) stays cached so the cheap - ## case stays cheap. Per-CLI invalidation - ## (`invalidate_uvx_cli_cache`) still pairs with specific events - ## like `_on_install_uv` where the binary name is known. - if force: - ClientConfigurator.invalidate_cli_cache() - - ## Force the bytecode swap on the same scripts the worker will reach - ## into — same #233/#235 guard `_perform_initial_*` already had. - ## Without this, a manual refresh dispatched before the initial sweep - ## has run (e.g. user clicks Refresh during the deferred-initial - ## window after `_defer_client_status_refresh_until_filesystem_ready` - ## cleared `_pending_initial`) walks into mid-swap bytecode and - ## SIGABRTs. - _warm_strategy_bytecode() - - var client_probes: Array[Dictionary] = [] - for client_id in _client_rows: - client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id))) - var launch_context := ClientConfigurator.capture_launch_context() - var server_url := ClientConfigurator.server_url_from(launch_context) - - var generation := _begin_client_status_refresh_run() - _client_status_refresh_thread = Thread.new() - var err := _client_status_refresh_thread.start( - Callable(self, "_run_client_status_refresh_worker").bind( - client_probes, server_url, launch_context, generation - ) - ) - if err != OK: - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_thread = null - _refresh_clients_summary() - return false - return true - - -func _is_editor_filesystem_busy() -> bool: - var fs := EditorInterface.get_resource_filesystem() - return fs != null and fs.is_scanning() - - -func _defer_initial_client_status_refresh_until_filesystem_ready() -> void: - _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM - _client_status_refresh_pending_initial = true - - -func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void: - ## Godot can still be reparsing/reloading plugin scripts while the editor - ## filesystem is busy. Do not spawn a worker into that window: the worker - ## can call plugin GDScript while the main thread is reloading it, which - ## crashes in `GDScriptFunction::call`. - ## - ## A manual refresh request is more recent intent than any earlier - ## deferred-initial sweep, so we clear `_pending_initial` here. - ## `_request_client_status_refresh` warms strategy bytecode itself - ## now (see #233/#235), so the safety net the initial path provided - ## still applies to the replayed manual refresh. - _refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM - _client_status_refresh_pending_force = _client_status_refresh_pending_force or force - _client_status_refresh_pending_initial = false - - -func _retry_deferred_client_status_refresh() -> void: - if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM: - return - if _is_self_update_in_progress(): - return - if _is_editor_filesystem_busy(): - return - - var initial := _client_status_refresh_pending_initial - var force := _client_status_refresh_pending_force - _refresh_state = ClientRefreshStateScript.IDLE - _client_status_refresh_pending_force = false - _client_status_refresh_pending_initial = false - if initial: - _perform_initial_client_status_refresh() - else: - _request_client_status_refresh(force) - - -func _run_client_status_refresh_worker( - client_probes: Array[Dictionary], - server_url: String, - launch_context: Dictionary, - generation: int, -) -> Dictionary: - var results: Dictionary = {} - # Command-shaped clients share one attach launch. Discovery can be the - # dominant cold-cache cost, so resolve it once per refresh worker rather - # than once for Claude Desktop and again for Codex. - var resolved_launch := ClientConfigurator.resolve_attach_launch(launch_context) - for probe in client_probes: - var client_id := String(probe.get("id", "")) - if client_id.is_empty(): - continue - var details := ClientConfigurator.check_status_details_for_url_with_cli_path( - client_id, - server_url, - String(probe.get("cli_path", "")), - launch_context, - resolved_launch, - ) - var installed := bool(probe.get("installed", false)) - results[client_id] = { - "status": details.get("status", Client.Status.NOT_CONFIGURED), - "installed": installed, - "error_msg": details.get("error_msg", ""), - } - return {"results": results, "generation": generation} - - -func _poll_completed_client_status_refresh_thread() -> void: - if _client_status_refresh_thread == null: - return - if _client_status_refresh_thread.is_alive(): - return - var payload: Variant = _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - if payload is Dictionary: - var data := payload as Dictionary - var results: Dictionary = data.get("results", {}) - _apply_client_status_refresh_results( - results, - int(data.get("generation", _client_status_refresh_generation)) - ) - else: - _apply_client_status_refresh_results({}, _client_status_refresh_generation) - - -func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: - if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: - return - if _client_status_refresh_thread != null: - _client_status_refresh_thread.wait_to_finish() - _client_status_refresh_thread = null - if _server_blocks_client_health(): - for client_id in _client_rows: - _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) - _finalize_completed_refresh() - return - - for client_id in results: - ## Skip rows whose Configure / Remove worker is still running so the - ## status refresh doesn't overwrite the "Configuring…" / "Removing…" - ## badge with a stale dot color. The action's own completion handler - ## will repaint the row when it lands. - if _client_action_threads.has(String(client_id)): - continue - var result: Dictionary = results[client_id] - _apply_row_status( - String(client_id), - result.get("status", Client.Status.NOT_CONFIGURED), - str(result.get("error_msg", "")), - result.get("installed", false) - ) - _finalize_completed_refresh() - - if _client_status_refresh_pending: - var pending_force := _client_status_refresh_pending_force - _client_status_refresh_pending = false - _client_status_refresh_pending_force = false - _request_client_status_refresh(pending_force) - - -func _server_blocks_client_health() -> bool: - if _plugin == null or not _plugin.has_method("get_server_status"): - return false - var status: Dictionary = _plugin.get_server_status() - return ServerStateScript.blocks_client_health( - int(status.get("state", ServerStateScript.UNINITIALIZED)) - ) - - -func _server_blocked_client_message() -> String: - if _plugin == null or not _plugin.has_method("get_server_status"): - return "server incompatible" - var status: Dictionary = _plugin.get_server_status() - var message := str(status.get("message", "")) - return message if not message.is_empty() else "server incompatible" - - -func _refresh_drift_banner(mismatched_ids: Array[String]) -> void: - if _drift_banner == null: - return - ## Sort so set-equality is order-independent — `_client_rows` iteration - ## order is dict-insertion order, but a future change to the iteration - ## site shouldn't make us repaint identical content. - mismatched_ids = mismatched_ids.duplicate() - mismatched_ids.sort() - if mismatched_ids == _last_mismatched_ids: - return - _last_mismatched_ids = mismatched_ids - if mismatched_ids.is_empty(): - _drift_banner.visible = false - return - var names: Array[String] = [] - for id in mismatched_ids: - names.append(ClientConfigurator.client_display_name(id)) - ## Active server URL is already shown on the WS:/HTTP: line above the - ## Clients section, so it doesn't need to repeat here. Lead with the - ## client names — that's the only thing the user can act on. - var verb := "needs" if mismatched_ids.size() == 1 else "need" - _drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb] - _drift_banner.visible = true - - -func _on_reconfigure_mismatched() -> void: - ## Re-Configure every client whose URL is currently stale. Iterates the - ## cached list from the most recent sweep instead of re-running - ## `check_status` per row (saves ~18 filesystem reads per click). The - ## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any - ## entries the user manually fixed between sweep and click get re-counted - ## as CONFIGURED there. - for client_id in _last_mismatched_ids: - if _client_rows.has(client_id): - _on_configure_client(client_id) - _refresh_all_client_statuses() - - -## Arm the one-shot post-update repin (see `_pending_post_update_repin`). -## Called by plugin.gd when it drains a `status == "success"` self-update -## marker, i.e. exactly once per completed update. `from_version` is the -## plugin version the update replaced — required by the pin-only gate -## below; an empty value (marker from a pre-gate runner) arms nothing, so -## those updates keep the manual drift-banner path. -func notify_self_update_success(from_version: String = "") -> void: - if from_version.strip_edges().is_empty(): - return - _post_update_from_version = from_version.strip_edges() - _pending_post_update_repin = true - - -## Consume `_pending_post_update_repin` on the first completed status sweep -## that could actually observe drift. Runs from `_finalize_completed_refresh` -## AFTER `_refresh_clients_summary()` has rebuilt `_last_mismatched_ids` -## from the sweep that just landed. -## -## Blast-radius gate: only entries whose SOLE drift is the old version pin -## are repinned (`entry_drift_is_version_pin_only`). A mismatched entry can -## also mean "points at a different editor's ports" — this dock ran inside -## a side project with custom ports, or the user hand-tuned the entry — and -## auto-rewriting those hijacks every AI client on the machine to THIS -## editor's ports (observed live: the self-update smoke fixture on port -## 18000 repinned 21 real client configs). Those stay on the drift banner's -## human click. -func _maybe_auto_repin_after_update() -> void: - if not _pending_post_update_repin: - return - ## While the server is INCOMPATIBLE (post-update stale-occupant recovery - ## still in flight) every row reads ERROR, not CONFIGURED_MISMATCH — a - ## consume here would see an empty mismatch list and drop the repin on - ## the floor. Stay pending for the sweep that lands after recovery. - if _server_blocks_client_health(): - return - if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): - return - _pending_post_update_repin = false - var from_version := _post_update_from_version - _post_update_from_version = "" - if _last_mismatched_ids.is_empty(): - return - var launch_context := ClientConfigurator.capture_launch_context() - var pin_only: Array[String] = [] - for client_id in _last_mismatched_ids: - if _entry_drift_is_version_pin_only(String(client_id), from_version, launch_context): - pin_only.append(String(client_id)) - if pin_only.is_empty(): - print( - "MCP | self-update complete — no client entry drifts by version pin alone; leaving %d drifted config(s) to the Reconfigure banner" - % _last_mismatched_ids.size() - ) - return - print( - "MCP | self-update complete — repinning %d client config(s) from v%s to v%s" - % [pin_only.size(), from_version, ClientConfigurator.get_plugin_version()] - ) - for client_id in pin_only: - if _client_rows.has(client_id): - _on_configure_client(client_id) - _refresh_all_client_statuses() - - -## Instance seam over the static gate so the dock test suite can fake the -## per-client verdict without real config files on disk. -func _entry_drift_is_version_pin_only( - client_id: String, from_version: String, launch_context: Dictionary -) -> bool: - return ClientConfigurator.entry_drift_is_version_pin_only( - client_id, from_version, launch_context - ) - - -func _apply_row_status( - client_id: String, - status: Client.Status, - error_msg: String = "", - installed_override: Variant = null, -) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - row["status"] = status - var dot: ColorRect = row["dot"] - var configure_btn: Button = row["configure_btn"] - var remove_btn: Button = row["remove_btn"] - var name_label: Label = row["name_label"] - var base_name := ClientConfigurator.client_display_name(client_id) - _refresh_client_config_file_buttons(client_id) - match status: - Client.Status.CONFIGURED: - dot.color = Color.GREEN - configure_btn.text = "Reconfigure" - remove_btn.visible = true - ## `error_msg` doubles as a detail slot on the green path: a - ## successful configure passes the sweep note (#877). Transient by - ## design — the next status refresh re-applies CONFIGURED with no - ## detail, so the row settles back to its plain name. - name_label.text = ( - "%s (%s)" % [base_name, error_msg] if not error_msg.is_empty() else base_name - ) - Client.Status.NOT_CONFIGURED: - dot.color = COLOR_MUTED - configure_btn.text = "Configure" - remove_btn.visible = false - var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id) - name_label.text = base_name if installed else "%s (not detected)" % base_name - Client.Status.CONFIGURED_MISMATCH: - ## Amber matches the dock-level drift banner so a glance at the - ## row + the banner read as the same condition. - dot.color = COLOR_AMBER - configure_btn.text = "Reconfigure" - remove_btn.visible = true - ## Drift is usually a stale URL, but a `{scope}` client can also be - ## registered in a scope the user did not select (#872), and - ## "URL out of date" would be a wrong description of that. Prefer - ## the probe's own words whenever it supplied any. - name_label.text = ( - "%s (%s)" % [base_name, error_msg] - if not error_msg.is_empty() - else "%s (URL out of date)" % base_name - ) - _: - dot.color = Color.RED - configure_btn.text = "Retry" - remove_btn.visible = false - name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name - - -func _refresh_client_config_file_buttons(client_id: String) -> void: - var row: Dictionary = _client_rows.get(client_id, {}) - if row.is_empty(): - return - # Re-resolve on every refresh so the Open/Reveal buttons follow the entry - # when it lands in (or moves to) a higher-precedence merge tier (codex F3). - # F-3-4: switched from `effective_config_path` to the authoritative facade - # so multi-project-tier cases resolve to the LATEST tier (matching F2 - # status semantics) instead of failing closed to `path_template`. - row["config_path"] = ClientConfigurator.effective_authoritative_path(client_id) - var config_path := String(row["config_path"]) - var has_path := not config_path.is_empty() - var open_config_btn: Button = row["open_config_btn"] - var reveal_btn: Button = row["reveal_btn"] - open_config_btn.visible = has_path - reveal_btn.visible = has_path - open_config_btn.disabled = not has_path - reveal_btn.disabled = not has_path - if has_path: - open_config_btn.tooltip_text = "Open config file:\n%s" % config_path - reveal_btn.tooltip_text = "Reveal in folder:\n%s" % config_path.get_base_dir() - else: - open_config_btn.tooltip_text = "" - reveal_btn.tooltip_text = "" - - -# --- Update check & self-update --- - -## Tolerates a null manager so test fixtures that build the dock without -## `_build_ui()` don't false-positive on the worker-spawn gate. -func _is_self_update_in_progress() -> bool: - return _update_manager != null and bool(_update_manager.is_install_in_flight()) - - -func _on_update_pressed() -> void: - if _update_manager != null: - _update_manager.start_install() - - -func _on_update_check_result(result: Dictionary) -> void: - _update_label.text = String(result.get("label_text", "")) - _update_banner.visible = true - - -## Apply only the keys present so the manager can ship partial updates -## (e.g. button-text-only during the download phase) without clobbering -## banner state. -func _on_install_state_changed(state: Dictionary) -> void: - if state.has("button_text") and _update_btn != null: - _update_btn.text = String(state["button_text"]) - if state.has("button_disabled") and _update_btn != null: - _update_btn.disabled = bool(state["button_disabled"]) - if state.has("label_text") and _update_label != null: - _update_label.text = String(state["label_text"]) - if state.has("banner_visible") and _update_banner != null: - _update_banner.visible = bool(state["banner_visible"]) - if String(state.get("outcome", "")) == "success" and _update_label != null: - ## Visual confirmation for successful terminal update states. - _update_label.add_theme_color_override("font_color", Color.GREEN) diff --git a/addons/godot_ai/mcp_dock.gd.uid b/addons/godot_ai/mcp_dock.gd.uid deleted file mode 100644 index 5868719..0000000 --- a/addons/godot_ai/mcp_dock.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8yknttdjanm5 diff --git a/addons/godot_ai/migration_bridge.gd b/addons/godot_ai/migration_bridge.gd new file mode 100644 index 0000000..50874db --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd @@ -0,0 +1,212 @@ +@tool +extends Node + +## Verifies and stages the embedded canonical v4 release while the migration +## capsule is still the enabled plugin. Every step runs in GDScript on the +## editor's main thread through the same installer scripts the v4 tree ships +## (`utils/release_verifier.gd`, `utils/update_installer.gd`); nothing here +## spawns a process, and nothing here writes a live add-on file. + +const PAYLOAD_ROOT := "res://addons/godot_ai/migration_payload" +const ARCHIVE_NAME := "godot-ai-v4-plugin.zip" +const MANIFEST_NAME := "godot-ai-v4-plugin.manifest.json" +const SIGNATURE_NAME := "godot-ai-v4-plugin.manifest.sig" +const VERIFIER_SCRIPT := "res://addons/godot_ai/utils/release_verifier.gd" +const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" +const REPOSITORY := "hi-godot/godot-ai" +const CHANNEL := "stable" +const MAX_MANIFEST_BYTES := 1024 * 1024 +const SIGNATURE_BYTES := 512 +const PENDING_V3_UPDATE_SETTING := "godot_ai/pending_self_update_event" +const INCOMPLETE_CAPSULE := ( + "The migration capsule is incomplete (%s). Download the release again, then click Retry migration." +) + +## Kept byte-identical to `utils/update_manager.gd::RELEASE_SIGNING_PUBLIC_KEY_PEM` +## in the v4 tree; `tests/unit/test_release_support.py` pins the two copies +## together. The capsule cannot preload the v4 updater to read it there. +const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p +e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy +wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9 +yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk +lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu +qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc +ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx +h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P +584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86 +XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k +YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG +1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ== +-----END PUBLIC KEY----- +""" + +signal state_changed(message: String, failed: bool) +signal prepared(package: Dictionary) + +enum Step { IDLE, VERIFY, STAGE, DONE } + +var _step := Step.IDLE +var _manifest: Dictionary = {} +var _manifest_sha256 := "" +var _from_version := "" + + +func start() -> void: + if _step != Step.IDLE: + return + _from_version = _previous_version() + _step = Step.VERIFY + state_changed.emit("Verifying the signed Godot AI v4 release…", false) + set_process(true) + + +func _process(_delta: float) -> void: + ## One step per frame so the dock renders each status line before the + ## next synchronous hash/extract pass runs. + match _step: + Step.VERIFY: + _verify() + Step.STAGE: + _stage() + _: + set_process(false) + + +func _verify() -> void: + var verifier := _load_script_with(VERIFIER_SCRIPT, "verify_manifest") + if verifier == null or not _script_declares(verifier, "verify_archive"): + _fail(INCOMPLETE_CAPSULE % "utils/release_verifier.gd is missing or invalid") + return + var manifest_bytes := _read_bounded(PAYLOAD_ROOT.path_join(MANIFEST_NAME), MAX_MANIFEST_BYTES) + var signature := _read_bounded(PAYLOAD_ROOT.path_join(SIGNATURE_NAME), SIGNATURE_BYTES) + if manifest_bytes.is_empty() or signature.size() != SIGNATURE_BYTES: + _fail(INCOMPLETE_CAPSULE % "the embedded v4 manifest or signature is unreadable") + return + var expected := {"repository": REPOSITORY, "channel": CHANNEL} + if not _from_version.is_empty(): + ## Only the final v3 runners publish the pre-capsule plugin.cfg + ## version; older ones cannot, and the verifier must not be told a + ## guess. + expected["current_version"] = _from_version + var verified: Variant = verifier.call( + "verify_manifest", manifest_bytes, signature, expected, RELEASE_SIGNING_PUBLIC_KEY_PEM + ) + if not verified is Dictionary or not bool(verified.get("ok", false)): + _fail(_error_of(verified, "The embedded v4 release manifest could not be verified.")) + return + var manifest: Variant = (verified as Dictionary).get("manifest", {}) + if not manifest is Dictionary or str(manifest.get("version", "")).is_empty(): + _fail("The verified v4 manifest is missing its release version.") + return + _manifest = (manifest as Dictionary).duplicate(true) + _manifest_sha256 = _sha256_hex(manifest_bytes) + var archive_checked: Variant = verifier.call( + "verify_archive", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest + ) + if not archive_checked is Dictionary or not bool(archive_checked.get("ok", false)): + _fail(_error_of(archive_checked, "The embedded v4 archive does not match its signed manifest.")) + return + _step = Step.STAGE + state_changed.emit("Staging the verified Godot AI v4 tree…", false) + + +func _stage() -> void: + var installer := _load_script_with(INSTALLER_SCRIPT, "stage") + if installer == null: + _fail(INCOMPLETE_CAPSULE % "utils/update_installer.gd is missing or invalid") + return + var staged: Variant = installer.call("stage", PAYLOAD_ROOT.path_join(ARCHIVE_NAME), _manifest) + if not staged is Dictionary or not bool(staged.get("ok", false)): + _fail(_error_of(staged, "The verified v4 tree could not be staged.")) + return + var stage_root := str((staged as Dictionary).get("stage_root", "")) + var tree_sha256 := str((staged as Dictionary).get("tree_sha256", "")) + if stage_root.is_empty() or tree_sha256.is_empty(): + _fail("The installer staged the v4 tree without reporting where or what it wrote.") + return + _step = Step.DONE + set_process(false) + state_changed.emit("Activating the verified Godot AI v4 tree…", false) + prepared.emit({ + "stage_root": stage_root, + "expected_tree_sha256": tree_sha256, + "manifest_sha256": _manifest_sha256, + "from_version": _from_version, + "to_version": str(_manifest.get("version", "")), + }) + + +func _fail(message: String) -> void: + _step = Step.DONE + set_process(false) + state_changed.emit(message, true) + + +## The pre-capsule plugin.cfg version, as recorded by the final v3 self-update +## runner before it extracted the capsule over the tree. Empty when unknown. +static func _previous_version() -> String: + var settings := EditorInterface.get_editor_settings() + if settings == null or not settings.has_setting(PENDING_V3_UPDATE_SETTING): + return "" + var parsed: Variant = JSON.parse_string(str(settings.get_setting(PENDING_V3_UPDATE_SETTING))) + if not parsed is Dictionary: + return "" + var value := str(parsed.get("from_version", "")) + return value if _is_pre_v4_version(value) else "" + + +static func _is_pre_v4_version(value: String) -> bool: + var expression := RegEx.new() + return expression.compile("^[0-3]\\.\\d+\\.\\d+$") == OK and expression.search(value) != null + + +## Load one of the v4 installer scripts by path. The capsule cannot name them +## by `class_name`: those globals only exist once the v4 tree is live, and the +## bridge must still parse when a truncated capsule lacks them entirely. +static func _load_script_with(path: String, method: String) -> Script: + if not ResourceLoader.exists(path): + return null + ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), + ## already compiled into the resource cache before the capsule was extracted + ## over the tree. Ignore the cache, deeply, so the capsule's copies and + ## their preloads are the ones that run. + var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) + if not loaded is Script or not _script_declares(loaded, method): + return null + return loaded + + +static func _script_declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +static func _read_bounded(path: String, maximum: int) -> PackedByteArray: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return PackedByteArray() + var length := file.get_length() + if length <= 0 or length > maximum: + file.close() + return PackedByteArray() + var data := file.get_buffer(length) + file.close() + return data if data.size() == length else PackedByteArray() + + +static func _sha256_hex(data: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(data) + return context.finish().hex_encode() + + +static func _error_of(result: Variant, fallback: String) -> String: + if result is Dictionary: + var error := str((result as Dictionary).get("error", "")).strip_edges() + if not error.is_empty(): + return error + return fallback diff --git a/addons/godot_ai/migration_bridge.gd.uid b/addons/godot_ai/migration_bridge.gd.uid new file mode 100644 index 0000000..d53f987 --- /dev/null +++ b/addons/godot_ai/migration_bridge.gd.uid @@ -0,0 +1 @@ +uid://e3r4fem8egus diff --git a/addons/godot_ai/migration_coordinator.gd b/addons/godot_ai/migration_coordinator.gd new file mode 100644 index 0000000..ab2ab35 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd @@ -0,0 +1,213 @@ +@tool +extends Node + +## Prepare the capsule's value-only handoff to the shared activation runner. +## This coordinator finishes before the runner disables the capsule or swaps +## its source. The runner alone owns the scan and in-editor activation. + +const INSTALLER_SCRIPT := "res://addons/godot_ai/utils/update_installer.gd" +const ACTIVATION_SCRIPT := "res://addons/godot_ai/utils/update_activation_runner.gd" +const PORT_RESOLVER_SCRIPT := "res://addons/godot_ai/utils/port_resolver.gd" +const STATUS_SETTING := "godot_ai/v4_migration_bridge_status" +## v3's `plugin.gd::_ensure_game_helper_autoload` wrote +## `ProjectSettings.set_setting("autoload/_mcp_game_helper", "*res://addons/godot_ai/runtime/game_helper.gd")` +## and saved project.godot. The capsule never re-registers it, so the entry +## dangles for the whole migration window and points at a file the v4 swap +## moves away (#946). Remove it before the swap, matching the exact target so +## a user-owned autoload of the same name is left alone. +const GAME_HELPER_AUTOLOAD_SETTING := "autoload/_mcp_game_helper" +const GAME_HELPER_AUTOLOAD_TARGET := "res://addons/godot_ai/runtime/game_helper.gd" +const REQUIRED_INSTALLER_METHODS := [ + "acquire_lock", "release_lock", "discard_stage", +] +const PRE_DISABLE_DRAIN_FRAMES := 2 +## Unknown pre-v4 versions still describe a major crossing. Manual-major +## client repinning replaces any owned pre-v4 entry, so this fallback is +## identity metadata rather than a client-selection authority. +const UNKNOWN_V3_VERSION := "3.0.0" + +signal state_changed(message: String, failed: bool) + +enum Phase { IDLE, DRAIN, DONE } + +var _package: Dictionary = {} +var _phase := Phase.IDLE +var _frames := 0 +var _installer: Script +var _lock_held := false + + +func start(package: Dictionary) -> void: + if _phase != Phase.IDLE: + return + if not _valid_package(package): + _fail_before_swap("The prepared v4 migration handoff is invalid.") + return + _package = package.duplicate(true) + _frames = PRE_DISABLE_DRAIN_FRAMES + _phase = Phase.DRAIN + set_process(true) + + +func _process(_delta: float) -> void: + match _phase: + Phase.DRAIN: + _frames -= 1 + if _frames <= 0: + _prepare_swap() + _: + set_process(false) + + +## Everything that can still refuse without touching the live tree. +func _prepare_swap() -> void: + _installer = _load_script_with(INSTALLER_SCRIPT, "swap") + for method in REQUIRED_INSTALLER_METHODS: + if _installer == null or not _script_declares(_installer, method): + _installer = null + _fail_before_swap( + "The migration capsule is incomplete (utils/update_installer.gd is missing or invalid)." + ) + return + var activation := GDScript.new() + activation.source_code = FileAccess.get_file_as_string(ACTIVATION_SCRIPT) + if activation.source_code.is_empty() or activation.reload() != OK or not _script_declares(activation, "start"): + _fail_before_swap("The migration capsule's activation runner is missing or invalid.") + return + var cleared := _remove_v3_game_helper_autoload() + if cleared != OK: + _fail_before_swap( + "Could not remove the v3 game-helper autoload from project.godot (%s)." + % error_string(cleared) + ) + return + var fingerprint := _editor_fingerprint() + if fingerprint.is_empty(): + _fail_before_swap("Could not fingerprint this editor process for the update lock.") + return + var locked: Variant = _installer.call("acquire_lock", OS.get_process_id(), fingerprint) + if not locked is Dictionary or not bool(locked.get("ok", false)): + _fail_before_swap(_error_of(locked, "Another editor holds the Godot AI update lock.")) + return + _lock_held = true + var from_version := str(_package.get("from_version", "")) + var record := { + "from_version": from_version if not from_version.is_empty() else UNKNOWN_V3_VERSION, + "to_version": str(_package.get("to_version", "")), + "manifest_sha256": str(_package.get("manifest_sha256", "")), + "expected_tree_sha256": str(_package.get("expected_tree_sha256", "")), + "editor_nonce": Crypto.new().generate_random_bytes(16).hex_encode(), + ## The v4 plugin reads this after activation: a v3-to-v4 crossing + ## may replace owned client-config entries broadly, which an + ## ordinary v4-to-v4 update must not do. + "replace_owned_mismatches": true, + } + var runner: Node = activation.new() + get_tree().root.add_child(runner) + var accepted: Variant = runner.call("start", {"stage_root": str(_package.stage_root), "record": record}) + if not accepted is bool or not accepted: + var reason := str(runner.get("refusal_reason")) + runner.queue_free() + _fail_before_swap(reason if not reason.is_empty() else "The migration activation runner refused the prepared handoff.") + return + ## No capsule Script or suspended callback crosses into the scan. The + ## runner holds only its independent source and copied package values. + _lock_held = false + _installer = null + _phase = Phase.DONE + set_process(false) + print("MCP | v3 bridge handing verified tree to in-editor activation") + queue_free() + + +func _fail_before_swap(message: String) -> void: + _release_lock() + _discard_stage() + record_status(message) + push_error("Godot AI v4 migration: %s" % message) + _phase = Phase.DONE + set_process(false) + state_changed.emit(message, true) + queue_free() + + +func _release_lock() -> void: + if not _lock_held: + return + _lock_held = false + if _installer != null: + _installer.call("release_lock") + + +## A refused attempt leaves no staged tree behind; `stage()` would clear it +## on Retry anyway, so this is tidiness, not a contract the bridge relies on. +func _discard_stage() -> void: + if _installer != null and _script_declares(_installer, "discard_stage"): + _installer.call("discard_stage") + + +static func _remove_v3_game_helper_autoload() -> Error: + if not ProjectSettings.has_setting(GAME_HELPER_AUTOLOAD_SETTING): + return OK + var target := str(ProjectSettings.get_setting(GAME_HELPER_AUTOLOAD_SETTING, "")).trim_prefix("*") + if target != GAME_HELPER_AUTOLOAD_TARGET: + return OK + ProjectSettings.clear(GAME_HELPER_AUTOLOAD_SETTING) + return ProjectSettings.save() + + +## `McpPortResolver.process_fingerprint` from the v4 `utils/port_resolver.gd` +## the capsule carries; final v3's copy of that file predates the method, so +## the lookup is by path and method, never by class_name. +static func _editor_fingerprint() -> String: + var resolver := _load_script_with(PORT_RESOLVER_SCRIPT, "process_fingerprint") + if resolver == null: + return "" + var value: Variant = resolver.call("process_fingerprint", OS.get_process_id()) + return str(value) if value is String else "" + + +static func _load_script_with(path: String, method: String) -> Script: + if not ResourceLoader.exists(path): + return null + ## Final v3 ships older files at some of these paths (utils/port_resolver.gd), + ## already compiled into the resource cache before the capsule was extracted + ## over the tree. Ignore the cache, deeply, so the capsule's copies and + ## their preloads are the ones that run. + var loaded: Variant = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) + if not loaded is Script or not _script_declares(loaded, method): + return null + return loaded + + +static func _script_declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +static func _error_of(result: Variant, fallback: String) -> String: + if result is Dictionary: + var error := str((result as Dictionary).get("error", "")).strip_edges() + if not error.is_empty(): + return error + return fallback + + +static func record_status(error: String) -> void: + var settings := EditorInterface.get_editor_settings() + if settings != null: + settings.set_setting(status_setting(), JSON.stringify({"error": error})) + + +static func status_setting() -> String: + ## A failed migration in one project must not block a different project. + return STATUS_SETTING + "_" + ProjectSettings.globalize_path("res://").sha256_text() + + +static func _valid_package(package: Dictionary) -> bool: + for name in ["stage_root", "expected_tree_sha256", "manifest_sha256", "to_version"]: + if str(package.get(name, "")).is_empty(): + return false + return true diff --git a/addons/godot_ai/migration_coordinator.gd.uid b/addons/godot_ai/migration_coordinator.gd.uid new file mode 100644 index 0000000..7b3ce25 --- /dev/null +++ b/addons/godot_ai/migration_coordinator.gd.uid @@ -0,0 +1 @@ +uid://qqsn55jmog6v diff --git a/addons/godot_ai/migration_fallback.gd b/addons/godot_ai/migration_fallback.gd new file mode 100644 index 0000000..5030a4d --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd @@ -0,0 +1,217 @@ +@tool +extends Node + +## Puts the final v3 add-on back when this Godot is below v4's floor. The +## user's own updater already overlaid the capsule over their v3 tree and +## discarded its per-file backups, so without this the plugin stays dead +## until they upgrade Godot. Parented outside the EditorPlugin so it survives +## the capsule being disabled. Leaves with the capsule once the fleet is on +## v4 (docs/self-update.md). + +const PLUGIN_CFG := "res://addons/godot_ai/plugin.cfg" +const LIVE_ROOT := "res://addons/godot_ai" +const FALLBACK_ZIP := "res://addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip" +const LIVE_PREFIX := "addons/godot_ai/" +## Every file is written and read back here before the live tree is touched. +## The update directory is hidden from Godot's scanner and from git exactly +## as utils/update_installer.gd hides it. +const UPDATE_ROOT := "res://addons/.godot_ai_update" +const STAGE_ROOT := UPDATE_ROOT + "/v3-restore" +const UPDATE_IGNORE_FILES := [[".gdignore", ""], [".gitignore", "*\n"]] +const MAX_FILES := 4096 +const MAX_TOTAL_BYTES := 64 * 1024 * 1024 +## Capsule-only files; the restored v3 tree must not carry them. +const CAPSULE_ONLY := [ + "migration_bridge.gd", "migration_bridge.gd.uid", + "migration_coordinator.gd", "migration_coordinator.gd.uid", + "migration_fallback.gd", "migration_fallback.gd.uid", + "utils/release_verifier.gd", "utils/release_verifier.gd.uid", + "utils/update_installer.gd", "utils/update_installer.gd.uid", + "migration_payload", +] + + +static func available() -> bool: + return FileAccess.file_exists(FALLBACK_ZIP) + + +func start() -> void: + _restore.call_deferred() + + +func _restore() -> void: + var entries: Array = [] + var error := _read_payload(entries) + if error.is_empty(): + error = _prepare_update_root() + if error.is_empty(): + error = _write_all(STAGE_ROOT, entries) + if error.is_empty(): + error = _write_all(LIVE_ROOT, entries) + if not error.is_empty(): + ## The capsule payload and any stage stay for a retry; nothing is + ## removed until the restored plugin has been enabled. + push_error("Godot AI v4 migration: could not restore the previous version: %s" % error) + queue_free() + return + print( + "MCP | v3 bridge restored Godot AI v%s; update again on Godot 4.7 or newer" + % _restored_version() + ) + ## Disabling the capsule removed its dock; re-enabling after a scan loads + ## the restored plugin.gd, exactly as final v3's own updater re-enables. + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + var filesystem := EditorInterface.get_resource_filesystem() + if filesystem == null: + _enable_after_scan.call_deferred() + return + filesystem.filesystem_changed.connect(_enable_after_scan, CONNECT_ONE_SHOT) + filesystem.scan() + + +func _enable_after_scan() -> void: + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + push_error( + "Godot AI v4 migration: the restored plugin could not be enabled;" + + " the capsule payload is kept for another attempt" + ) + queue_free() + return + ## Only a running restored plugin makes the capsule material disposable. + _remove_capsule_only() + _discard_stage() + queue_free() + + +## Every entry must be a regular file under the live add-on path: no absolute +## paths, no `..`, nothing outside `addons/godot_ai/`. Read everything before +## writing anything, so a bad archive changes nothing. +func _read_payload(entries: Array) -> String: + var reader := ZIPReader.new() + if reader.open(FALLBACK_ZIP) != OK: + return "cannot open %s" % FALLBACK_ZIP + var files := reader.get_files() + if files.size() > MAX_FILES: + reader.close() + return "too many entries" + var total := 0 + for name in files: + if name.ends_with("/"): + continue + var segments := name.split("/") + if ( + not name.begins_with(LIVE_PREFIX) + or name.contains("\\") + or segments.has("..") + or segments.has(".") + or segments.has("") + ): + reader.close() + return "unsafe entry %s" % name + var data := reader.read_file(name) + total += data.size() + if total > MAX_TOTAL_BYTES: + reader.close() + return "payload too large" + entries.append([name, data]) + reader.close() + if entries.is_empty(): + return "empty archive" + return "" + + +static func _prepare_update_root() -> String: + var root := ProjectSettings.globalize_path(UPDATE_ROOT) + if DirAccess.make_dir_recursive_absolute(root) != OK: + return "cannot create %s" % root + for marker in UPDATE_IGNORE_FILES: + var path := root.path_join(str(marker[0])) + if FileAccess.file_exists(path): + continue + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return "cannot write %s" % path + file.store_string(str(marker[1])) + file.close() + _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) + return "" + + +## Drop the stage; when nothing but the ignore markers is left, drop the +## update directory too, so a refused or restored crossing leaves no trace. +static func _discard_stage() -> void: + _remove_dir(ProjectSettings.globalize_path(STAGE_ROOT)) + var root := ProjectSettings.globalize_path(UPDATE_ROOT) + var dir := DirAccess.open(root) + if dir == null: + return + dir.include_hidden = true + dir.include_navigational = false + if not dir.get_directories().is_empty(): + return + var markers := PackedStringArray() + for marker in UPDATE_IGNORE_FILES: + markers.append(str(marker[0])) + for name in dir.get_files(): + if not markers.has(name): + return + _remove_dir(root) + + +## Write every entry under `root`, checking each write and reading it back, +## so a full disk or a refused file stops before anything else is touched. +static func _write_all(root: String, entries: Array) -> String: + for entry in entries: + var relative: String = str(entry[0]).trim_prefix(LIVE_PREFIX) + var error := _write_checked(root + "/" + relative, entry[1]) + if not error.is_empty(): + return error + return "" + + +static func _write_checked(target: String, data: PackedByteArray) -> String: + var parent := ProjectSettings.globalize_path(target.get_base_dir()) + if DirAccess.make_dir_recursive_absolute(parent) != OK: + return "cannot create %s" % parent + var file := FileAccess.open(target, FileAccess.WRITE) + if file == null: + return "cannot write %s (%s)" % [target, error_string(FileAccess.get_open_error())] + file.store_buffer(data) + var error := file.get_error() + file.close() + if error != OK: + return "write failed for %s (%s)" % [target, error_string(error)] + var written := FileAccess.get_file_as_bytes(target) + if written.size() != data.size(): + return "short write for %s (%d of %d bytes)" % [target, written.size(), data.size()] + return "" + + +func _remove_capsule_only() -> void: + for relative in CAPSULE_ONLY: + var path := ProjectSettings.globalize_path(LIVE_ROOT + "/" + str(relative)) + if DirAccess.dir_exists_absolute(path): + _remove_dir(path) + elif FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + + +static func _remove_dir(path: String) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.include_hidden = true + dir.include_navigational = false + for name in dir.get_directories(): + _remove_dir(path.path_join(name)) + for name in dir.get_files(): + DirAccess.remove_absolute(path.path_join(name)) + DirAccess.remove_absolute(path) + + +static func _restored_version() -> String: + var config := ConfigFile.new() + if config.load(PLUGIN_CFG) != OK: + return "?" + return str(config.get_value("plugin", "version", "?")) diff --git a/addons/godot_ai/migration_fallback.gd.uid b/addons/godot_ai/migration_fallback.gd.uid new file mode 100644 index 0000000..0851f5c --- /dev/null +++ b/addons/godot_ai/migration_fallback.gd.uid @@ -0,0 +1 @@ +uid://bym2nenea1nvd diff --git a/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v3-plugin.zip new file mode 100644 index 0000000000000000000000000000000000000000..be3f0888483a75b31261f56ce907d95515a7208c GIT binary patch literal 1890142 zcmeFa+j5&%mgkuh(=id^bw^Ch-B)mX60`+SvYmFOeCVM`%4Ve`a!AU`Y?T`TNsvSe z1kgB;V!E=TulfOc<`t%6o@y>C=0WE7Uu*5}1dx=RnN?jerA$jC@O}HR_Bx;T{%`-s zfAepD;eY<-7ys}7{nP*T`M>$aFZAF4%!j?x)A4AwaXvmB&pW-r#aeXx3j_+Zs=9ri1f~dDc8x&pvtd$*;2S^nA9to1KhD^XcGtF&|H7Yis-c>E&QH z8;nQUV3u9 zV051K79#*_04hMA4e=GVPxpNCJg-fT8L8T1%0I~|`aF8iZ-Z?5^y2E+a=YtAqF zS>rI?Xsp{Lr~Te=Z7^cAewMzuZM%nLCm1n z4Ggn2R=$|A1FiF6b~!#Boax`bZELbP9u8&~53|#Oh6Xk~In##*&xhK_#(0{|`orNG zBMex#?X_5)JtO!48|DuX&GgCj#rU$ilfi85Y%v`%B|zbc(=i~k8UN5fnd=j+{A@fN zj<2;fj_h=x{m!1OtsU`I?|6LGx4rl&kH&MBYT&j5utoT(##gh8-f);5_XA+e3wZe` z-wwN59M9$){Gd0?CgZ8iSKnrv`M=-E4)>lOz1lq3$#xI3{e!(9cDHx7v&QBjzc(Ic zuXc~V-+OtK@x;OAi=*FXdrz~?7r)PbyZd7MVYc(T{ezvu!))(hZTI>9v)vs&+kLV1 z?B(|Ei|?{;dGEy@sJsh8Gw9J?rU_%H-5uV0nmyk+*!rH4H^1F|wtMvZhigxFk6viZ zr+Ww4X12e1aJ0Mi^4aD=w*T^AfA4UI>9-m7#qNuz2h6hbeCNecn_2lR+xdZCvcvB; zpFPu5Ynv}w{ejk(ZSC#<{$TgJ?~k(Y_nvL<@Zq;RtZeh!XFEO>o7#G|x%>QKw!Qg$ z^E<8XAlqY1sKy*N7H*N5!#;3$9f)$ZZW!))_l z_fP3OAhyeDIO{cYF+dG@j7>nb1VOvZOx7YS7 ztmcRR(Es|U|LGUM(0~8S3RZKlv$_3zr+s<)LC#LZYepksEvyAnhk!sK!~0$~9Dt?H zc={kP6m&J{8E(IRK0fUaGv1x|-_EoB>3BXq84rJKUd-o{*^`Y8D6~HmtK#cPeA^z3 zHwG}T@#(^-e?4nH-`ZaXCv)a@JRO{#L!Dd0-r}^+_@_{^{(cKYL2WqO)nE?gZ}d$6QzhmfbnlWg0G919DX2w;roAF|r#SZ@QKmVVQ&TOJB&tMOIUOk!i z;cwZ9dHBtlOU9}p!#TX-VK!aB%V1mlIP3SOCl~tGXx*5gPkuR`vRIMn*%Xe^(=(To zIotc-gY17_3{Ku;hx6WazP9$b&7LMVS+_0=x-1RuVe5?x!kE_NQx~w6mR2 z?|9g^vzQDQ=Y!Fc?4ytNV^QDeKx}!3{rMaYG|TwN9@9?=+8=$i_GvrY*TQVZ0&Ze1 zVqqVBR6Hy0|HfF<1P(sqJ5K*>aK4!K8OYe0uO#asn_A$}RCvTF8h6&8UU4)MC}4#U z4i>CWUiFU;8Mr@hXD`OtWpA{AeesBm?1E<6|4)m-w14W5Jn-Knhme1**v)6{Uwxc4 zKWl&SG4kbPe970R{nK^6f4#UW(2_mO+C79sZ`ht)Y%H!eKtcqIv2oC1uH)01_iqqZ zqgeL(T9$ov+DBRrXW!`O+2ZoDH@*30H{MuWeYKH3TjPO`KDz9k>>Xwsi1g9o?Gpf$ zW!-M~xHr4tr<28Wn6;kG4$rbgFO@aLLRew;_nE!HyFTu#!RT~+jo95EU-zen7f7`A zeE5kzLnt8@GDJZ7C2Q@x?Vl_p5cbE&#GCBfn|)|KYn^0`!SwR(MNDRoS;e6Lw&8p5 zb$ma*>>p42*IBc&(_$^LReL4@c5zi6VltR4K}Mg6JnbCHk3;KrE}Ulmy$&Bhaxm7o_#yOE!B3yxFbC0X-zi7VCT9nCM`7lV^FxVOwF%gT7P#O#U}uCK4#aOcDY+oyf- zcis5Qc3CxBTYI_yk6A;Zu1R$Y#ZfBB`C=f}Dtes39Z^0s`IGGRIa=D{*cNp$Xq`*z zbX~R88x$+K>nUGQuFJkg5%{qgTJCyHV*MZg&%bSs{`Pp*3OQkl}pq-(Q8@O%Es3t^mJpY ztrD{&~!p9gduC0t*3E(8oeYS?3k3# zg@&|9*%BAdKWI(y`GhSvv1*n ze3{nq26>)u2(lR=V*$^JWW-I@Y+v<9SJ^-Qm;ahAuHM?eek_?GD5#llsQ-2{p0Pd| z0;BWR;556Q_9ohM)&s;Ye_U*_FkpZ3rUP4;FJ}BY?O#eU-$;tV`K5iq51HJp)}eNme(o96t7&zAwS7ZVHtd+gDqnaCe%fL zg!<4Qj(aHjbH3CV3B#(mfz*_0Gh=EOlV%0N%pjWC98F4!Q1;~OG+wj88KR-pb&q^> zICmecYphpBI0$@vpOF$U%A@@)mggbzciun0d7rWVzi$ z@BO?1Z+LIzfZMZ+pR>Tz*~QA?IMJUo9Ot`oxaX#FZ3T+I2cA@kqkJT+@Tnx)^nDIk z>pQcz%0q>|vkxmd=!@UEN_hV=QFn&34a{$;OU8U4n|y%s6fw5g+Pf^+2NrMBfcBP; z7UWXmz1n^waj1p~pQrGT;~t8%2AH+c58*fZ8D>6&VaTy^J~sa90^tr;`cO>a`eFd< z!GJj}acVO_YRvp>FrA@fzXS%T-lHsOlDIc!{p{C|9zD`<$Qf*`<74<5j->BzV4STV z;Eb;hpMAnVs2Oi0%6hOT*$qAT+}RmkCs>T_!a0>so#le0Y<(C!aD3$sA7h^GCOPf5 zd8wa$@c6UOC7HD%an)FBt8{`)+&dqQXE;tIj954S*Ru%S-lU&hNU&;*cAU%h&eP48 z&yG40hn@XB40cY(w(X2EegEn(|MoY(0x16N;So-Wm-`(V{5zeU7eCn9ez&*1chuS3 z?Nq*Q#5>P-U*tnQe)P#_>Eq4c(r4>r-$q+Y@H^S z`)Nxru7`Y+wv&DSS!Rm>KjxX%BnjG=o2hj<7~*il&KjM5iTJ|1WlltMHyRp%k37KH zur!IlY~Fjr4x|-H-@3-nW_!Y(ma~kb8;^!J*|7w)ALBLd%{j|K7NsM2`5hjbBZFP% zxqx`E_bo0Pj`8yZ+ryosBRnOY(jhtl%F`NrARSM7wsLj_$?6T@2+zcjT}(Q2{F0;d z`pduF+1c*|$?v`B?EDTlZcg&Pw*Bi)Cp_W zpF`Cs01#QpBya8yq{E-~TRz&u>>vO0e_J=oH)Rrl%H(h1v0xn2gTqA|?g-aN#;8*U za48L(MIb{T$~(F+ZO)vkK1|44`AgkVi=HyaO{ArvT~B*iw-fVr80d6xid^pqlV;s) zIv&rpQ%*RZDRC%v3@!Sv1$urT%^AX!rRJ-n`CpL%dIeZQ2?#*INNr;;rHx;~^)8Yu3yNKTwyoW;OKC zmuCZW<;d~O^hSG|*>^8@ORt4H!nI^!i#y7)-hAG3`(f;Gz+Hr=#B@)f2Kwj(u&P;9 zaMP9_@5q>~&t@7~Xr;&VCE7 z!&l%28~ly0U;p%{_}<=5@H-6VPqK;mRZxM!ATVC5lkSQ+!})^~oG5Hc>wTEv#}~H& zc~0bTI_;nJ7DFD!-HunBeGUgW!EVIliB_CyEI@;=9#)8EKNzC>i;hO)d`FNUUOWS_ z*;F97-~j*Wu_>^Tu`YYi9}B^F%uorA0o5goV^k+mC#^0Km08zDdUe67vE(qKAt)cg zz)RR=ByZ*=%rEPkC1VTFlnF(^G$?Y~7u(bha1KA+{qE(#&UWYd?&0&zqpk0aN!nPp zR?Gn{7ys(uxG#M;yOc-3REN|43FxH23J}t8J%+~8h0=|_*~f4I6YjQ7y0|}Ooock) zbU5yx^w^~{Bo5lF9>ZLNu349%^)PhcG_dJUu~zt700)ZQB;;{u&g`%Tm)f9wmWeI{ zYYvf}+q-+6Z};|&4w0_=BFg64gNDCRLV$-%_hr^td+>4AnD!7q*`cj$2bS6_@T4u7 z)8yGUCv^4t@sl5cpK$VPeP%z|2cT#AgdZQqz>cu#3vd^nEAKEcJsbAUXJ0>h`$$Ry zf7aIpbi>6|qOXFxt9EO(A42qftyZ6nu4KqZ->cofbs ze$6EpCUe&MM(Fos?ExSr?3AH*_D)@;MOFyYnm9m`veiyX^zUSgY z%GQ>i+xqXa@U++%s#t1#poK2M#ass(9Xp8K7m#Vlvw&^;_>(W%kNDr?Cm)_}T%JrC z*@tC_`k@_2*z_{_EP6wM^aidp0m5(~R55YcOX8#apf7Xw*}v)y7aSBc%p*Ges-oSJ zde0DNprL8{ixF{BS88UX-eh(G?}94M`=bS<2>(K~5-1o#KUEwEt%2R5?Ts&+E?(pe zQ&iGUe z^CXUku&i@#L)j<7Wm~V~^i>b>FcZ-`7=X9*k1xWvX&w5~dDRQOT9#zSN-cb+(wW(u8vk7^Hn7`%;7l z#oUB}NofRtxK4J#F42`hN<(jCm2gVv+x4&wMr^x-H0n<~b8L$)!Dd7myxcY5&|#4p z1iL3a-MmB#@C2nrj?9SnC%pdR}fCYFDZ{$HS?R!V01Q? zu+TtoOr!&BL%`zb*B<0ERC%*hnfK?W#)&)0kAW3b=5PgU<@#1A>_}Z$M{h@xH`W51#_wC7 zwc$lDlHc}GRME@3Qo&(O=2Hl&{}K-7wr|(;cBF%tt1H$(W}JN$-+!~wMj2?!mCS0* z+N`pS0+(*V-8xRy840H(QMQWQ!|cS4+}|-N_MQk<9y954y8B-KGZ7KR9j{A{ zY)iPf^Wr5*Fkr7RwS=q_qV&w_`r}40Sq(7ex*noE)NzxST$v+eR7*Egc2FntkeLukX(Ud`hoIX_OwIr8?Fsz+u0aLW{Y6GZ~r=j>6j-j3#&l zu9YFAkXXi7%wP$Ka-#Vbr9)^e`66YCue;r=C6NeV*8Xhh8hBy#$|cs%HuraBrH|Ze zfDSUhY5yEbt(@#X84lb>g97s^8f>gW9CHN8={W_&Xocd~p*HC#(Ru(eAz9qi<2NX# zA7?dHJ$=Cu7;I<46@0ox55N%_R5k=Zh#7+=N*L=TT{vrg@cC~ZlNn~(*|c{ZOm!d6 z;t>ZkM2~D2(>E)sjJ)&f82UIjSQP%3=6ZWRW^Zfs8Q&9nI88Ah9dAJ`=K`-kf4E0u^JrBmAV%w0;7pRvcGZ5rZkZt-i)t#HjE0> z*W9RB;bR7ZxGC131TOmlK1@k)_*tj;RY!8Z%6~|5K^Oy~0|I-!bF2h67?Y$_AmNSt z{_>aMFOh1lr`;G(c%D2Oqju@=@vicHdGDCSJU$wp;ie_fVGd$)B(6tb!~X7ecCBE| z>G&MS45l)^7UK3pv#n)8&*tOFy4eia9)(BT{FKThlR=}{ow;AYT z&P#k%HabdhTKx#dlAvNpPnbUK1K-p1YH%gj+1?qOxJibrWnqMoOdJaEnW-gc_*@M| zS8l@QzZj5yKsc>`LGGdkJCHkpOiHb2HJ-?ccnjH}gaygihEi~%Os6^Zu!!8Dv;s%m z3<`Rd8T9^myuuRrv$0vEUzbi8mrCH7PKd<_q0nb;;RVj_KluEMUzgwmf(_qbEEgFi z&WZ#~L;ni*0WQrr9`^|-bulG@c>d0g1y6R9uY$;^e{z`eEfv`a7p$|p)=akW_*gL- z6x!#7XX%OO*|L;V_~<3c>u+1<3kXys7!D)t1xGHr@I^a&Wo#7u&?`^!XWS^3X{{_1 zmfXTCe#szGO7$pSV^hP;anf8+X)q^U-yY=^OZKhrONKiW`Z&Gem{d~vGr@GNNJO7j zD1of`5EfUR^kI|nBS6h`E4*qNCHxF3Oo&rSc~QA?F62z+RzA37!cTun-ocfRbPSp% zJOtJ@$u~#<<~)K_d*|mEH1eNYf~w~LrBojxr?y_g0%d4NjKlaI5=}$NM;IclWkJkm zmc6tUsnPP*L7a1Wge|5{R^FQ!bS9n>#S3Dq#t}{}h>{$I3Sj6glWVe5_3VMFX{B0I z$zNH8UG=TIKzPfOt07sl#GU!&2j#kftls1bGzBt*Bmxepw|8iMH^*gSh{~?&45Vrx zpW{pIsuNG>D~U!Ow=lLWbA{J@#i%yk5aHzJ4$Gwqevi!hnfcGQngoa)9eGGt$ISmoofxmFygo5IU z1rW<{TzX4;ap#3V%tDu*SXxIc!v{~UPZ*rAG>;B#^xvxbr+?Zx9qW(@8dun;c^6pV zXgpFT!QhW%a6)lLqeQ_G2J$BY95Mx(GrT{ld1APQqK*2>xKG69O#7E4#v(YdK#DQL zM#EJkBWdT}^3GdP6(lM3Fi&|@^;BkdfkdTgB#yDDBE6B8HhVK5qmNlUB|?KLY$WD5 zCnJT&2wS1WSRTKtDMVN8E6*r=Ga6rOpl+)bz-i^M@h9YQ&6j}>)>3>Ghtz8?M;ZXV z&fuBJcSJfUzVtR6K0fp$M`R0e2aoiH+=qY9h#br;a2OF##2u1MiHg!;6x>61qQr@f z@%}l9izgT3H$*RG@|l`vU%6VM71{QW7X)KrV$Iyu>|#{U8b1`rAELOK5R?eBQ7pan zmv&5D4ts_r;t12xk$1`QnVz=Jl$9^hvWUIgrY&Ylgpx#ZR_f=fEWEwJ^GaYvdSQwCT`F;xMa8LJzKqe`+MF zr)k$P78Rw0ZWS#!4#)JUxnYW1jvA}Gd`Mj{va6ouJ;1>btVpWLAxrhNb6$2usv>1vuIewj(C$2=C(mDEwMEB=~G6j|mA?(n4 zogu^mzD!D)J)+@a*~UndaLpsOd4geGyA3ac>9_Jyd(>Cs35%K1*iq=GNhvi&Ef!G4 zu81Blt7t^3i&!lOa;M(Ik>8>B)CSKrxwNIT(0O3R?BUcoMuDUw0_3;o;F0Pu8tNj;NtPgj{DDxHpIVFL^KyP7)}_az99i z7(QR$m7Z|Rz9VwcQ8L|(Arg*NKKHpm+ank--iwypE|&|mBFjDzTMl|tO3V|ZB@b}8 z_a=O_R(r{g7^s>2BavsO94*Uh7@e-WvxB56SNC1Fp%`R5o|x2SIv)h!;j@*&3&73k zqh|vm!@*hqO;3U-kbec2~LD!CwmKv`TZeQ%KX6AJ2`W=yVd z-}V?9xA-l-p=AoW?VTk837!-KB^}r@#w|B;mYalluW^ilMoJ=zgNXyyI;d93l+}rw ziKMoY^(`YO)UY|3h9*1B%>du+ohWH8ISa%`Px>0(Q>8C4j4cY6nB{6tR>Lx7nJh`a z(1ECFR2F#0=M~v+52l;G2o<_D8*M_jPX6(za9R(dg2NGO1Ed6xK2hn~cV=3p$H{fX zzdIva42-(5+|$Hn%B$Br`yM4c3?EI~b4jr({H>^qX{;EHv^!D&4CQ?JpUv0F5P`Pw7!lII&%rFGU^^3~Wt)4n;4G!BEwafGqr z9+2>Hm{=ArM&?KE0J4-ro8N8Eloypd$*{Y2q~{cY8s#UmDK;vGg)gLARnTnv9XLWq z$|7lIu5ITqCg{VlN~KM9erWm;eP|ZC^HT1w(tB2_%+*O(geG4n)YFp>Q*N_yXdLXN z#r>F+=B<)SZk{$cGfqssZ)XR%F$tk%t0wnEVX|c~JfSF$wv#0zy`;b#G3b)1+a{`Z zCwh0$X{fNn%cgO~G6trX~N7u%sM0qHB=tuQrL@(<^j2X!Qd&yfX^%?vwsHdc-d-;rX!GE&oZ}~QZz`A>a^SK zf=t#UZ7kIb62>4u8M;-?jwO(#SOi@WA`3&yQg{N72cW-{P263oY9ml3SOByvy?P?+etX&3pGd{z((M3D5DRT54Tz3sy+hXh#zLl zk7p*_mzKcUny=wN5fRZ~AzxiJmj{bF6BQ=?IW9ved~0|6K!v}@ay#tq<172%SC76Z zJcQCslE4>@gnX%a2;C(pw^ecn*8G5Y^K9iUEm=)IG*-nSXi-#EEhEN{Z{~fm%$9zL z!fpmmOi_xqba9I|XrRb9aHUr-XIrR-!T`K&=Asv0`PzHx61T*5zHd-R|bUe9TFYT8|Mv2tijsTx+xX%rp%y zR^+YxsXC=*Z{3XA>*d8Gh|Lj?N>NCav7YVQyrpteqp7}AWHsx5X=Qz6e#(Pt z*g{pxj*ugJ{OHl+Pi>7OG+dMr%R#nU4q_?i=DxM19rI%XgNg*rsz_a}uH{n6lkr`V z)kC2Vv&Cz9#Z@2U&Uj_*G%`A`pzrBjYc8^3O;qH|S8a*TpuAzXV;IV*P(?#LqP1CU z-_DH;l}n`sP!nuTwDN+;+|`UAMNV32rHs|HBDEMWlPId{@GY+2+?p^Tg7-I^J|!U99dUQixAYMYnrA@yhTR{spnGPiW_FKsL{ zW0*{%fsYs^9KRR$YS#V~j`3~jP1JO74ma=<5buB#~tcTYvildQ^`}1oFa|4wb zKM6Lmx(d>m-Vs2pV~IvHTA(x(u%MoF4_8-$LQ~jCm>BTUs47)utE;f=@Rz22fy}BK zK!3F_?yi-sn#D`Cx3Cu6wN^)f$_rtql7dejefDe9bO?(i139q+X%=?K1AqNtUR%IV zsW!M}!;ug0IE-d%i>Fs95*%>ENtgcEA8cihi?`#IiOrOrbdAc?VbhQvtvzse!X*1j z0Y-Jb(;3ARUuCyISsD{Z^0$jiCGtR~gvhBlt`})@RL+V(S(!Fu+fK6PXxyUZ#dzdn z(AsVHcTa?Mlr>>SPr3wgylC!}%mJC6iVvw;*;eI5cYMCN4BTiWcDnPQP-fQZ6eq ze8odhYvr?Vd;n8y>Auu^!_p=wmF0)@k=0*kAAg-a<~Ih8HF`q>@T*>i-kiAB8bWZcjfa`BFeemqV`TUddE#CERF&LF%pRPQ)_0pfD%P`Qb-V|u zk&eAmQ<)XS_Ys>EgG9lSjT>oEtB2BSWFib`Ft&1a5ze&cMD>9yL8TLsXl#Q%2q90IYEox%kH+TJ}GysmGn-YCs)uj-i>8Ncj%UwUAs+z;YNLDUMBbTWa;#v06 z1l*jENQv?*6OZhimKr|U>d{~|n(5BHAx9W+$Y zpxlL5X3^~O7lhS zazv?xa8kU$0W45cf6k^XRMYXuS-g_`D4?=V@qOXXTgI#9-(m=93`~Oy&`2mnn1hzY zxO@U*T8U4o35fY}7y#Tqnu264te%$RuC#JwEXCl+3L{&%i5;sxc3vEn zA1GaGodyq$>9-5U5r^4uhRGr45~y^tHLar5rLH8)OfXfZfrWyV{!y;?2;%q1rph4g zor43KJ3IUFQ@U44$!Q@KW|x?Gb?_H7Oe%m8vp7PF2yUZLQ+OPr@6PRVT1%%^P*@qF zCpPZG67PMdlQ54vT)QLBiL^)$BXR_}CRx!o7&M+q`bQ$^CkOh>&(VrxDC|)COkPIY*J#FwVXp+{P!w!y7Yh^|(GTKYZF$|-S3?b25v#r| zHSf_-IIGl$yWg@re=G%tqw}vhJWqx=252fB4LUiA0Iv02nh)lLKdoD}@<1d8Q))$s zA6KT7J#Fmj!P37W(a_yE4OiYC6M(}W)i+s6%0l&zvemATMSU=`CP zKOG4VK@2YY(rcSt?*^R_ezuV?^Q}A5=d-*>#-qpbf&bC{+|C*0rVdxe zBJ8!1B3N$6UXY9t3tVt~Ez?}@pAmR46+61!1n6vxiYtJ$p&f9C+7$LlRW6j+L#wlr zG{&*wBy>5~kjyP>W4J~Gr$9-xGa$PzP?aQotBNUAU^ST%PGTAHau8DGZKFg*DW{|E zWVmm)@@}Gz^%bAJbqs_|8KGsJbzmMgOdPpr%Fe8_xathfIwMv5;B>%OOm31~#iq`( z7ss1abLy~`M#A(!J=dmc-kG};pFrjO5$rUxk=4S%%1@cZ3QEgQlr1@IcAtIOl9PUw zl+l2q+A9GS@ zB6LwRT}y=Tg6TG;&$UBvARkW}qEEJ*m{)r4bv@ftQS}n*F$}Ttb#;Fd22Y(w2gf9F zK|)Ahp7dzp6>?TWF;8={G2(fX-8|h=rf8C}yh`smV`Aif#gl}LAVMh(POix^$=w4^ z-Nxoj!NcYdh~*t%^Ga(-0I-m}`_3%Cw5-4?4zJf_nm~gBTS?=+PfL_jtOQNQ3Z#kQ z3cOshZrlgCZFeO=maM@FqR~*S_{~JSvP)80xAUD!MNYTmDf97hl$oX0xtytkiF;80 zWUYuaVDK)1I}ALDQ)MeC=MMgB`DIjchv7;uCE1~}6Oqk%uSq$II2~0t6J^p6RlPU5 zClPHk=Z+nsHvnf10U51md8LAx)}M9?aF1#8B-udGErB*< zKIw`yriYaq=d+_U1<*(+M+t9kIl+x91sKg9lEHn!fFtyM*K{o*436F#P)x+~6@C-T zhKo6;%=t85-DmZd@XTLsJG!|6Ttm9r@c*>_+3NDDXjxllqamLtw_XfYI1_nQZBNWKLf7Fwk4jlHC@&qDT}QsW!rgXVY6VqV0MT+ zoKj3V=bjl+4asP@Q5fEgPr6c(q_Vve&N*B_&o(JHj9+Phe85M1|2tB!bgPJ(PkV^I z+|(saBh=Bb1_ZNkLlct4HA|5O<{nJPBVFaB7sJf~{hJ|WC#Oz>8ptzVWWy0#8(nQ! z_X2*v8!&`h2z)LfYGIy)4rSkhl(Y@&Y?G(rGHyN3y;OxMyr9-2LgDxN-ulHRv78KqgHK(u;M^bSTrdF-~rBr^8jFzu+S;;E%*>SB!Xie!Gof$FotjVKb`sx?#qr3x+pH=Z z4%a_+|F0fl?Muj+?q?yp(>^(Jy72}CYo%;$HdaMw{8r2=EDc2@e)fBNY^pG{d!&QH zsc>&0fh=vAf&JzpVE)>gMrO)kPfdEp`5r@7*5QLcaeqZxuQ=VC;9f_jcc5?bl2 z67w~{NTA``0y8W#IX&i9-h=oi(vR~m*B;bZRC^iy{8dolma>fJ;xlTee!#_(bkDb* z{kK1!syHfLp6-8qb#IzmmXzjL(R7lT*z#ej8nW#MotWXQ@SzH$?G94If$%Wpj&2Us zWRvGxIfu&mLsV)d2jiC-EVpREqILi$PuG^Mw9rZo&49#3@A|C?RMW~*(sWKJg>i0e9!~6vyq#XWY z4f%(Pew((w^k_PU#^4m=TA`u_Pug7ko_^%hd@Mbd0=a*3t>)=icgPKUf4s@Q+dlNh z@=9MG;ztOBnHp^ zCB*B6c!w7RM@nTXk;%+(NI^%wxTmhY{D)s=@JHpj&}YA9M-PcF>y`x|MP8K=K;#3B2k2g3X$)j%}h znt^`$lQe)b$=9;bPH_n7)#m`y00&8Jie}=mMLerd@MqQis;INgqY-rD=RH?4zxT~x48E& ziC+>OL%@>6qG~O_0e(bs#*Sux{S5hVM6$lAp&9tBm|zf@4Vzo3@V? zjBSTa41e&sSH=MgiyTpqi$|=tz?tMqvc#0U>?Pzg?T9Qj!>z|swmE(0GZ!IAJah^6`5pW41%NiGaQPyQu3tf6(g&O8>@Or$8 z{0;>$r!W5o^1>@XKw}y10(V0RN0tqhl+KFRqS%_hu4{}+cibDn`8*G^yZT_nDibBL zD7uVs6L@9jiZ|okXSTs~3wUL)@^#{YY|@DC+lCFs72uVzs*afmeZSjK%;!TTSEope z@FBT4XkDeOV{s`^+IkUzrCzjDg#4jOnH8kgdJe(BiejPp{mrBAvs;~{8(WJhJth@z z2w$muoFy_;No6(X%(Gp$N^YWLAhH*8PsYyIYhpgP+dJsPebQ&?Col%)6iDeRD#dX5 zlMdjY3eDVjOUPZ`)_Y-CZjm`-#>`k%O<1UDc9aGJzdQA zm!p$xc~5U9xP4$c55C3-bmtnOQ?tUD639W-%CWVI0Be$5O1KfDfPx7(%5KJZlUji` zCAsxaK+zRqy&XpHEdy4O+hv{vRM~A>M_qY(-;%1bgW_G_ldon8PXBDVq3_7i z=pl92iSK&~X^Px&_(~HK&h%MuW*{@puY`DHA$+CON)+^`wQSmd9=Rc-P}bN(`V)O}D7lQionwl3Fb@AguFme%C1V zT!-FTPs9JiM%&-C7Nb^??$zy9DrrlxiQQY%e~ZH;cwx&6LSQZAIm?M69gGACMVfKi zc*)PZrRnf!faX0PAsI%?^#k55qul7lLXjGmEIh9vFm0v0&+=aG zMkLo+1gErw#F7<)G)<`F6G`WBFADpx^^^#la{rwnSZ&ik55t5(1qnIO%I@591?3&p z2@bQ;V1H3);bk+<2p^+F*!W+a)4_s&N$N#kH!6{nq*NubXz^={%{!_(qvp08)l$*0 z*$r&M^67Lt%dssfH@-|EE6kJwS(^ll%Hiuc@APxFT z$-#WdwtQhq!;}Ef-?y)&x(a{4=Fu|e_HFYs{IV~CJqx;rHaMN1$}Lw2NaT@!c@N3q z@wyblS0Re{YfUS%(7PvW0eNm8FpM@$Mjb9xcA`*E(Wn`PBhLfTvnU=)9}`wtaL8WW zJ*4L&@vi*T=^_XJw2>SVvkem-`2$=QqHDjDmn0%lM|B$h46URaKQ@RUZ+36MZS)p< zQW~tajZGD%qAvRS2;YkiJf~#oTI2Q$&uxQf1qa;^O-RNso%0{n9VxnmKB)eLr` z=Z-HSC3189D=u5&9uVY{S?G31-SjY*LH35$`_v9c^rzzW7$Yu=j1=gqrEh#^)Tf{T zmIC2wv0Pi;8X_8pwmxPIKY?AvQilk60NuzMrLB)iy}Z#KBT0Tad|a@>yLID_bxvO` zI*fyQJ!rv1bD?R#(Ym%QK$tVtLMUcP4H@;8ouKPf<&*FZ>h2?XAvG?&eM(U+qY32^ zB))&OV6pmrrFS=B^VUdDd8jiAM2Iv=6eGrvTOb|I4)5b-du{d3M>O&x1yE}oj5sIZsm3rh7qqZna=#Moqj@X@S+qJ# zTMSXA{YYQ}1t@o6ZX1^mCrl+Yph=I`zU=4GNOf0FxMt+Fky+r23mY(RJ3`hymVoYY zGn8|++Y!#j9G}>Qut1zb8ES77^i4_z8$`9?;Y+F<5LqWvP=9YW3Nte@PR3kKZvw?el?Cl%xz zl0o;v#}GJ4zjajcD!@XSg>h*mt_h>t@QViX3lJPwC=wzV*VKd_EY&I`F@kzb#JY7S zEwBw|9~eqK(_+riduuV{v>coH*Mw}yae+z|#F|~!pUO$-Wn!)6tmjjzDoIz#JJ`A_ zyj!kyS5iEz+qo?%4%O!21Zf(<+O@@F2!w2&X19J&oG{-cFSw}@Tp@I=5b*m$5(fxsF@|4>cnz>vf-%!z`-vn7{8ILaYd(D*>SsP z!q~_^7ooU))ukW1dBdT$FwP7K*@XK4R_!yx%A)Iy`%^SlGWtTZOfU-8X9h}Yo?O9_Wqq+9FjF+CzxK=BxDPw0m@t3fHgw^3k zlFR!&qxZSf$^1Oa$rCGEiCFQ9PnMHfIz=B(+EZ_=b5hvFQ3YG98S13R(+W!@{tEvZ&R9Gb zC<9Vtjr5H|QKCjw9a~ZiIhS&*j2Ftxec2NHFj5LjCS3h#kSJhs9RE;EnPktsJifmM+8+ilMY5Fjuk1-QKbf{ z#3+|&C^5nk<=egz>ocBpm{QqYzNGn_$m9Y^*Y$ZUJuG<-kv1d+)GUvKGy5cE^CjTe zbhNR?l#|rbiDre(?4C~fEgn4hIBP_fu&VI&oHs2sWzG3=QdM2gmw(NkvrBVZy@C4* ziqSBrR6cA0L$2YXBHF%#VLWJE&vD!ht8s(_OLx)NU8icsv9J#}oG z0-K3>q=svfcLU5Yw+P1E4ys1B8_2S>^z8l>vslfPha$d!29j?96~!;dgA0qX33PCV z+`-dtshj_ZOWvfYNckf1K#nLL6vcZ~0!-_cqi5k7u|S4KEQQCUHMxmDAFC9V^a{VB zz(&jIAotINZHLLPfpAH%VpUp;wDJ>AJkKf;We_Ar&W?iYv{CwQV#yeNlKH~*g6dbS z92nq(pP3opix8!n2Y6dxS5A!;$tq_!*>I6`sV72^3}n}QpK!#w02m%CW`eWvEtrArrlq;#Q9tFL92Vma3$w)S zV9ie~Xe&&2k zBEZ$NgLLKBea)J!)r+fpAuOy=0+U*G?&WZYqypl@XR@%hd{b)mrM_*;L2Heyt+JpT z0Z^}2#;nCtOTs7pnB$dH2*0lc&T3AdLYu7ws-;@D|1chmn&oK&tz0pfr9ioq)gNn) z0cSXv&c<;5Iv}rYHi;uoRPj!McT1)`?-_+4f7{JhUX<}6K~frTMKxaXG1!y#ZcQ6V zXW*RE1?FPZyMj_=`lcG1;(cEWNMxNew1ZpPfc$wEnP|hVR0-Rqo!suz2tBCg9*HHY zVL%z7{mAc6y5=qOy_9Y+I=Qr{+H;duTRsr!fP>+w*5?!>Td0hx{2IXyj9%{&k`|^% zZ#_i;yc7-d&FhW3s(wl%vujcD<>~74<_kLckh{Yr&`%17D9Xn8+U{=7#*=vq?aUcl z&vpeDDUMj_)VpIc`m9_KZS?J;lV<@kDJA{%Ou*W8@U-$O#__Y(@j*73${gz9oI@@ zlYN@DMCWp)xHe0jEHpvwoP=xKIobHT(cg_4gv|6v?j+UeRZC-i7j6_6H(&gG0TOH> zq`+!Jqag6={ftNu^DZ8^Gu5!-g5xIHf&gP)tL;lhK)WFcGNjA8bPObkg=n^vW65Sy zc(_OyfwRx86Z0G7Gw~g6LiJ_R1=E-9FWSH&BU^xLOS&mb*rwr{{q{M21?>LGV8hU@ z2$tfrXa3YUT}l9Sq4nFKUE_vH7MdTvTRs5160wl_wa2wLo$zo2`0CuswwSsiBc_EW zHL@G*Pp}~_VSW!oy8h{x;BYy4JfNXH&Td95e#YJ4rq^B#dW4T};n&t>K96KF54_1E zeNtaEnnhkLL?cIsRB*Red_>s*zDRT4)>IvCW5&D&{N+malHk3|y>1T7pO4GAHdF4> zn&ckZj!4`v{O1VeJem_CxfsZ$afy#FNzUq#I>YwkEinT#j)M;4obMN2(meuG0N!mU z9$O!e(Y0CryGRy)g=X}yH@#D-915+2XAp7hpNTb5FXHi6PM< zvNE3JR7*^PVunU89Yl0hUwbifZ>JDCNrAOK=MiYcdz}VKr{*fncICC0YK}KeX zZV)gNRNG+39uoId9rNUJWyxv(EjX>y{?yFCr>#$0pQ-UO4%~TMc*fD1b#u#BtVxKU zzLS(zBzYA4n77#|RZC1TT6warQ2Z;G{ujih zD<_#e>WG61&#pYw5n}k6ujJY#UM-Wq@@`3Fl~{G|)v#EKz4A#rTU@>6-SkJ6P-3XMZ1!O` z@!s5Ug$eymP->me?BDF~+7O?%3lFst%4}9|jf$)wiE1*KZ1CUPjm4F597e4g8Zv9V zt!%n<&wBwMrXY>9sgtvFJ07#rp2D8mqyDw31f+_cowtYM-f5GrQL`r-8(2rk+YNJ^ zcY1>jAH2Qt|u)R{^xl zNw&YUpMCb(Baa%8`-cUOB{A=*_oKNI1u#qR!?XD*<9+h6nR4yNT+8`i{%d^Jl^o3W zEk6QZB4M&w|6Er*sH*bQ_T%=W*7W4D?lMHkVECBUaB3hoL$>=dS4)^8?+!_BZ7EYj zXL2*4qKq32V{^^;H<9wPMtmix$5VuL*(y!oN#hadbuPfxG`%ZJAZj)~7MM5_`}~`) zvqu8;6)fVaO#uu8G#`Oeyi`E*YT34P0&C%f2k-I_1ci-rI$K+2KT<4`xGI-k3w+P4 zs(mZe-fTPX#ft-&OA(|6c`MhJE~R_|Ue7N!pquj^T*36?Q zCU*&LtGI`oOTgH5LRj+Wo3@0SBX&zD#O`nQeGt!ea!iLV_Z~iSQ*TNyTT0-t1nrh? z#TE%*#^v`kRuRfrQ2owLJXcjSq?|cI25UNx92Vv8%`jV${KUB)Noh^+1LQ-Jcqv7& z?YqW@--qR5oRG36Ju&WGwfk+RrY} z;j|AcmgS2whIwSUsd6AeUiv!UMh?i;0yEVyR-Kvo&8W4cuT=*Wv~FO!anaHitXcK`>-^mxm-{~7 zwXVe|tt_#88_6A#P|lZDJb6%9kWT+2GU zz|t9{>7XNnXt+azkC-p~{m}Im{3@M1xRHimMuJsX({bD^a3G|98U=Ax5vuWdZK!nd z@Lz=+$|e>a;!S_uxR}o;WW-G8QdO>J`H$AxfD{md)yC!YwW~r&66h0z*7zFF$`v1S zXO2D7n=`>>!|ulkW>e+pMwl$X3-kj%2>=COvglB=;Q~3wWoUrPIm_+uemEoFi1$&2 z5!mj4Z#PJ2(BNyiiorckY0ct2QX^^$LWN7pTY6G^p}BKiYjNeIG~z8>Gx*!mZjO*G z#~9-Z`{ubBiJh~zQm9K?laURKO+Y#HjhL7AoOUaDF&=evox_uCb2{zayq1PJI#)@R z3Y`=)hXL9}Y>iec4qhNpBH=2qlo2GQIDdb1wBOm^J2+Zj_lDZ7*7fXN1}hLO4fX1< zG!UgvQVa988m1iHc3!J6boGfz-W6lkPOJp7XzUL709^Am@QFEE1rZeE{0R4eJ8k#R zddefl8DfbVp3PzOrR)cD&oq@L)RJDGGi7=Gt=g@Tp!A)v1tA6Y)eCg%tJ>CsmHE(V6|g~&1}=ryT| z#;h>s@~0cFNfaMISwhj}S6^3N6S`25tSjlc17Ot#mzZg>yVCs9R$QMjo*R1zJ?MSM zwi6V2lqRUrhX~TXy@jbXT9@LL$zET3s}xl_BY=^VBd%N{mP$w{i2#R7b&5+4>Ts(S!eX`qahj$ICm`;VKa%11{aEG<^$j=VvPouurO-R^Hg1+d6xN(oLf18#$75gdp|r z>#sd$X4!pERVO@$+wq9kY&_voTFak}N({uh_}l)n{kJOX=B|qi@iKVSWU zzH_wud}r_FQRn$#3ePdLGcNN%cy!GS&)UL-t%@@q_x)NH@ukG0)26M2ZFK<9Cb3vr z(i@Y8x#H#cNW@{+hS*Him+>!FsX`90cjA=rO5Ym8mw{H|x$lVW)y64_DHn(AdbzEu zacDSLXtHRqu}jjZQ#&|54Bb_IJq9o-zEmoW>$Z4yVUERRqp|$tF%GM7N#(T#P`zIP zpm_&N+PN4AyuUb8J8C~myPv*;XB&tSgPesTk&C5URCwR`QM%mT{o}c%T!TjdqrO?p zYu%%6YMH;GV?sx~YqQ$MJ;svO*4WAmR+ya&=gQO6O^(0B!X#SnUsvOWUqXnpK*B0t zNXRtr24ZJfS(B*p3Xdn2(bZdm#ehr|9yOCMzvB78LacbpeAvwUcbNzcx0sYHO6t){ zv#Y_()`GrDm8CLLvR5!xt3gHZ9!asqX)+v7?4D#fN9Ciyl<0F8JE_*@P2uuUlvvj! zui=H>w%tP`a`&=gIbNM6BeBL>?V&XeFzqZWork~&D+erG$+8S+wqrW#wX%_lv~DlQ zou3ZI%3zW;z#^!TI@sAf+UdO5>%7{1vAy?7o*FfAkclL5nyLRDj&u6ey(YHIhP} zkKv;)Zx(uHKXJS@Nqs*(JxIHQ8iJ{&p}h>!T4?fC1o{tH}X#ePSjlNZ0Amk(KWa^ zk!I_(*iyL1IAh6XG~9L7S8j6rp5%n8Twi77iA+c-vsb!ST_DU8ZQH@*tR}7@?5e!& z^G}rv;gtficP$d8w{V#IKyi>P-O6e z9Ci+mws-eB-|p=l9UdKQ?(2#jqaGw~d7!G+u9-pmz-}u)Rd1vF$Z=`E7L@H4V zQB+&{6J_?llKIC`KtZ}-w^T!NJF%At%qKBIlo0V(C?t%j0xAPcwQUW5|EPNSg$wY4DEj=YB#a|R$MHdmY z7%l(~5#pB?3S%WiG=)>+G0ANjpbjok=u&$nbv7gB@>XKwZgb2?Yx%}e)|3trH{X*9wxt= zau?+;qE3%s4peLjeWL4h%6TSFrCM5z!tfxa8_FW!05lEV(T}Cwg6)CJmhEy3-qC7d z%6(gMZ*;8isS(@LHX@BfnKql&MUeasgp#L*Hl=klsyRi1#^R&P&N2Icu?T+$o>*E-qVwaZ91-7kCiKv2ndzurtud{N<)sIUeVV0%M!{s?$INd#8g~ zT%|LRf+<#a%Rwxz>*y5(qU6ZkptM$k*`Yp03Lgt7as zwBs@kWQ0{9n`etN^cl6-2rE#CIU;m)RoI7LIur3WI)*#WP)dBp@}NM|SjrOKI3TVK z2gNO`@oqo$q||1o(u{_gK)A2(nBNB;Tofrq zu;}LFb?M2KgoRt&V$m=&DFVx9SDoArgxSrRhy@U@hgQDFqLk;B<0&}Wx|+2tx%8sH zm`19Vgp2CGJ%%_VlU1p#vaTBZt8`R&J6SP$-MqLm7BAr#S#$P=yvuMp+E>)HAt}$O zF*LX2dW@wh8LSvfAEbw3EF9I9p;U=TUc{>LQmU*R;X`%Q> zBC6zU2c|o_xm3($9Yj#Rp0CcLs@VqedxjyQH8x0a9s^GmRg^0#Jh@l>G~W&`bxCOs zMMlzKFVQl(+FRGk2$U_Sb!{@){Mq2x_Y&|r)8F&Hai>^Wzx-NGV65R@(LTlj%U^G(@MT$i2WG5}|ia2h>-jwr`$coAj&=p--MO zNBS*5*Vu4mT1KGi$pK+#$Wi-1Byd|T1ZlXG;8?62&K&&?DaN0VPkT4(U&>oB1F_?5 zd0ib)y{Ai#aPlZx5skEEJ0bK?(BE`H`6g}YEuBifij#}+03wK#NALaT^%c3sB=t|G zApo`{;w&S%dnZQjez&(xBlFGO&hx$P9p5sTLK{G%Pvvd<+h{vl-_~w>O)Bv`)AJpL zXbyJyVdupU;$)4=aE+CZ9_}0+?Y{WV9$yW4)pVBmQsr1`Dc~O{yS;L#4nv)tkX&ew zI!8ea4KA#tM!2Grte+raPaGyAjaewt;pCMiM`7*w4P8)_=7Ei2gLZdD1R}hQ)*lAC zRM~d3bKK7{4hVs@Y^flu<6NyTifq|0K`b>j*?hUZ(S-GXwk;edhD9c7>FIH zK*^K%m|tldk;_*}ac5(w)F3xidX+EE(x-92eg2!r(5qV;4$LRn554K2hn9ka zbtRmeRlEvhabXxIk}7~D42?SCphz|mQc3d1leHAD_I%7~UiUF>copfIj#zJo>#iZs zL;Z6hk@rUmNmqJ604fobn6s+g+JpnH0(ZQF$=;z!Up`Lxa{~$##|l1StkrikeW$BDvgvr@S&oxx$EF+dn`%5mnh`+CB!sV-{+H zQDv>Ed{-1{7jfuuw08M*njd`e*u+<2LyjO}82#D@;0;n> z1f7~8k$+=&-KER}C6+=avu!LeE(Mp&QLG30LR+nv$Q}<_sv!fFW|JOW$)xfa9UR{X zA8D~A`Hmig`Y`Px;Mo>;{=q?61<^UH>=x6CK5*V~OTI7jF;dfQI;6%zx19`5y>SWi znPP>P&RkhdrXdZ}R@JiXMJpM`L1v7f!+c?ikhjWqvqmqHB`3KdK5i5eF1b+RyjPxeCqsEK2kz_s8UYhbY6q%J)8saE1^`5Lw+>4R!L0m90yJcsZqvl z&pok3PIMF_BC~fZ3D=OB09@BgB}z?x!FMr1!}?2mG+?Pv!vRk;#mpuf%F8nY!;|Wo zxU|4M0>$w^m=+$*OH51adHFZ!V&mvwYzdY`scd?T1wvD20}&@GUa9D_fx$*zfh4M3 z(c-aHdhCN)gvMkx(4^IUmi3(~0-sB2v6zxH7OS}>i&Z+8<|{p2V_xKnNt13Us{Qw1 zvt*+mDOz>y31&Vr zBW6pK;5ZwC+p<6K>}iO^WLrvu8A8Zt5YP*)_-x3W+6)t|Ns+%tCY~|yjXz)~TEUMB zPmx$-H}IvzM|uQRqG>fc=G)3fbuO`|*w4=IHn)yau2>qP+}gGK4_|+EIUuU?O}CxB zl(Slk_Hjt2afi{R<#%GR%wPo*ya~n}!9+9`Pn_nHFBT z>U%9E!><;}H|Fe81qA_;Rtim{o_at1*p_zWn;}ShCfMRK)Ea7wytS?zZhY zb7m@fsGj)X@u!~(jij@v);LWACy5`;8gTOGIZS~v575S%$?dAN<1bx6B#in{av>+F zb-(||ZU8FJrrdHPT=+xwI=1j*L!gddJjmX#Om~Y-l#*DN7}L_CG%qDeud8$3H!_J{um_j`C^39YF-zgilt8$+`k+>Mw8S)SqWUWoI|7V zMBAQ_iYJ8XZgVj@$>$qqrclvdGrUxvR~G{mFJqbAWsM>A@kv^XTH7XSk?d``Gmcz5 zhB~keMwi4`m~%_*cW=CSy9@|2Idpx86jD6Fk})WW5WD9lQURpYM2!XG6Md*p3@F{k zuR_f?G%uUcxPA8-YTUFp6lK~65joPTZqe}K10 zld?F6-Y#VFEw4Zd-Z0eWT8VzQyy-~8%W3w-=nZ=0D7weJ0*a|Xe?cZ)5k%Wc^-grw zz=5Y4E_vD1YYiLm7!^&25f`THD6>5kzZH4w2?i!Y1{%5Kk7$6rOac5R!x%4$+Z3PMS_% zNhd@<_gqzTbGz2DnBnNgEx8LzQ5#mQgkuMYxh6E)8zr`4CVA6)vmwmJ!uhRbAdBTZ zmN!eecX~K_9tg#Im}kJ3m3m0QjJFGIN~@}PIAsD!mLz}k{=v?x&4cHqdH^w@$G?8W zpPIk8K%}hojS>J^7+f>Xz82I}(TuYEI@W!%nOqF}oU}LV(8|Q=lVXv>QXZvl;v8Rp z{Z+`8Z-{vq#R*z+1)iIZo*ObxcMlGa!ep~-7m^+0RUHo?0>R>tk@3bko^o31zJwj1 z>?}fAVL~Uq1~Zw?v8Yrdn7-4(1zN2^w3y6Q;o*mbutF>E7lm%+k;Rm#Ds6~0glf3=Y+Im8Fkn=z z1@{(Ke#+YjEhue8rjy4_MmY7wa$UV*M;c0LGP>DHmj@6Xah1df*xFFos$SuT&)x_v@0|iToD8cG@%YLP9+iiRhx2F z)dvH3l-qubXIh{z+>RXR++?>L*-IL~+j|bo0;Sy1FtNBBJ9Ksg%9-E9+it)Bbh7Dc z*nF6zO7HJ(TN}I9W5;J%%E}H_{7m>}(h54bWU0hhjL!+0STD3485!Tu_CjYcPXeNo z>ks6lAgY$|$aQOQ%;-tj2)*)5e2-BB(d7N0;Ou%XIRrwjUD*Z)JrHgpjZHas;zFxm zR<`X`8Ue)u5vmtfi>TJ)yX;c{bN6d_XS5Jv3@3yRxVV&Pu&M)^ya`QvZY~SZbKiIt zMMV!wf=$?5ur##l(4_JfPr%S{yToTeJaHdsz5&~|l$yLBP@z*XyDL@u@(u|+h!@#* zv`bF|hlF*+hiR&noh5?H4`k_r(uLq4T8Hq{O`OPKE0izs5bcKUDh>)vG3$h5N7 zaf{K)zzz0fgbd6qeHfT1zmm`cx5W{I(2aMq%S9BFQ__uk0_4DR*9xKvL6tHZM3z2C5n@24DYS6!aC!(s*Ma8dOce07bLRNs>E0N2{%AZ+HA?We4ZeNf}@KN_k z?y3Q4J**0L3HKlXngWp@C^zfj{?69!)7_neL%M5y{q?69^dE7fgAF9zGa*CnTmuWl zU{Yg5#!P)*Jfx%MkcJxAruE~;|1M=BiSzMft}}Rn`+Em+T{X+^M6c4Z;mAacq%%a)T>8F0(z6bJs<)E8eDwxxKf4bcl4%^&%`;Bk^F-;PrM=*R6~NM^DjbrGTz^ z!895QvLa({A0{h8dZVq{6at-~ar0m~a4uEe)zxOxXOC3jXy| z02fpJ13mVzaFy|cqjEAIvkQgU#dx*0NEy%DCrO=2`N1$~G&F1)FM!hNyh1Oe4$u(^ zFzC=wswFS-V=&h{nO6pUv$JjgN|K0X9G^Z?!=?)6p@zDzfCCd;?G$w5EZ#~T;&+i> zY#>3h!hfZ`D(^xCQY1!rhK}A7mfCGce1^|+WX(1-NKJJM`Rth^1^N~=<4V7hxP}9Q z%91&p_|}il6{#YMl=V6dj$kQ4robOkpqDKuT^gwD-867NH(RtS0|Piz{2) z&_LIXOj}$upDLM}lDT5rI7cS7EjKii;{7dJh5R&TG}yhv@}h7dz!8&k#w2h9c2HT? z*-*=<-b;Tno~kfZy9srH2U(Qivcil~c1+W#D%UWdw|I6cl$BI#4R)t?z2%M{FC%!d zcVza3HWFUrQcjFqxXpz?MFTvp;dKe4lOYl|UkoRY#YoM#__%)QZ zkv3gXrKHK5&n)TRFb664*0Lp+p9qq7(-;yd&^RQdOwt<=%E9Vlc`;b%)sE+2(i z2WmS>V+qHrhH9j$X+J)V>lNG7?xAf%YQcM?MOzoSma=N_o5{U}=Ssn1I8xdD1>s}K zOAwZLts5gSm6F3&Lz1fF)`uy`q6#;EiHa%#_!e{Il;v5o$>avkWulU5kVI(C}UTZq2#4aT6uhL^nJ9 zv^cCNZ}zJ+v>|883q}}Ezbph5W+(~4Iyut`RFLcUX_pQ=F}dzf!U7NU=V76ospzDj z$@Kf5XurG4SrGa1$h*;BrJJ94Pv}8V7)UM$PwLv?U(~uyt!k0vt_KM~s)Yo)NtbTQiDSj;iquibJKA>z>$1~k>G%UG^Xl^`(lKUc&skDI z)I`1aLTrQ&6&98?FA9@;(YAqKLf^)cLVk#4Er+j*b*j2tm!CMCs$u_Fxx+QaTWx&) z)Oc4wF0=kR2$pI(V&=+u0C^$9u~}4 zaHbOScPX&AjbkOzeK!dOGT+lxqL3taO8Oid^Q6;q=@G)^r$7C9`hW;t2$YP(5-8rE z9yOR_nc>Q6dQ74*ucyyRP$*jr5g&sSBuj$vfkbJcZYGjrX}r9X&PM&%s^bK11*TVJ zcv*UTca}15tjALnf?4I)v2e?Zc=wNM5A3Tvye&mXb*GBbM-H_fAU><FY!85xAFX zQurHfpwJqot!+)Ble>QaF_Ja;_+MQbyX=U|vWnlM6dY6*23jTX>!_wnukS-0p~ z%dS&Og9kxX3=kHUjHq9lY0~Ww&kd2v8(M%m-WW~7oKa0|MKQe0Yl{)~S*BSd5~q8I z#rm`&xsBw8e3BcsF$SGTMXmupTr7EL!*lS`b}`w~gLE6ft$9jYY)|u`PV{Y~>$ZzK zvg{s9EkmGGoKV?|0Y|U^BdKIzduW2#g48p_w;q7axNWIH%)Io0t%_P!rW6I{rG=hC z!n)j~k)IIb;0yM=m`H)kb||=u~p8n96{Q}a811s z*~i-nl~6Nr1ZCa)g}^A9NXR-%ZRlx{nF>%W2fO7quKL9pjVPs?`@8EFpw&Q0Mik1Q>VRnM&|93M4X8i`<^H^n5XOACvWixx+s1*J4?A#g+OOSKerTQsHixR3cz?3?^U;)i;4sFW+av$nx_ zxHT9{!yUO_!b=VOZrZ0xg(bf?Z)t6XK?}Q@mdl{TbGj7vk)MmtNAWldrbdj>T1@LC z@F5#_Pykb-<0$H#;|XCIl{mTQ5kNIsO;<*urZbZT{8@|=?l{tQ{9RJ)j@Xn>284(n z$a6U~KG!>$j`8hi*RuLK9T*^f3RX+HsJIBKLjFh|-7k8Gd&QL9NMtO~jsVKne*AqC zJT^nwxdR%=cX6BoHWi(_0H;Okm&rP(!V2W!{-PQZTBS@cvFW7YqZClsa?6wHoT_QT zE?2g6oKQMEMF0z2Pt3`n?EIhe4r=Gzsk#bcwzF@F%`=LX8R%y!Y8=(`lQes(vlu`P zdO*hmSlz3plP|*&zGi-1CG3>^d>pZ)0TKO>?p+iZx+4a8E#FdMtl9ug9^)Zho5YQ9 zu=oO+XXBH_3}1cXVs_xpj&fcEjz|u34bv@;`gw@MgTcs~^hEodp zl)tvg-Y*PYem#*1v?LLRBrSjIJ25iL9Zl-Y?6EC5fNt|5y6BkgU5Z-ZU=^CgIfx?g_A4>jk4hBbGRmlXEUl*;3j<_h0j`PBNrU{GR}*b}$c$RRdpqiI72A_tT45+Ms@zXeRZwfPD~ zv7J5Z696=Bijpo@oO=r0rv-0NmAr902~jjx{OuY??38*NQ{TfiLBG3GOA| zL&?}NdxC}7(G;OX2t!^ZrQp4F=Ck=?yTqA|htOeE&zlg zAL;CfKnz1I1di?OgD*b)jly$&3Klz*!$^V*sG*=H`6*3tG0|GM<`75B|Ht0j#MpVI z`(Bam6_T4FR{}BGXQ!K#JKj}xI-O1@IiV|dIq_-RWva?aH$Ktc^<}%-^>ykaNp)_Y z0fReWfW)04F+c(#F=mJa5;Ks{9m0SCT!|?IW|s;4{{QD$>s{~OyQ=JTpAU|RIvsoO zcdvJ?XFcDa%B2tYtMpGK**yfL?Uqxt3WS_(k0-RmPIJbE`s+;9J?L6%$Kp379G!fI zkNr|p!@*0vKT$Q@j1R(w840YCY&e)gQL?YKl9d!%@%1zWP9iRK_|1{>I#X_gHyYVu z7@U%OA=5^jzXXiDvQd!d`b@vRXIH=vt(#W{5+&*Oo@umcXJ}Vx+gx5TTVHD|Pu~X_ zKV7t7)B5wiot~Xi8IskqrgSG3yIe18A1!1trI>MRu5X{xlcO7ZS_BfSky<0oX_Vo` z!Z~oGSQJxY5#vIY%b-B*_8wdxQOvbRz$27>8Al3`pDtNBl@^W#@M5QnZgt1B(*4ok zk>(sBD*fNZm|55ApXpNy$5;SG0+JE&*E|)FwlMW5rC7Sc+X7(j4N-gl;wtoTfm2s&ZmL;c2`>USH*ZZPK z>^ar+(q`t7Syk+W%&|kES?^hY3=8n@V|z36lH#W!s+O~WHbfEX@dN=irzF30caexK z862ft4YgmKI*P4p`Sn!Iwn*9qw=OTMQm|imjvNXbSS>Mvv;Wkyj5qgxDxszHy4Yz{ z_C2~#-;U|IlI|)KU%Azggm&1yYy!qZSW+4XQ?VpK5w2uJOk4s=NF8@d45%D2pwb_9 zOvJRCTOViLJxlIO)&%#j1ga~_Cv0JGPfvbW&HdsDdhp)y!faDX}UD{v7T=WFdB9V_($hMriS>2r`1PHVV z6?L$vo~)Qq+As+~QsL3dohIU^r%t0UV;Oy+;#d!u56J7dH*Vxx)Xz&>ZTct1H#Fm* z;TlEmHD1Yt~k{t(B;6wT;Y+SVoL*5cyoBN0DlFfah-S1%<6tgYrnPVvWDws9BUB z4QltK287x+4kH5UX9{dO5h)AB(U;%($>}>E$MQBl@=g~qtX2J3fdsArr#m+(S=fI? zL$h!YO5b$zi^AIECta5YT)06m;8z7Pf4{hKhY!zEV{MVVxN#4Y|RAwUqAUJ^v zX4M);o#c=-D8?(AxRf8)9XCIi+OU|DcN>prxw$ZGRuI={ArW6qty|bivv$?l+`vcT zQUL(*F9z7kAj&>hGVriPWJMnic1|!(<%&-4G+l6=AT0(gFPW0{j-E^Sf-vZh3J1B< zSP|n0+f`-Ey1RztUdXv6{PauFOp<0h`n;qPD}_nxnT$9m4&iK6J2^es?nXCHpm;S@ zlW$CD?DUzGG1{GM)mj&=1vyRE3fHvmSA-m&{B?-hbhnZ&sn*44`@6DBokE->Vu6k@ zp?(_s53qqFlJAo^&0us8N1^8ci`lj{>>e>Vc7ONr(b;{4v{p_|@HkLE7#XZT*D=I& z5CfRKn7EsmwT{6>inwAD@ON^8LATM+x4aTrgCdyVOxT-=@=yJq>aaSbt4} z3>AxVTh4Fhk6KK?l{-Kw6xEr`C0W$89VW6-cuBzE0LSwBD4#xaoU~lER_!3+%y?s> zlu$2nzOfU=`WEk8qA;}7L$Mf(eGOT(B}{;(>XTJ}nPo6~JnqnyfcjZ!Z95xzR;tQW zBDVf%jy|8!To>MV=Q-3>h?_tgTB@2>sT|Am$aZKQ zpqfkvQTIfAA4>TV@O)nJ6M>F%H~LQn#k(ztlapN{^6PT3p?kIY5=+l?-|4?@*m4TI zvsTYiHq>Qv*PU9bHKx^3aoT)>WCNa1B}IHN&2hJo2d`LE4;2+~hUfOA@1&yT#LQO$ zT#m@gS)S-9&QaqPr2&ySd5Mj~wf2umMPh~ARfhUYU=%?R&7 zmSl{mCA-N#ydoGWs*#9pWO^i2yeIOxAvEl8EBG?)bBi?fFsRBitoOvtjH3sQwj*Q? z26lYJ*YejnN4ExK)amE#58v-I2P?j1)iDQl|MCjlAI2RT4F7q-a3tc6Mk2csN9ZDK z&kpH*lnWVs?@FAltM%}6c3F3IFn6+9QK;)$R5jMW7FOP)>`aX-t@!rbT5XP|OU$ff zqKR_-4!AE7ii=)MY#ifw{~`uXga=HfUS7J&(_C^-z>urqZ~Oqp1&YbQKBO@fkLdD^ zP?9!oQ4+;Ds_P^ND4Y)D6&o>8jCV-Ih!;@J_zg$U0+X>s^O#or^y}e<$jVT(ngV}x zTj&Q9oZ^q@?!{JW31|qJ>@ShKoHe38^Q70%hscW4TrU}op0(pZIr%B)hi4b6X(d3U z77XEhmty_G#X8Yx$(a`?7vQ<0O#*2zPb%ycyy?n%8(^!!8n9Pz=Ru*aB{4sDV3=wa zD?rf<4}}WHZd27BoeGN9u2dGpNdIMw5Uz%Zx}U)KE>E@+YI&7KTQg{x>e*COILkno zdve?^9x9}8A`OFLp8WbB{o{Y~R{Zm4Z~bro&CUPt-{3F$_ow_DN^Pg@g?Cjsnb}SM zNZ!@O^SvGZhqvg?tG$a4U;fp_#lgkzrj~yHKmNb}r>}3n^_Ko!xO8b8w}`+j zC1&woxqX6hCKLh)W7&^8=w!|<&%T4m44rr_vuIca*ImN2-cWJ7ih-;9d6ouI8p{AS>lfMfxn{45o zjqWS+06P{0giy4pQuRp?IU2xC78P(iKStk+Y%0hH^wMTaV$15G{~F|=^cvN7#O_CO z9YBxh-0{wT?DHQb*-fs|JH>7 zC%uU`Mt?Mj3lD#E2LyzUpIIu!l?BsL@;MvR%bo9_2l9>rhJm9Z-rQ?5uA>9{>EY(F zTJe!rv1_K4U6S7W`!Z*ZHqHJEMw@O%MK7aaurKNjwP>R!f+>*ra3(0$`@&YOn6ZiP;Dy_iFlHkbOw+B1r@NUKk*~w> zUrC=RuR^PhE^~;gv!id9@O}UO;ApQ-WMTG-*pFC3!)1IB54}VmRr-rD@!^BVzb-Bb z8r7Wd%Zh1JLkXSH1%uM!CBoABbn$oUr;TQG`-9uJ2MeIT+76u#R+rD!U>5wq zKYNORhN~iBEn1)2KKUfP@6cj_mZzB3jWH~=jOJVrx zrqZ1jZh!E8n%l^HjV}^2vS2ixWd{^E-m>eMc199hiO0DJ3YWZ19-V4zqI(W zN^aEaK13wM?wFYv3~yA*ukAKuTWlx9+s56|9Y{l#%}YXaRf2=~9kds<`7&He^nhNe zUj)UuQJE;E5?1rMsp?j5ZO`{2laX0Dx)|_D62Jr6?^#HotO2#DngZHe$-&RKsH2db=l)OwSF4G9vuU>*vnCLv0x@ zo*f(y%?B!PT9NsZdz52~#1Ru2NT410L;_kQtOPlx(s{D0T!)ZBq3yEw14f2KBe1*- zURgQNkYUdJeeowG;j6D97jUcC1-Xau9tp=747wG)*Vmz-g6@RCq56@GwX21)FtXDT zHs2oi34ihro1W8sX`*k5BcZ{OG^@1H%n4F7P=0KbXxl?vnc*r9w8bT(od}te_*T~& zH85q&Vl|``K%zC9i^rayUL0;$UuV@6b#KzQ7+;{4syoylYTPl`W@h;Eg22h0=a3Agkpz$o%AK9r@C&LS=useCF_yWF8V>{mly7=* z@EIrbs+g!{9+~!$cO7VkbJg=tWD2}gP79k$jX&6{coRP&UInU#l4iB8^fG{i^?mBv z{Fx6z=LB1PFS&>OoBU2>v*|r+w3uST44t^Ng1B@6l^>E~rO#3|k8Ou?XB<}kQ|doj ztwW~r3-2Ye`D^oUOJK1bH)^o<3P_nQi1-a(hSq{5$DM_{IlABp>MkLme;Mefz{ zfkBY3RXC^^`DruFP}D(Xs*Ge{Sr@eu62;3!u}t&9pj?>P_Qg0DD=&4N-DZi;$Z_mT zOvS9O===qy+cbFRmvmWi8hmnW|G&*bEhOR?DKT*Aug;IoFpR+tpAeMgT%G)^hHi>t z1#pV7riBvIBM__kywFnpO|TT1lYW6)x22jEXW<+@WmF95()Vf5Sh|F#2z{P9SA{-zviVFSi~j*LfT37OZ{2YCn(26XKkh)_0ctP*+aQjrQFze8CE41Ps0b9b%d9{O?;8|T3p2AiQWX}5@U->tp=z0QqdKZL?L19{ zQ^EjZ5N9eo`e+K&c9UwM07jr_HN{dlD_$ajvHDI8!jLDWzmeAQy~Q%gkos;>TbsCX z+Q`!3xs4=W#1(Wq7q(_@a}}4Qa}i{b#?y#ayaYN}i(qLC>ccSAMJ+loxF%4I%pFWn zRlF^Aief0cQT?o?P0Z5YBn!MmXIU3awgC2Dkj~c4bI@Z+JWB!=OUYf>B3eo;HeZ#> zTq(wHRTwLhblA4q;)!)^Kp=Cu9HQDzIjb?#mjz%v4@iQG{mUjk6Ki9e4c`&3 z7S$POzPYMaS%ErwRrD#XAL`Mh$Cf5uL8RpC2^?>*#stAyJ4f0(c!A=ggOeI$QC^Z@ zw2$W7z(IMMdWuE{L5tJV5jt^av3Op^`xWyr8ibRYM98{G zk2tG|3-Htl1e>V~RY=Fns?Nenyba5JU}a$qcDcu8^f)Eg9iNaakhIC$SFIheh^op{ZY?DT8F{T9wg8huUQv`L?IgS<9} zLxI9D1I5t^yyLRuFm`46vQhlJ7hgg`8njcCFFB$DRIVDHx$Sbz*T_Yowe^CQ)cfLa z_Nr9i2rdiut!`&Fejh(iPFRy`q38As`hBd#loF|o3_#eWQe%54QjF!IJS2X5cuSI_ z_(=kPm+rTr9Thi63Nbe6#+m3F_UIH30t9RqLSt?&WxbW?_ zbN4>UiK|->N-6dxXF{R$6kblE{ANK(Oz~DhYMloIMv+!{5@cg?aA||WrA8X?!89@+m8nDw4_Dh_j%-&PM zs^pd%%AW3K&_~|ua;he+LjEZsmAoEWZuh$*uk&LpNW&QOBk3JAowQU1$sfQ0{j%BoOl@UnQ_pm`<#M6&^OW^Gli>wr za7y~@Y$}p1q*aZ-m+vLwl@DQjdP({Yc@aPCaJD>^uZgFoQ$zFe%d@N~ZLU@smBb1A zNMby0S4&jR(c~J&FZIT32`nf#wPshz^go5V~A^KYkW@wN zzOcgF&}@>Z^&SXP%`eTNKdwd<{8*U~ifWq2hSQ4d5i3wm>wt0sX#{cB@M@q7A;=7S zvh3#0yTSVjsNTLUN3A`C3Y?!G?tM4zj4i1=LGHIBy{F+6YMmVwg!bA+*0C_t%=^Xe z;koL5HEqUx5V69WZBEhZ|J36UO}LK0(y9FDgiLQyUFo}=d4f8sqN>n|j_AlWfgQ2F z^jwI|hV~pe`z1R=5P+TethZt(G28q6F#76-pS+l-;s@StILKYWEY4y()q$~SgmO+@ zzBe6VVOQq2F?D(r~)T0O(z9Lc&6*+10mwjFTz)y z$3^W$w7=Nn%nD$TXP#1-L~30wx)Eh@S}gP@tGB*?*t9&VA?1|25>RUu0r6fC!7a=r zX(7B;Q<7FG>aMwL(D(gL3!R5TC0RpD)t}`^jUuDV=T&GoYd(Ga;5Xy7mF0Wm)vq3} ztTE+s`pd7@9&D_{w^1W|SD3cDRAL((c(C&5$;NNHU@vK14iBpLH3Vrx!d9^e#(I^) z0Ah?5HE4tx=+8BkoF!bhlN5?c&otdg?2jHfY~yxj>QEAw9CmPI1(CiadzY?c z1ZSM~D7zVlzGXQ?o%DsbN?rx`vlr@Yo$S#ANG-kbfp7@kD}kvBe#65KLv2s6BR+aEtpwR;?AT0pTlpV$p57#(rhj<(f{;u1gZQe_ zrTeyVc!zA93=l&;62r5`y7qTX>`WU8`Ddp?ydfoBcc8`jn>Ov`&Zned1 zQF9mPqkoY%D{E`xr;nGvgj|+2bqZQ4x9;4Ua?zaxio`1Hz*J=&X5Cb&IQ}^devnw$ zo0X+$!HP>JW!RCUlu9#2rhkO+{N&GRHWUMrlcQ^pDvY#fL4*O}!QNEa{HM2X+I)jn z&4PKgwYzL0SOQZ{Y>9AM<~<{%(}qSUbK$z$fp#FioA+hUUohbl7i%>aIFF;GTi00P zMWKUmvrDtfzf6I9=5=qA1a$IT>4e#oJGNcye{_KeVHPvO=}m5s*eMf&wF;@jR^UQW zcM**wRN~S}*|u}EdnyyxW<#+S;WZ^k46yX-fpg}mO=FXXGOW5+s!z_~C* zGlG&C!(VP{dS^m6B56}-E)2ol+Y9p>tew`EKBDqLKTCMh=Hf5`Q-=5H;OWNw(T5p! zu~!*|>a*2H#!~)>mWQ&9sgKTgiXn!`ad0wSFLKdfsvN$+<-sBCZ?VrJCh|hL_ z&p#YTUeAJR>D4<+WuW9k;2+KmB$)yY23^hqf^9u7W z&oVocr(c~NQRAu+N3;=LTwFwDk7kg z(sA|F^|Jl|A4Z;%{AV)J^jq5b?qAT@x|=KnqSx&(jghp>)HEinL_x4sdn=?zXT%4T zV9tvZ;YSB5_0hJ&J)p!-ad0alQiWYBB6h2D$iD72h0F6`3oBO>1~q#pO`mPZ>I|RO+|y z{PI1@4tUw!)yMZAeExK8<=*(wgY`$t8+X6JE=5-bb?`ZwB^;g8k$8P&?aP(5@#E!3 zE7Min5tT3mLO}~Y8eU9Ad7iF46q_fd7Z(a-c7OwrRJ>%f!7HT1W+tXqQq=2o2~UHj zGAy}ukp7e%(U?UIKy&z`ICvys#0BV8F=$hFjEg~~3@^_2PVE+U)|B(fCm`dvJ3r-| zQX#buH?JcRJY~(0?g=p$h-8VXg~lN@&Omy&HDcgC#A8S&y0*~XVnw_YZ4$#rAl1IK zY6|nR6pMR%fqlcpA7P{&Q1YSv!GrHbJk5Tg#d>$`pX)u|8X!$B^5F>?*F|4p_u>_k*k|d9igkCiZ?nY!47*m~ zbO7dJ+Kga9XabQ_-#LVqJyev-g}MM67FTR}d%yU4(f;slV7s*xgZq$y)6Hps%e;Apnf3vZ2#_i&!hI# z8aswKbie7=uQ<{BVfUgjz721ZU#DCgT0zNC%d3>2Sr4c`DdFe=mvM?S9~i4POzb#4 zs>#w(n<7jjqVnxP`ofBWJrN9W+`|bY@N2~uc*07Dr^4Fca&GDN8MBVF+fyh+7Ne2kfG=9f0^rSri=$Yv{stmL1wm{WyU5lT-ijS#GEOWqS-}5K4ce zI5g|r5JePXUHK0*3gnd1O(zrDZ27u@CPmV>sq2;IZBS91iAFbc=@|tb3GYD2r$zb2`bySq7z>ZEf3WVuJjBk@F%hb@=xkvO#z%(IP2@F(ll=(PO|)6K^)Lzk~K{UzCX=1dE`7bS3~R z7J?n?RD(vBpnYm?Z0mgqkXayIv)oDrO5BQHq~U2W{Md+62Fk+!B)WbC~-KSGH#xe^Vnudh`dH9oxk@+f?z z(7@&i6HQMvNFXn~;P{H{_sD}6j$6K&3*imfJ(K`CF77lX_ zUy44H!Kq_`GzJfL=}l;5$6E`t$9P9#k&>l!_*1nVIC$)r6DABnw-+1o9 z=J(=Y*QRu?^+B=1zUjmX9)uF{@LIW$fUE00%UG)R@nOHTO2;SDEj zh9~gQUh8_aote*YTIrnk$QUan2y#%IO0|VW3H?Vw-ilge1OhsbQ6GSS{_8e%eEnJq zu1-)w@=74(N#CeWn5rf)P~gLYm9O7N?cYm%{{gA1rpWl8IYLwO+vyD$)&!@L$h zg!(0@wMtgib$bnTqoQ0n6$np(3#&?A9OHJNQ;DhI%cBLJpPBRZCEb&SaMCEM00r1UdwtYmo6Gw$o`V=;9SrGXBbLxe zTF5k`y9Gd%>XIxC8A#MkM-DkYnc5EB1g+{;(!#wPUK3g8n&V+=?_{JSLo8QCwXj@L z`fg!*g`I_uA^F!P(~YGg@vP7?I06SD?S_1nIdB?p?(g9~<@Tbh&J6Ksq$$(Yb+r5P zYPN_3^}!}JYKOO>5k+CTTxD^5IlatPhS+C#g_KX_v~+rtOH9W0l;eY19Y1-h2a$F| zb>7xrV%L6?tUOvBv5D3W(bC@D8be-ScsuE~OCPLk+6)`WTNz|rfjH0zfs_YDl51WK_$)IO$jbr^HNVrnW7OSh8)t&yT-0w}tOfuC3y#fa1+4Ut^x1*9>N17Dr|2v9 zR_%J$JOp_wM`G1td6J4b=6L}f`bcKL$q_rlUZ+pxt-%BvKu^ZFr|0{dnvHvOe5MIH zXGbsKaHlw9zjXDd_$+F;fW@KEy93~QA=(S!7tZe!npSd$fx`7tM$0tWdX(N6?vn%4 zT>wqf{V^mSGjPKf2U*Y0IqMPgUMtkuU4e;c_3dS%>K@$FvVvo{$lb2oS|k$ml2k#X_8CP`F3H?WjV zq`xEyN2>fytA)7pBH17j*L)|!saAmiK5P)sBXLkNP%VYLj&Q`K`3kKE8xaBlr~uRu zTIfYvGe)l+sg3Skd%czhzuw!&eH1}fZs_P+&jJ(1EE{2%4K|H1w)q^DLZ_SToi1B3 zi&2m{$qN{asVk1(%C=Wtv?7h1+(E9lH~hMo64=^Tz6S*lgg;9#BN z$?(wYR*gPd2@FuNgYFFm3U=^H*lCx&w+<&;dVK*YGz1?lh zk_=Bfb5=?8gYW`Y+)rtbD_l+SDE{@^ODQ_#}SSaD~}OHC@p1KE9J_CK)|$8nDa8^3)*5Xx&8D=t|CBi4?# zH@DO<2(!bmzPtM95#npSytcOd+v>Z`D+SgXprfi@&{G;LG$9IizOPVF`bZDXk-lqk z5M=5F8dnDo)&oW3`WA|c7UMw&g9$EsUpVfxXPz16`HEjpl$IYJUpx6KdT9%>S0qEh zH8yJcV!d2W-cJS#48>bGLbO#QR3coGX2N%07o1wqtr3|0{N(7>%?c3tspFhz{YL2U z#S238c^GM1qd3*p(T<9e9Bc{AcQkmsvcAEcB(S||Zf(hX$bPe_UgHv^=sGlOv3s-k zosVGzv4vv5Co)IVcHU^gUC|wQST*Mht8(%}bS8>}5=E%fG6+L5y!_mBtcykh{Nxj| z*H)PVL%xw3LJAHT6;z$5k}e-@aKck0Va0OQajCe3TJ97(&%y^gwA@j+)Tt7YXy&mb zIU!ir!@(we+_Q9qg@({>4c>nLgP+w{`j?ygH17giA?eEe;6PICIwJVkmi35x)u%s7Pm%Du1oy16SVl#jimzI`nE>~}$YH6s22x}<=cT?qnAxgxq zQkKB@R-9#duuFujEc33JhH%@#H1%mmL6>1xPEl`9a-%hLN;JmTHtrs?5cz2ECA}4C z@WT#lJ8FLgjcm8n1C`XUPyx*EBT>804+RJmMLR4F6anG@@?0ERG%PHmDZe+V3({||>uMAGE*tv&#(p~={rcDf$1 zu7HIbWa(fB!UAX%khIq&iRdC(;RMbl_9b6F~~|zC-&3mQai} z{(*Eguv4jE>Rwjtul{*n$5NqFBYJN5h;WwmX?R8go#dRPO5 zCuShI zB#CQ+%uDT}zWS7b)o=Qbw!Yr_MxACOz&c&HoZi~Bq-5{hATBxpo;s~kO*Ce^|9JJu z%HzAM_f|5=K~@>cLD*&GM-EsZxPv^ehQ+W*n zKg%L<3v!y0u$0&@p15*(0+esa5?0rvtNBf2p2W_{zdqQXeB8Pt{M^Ikr}YxY_a3bE zn#6LeTGkgkdnu7K7zoqt6zag8ICbY*s+?J2L?b5NL=uOuve3*tf+-Bw38Ac~F2Uv23-V6j#5+elye?GQ z$7}X2iBMW>#%}wm_iK`=M z&O&&RuxYIsr#~YTPiy9b2B=iwE?q*nqvbrFdW2cyMVY^ra_1QVa4EU3S=RG+(#xm7Uj`z=don%k6%?e!M{gv;`M%8G^} z#Yk#e5W3YF?UOO=rGJaHOY(85$}1dYkG|0`AKiySw4|h<_G)5+d^*Cki36iH2nYZ2 zpZ)V*n!Ubv@`JxA6%%%1AMO%)Mn}+39``c!I{<2vz2>oV1&+XIqjrQ^)lfXUKWhw$~l8exJ&YMrl+C83Im!0WW+1* z8rVxe5|0!HrfLy}l0GcH-^!mt!HEQ%Q&LR7`r^UeFSM4o5=w;v?ArpkZ2Z)K_jjM4 zSrwCt&a6MckrF4x%4u_7u{WjZX%CTW07ekuwTn4^4XRk%GlSN1h)E)3NucRGiH)(A7nhxCj0>_cKF z^)jslkROM#r|JwXpfBo? zKaB(37t_#y&QcJmocHyqLDI_B_|l&sb6RJ-ccjYmSj=Wj#2=%DtMWNQp>ak~=9fG7 zh9TX8*s1cYOI^Y6YlE$^t{tg0Xr57|ZimWHS2Fw>>x(ins_$a9#U5IZb!e1dmiD7> zn9Wmvo<5VZN=yw1LV)lozB&F&cHZ&tk)06G(|_{U%bIufox-^qWZtX+bxH z91e#nmkG(WBDp)XB2X1=-T)!=nzb~WJIyH7INxZ908l&iP_#uIs4yi)W|5bTGs6k3 zTCQl>(m+TyQ7a2EQU(4&73K02J?m0K(22i?UQFpx{H)75m7MPr34uZl8ph}gJ^4`;BRt+V*)Ilx` zwR<5xa~h-D#XZlm5l6_uudpgZDkR2ITnFwFT1UBq8xddGq#-JtEiYKfD&`^i7M1O< zbDAADR@64vz!)Zh%Jq~}vX-0|4P6V{tFU$<09k5;#}O&#urtSKc6XLCBZ(|A&^B!v zxUd@5Mf_4*(8lCV9dVYJf)R%*X&X^N8@LrIfa@ONM5zp#I^Q+mbQ+dB0YA@5urY?>@N*EB;k-JgV%s?$DP1rz2~5bA>9jx%FK==5bo?gtCQd=xntB`oGr-EY!f7DD ztt4GSqw5l%rT8q;BD^sYyh;(oQUD+;Ic*}(8;GS1Eb~9ER#KklfbK}>GWqeB%WIR7 zU<`GClh=anrBIYG zv?O*%x-xmj=xS;>h^vVng}l0S(~d4=wM6av;9k^3iGxL%<3}u>IM(NT{(7OUA5|7K<|SuQ{+Lv#RkB(nsE-QPhYi0L)jCVwk(x z+sD+Ej9Vv&N3C_>=Fp6r$I(1dE-N#{@(5lI^+FWl@WY@|4^WNBam*|UK4)RB(z%i8 zX|SL*dqR#pm=Aw`+q1#Q4aKxOA(Rvke~bGD`{+JwHkf&No7fQju{W_-OX))D3E>wC zPlJcGZXb!^>)2RNs_dhb3@K1nvLDgoKT|bK0dGVJdD>}jZMuP#p;iiak7)w11aTp; znV)I#s_&Mki}!No>3m7~Ue9MUriJdE;;fzGa8t}#Y;d9M;j-_`s``H2Ig9S)l=L^B zVTNA{L9u3s4yk!-q>C-DZHzZo$M+vRez5+<_^C=xSN;%y@Jp&%i)zvyseIlkLG^sn zV0you(PO667ajSM9|gA$#kKjBs-MR$NNMA4<19XrN_8V)(c4Iz%U@J~1`R zKkKcku1Qzl+%ytTpCZLcr4ic6R;6G#OfkP9#jKi$xCFXLXUWTq@v?M|aamZ`;G(2L zS%|3cZHjYjYEUxR4TL#@gD811FpUnDCbr&{GDjvqz0LZ**N zIz1~*#kAm!zM47(E_zHRnB8UmYyCf$3DEA<2G6)PvVVI$%R)I|Pa^z~atu5P=G@Myu5#EHm znr+ zhQc_zs}A)60!9bYRltF>Z?9-hAwuel)FeWcZ&ixIgT(~2$_)H2VQSY%+^Q^@>rJ!0 z8&$e?@aai?v&AWsduC@hP4VzAUwpJWxRh-h5u2+2-sN-l0DJT%9DF*sBLeWc#jIdu z4>!31t9BXML9YRWTsy&SaZE&G3^C{ke?{j$h!5w2}@+!##trx4kj%8AQF-dQ;nI z-cnO^l-2OywVUw9j8C1wi3ZNCrm1{7m!_zpQeV^02FcsWffQ2mBujHbhWcVtM|9p3 z4hIA5Ba1SjXvQdISEXFkr-~g|UREipiWgi^$wW)z#%UsvONf<{5oGY9wgxHyO55u? zWG*y;&bX`sM8X!z7AF-LjPBZb_jE|Pkf#)-=0}7Ti<)+wwp1Vd6KP3Fcr&8-G9{s? z;Uby8?@9m~V6Zf>j_Q8LJ_wUIw(5pR@V^5!EdV9EZc3?ArA+zQ{8QYtF9QPE?Gr93 ze8rz$Y9N@_huwdYiU{jev6@N=E&D<%`P0hPaz$6k&FN=)QIl|xgdKIIQqgnpt4a|a ztEYjauPr*cERx&uszfrQ@40mr>GMS=10o~k>s3F@*16U5PQI3N%fH|Sjj)tT9bl;G`XwQ@c}f?2Ey}-< z3yf?vVySxqLasjd(4rXG$6A|l-!XvraO0kh{q(|PAw zyVX%w+-C(c^W10p?#*FA@{Gc=vN1W7gzsM~`a9_|`~Uo}NdQ6Qji|+)MA`%fu<8!1 zeLAr=eSt;1%sot)?%E})Adp*kb|8VF;&web$DTq{o#aK&^@>i2l%Yz)z11nrS^QqD zngd+};oZT?y$2hsYk+@FV=>*9_Q;VG&X_Wqti4mv^gNtgs);r+OZ-<Q!cathy|Q!kVb42@rR8iqY0$jvU@-?^DI&lUi5N!DG$zDo#DQI0Y%6zK(NWnYRb-#3nJnhN(L!J zX3!MZb>Yqv&`=#Xgz;c{7Z16LwO^i;)&g+)RhK5EZD@q0p|shQIFNe+cS@iXw2iI+ zuC6ncWx6L1%8=M}BA=B1+HXk&C;ay5{F7?Q`;DSkr9y8-(%GITiXa7^EN^__BvJK! zplLv)>nf$y9sphekWIZdxU*f0S`oRYG4zBKaAY6KBGe=gq%J!Xf8nlvVK^c4LuUgM zuBRq15_UzPlfm!kNfnF(hIvri*Jarfj(YkCM>=s71nABI2>d}DCfR`bQMfTF0%dR# z+jO~kN^q(uBOj%hPI1RMjVCEQZ`ZACO2ZDhGVPU=AzMAVjdZFHL?W(YjJBwoMyxAN;s}yy_b?@>$39Ynl!KP>slu{^C zBsQb0y1=~Ga>Sk-)~ziHm!?rGo06m$z>HR*66n~$hLb@tUZlPwIh1Lk7&_@a3s}rS z1)om};P&$ulK90iYIR$O-wP0QZk}yGWHh)x--F1L@);$TvC~szp$u$Ktt5d7C%SRw zR2$J3WdodbCf*&YiJ$KrR;LTc;p74&m+Ydf>3ocPR|ebaqXtabc-%Zq2wMh}<)yE$EDVF4fqp)KLGesLyTteKG1(oy0@ci_L@8kukqKw zM5sc2PwCj0Uo?R&A*r1=yRtBE@N5lSfeCpIJ`K*WzQ2dAy*XQL&MFR6s#Cxd6KK53 z`?2vfcotPDXGaJ$_-{$rn~YVyTC?x6YHb`Yd(hflK2*O(mJ{PQ#FH|4-m+awZK~U^ z-D#6&n~PhE!|mbMlTt2F+X(g3?vjm+Pjw>3LW!r$0V-MS@Em)d@FX2|E~uG9cEA>0 z&U`JRw5Lk&j&NRu#}wdWGtc�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

7r{iZ!m@` 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

@oG19p5akym> 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 KQTD?>D$0LKJ>z5m literal 0 HcmV?d00001 diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json new file mode 100644 index 0000000..0f5e238 --- /dev/null +++ b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.json @@ -0,0 +1 @@ +{"asset":{"name":"godot-ai-v4-plugin.zip","sha256":"535d8a7541871af8d991b07fe5031550dd6121a31b844400476b334a70612831","size":1971643},"channel":"stable","inventory":[{"path":"addons/godot_ai/LICENSE","sha256":"f2afe4af4ec80c453ca34704bc61d5d24a9bd948d365ca22699cabb8b4a18308","size":1078},{"path":"addons/godot_ai/README.md","sha256":"73ade921edaa0d7fe41278d07d40caaa8ba1d6358368c1954784dd1b119391a2","size":1742},{"path":"addons/godot_ai/client_configurator.gd","sha256":"fd283d5c66d097fd970374c249d75e680540b7704901b2c9d7305abb8efe416e","size":93044},{"path":"addons/godot_ai/client_configurator.gd.uid","sha256":"b11391096156780923df61e627d9241a691efd13330aaa0d69c44de3e91f9ccd","size":19},{"path":"addons/godot_ai/clients/_atomic_write.gd","sha256":"23042ee1ef57f72c102cadbfefdaf6cc2adf1d875a9f7e2f3c499dcbf74f91a8","size":12381},{"path":"addons/godot_ai/clients/_atomic_write.gd.uid","sha256":"69d170b100e1477dc969d937664bf32e9f1a41448fbddae997616e4d549f857b","size":19},{"path":"addons/godot_ai/clients/_base.gd","sha256":"1fbe00b374efebe4cd2b7c02418608102e2dc4b2bd25406471fc681a4a80e367","size":29069},{"path":"addons/godot_ai/clients/_base.gd.uid","sha256":"6ad5fd0be314b164e5a501aaf4afff8c17d3c406e4931b3d490fa55ed9399d75","size":20},{"path":"addons/godot_ai/clients/_cli_exec.gd","sha256":"1f49fcf5c2ed63e575858a13066745f5489ad096f7691261dd768da49e4ca6a5","size":8426},{"path":"addons/godot_ai/clients/_cli_exec.gd.uid","sha256":"0a011c6ccf204f647bb410e812452397b8d9d3f82e98e9c8c43255e8a8db43be","size":20},{"path":"addons/godot_ai/clients/_cli_finder.gd","sha256":"66ec387838fe5c574188ff6d57655ca30c32e992e445e7e1ab2fc957d1b3190c","size":6820},{"path":"addons/godot_ai/clients/_cli_finder.gd.uid","sha256":"844ed9a1cb168c6fa4a2ad7369a0b9e2b317a8845780fa551f0ddeb31035d4c7","size":20},{"path":"addons/godot_ai/clients/_cli_strategy.gd","sha256":"be3772eaa3c9bfbdd269310d58cf286a45008bf60e4d76493c651635466fc73c","size":20313},{"path":"addons/godot_ai/clients/_cli_strategy.gd.uid","sha256":"7ea94aa0a18f18001abe6c039d3f9101a20211dafe616bde1171de42e8878188","size":20},{"path":"addons/godot_ai/clients/_dsh_strategy.gd","sha256":"883adc4181073e1e7d7adacda749f55de0ef8b7f53de518bdecb23a8fa25b067","size":31296},{"path":"addons/godot_ai/clients/_dsh_strategy.gd.uid","sha256":"b603679cc9d3071b0029da2200a85ecbe367bc9f03fb1e352eceb544ebe1f4a1","size":20},{"path":"addons/godot_ai/clients/_json_strategy.gd","sha256":"6781f728f3b8f8498abbdfe694c6a49c50424ba9e7e278d7739919c60e298e30","size":48846},{"path":"addons/godot_ai/clients/_json_strategy.gd.uid","sha256":"0d7613d1a25a2637491759c219cdad8610156f840258238df5728eb18523a49d","size":19},{"path":"addons/godot_ai/clients/_manual_command.gd","sha256":"d77adc6b6f66f700ea1ae26adb5ede8b0f3e70620e071d8a4b15e79b24e64358","size":15256},{"path":"addons/godot_ai/clients/_manual_command.gd.uid","sha256":"323e682575c1bf3b1841ec517b59888bf304b3ada97dfd861ec317b8f714635a","size":20},{"path":"addons/godot_ai/clients/_path_template.gd","sha256":"bc3f01879b1469dc8a72e3025f698441e60f52207282c9fa978156c94fcf98bc","size":9696},{"path":"addons/godot_ai/clients/_path_template.gd.uid","sha256":"b110326fea223f45902def5a53b35522f269ad55848718b7ddd143cd63293b86","size":19},{"path":"addons/godot_ai/clients/_registry.gd","sha256":"0b7f45367f8b208bbee2c521b4513aee3d716c02cad2544f45743d5f815e5442","size":5956},{"path":"addons/godot_ai/clients/_registry.gd.uid","sha256":"0fb18ab129e0fba2eb85e7e5b784ce566c586b1711f76f8ab429610172a203ab","size":19},{"path":"addons/godot_ai/clients/_toml_strategy.gd","sha256":"f1f8481dcd568ef408b9de29a23792921a51a81ab70865463fbb77fcf1efe912","size":25296},{"path":"addons/godot_ai/clients/_toml_strategy.gd.uid","sha256":"3f581fce945ceabf62ce7397f0da3c9a64888f7d071fe59edeec9126735c9837","size":20},{"path":"addons/godot_ai/clients/_yaml_strategy.gd","sha256":"9ca3a596c973603192cb05ebc107d06e54552eeaef61ea3c252bd98c8ec05963","size":22431},{"path":"addons/godot_ai/clients/_yaml_strategy.gd.uid","sha256":"bee0844c9cd2c399c1105d4f741e804ac433a0dd89c338a63da9d00b1b983885","size":20},{"path":"addons/godot_ai/clients/antigravity.gd","sha256":"af2cb95aa3b1e132ac90dbd3eac2d5b993a0c8642368c245644b59b3afb63fbb","size":1815},{"path":"addons/godot_ai/clients/antigravity.gd.uid","sha256":"d247b7f9da8ed483178c3f12e207494060181af364ef829951a6de2dec8fb482","size":20},{"path":"addons/godot_ai/clients/claude_code.gd","sha256":"2660ee8d70f11d4d3d55b12a29551fcdbb749e3002c9eb4bee1894d4853bb362","size":5009},{"path":"addons/godot_ai/clients/claude_code.gd.uid","sha256":"18d1ebc89fe7cd71054545da54c04477465e69b8291d082f8c860abc77dd2c41","size":20},{"path":"addons/godot_ai/clients/claude_desktop.gd","sha256":"25cd87161a8985273b5ad3954b397a95af64ab205ecd8359c63da09a0efbc020","size":1634},{"path":"addons/godot_ai/clients/claude_desktop.gd.uid","sha256":"e430bb213b5005f3182aca544fdade6c8a7e89128705907c11228f699eb27fc0","size":20},{"path":"addons/godot_ai/clients/cline.gd","sha256":"f48504c220090cdb44914241e9ac760246e7e931678c9f4c9f119b651299f85c","size":2159},{"path":"addons/godot_ai/clients/cline.gd.uid","sha256":"c8d58f6b86221ef68289a090b836d31f4006d395c3f42ed3000fc027a020c06d","size":20},{"path":"addons/godot_ai/clients/codebuddy.gd","sha256":"7eca77746e458647de6c02befb3cfabc171e988af0dbe3cdfe12e6c055fa9a68","size":907},{"path":"addons/godot_ai/clients/codebuddy.gd.uid","sha256":"e3b12124e5b591f480aff9bc890bcc1169cd6f83907c1d7dbabd95e9f5964eec","size":20},{"path":"addons/godot_ai/clients/codex.gd","sha256":"1840261a6e6eafdde63a26e3a8d85334fde916f21079192e9b3957231232a103","size":1493},{"path":"addons/godot_ai/clients/codex.gd.uid","sha256":"4bdcaae2f5f0c2e850430a7cc275d64bb84b880df8076a1cd9710dfb9fdc17e2","size":19},{"path":"addons/godot_ai/clients/cursor.gd","sha256":"197be68bfa76b0ca142fdde802f14d706b26a85d78aa96f0476dafc1d44a5353","size":821},{"path":"addons/godot_ai/clients/cursor.gd.uid","sha256":"3625853ad9bb909b2668f94f22a592231612d216c384a9a19e59f295eece2314","size":20},{"path":"addons/godot_ai/clients/deepseek_harness.gd","sha256":"b45feb086a36a19f925bd8de8a619377b72df49ac911609cfd3cb8e914112f3e","size":2072},{"path":"addons/godot_ai/clients/deepseek_harness.gd.uid","sha256":"e9f7b2f0544a8c03f4e153af30e21a8c4e4a138814ea16e005416fdc127bc466","size":20},{"path":"addons/godot_ai/clients/gemini_cli.gd","sha256":"647e4627e3977de234aed98f762e7d9a624ed518ebfe189ffd6b4e14bb313d47","size":874},{"path":"addons/godot_ai/clients/gemini_cli.gd.uid","sha256":"7af07715d59b73f3e69173d39364eb4103f5d84eeee312e13ed04fae4c5279d6","size":20},{"path":"addons/godot_ai/clients/grok.gd","sha256":"b2ae578424587437e14a56c801ef2551e7652ffb01808e6b8285e4b2557d6502","size":1612},{"path":"addons/godot_ai/clients/grok.gd.uid","sha256":"e911f4916e044eea03744e3a3e9920d53d18db876a515e0f9415be6f76d3b89b","size":20},{"path":"addons/godot_ai/clients/hermes.gd","sha256":"28759488fa59ead7c094c59e31f8cf2080f644e17b806929d94a1f8645870782","size":1956},{"path":"addons/godot_ai/clients/hermes.gd.uid","sha256":"331b1b1d2699ddc02ed293d674dae6b2d5b69ed5e29a94ce7b6662de6ec24a1e","size":19},{"path":"addons/godot_ai/clients/kilo_code.gd","sha256":"28cca26f36ae48c0382efde216f7946fcc731cb5ef5a87242580df75b346d0a3","size":1937},{"path":"addons/godot_ai/clients/kilo_code.gd.uid","sha256":"73705b20213a8cdabde5c3207cbfd750acba3ecfdb12e5fc0ad2d32c861fdeee","size":20},{"path":"addons/godot_ai/clients/kimi_code.gd","sha256":"233c5719b1532a7d3d0ad533ca9584dc4eb0d21d3308e9d183ee64a789373e9a","size":1557},{"path":"addons/godot_ai/clients/kimi_code.gd.uid","sha256":"caeefab43e4177a581a0a527194db6580c308f640ff3755526b95602c7d284e1","size":20},{"path":"addons/godot_ai/clients/kiro.gd","sha256":"73a1e60d961f81508f6ab5263770978af3a9a0cb29adf5cf30267674c86ccbb3","size":829},{"path":"addons/godot_ai/clients/kiro.gd.uid","sha256":"e36281df5685847acc757533d5ab4beb44391db897e123ebb0b377ce554e0ce5","size":20},{"path":"addons/godot_ai/clients/opencode.gd","sha256":"fe0cdab3b2541f330367b97c718e3331be323a5d31d056e6502f399ce8711a5e","size":2475},{"path":"addons/godot_ai/clients/opencode.gd.uid","sha256":"cebbf0729ea920db3266906f32279189520176bd10225ebbeec69882d97b9c09","size":19},{"path":"addons/godot_ai/clients/pi.gd","sha256":"0afa45f304d7da41a624aafcb6d684c76fe6a16f3f54c6d5b573e93963be4471","size":2731},{"path":"addons/godot_ai/clients/pi.gd.uid","sha256":"6ca88777d2a2a46d7ae8cb27c96650c24821c2db31efdaf14ec1c672770b13a7","size":19},{"path":"addons/godot_ai/clients/qwen_code.gd","sha256":"af8c74797bf3b0617d0639052a86167cabff123fc110487a2f9a53033be5cef1","size":884},{"path":"addons/godot_ai/clients/qwen_code.gd.uid","sha256":"124a3efc1063037a79d1ef713b066808dff87f84b062779108e3df0dfe78c70a","size":19},{"path":"addons/godot_ai/clients/roo_code.gd","sha256":"7dbdbfa610abfea313d53497b35b3d9f482a6a8d67e37486d2d8e01c9fa48935","size":2149},{"path":"addons/godot_ai/clients/roo_code.gd.uid","sha256":"60128a673c0122f6cdc2562d2ad517805c4120b98ae24ebce02c5471a5a2b16b","size":20},{"path":"addons/godot_ai/clients/trae.gd","sha256":"3fabf591a0e7507b509116ec0539f7175cbed74346c82c3f56a2fab81d122746","size":873},{"path":"addons/godot_ai/clients/trae.gd.uid","sha256":"69409fe3a502dd271d2abddbd97ac31187034b3f8aa409226116e2f1bc7199b8","size":20},{"path":"addons/godot_ai/clients/vscode.gd","sha256":"e44f4c08cf67326ae312c83fa3bb1179840e5bb178352e50e6a432590e0d969a","size":1219},{"path":"addons/godot_ai/clients/vscode.gd.uid","sha256":"738e01d95a248402eeca40d764030a4a4063a246904475b49145c6c159d55ddf","size":20},{"path":"addons/godot_ai/clients/vscode_insiders.gd","sha256":"1b9da0fce3d453dd108b28b1e0b645feb12be4e56401f9d5b58aed95f3396a92","size":851},{"path":"addons/godot_ai/clients/vscode_insiders.gd.uid","sha256":"4fd8b6a9a5e330f7428dac1adb0fd0e5406343febd8bafad0a7fe3e5d214cf88","size":20},{"path":"addons/godot_ai/clients/windsurf.gd","sha256":"9a1a3ba1be1d27dd24684923a483fe8706a65343bf08e47d7625ff6ef2c0f453","size":1242},{"path":"addons/godot_ai/clients/windsurf.gd.uid","sha256":"6b348d4a775be6af159c77bbc7ff5df86a921928f76a1920df97060c5f3c5901","size":20},{"path":"addons/godot_ai/clients/zed.gd","sha256":"e087ab94dea5a41dd1046f83d120583b9258935668e81091ffa12161c4d7cb1b","size":1646},{"path":"addons/godot_ai/clients/zed.gd.uid","sha256":"737f5044ffdf88b5d9f00a52e69108378b4bf4dd404fe240b8865a1e13bd45cf","size":20},{"path":"addons/godot_ai/clients/zoo_code.gd","sha256":"260b57e7e7c7e32854f7e3690adb1e01164f855f8a828948b8f78fd3f9144637","size":1742},{"path":"addons/godot_ai/clients/zoo_code.gd.uid","sha256":"9000f73477a13428779c8e1db0631291275fde02913148ec0094ced66bc0080b","size":20},{"path":"addons/godot_ai/connection.gd","sha256":"802071f7d38251101d448c421ade7b09fb28c2f3438344455f2965dede857910","size":48377},{"path":"addons/godot_ai/connection.gd.uid","sha256":"5bd784acd459f5d89ce2290026030f9bac4586f2ab9fa2ce6f680acbba6cd7ec","size":20},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd","sha256":"5a103ae1c5c26f01028b35e85b1dfdb264a0d1cf04ad9588210cd2dc11ebbe30","size":4070},{"path":"addons/godot_ai/custom_tools/custom_tool_wrapper.gd.uid","sha256":"ce5f3ecfd8794eac6ae799df6b73ca6f9ee74637820e99e31eec831ff00bdf70","size":20},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd","sha256":"229fa14a9257878dd3dd7ad2628195c09ac9068680498105551540864e55cbd6","size":1498},{"path":"addons/godot_ai/custom_tools/mcp_call_context.gd.uid","sha256":"197960618a07c1962b7ae9e2d6cffdfc7e38f66c3980be5f3922e5dbf8be55a2","size":20},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd","sha256":"c9edada47c56e4aea1735b668d9212dae0420a75ffc66ca4e974962bc8351df2","size":4400},{"path":"addons/godot_ai/custom_tools/mcp_custom_tool_spec.gd.uid","sha256":"7472166e686079f1054ca3365154fa4d05db5a7fed8f137ce05d2f268b48f3b3","size":19},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd","sha256":"f329eac25097a201c3d00f342ccdb5c9e4cd5bd758bc5504726abe2a6c5b3069","size":377},{"path":"addons/godot_ai/custom_tools/mcp_service_locator.gd.uid","sha256":"4b878baf63f39983325ac5435cb6fc456090a24610f5db7dfefe1d8aa344a9bf","size":20},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd","sha256":"d9b69c3464dc09e4e1fa224f4cb8b4c928a9190d6d44bd2216c48e3026ea70dc","size":10436},{"path":"addons/godot_ai/custom_tools/mcp_tool_registry.gd.uid","sha256":"1c6f5521d3757f347c4cd4171541328008245c9423500f379c4c9bd9ed0cf0fd","size":20},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd","sha256":"13766ef2d32d90bdb86df03010e507dff6251959b115d0840ead5695e9294b7b","size":86484},{"path":"addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid","sha256":"be3050234c07a27e62be35eb521fb48d37037de4beefe88cbea9e2232051d75c","size":20},{"path":"addons/godot_ai/dispatcher.gd","sha256":"6a4a802a0d7b44837d9a25b9b95199150881e1acf7e7bd5484f7e51425db921f","size":22386},{"path":"addons/godot_ai/dispatcher.gd.uid","sha256":"66d99fb65718903951361405e5f161cab7aa44636aa080c0bb4c7eef774a10c6","size":20},{"path":"addons/godot_ai/dock_panels/log_viewer.gd","sha256":"3975f54e49a4259f6cba23d031426ab4cbd90efda6ade75f0d3263d91b202a73","size":3036},{"path":"addons/godot_ai/dock_panels/log_viewer.gd.uid","sha256":"0c49d5ca8c63cc2d29614dbc7cbc1dcdc046f4dd85ddfd46cda93c3b3246426c","size":20},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd","sha256":"718b8e592abbe2e5b1118a04c452735fdb7e247904ac971003692ddcec3bc225","size":4252},{"path":"addons/godot_ai/dock_panels/port_picker_panel.gd.uid","sha256":"c4c8bfff382983add25baf1000e1214a6c6d227cb9ae4dd9c94e078513b74cd1","size":19},{"path":"addons/godot_ai/export/mcp_export_plugin.gd","sha256":"e32fd58f473c4d9c974d866ada447058e1f71b933aadcf4b1ef6c6c4ad2a470a","size":3039},{"path":"addons/godot_ai/export/mcp_export_plugin.gd.uid","sha256":"35c878e5453ee13abf3955993de021f7cb71b3c50c30a2b3dc84236959eb7e6e","size":19},{"path":"addons/godot_ai/handlers/_node_validator.gd","sha256":"f24931cfddfec7e0b311e9f53778a5ea7e75026c5a776a6ee75ef1d82050bb7d","size":2864},{"path":"addons/godot_ai/handlers/_node_validator.gd.uid","sha256":"3d293beeb180ea1e5ed05a17655a9ba61498d49ddd3ade61608cd56613cc27bb","size":20},{"path":"addons/godot_ai/handlers/_param_validators.gd","sha256":"d9104c864b375304cf54d7d8825376bae3f606c7ab0956c302b8714968036a33","size":1397},{"path":"addons/godot_ai/handlers/_param_validators.gd.uid","sha256":"f77bead393cdf61b23131c5151bd75b08f6671a2457cff7ea3c7cfe38278eb66","size":20},{"path":"addons/godot_ai/handlers/_property_errors.gd","sha256":"92ddc1dfd433a4aae7d4088a53a0cff36b0b196aaca9d0488e43b7f2614bbd97","size":2837},{"path":"addons/godot_ai/handlers/_property_errors.gd.uid","sha256":"2b49d3d85542c5e0af947ab114fbb7f71885d0310f35a026411323df007c0a2c","size":20},{"path":"addons/godot_ai/handlers/animation_handler.gd","sha256":"7914575e6f8e8ee216a17b43eecde176ab42f61343a218fe88ca29a4feff4789","size":33812},{"path":"addons/godot_ai/handlers/animation_handler.gd.uid","sha256":"47b49982ea7e09cddf957ab596d401b28b8838190a434e07c2afd02ad5381567","size":20},{"path":"addons/godot_ai/handlers/animation_presets.gd","sha256":"39cdcfb12975d194b6cd58c75d5aa4eb08d31aaa36558cb68c9e45198625f47b","size":20151},{"path":"addons/godot_ai/handlers/animation_presets.gd.uid","sha256":"8678c72d4d8d9883c7f0282a80058f0d19c41aaf82d32e17215cf3378171c1bd","size":20},{"path":"addons/godot_ai/handlers/animation_values.gd","sha256":"d6d2d066eee01c312421775e8188aecc6fb235b11329c8b4f5bed36fbac59130","size":16811},{"path":"addons/godot_ai/handlers/animation_values.gd.uid","sha256":"d251c65c53fa71891d10e420ed8ca06ac1ca308f853b6a758badad17acc90aa9","size":20},{"path":"addons/godot_ai/handlers/api_handler.gd","sha256":"9d32daff53e0b78759ff20bca3661ecccd75bef91b9e8bb7feb7928774807fb5","size":3427},{"path":"addons/godot_ai/handlers/api_handler.gd.uid","sha256":"b22ddf4e5b697aab7099ce15d47c2086624997f8e86d2e93c92a585fe23eb5b8","size":19},{"path":"addons/godot_ai/handlers/audio_handler.gd","sha256":"4a1f4832b6cf1b90de41a126d1ccf762c95b18d9f01c6e86cb1b475c4d28c3b1","size":11600},{"path":"addons/godot_ai/handlers/audio_handler.gd.uid","sha256":"64b5a9af05623f153e788733749516412509423eb57b945146b0778cd867b146","size":20},{"path":"addons/godot_ai/handlers/autoload_handler.gd","sha256":"b35b70096de141afa97035127702d6e6dad7f1432218a22dee217a2bc9cfd8b7","size":3112},{"path":"addons/godot_ai/handlers/autoload_handler.gd.uid","sha256":"aa05d871c5800e244428010f2a19dc357f0f29b8a025b8c64614357c4db31956","size":20},{"path":"addons/godot_ai/handlers/batch_handler.gd","sha256":"d951813ac00f536e2f8d14d97d4949ced152ddb94e8a305852193e3bb94f794c","size":10760},{"path":"addons/godot_ai/handlers/batch_handler.gd.uid","sha256":"e506e0940373f6e8dbf2c775e1bb690cb7c75b1d877f6e1a0d2493b2d364ce05","size":20},{"path":"addons/godot_ai/handlers/camera_handler.gd","sha256":"e17a7e037ac887f5b7b78e462ffdc78c51d6f0a11cd846cf5b274507383d99ef","size":40171},{"path":"addons/godot_ai/handlers/camera_handler.gd.uid","sha256":"bd89c9cdb3c8834e74b6a8e2fcbf233b516f1c471309f28c21f0b020b9c31792","size":20},{"path":"addons/godot_ai/handlers/camera_presets.gd","sha256":"b9a3f6814f183ea15cfba625742496fdc6fc26f0952294cf89f526fab653085d","size":2208},{"path":"addons/godot_ai/handlers/camera_presets.gd.uid","sha256":"68ef1f3fe020a144155eabe99b2c2c17feb113c095a41b3af57442dc5d36c471","size":20},{"path":"addons/godot_ai/handlers/camera_values.gd","sha256":"8a0483e799d7e27bfb513670ece5fd0c8638b288fed8ec2c92971cd06a371cbd","size":4251},{"path":"addons/godot_ai/handlers/camera_values.gd.uid","sha256":"fa6f2ea8c1bb70167d06426cb01dd29ecd47d8b3cdfe9efa034cfdc16d95427a","size":20},{"path":"addons/godot_ai/handlers/client_handler.gd","sha256":"7cd2d0873d7b1cdff84abc310ca6b9c0de69167015a437bb93c219f19bcc5a34","size":1922},{"path":"addons/godot_ai/handlers/client_handler.gd.uid","sha256":"2599d215b73e6a60b767aba1083a68da6203674c13bb131d29179e98eef029cf","size":20},{"path":"addons/godot_ai/handlers/command_handler.gd","sha256":"2cd67d1e5482c1f1cb1768aaca18b80ea4902ece50ce2b2b67e23c721ca1ac02","size":555},{"path":"addons/godot_ai/handlers/command_handler.gd.uid","sha256":"47fb104a35055fa877816068db671b7dc8afde68734e64ba62f591f54eeda078","size":19},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd","sha256":"e0d50f72bdc61ee3c6ce0dade726cc7d9668c747af33290e17d74bb92861d0ea","size":9717},{"path":"addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid","sha256":"ccb3400c75d59519bdcc4da1931831bae093ce327a9c5fd39938cb3710e37cf3","size":20},{"path":"addons/godot_ai/handlers/csg_handler.gd","sha256":"45216f2820921f2e94a1ae7c849368c9a738c51d7dda2525ec888b1bf6d5e3d8","size":4479},{"path":"addons/godot_ai/handlers/csg_handler.gd.uid","sha256":"a593088c8622b9ac81d8a4790eccb1630e79ce57568a87b2c2f87edb6ba25aba","size":20},{"path":"addons/godot_ai/handlers/curve_handler.gd","sha256":"b7791298b4362de8191d0b87605dd05d4bb5e408b803d837482cb49d15680050","size":9363},{"path":"addons/godot_ai/handlers/curve_handler.gd.uid","sha256":"7fec875de89cf7a634b5fa787d139c601face5e123df6134dc13eecd9e3f3285","size":20},{"path":"addons/godot_ai/handlers/editor_handler.gd","sha256":"6d88b551b5ec92d210c9ffc0956217229ab91f4959976d0ac81185ed8f11e6fc","size":45777},{"path":"addons/godot_ai/handlers/editor_handler.gd.uid","sha256":"d36224d3a7b803805c0e80ce017ea7dcdd395c360ccce81a102edf9b264cc499","size":20},{"path":"addons/godot_ai/handlers/environment_handler.gd","sha256":"51fa05f0f0c2f4c8ce98569380955815796f6ec33ab198f57be326bb1372d61c","size":6731},{"path":"addons/godot_ai/handlers/environment_handler.gd.uid","sha256":"88f573289f80073f1b7556c25c0c235762e9454c38973a71d57a3319e34da63e","size":20},{"path":"addons/godot_ai/handlers/filesystem_handler.gd","sha256":"9dddf357cafb9ff774f5c1167a71558c41c8956d071b65bf573bfaec22c66988","size":13807},{"path":"addons/godot_ai/handlers/filesystem_handler.gd.uid","sha256":"3156268e89d4c2df4d58027620252304ade3622dfffa63621c9cdfa9ea4d8676","size":20},{"path":"addons/godot_ai/handlers/gridmap_handler.gd","sha256":"308eca951d561a84c13430f5c71b2bb92c0a8bd86f175ea126d3cd47726bc3e1","size":7927},{"path":"addons/godot_ai/handlers/gridmap_handler.gd.uid","sha256":"2d7826639c1a23d5b6b2949b4349200b11d4ebd633e1f3fb22b1207cccfab4c0","size":20},{"path":"addons/godot_ai/handlers/input_handler.gd","sha256":"5c24996f5979ebacdf0321f0383917ae9a31db3aedae2d93087a6c6279f85659","size":16046},{"path":"addons/godot_ai/handlers/input_handler.gd.uid","sha256":"33ec35e4bfce00cf0af9bb186a89b6d489690418209d6b3fccdd2d3e5e5a1fce","size":20},{"path":"addons/godot_ai/handlers/material_handler.gd","sha256":"4d6ad02aaf8a2d4e405e62d5c89f0071d6de28dd46a2754821dd8c7a9c57fec4","size":28527},{"path":"addons/godot_ai/handlers/material_handler.gd.uid","sha256":"2ce5a5847745f8992c75e6634fa8292ad145bac78e97a75df6bc3ec5ff4f3c01","size":20},{"path":"addons/godot_ai/handlers/material_presets.gd","sha256":"cbcff9a52cd25d36e7f462f9b81e85df04d7570afc340f5f741d6a04d80c06b8","size":2217},{"path":"addons/godot_ai/handlers/material_presets.gd.uid","sha256":"25ef884fb537b484f3509bd84558598661ed2f48cd1121893b18203aea55f114","size":20},{"path":"addons/godot_ai/handlers/material_values.gd","sha256":"899d5f9fd614f21f5b98c57ce062d792ef1d6bca204d860e2476680d364e7971","size":6776},{"path":"addons/godot_ai/handlers/material_values.gd.uid","sha256":"c610639c2977ceea714b5c0f9dd97251fd2a4b5b3a500d8ca34cff0b65f502ec","size":20},{"path":"addons/godot_ai/handlers/node_handler.gd","sha256":"d706c30a6286ea1a568f94c40531cfdb2a4cab48058118a841417268d4185819","size":53928},{"path":"addons/godot_ai/handlers/node_handler.gd.uid","sha256":"8a6a204c9cc207814aac0a20f3c2837c174dd8b7d0ba452f2a8694bceb8370bc","size":19},{"path":"addons/godot_ai/handlers/particle_handler.gd","sha256":"f665324b19c2e2e12e57da229b51e0401d83294af12cd0e5934a861b220124ac","size":30982},{"path":"addons/godot_ai/handlers/particle_handler.gd.uid","sha256":"e01cc4640e7f68f31ce6a709f5da2b00e1e14fb93d970101bd4df72be351890e","size":20},{"path":"addons/godot_ai/handlers/particle_presets.gd","sha256":"6a9d93719af847462f057a8ab58dfef61965dbc360f60f045093f4c30187267c","size":8261},{"path":"addons/godot_ai/handlers/particle_presets.gd.uid","sha256":"67e6d4b02f715fdbc8abc9df4667176b7638658f46049d4c040d7ea5b6118b00","size":20},{"path":"addons/godot_ai/handlers/particle_values.gd","sha256":"dc5d00ec054b1aaa4c7c6db2de82f5f18047e2b13bbe5f93ba27c83427ca722e","size":8356},{"path":"addons/godot_ai/handlers/particle_values.gd.uid","sha256":"7843a953959bd04557fa586070f376ff1f9fb6ad6452d53fa6602dad80ff2059","size":20},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd","sha256":"a40de61c3d574a8a4b7c01d9e59c69efc9f6a44982aff43771a956abf09ebee3","size":29747},{"path":"addons/godot_ai/handlers/physics_shape_handler.gd.uid","sha256":"c338b89967011e02e6f143c3115da1c25793dcd2394a2e286f5a9f787f8c0d19","size":20},{"path":"addons/godot_ai/handlers/project_handler.gd","sha256":"ca5d7b9085104824cc9a62802f1bab2e0f34067bf16413b1c857e8f57c4cb892","size":30927},{"path":"addons/godot_ai/handlers/project_handler.gd.uid","sha256":"d2de31aa9f55873f21af809e8933555f5975d208e4555f3e1b788e5d9aad53ca","size":20},{"path":"addons/godot_ai/handlers/resource_handler.gd","sha256":"7b43ae86b51630a9a605963c56ee10c5faf6dda0954fc8924d60eda5a354cf4f","size":24437},{"path":"addons/godot_ai/handlers/resource_handler.gd.uid","sha256":"e57815d98359fcc777f4b5c05c0561299da4aff4c1ccf14add572e068dc53daf","size":19},{"path":"addons/godot_ai/handlers/scene_handler.gd","sha256":"38ab2f20f669ed3641d1606db685993aef8f922a3ab25946417127a22bc04317","size":15888},{"path":"addons/godot_ai/handlers/scene_handler.gd.uid","sha256":"7905d18048c92df68d4ad37c176ec366ffaff0f108d8aca733c48142a8f8aae1","size":19},{"path":"addons/godot_ai/handlers/script_handler.gd","sha256":"2d492cc34949dda110791a87819faa233e3983818e016a977a75465dfcb4d09f","size":21903},{"path":"addons/godot_ai/handlers/script_handler.gd.uid","sha256":"4105d57bc4d5524fa01d0a38a227f03ce5ddf0944206c21f72169b030c33a56d","size":20},{"path":"addons/godot_ai/handlers/signal_handler.gd","sha256":"87183b18355079dbb8350bc81948028a7ac82090654939fea0fb843887733d1c","size":11116},{"path":"addons/godot_ai/handlers/signal_handler.gd.uid","sha256":"81475e35a53bccfb598d9679652169729ea2fa69f365f78005454bd1dd762210","size":20},{"path":"addons/godot_ai/handlers/test_handler.gd","sha256":"91fb4553f92233738d32e30ae1b0e1c6f98b49127ae6fd51fa05eb2aa8988ffe","size":14556},{"path":"addons/godot_ai/handlers/test_handler.gd.uid","sha256":"e92068b687252be24be2b95b3b93eabf906282214b583059c2ec6a45f69fa29c","size":20},{"path":"addons/godot_ai/handlers/texture_handler.gd","sha256":"8c36a856d9fe062075af5427655fab9d9ad9323d70acbd54147f873ac9f8f895","size":6466},{"path":"addons/godot_ai/handlers/texture_handler.gd.uid","sha256":"3bdeafe73069a8f04b9486d8b6697e63c4971bb01f7bab619eaedded39722bf7","size":20},{"path":"addons/godot_ai/handlers/theme_handler.gd","sha256":"cf717acb4c440f6697cb080131c61f35a0cb0a1deeb7da40d167736564a74159","size":18738},{"path":"addons/godot_ai/handlers/theme_handler.gd.uid","sha256":"e1c87aa079ad87e7624994e2539414bddf2caadd180458fd49dafd0109982004","size":19},{"path":"addons/godot_ai/handlers/tilemap_handler.gd","sha256":"13c334e9259965b0990bd7b64fcc249befb98e7cdb520f7ab26cc45558e2ceac","size":6052},{"path":"addons/godot_ai/handlers/tilemap_handler.gd.uid","sha256":"3c688fd5b5f2929b092a9d59c7a55c0878834287a78e18b333654c1f4c2421f2","size":20},{"path":"addons/godot_ai/handlers/tileset_handler.gd","sha256":"d6b11b0e75444aa3e8f9473c7b2ff2207b772f370a77978667541907cd7d56b0","size":5968},{"path":"addons/godot_ai/handlers/tileset_handler.gd.uid","sha256":"44eedb2ff0845c76c196b498a020e9aeb667cdb1b45e4da5813ea87a0b9b9ec7","size":20},{"path":"addons/godot_ai/handlers/ui_handler.gd","sha256":"c3aa8fea776e6bf441f940b90950e890d230b3088acff19381680f61aaddaed4","size":19080},{"path":"addons/godot_ai/handlers/ui_handler.gd.uid","sha256":"a0e5f401b82ea94d81b4ec3b7a2a8905d47258335f728932c01bab75476b84ea","size":20},{"path":"addons/godot_ai/mcp_dock.gd","sha256":"d9766235bd329ae3f63e0e16b9d906fd5b053a636dc613051cad4d4b200b8b03","size":118796},{"path":"addons/godot_ai/mcp_dock.gd.uid","sha256":"62248eebd93ecd7e65fae56ddb14ca23ec0b85989079a6e999bfd60c314712db","size":20},{"path":"addons/godot_ai/migration_bridge.gd","sha256":"851afbc299883d6dc3d091070d03a791804ab0f84a46cfacecb2a4b7f49c2e06","size":286},{"path":"addons/godot_ai/migration_bridge.gd.uid","sha256":"81cfb331c7aca2fde909e025312caf3dca2b52499e6aa7e95510c6bc01d24eec","size":19},{"path":"addons/godot_ai/migration_coordinator.gd","sha256":"3dc10fb657cb3485cb80115348281f2e666dcd8c6ee0caec3794824a15ccf100","size":626},{"path":"addons/godot_ai/migration_coordinator.gd.uid","sha256":"098e70726396f27526b26ec4f4ed115d7f81ae1a63a9eae736bdffd5c63988a8","size":19},{"path":"addons/godot_ai/migration_fallback.gd","sha256":"50a5f046be8f44169135d92fa2f945c3454c13b42b99398eaae67d0a54418e08","size":247},{"path":"addons/godot_ai/migration_fallback.gd.uid","sha256":"6c8781f55e5d61deae64832a79c30273ea73b95857d2b847d354bd72c46bf42e","size":20},{"path":"addons/godot_ai/plugin.cfg","sha256":"6447a6e9b4cf265bca7947e8cbca6b12192d2786ba0cc85ef4c0b5ce61535f0f","size":127},{"path":"addons/godot_ai/plugin.gd","sha256":"8bc48528c68bc16dae211816a93b7b8d2fca2983931e2c1b116ca785d71dc687","size":80914},{"path":"addons/godot_ai/plugin.gd.uid","sha256":"35a798ab044b50da51e1624b5aa951551b83aa45573ae2fa0ae378146cbb58c7","size":20},{"path":"addons/godot_ai/runtime/draw_recipe.gd","sha256":"51ca515c420abb816991c5a730e9ee166b5296086cfad98c89c7abce68a0edff","size":2131},{"path":"addons/godot_ai/runtime/draw_recipe.gd.uid","sha256":"7ae9a6fc4fe77482b2189b7421f024a35cb544e8b1368107e7f0f26598a778c8","size":20},{"path":"addons/godot_ai/runtime/editor_logger.gd","sha256":"d4093bea61f6a8275ff752e85b2265e6f0834f420bbf08c3a59b62b57e7a06af","size":5510},{"path":"addons/godot_ai/runtime/editor_logger.gd.uid","sha256":"587fd5ccabfe31b6a3c78742cb37fcfa696b6e6b9e27746f455a5e3547508542","size":20},{"path":"addons/godot_ai/runtime/game_helper.gd","sha256":"99a0391661d957ea5c0210ae51265ce1f14e68855edd6e73d8d048339897328a","size":55041},{"path":"addons/godot_ai/runtime/game_helper.gd.uid","sha256":"2bb2be95830555d56ff38ad2bcfd69ef29b4a55348f879a9cb61b97b727558ff","size":19},{"path":"addons/godot_ai/runtime/game_logger.gd","sha256":"54f4657c2b9ca26b0c8152f21c5d48a2a71a7cb82c062973218172d507e989ac","size":5606},{"path":"addons/godot_ai/runtime/game_logger.gd.uid","sha256":"e012598e90decb26fe0acee8a5c722e83ab52760470e74f13a91989c3a0f11b6","size":20},{"path":"addons/godot_ai/runtime/validation_logger.gd","sha256":"cc1254c56343d69bd531610b25f99b57f4437fb5b10b1c64312247005bcc2c1d","size":1065},{"path":"addons/godot_ai/runtime/validation_logger.gd.uid","sha256":"e1ec736f74292480f977c54421dcafb68c5def09ec2bf4b78569521b37bfd816","size":20},{"path":"addons/godot_ai/telemetry.gd","sha256":"92755278b554dc0a07b29430edc81abd416e21dc69fbf2c1a569868147b95299","size":8767},{"path":"addons/godot_ai/telemetry.gd.uid","sha256":"641f5ee47d5f74c0591652a500324a81b03b8bd152d874caabceab1489d78920","size":20},{"path":"addons/godot_ai/testing/script_error_capture.gd","sha256":"47363f95b07cff8d2dde46956a7dc46d1721ae5c4b5679633c0038ca0b0ea5cb","size":1102},{"path":"addons/godot_ai/testing/script_error_capture.gd.uid","sha256":"99acfa0494027479551559fb293ad0770cf463042bf4c6974703b873916990f5","size":20},{"path":"addons/godot_ai/testing/test_runner.gd","sha256":"73f1a3efd4024bf6214128c5229c4307f6ee04532a0980defedcf5b3b46301fc","size":14841},{"path":"addons/godot_ai/testing/test_runner.gd.uid","sha256":"af6daba585151379f5275a4dca4418521df77249d71f82050ba44135fd04650e","size":19},{"path":"addons/godot_ai/testing/test_suite.gd","sha256":"7cb7e68509660880d68f98d01e775354b767faff58e31a9f90d33e3857bb5f24","size":9027},{"path":"addons/godot_ai/testing/test_suite.gd.uid","sha256":"20d26aae67a6df5b2c40d0f3bd513d1257d4adc8320d9f559f8a87c85cd19744","size":20},{"path":"addons/godot_ai/tool_catalog.gd","sha256":"8629fb510f27b8b4f8c8d45d440eb19cedfafa754fa136ca572f9282d050ef87","size":5299},{"path":"addons/godot_ai/tool_catalog.gd.uid","sha256":"a5067a52ef97b89d8c9d587679d661d0c5ca7d550e0eef3c3b8a18bb56f31f68","size":20},{"path":"addons/godot_ai/utils/allow_hosts.gd","sha256":"2decc72bb5e3a880a84b4ee4536050bf458f4f4e4987f3672319fa037e64ada5","size":6343},{"path":"addons/godot_ai/utils/allow_hosts.gd.uid","sha256":"6c02022e576afca8d8487f87481b8618543df37030d9e0f19bfdf5d9d9c7b373","size":20},{"path":"addons/godot_ai/utils/class_introspection.gd","sha256":"f0708952ed838349630efe705f70dcc878c86073ddb1d8f37aacd7b5c5b5eca7","size":9452},{"path":"addons/godot_ai/utils/class_introspection.gd.uid","sha256":"f4cf97e1f9676bb5bfe0c3af123ef61678a7881b176cf4cd68114c5642ffe3a9","size":20},{"path":"addons/godot_ai/utils/client_job_owner.gd","sha256":"df1096c71a18842f2fac07227c7af05026eb010ed972bb001fce672d0dd7d86f","size":34489},{"path":"addons/godot_ai/utils/client_job_owner.gd.uid","sha256":"753fdba75d6a597d1e7fbbbe5657c35e80a96d009e36f4746157d3c07d05b082","size":19},{"path":"addons/godot_ai/utils/client_mutation_lock.gd","sha256":"add3b8894d56b66a43ece0406b28601c4d4572ba151e841025499a3ca240d8dc","size":4903},{"path":"addons/godot_ai/utils/client_mutation_lock.gd.uid","sha256":"0e0a9933268cb5eb04f14db2e70db024f069a911ffdf06aee572b238614a4719","size":21},{"path":"addons/godot_ai/utils/diagnostics_capture.gd","sha256":"0eaa9aa3e00274405091cb6d74dc1042341efe88048b6bf168955c5c1b873530","size":2386},{"path":"addons/godot_ai/utils/diagnostics_capture.gd.uid","sha256":"811bc51627303add74eec6dcc72f85dc61aef633c0dc3231442d8eb4b7130fe9","size":20},{"path":"addons/godot_ai/utils/editor_log_buffer.gd","sha256":"f21b0a767b05bd1763f03d1b81e63ad36212071529c1e415b88d874deda0ad43","size":3395},{"path":"addons/godot_ai/utils/editor_log_buffer.gd.uid","sha256":"acfbe64271596c496835b86f4b4c2642cd8675e85d456d4ea5e6a8422da5cd29","size":20},{"path":"addons/godot_ai/utils/error_codes.gd","sha256":"8895a773d74e3b8e7602a4109c8aee8077abd63d3223392f9a87884d27787437","size":9149},{"path":"addons/godot_ai/utils/error_codes.gd.uid","sha256":"df7b436601e68a12ecc38acbad7543bfe0fe6cb152c716b8f9dc7f70a417fe80","size":20},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd","sha256":"bc2306f4b27d5ae3d8b9a76a00a773382a943ec3df4ce412f92d10bdac336308","size":1072},{"path":"addons/godot_ai/utils/fuzzy_suggestions.gd.uid","sha256":"07f5f545e8c14bd19b4fad4bc5d683fcf4268bb6adfc1eda5d30a1603bc36459","size":20},{"path":"addons/godot_ai/utils/game_log_buffer.gd","sha256":"c3f9e9247ec8b70032bca8ce309c90c938238e859b82f11a7679ce736da55a52","size":3080},{"path":"addons/godot_ai/utils/game_log_buffer.gd.uid","sha256":"2d288c2000132251bc1b969c654ca3b9b7593ffb8e32aabe5faa81c0a7d8303b","size":20},{"path":"addons/godot_ai/utils/json_values.gd","sha256":"cf8b8ca0b9becc9fc7adeb7200972337ef2ee351f77b7cd11cebac90bf7321f5","size":3642},{"path":"addons/godot_ai/utils/json_values.gd.uid","sha256":"c52c80ff8b4fc6120879a598831139a5971fc98551aaac1aaf542f74ec8fed55","size":19},{"path":"addons/godot_ai/utils/log_backtrace.gd","sha256":"d7df786a4437991f403715e6bbdd20bbc396009dc5baf48f7dfeacf75ab2b27b","size":3887},{"path":"addons/godot_ai/utils/log_backtrace.gd.uid","sha256":"450dc80371aac8d4d974f042ecdedc15e0766a2442e8765fa298cb3dd18b6a20","size":20},{"path":"addons/godot_ai/utils/log_buffer.gd","sha256":"81d8a9e24c73b745b3dc90b6b7990f4babada9687189bcffa4379d84f94fb919","size":2569},{"path":"addons/godot_ai/utils/log_buffer.gd.uid","sha256":"cdbbcffb78756e9d9b68d19412110fd6e8fb7a8b2dcfe02d9490b2dd1151d1e1","size":20},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd","sha256":"1c6461816767dbc1d3b0ffefffe4147ea06fe5d8137a7760964b131aa73d0112","size":267},{"path":"addons/godot_ai/utils/mcp_adoption_label.gd.uid","sha256":"6720efd543a8f55ed448f5e2a8216a9950adca1942714958932b1149920509cd","size":19},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd","sha256":"589cd2fe35e19a1484ddc10afee2b0070b01b90bda877548aa3f4ff34342f46f","size":4010},{"path":"addons/godot_ai/utils/mcp_client_refresh_state.gd.uid","sha256":"509aec8279aced0e854c8e766e9719cd8a436d945e96ee2e1d223d9f16ea4c3d","size":20},{"path":"addons/godot_ai/utils/mcp_server_state.gd","sha256":"9d44e6d320396375c639bd13f52efd34481cee97edad9b3f244de62f2c7b493f","size":1825},{"path":"addons/godot_ai/utils/mcp_server_state.gd.uid","sha256":"0454bc89b123487b8a90927318fa4ec91315971ff0e798395191bc5d225e6f8a","size":20},{"path":"addons/godot_ai/utils/mcp_startup_path.gd","sha256":"ec7ba9d388b6a565598d433eb43ea40eeb9a8006df1cf05fbcc823742f7fec84","size":455},{"path":"addons/godot_ai/utils/mcp_startup_path.gd.uid","sha256":"570bec0886f6787fd3a74de10f76df90840b284a02f2edf4f4dfff956d7b2933","size":20},{"path":"addons/godot_ai/utils/path_validator.gd","sha256":"9399ba742fc8af571b6ceaa12504efef1a4fafbe6388f74c4e48a20e06ce43fe","size":11101},{"path":"addons/godot_ai/utils/path_validator.gd.uid","sha256":"dbbce00244442b32cc776f39d815a4c09c5ce840f3da001d5aa5db37dd1fa0ef","size":20},{"path":"addons/godot_ai/utils/plugin_reload.gd","sha256":"6fac4ed4e5b3ed6ba54f75d7f9ef6708cfacee8bd532dbad3370a519fedca87c","size":5092},{"path":"addons/godot_ai/utils/plugin_reload.gd.uid","sha256":"56054cb7d397405f0a1137b14cc46bc412207594fc28de393856d13bc9e29131","size":20},{"path":"addons/godot_ai/utils/port_resolver.gd","sha256":"c9d6c9b97501277d86b49ebe84c669b2b612c2e6a4a8674ae505d3bd4840d5a8","size":30181},{"path":"addons/godot_ai/utils/port_resolver.gd.uid","sha256":"4f386419a069705d93a56b64395add29e23e9f74745934a515813c119eb96c70","size":19},{"path":"addons/godot_ai/utils/release_verifier.gd","sha256":"23ed32925d8d6d73874b78b2fa5dbda1f20e57ee3cc433b4bcf25aab96cd1528","size":32268},{"path":"addons/godot_ai/utils/release_verifier.gd.uid","sha256":"dc0b8903879c85733a651a06cd702bd3d3c10922c8e3ee7436d49cccafd8964a","size":20},{"path":"addons/godot_ai/utils/resource_io.gd","sha256":"9afe123cb81f2113f28e7fdbf88daf6a464be6cd327082b813a811b3a7e712c1","size":13172},{"path":"addons/godot_ai/utils/resource_io.gd.uid","sha256":"1759b54639494e1fd14b97a87cae668500555e0141148a9e2aea04d3d1d2b48e","size":20},{"path":"addons/godot_ai/utils/scene_path.gd","sha256":"ab457ce39833897d7448a01e4fe397e44cab022eb407c0b3384bf5bc2bbec7fd","size":7545},{"path":"addons/godot_ai/utils/scene_path.gd.uid","sha256":"1d74b073f8cbc0778b3facbba2a7039553a657710f93899c2514104b221a6560","size":20},{"path":"addons/godot_ai/utils/screenshot_encode.gd","sha256":"6655c7a62d8458656303dc97744f0122aa32b46506d5b6c8b921e599241b085b","size":1487},{"path":"addons/godot_ai/utils/screenshot_encode.gd.uid","sha256":"010f223143d099028ccafba428f9cdb40c05d59b0a949dae49eab339bfb8b006","size":20},{"path":"addons/godot_ai/utils/script_work.gd","sha256":"8a0bef00aafda06debe57cd3562e299919b116548ae07acdbfa86328a989bde7","size":836},{"path":"addons/godot_ai/utils/script_work.gd.uid","sha256":"5caa9666d97006f759ecfa3ac3d9f74688563daaab595347fd0a962d40f0db47","size":19},{"path":"addons/godot_ai/utils/server_authority.gd","sha256":"bbca05a304a369a6ab77a2af34608c0f738e801b13715344b22ccd4f735e9e13","size":3745},{"path":"addons/godot_ai/utils/server_authority.gd.uid","sha256":"093b575d10ad2700e48f454a18e3d00c7df64ae9414e6f77a89e14c0c5eed845","size":19},{"path":"addons/godot_ai/utils/server_lifecycle.gd","sha256":"7570c099314e67d777b67606ccb708f490c7cb35a93ec815911bc8d1ba81b0ad","size":67067},{"path":"addons/godot_ai/utils/server_lifecycle.gd.uid","sha256":"546899b3a5d4024aac3b001ca634e8e74d74343faba27e491ac03af2c3fd6bb1","size":20},{"path":"addons/godot_ai/utils/server_version_check.gd","sha256":"534ca6fd105415d2e9562308a23f62e8e0af6fb0563caf05b9108d1007bfa8d3","size":2597},{"path":"addons/godot_ai/utils/server_version_check.gd.uid","sha256":"b71827a9383a60c02e26d59c19530cfc65a2df0d523220780e5283a9a8573f82","size":20},{"path":"addons/godot_ai/utils/settings.gd","sha256":"8e7409077186eeb15a4a88eeaceb79bacb01d9cfb72bbee4bae745e5c8bcce2f","size":6074},{"path":"addons/godot_ai/utils/settings.gd.uid","sha256":"99e66f1dfe502b81097d3d83819bc211e42ef3b3a97bff4ffd3d672bf21b26dc","size":19},{"path":"addons/godot_ai/utils/structured_log_ring.gd","sha256":"7bb5b56f3b5ec3ba4427b44ac5867005c01ac8a3d84735afd38479993e1f4d7f","size":5282},{"path":"addons/godot_ai/utils/structured_log_ring.gd.uid","sha256":"5ff3dab0db39995b662e5aea73ae0223ba92f6a219a283141d29ef9bce6667a1","size":20},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd","sha256":"a7e6fc7f4525aa64ceec15a7ab3eab6188f32f744fdd714ad1f4a819e9ede236","size":23645},{"path":"addons/godot_ai/utils/surfaced_error_tracker.gd.uid","sha256":"09b485e56fdd3a69a42d9b74400816bd36943fe15d0781da3aefe4b51be0dc6b","size":19},{"path":"addons/godot_ai/utils/transport_capability.gd","sha256":"9b4d7e41620b1b73aa3e14ba46f57851aa1e38c8cbbeaf365c231ecbc07bebad","size":10590},{"path":"addons/godot_ai/utils/transport_capability.gd.uid","sha256":"b3fdc46837d80983269f7c5cd1b3de88577f9a4daa3cf0312bf72cdb87703fff","size":20},{"path":"addons/godot_ai/utils/update_activation_runner.gd","sha256":"432aa506051e1ffcba51211bb91e9a2a7d98e3c84c8937c7382e70eee95c4601","size":16706},{"path":"addons/godot_ai/utils/update_activation_runner.gd.uid","sha256":"6cbd1c1da3fe5addb3144e517eeee7d8ba775f2806bf822f60d87dacfca03cd4","size":19},{"path":"addons/godot_ai/utils/update_installer.gd","sha256":"d0abd539eda13b8d3b6a00f3b4db8534cf242872a0772853115d5e16f6786d0a","size":22372},{"path":"addons/godot_ai/utils/update_installer.gd.uid","sha256":"100a3707894816165ab33e7cadbb07ec9d7346dcde22ff89eebbced7a82f018e","size":20},{"path":"addons/godot_ai/utils/update_manager.gd","sha256":"615bcde9ea6aa30219b319c0aadc4e17a5bf4abf9482262bebe650f0aa54de12","size":17826},{"path":"addons/godot_ai/utils/update_manager.gd.uid","sha256":"2a2e8422a89307dc7c57711daa1aab0629b5a54ec7f108097a36c2273a0735d0","size":20},{"path":"addons/godot_ai/utils/update_mixed_state.gd","sha256":"97195f0fb8cc005d6a91707dcfd1f14c91c6111f5d675d325de110763b87cd8d","size":6295},{"path":"addons/godot_ai/utils/update_mixed_state.gd.uid","sha256":"ed8d5d6740f1bcc183897ec4407487bea769160573353887d7811a8decf8ca3f","size":20},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd","sha256":"2f75771088514910b48ff26269e64064a37984f50ba91284185cf3955985269b","size":6444},{"path":"addons/godot_ai/utils/uv_cache_cleanup.gd.uid","sha256":"d209b2b5de32eb6cfeb7b18c8e785f26011d44326b43794478967da7705eace4","size":20},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd","sha256":"9c3da07655e2a0378de430a4612accd1df5709af8555fa9a9df90d0b5abe264f","size":3652},{"path":"addons/godot_ai/utils/uv_resolution_policy.gd.uid","sha256":"cde159a474d570d804fb1efba4b289629b4a830b93f89992a8dd1224c115fe60","size":20},{"path":"addons/godot_ai/utils/variant_serializer.gd","sha256":"25d6e56e62372ea4f1dd7b5be7a1538931177d1dc93ba36687cdb1f2949a4598","size":3150},{"path":"addons/godot_ai/utils/variant_serializer.gd.uid","sha256":"3954570c851e2a1c908b24dfe1e542c622feb5efb1ee5137bdf1a201cadafbbc","size":20},{"path":"addons/godot_ai/utils/windows_port_reservation.gd","sha256":"fbf0060529259bb97dbeb8a0a0f4dce8bbf0ab6a140b9e62170b67908763d14d","size":5248},{"path":"addons/godot_ai/utils/windows_port_reservation.gd.uid","sha256":"f369812cbcfa0201ed23ca05081ad8981a0200f53a8ef7e4ba690d722bf13abb","size":20},{"path":"addons/godot_ai/vision_routing.gd","sha256":"9eab92fda631edf36297712937c8cef1eff5cfbe804e3634e8422b053f622c1a","size":45078},{"path":"addons/godot_ai/vision_routing.gd.uid","sha256":"47ddab7fc353d2a49c3dcf4bbaa5e0e9f05116f7c7fc6bc5ece12246ad0a1494","size":20}],"repository":"hi-godot/godot-ai","schema_version":1,"source_commit":"7968ada03044473372a8ab4beadd975c60b9d94a","tag":"v4.1.0","version":"4.1.0"} diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.manifest.sig new file mode 100644 index 0000000000000000000000000000000000000000..d7a34b83c75c4392ade1c1c6fadda8850cbc41a7 GIT binary patch literal 512 zcmV+b0{{J%BGk4{vwlX1!JcO!7EkG6<2Ard7EQ_cI1bM6RM7AS+d5V`@?@s@!sRTz zm4yC=9dXS3GW8 zd8I{m3ehrUfd&Xqja{X>wJPm?w?Jy2_PVmN=7uq)0O{{@;5~7swxM=p_I33n%BtQ> z1aP2*SsH=H344Cc5UssAJYO4>M}zWy0n+o!ltB=l&f}S&p3jRl;>5%g3KrGLd|gFt zd30wwBL9vobrxq6(%FW3 zalep-*;hFP1;CsQvzeG4{UbBP=O#kU-Ybzu)a<^|O)#Z*Z4BDknz0YOE64Ynf$8TW#bcb1(HH;Ei7o5upN*62ef CF8-eY literal 0 HcmV?d00001 diff --git a/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip b/addons/godot_ai/migration_payload/godot-ai-v4-plugin.zip new file mode 100644 index 0000000000000000000000000000000000000000..bf2ee8f1532fbc8e98040dd4acf03361ff23c69d GIT binary patch literal 1971643 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

cS+a8KNK_eWXJk!T#2gsQk7xMZ7 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 z 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%0A_|fdw0Gd^Ley4n4VlqfX9soUI5mH@4lv+5wiJ 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


qD&IEqj+4t74J)$k~KN$ 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Š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%0ApjR9`vude~7c8uyzeQBSv~cB%RJDJLM>(tiG+ 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 void: - _lifecycle = ServerLifecycleManager.new(self) +var _dock: VBoxContainer +var _label: Label +var _retry: Button +var _bridge +var _coordinator func _enter_tree() -> void: - _startup_trace_begin() - - ## `_process` is only used by the adoption-confirmation watcher; keep - ## it off until `_watch_for_adoption_confirmation` arms it, so the - ## plugin has zero per-frame cost in the common case. - set_process(false) - - ## #740: register the export plugin BEFORE the headless guard so - ## `godot --headless --export-*` runs strip the game-helper autoload - ## from exported packs too — CI export pipelines are headless. The - ## export plugin is inert outside exports: no server, no sockets. - _export_plugin = ExportPlugin.new() - add_export_plugin(_export_plugin) - - if _mcp_disabled_for_headless_launch(): - _headless_disabled = true - print("MCP | plugin disabled in headless mode") + _build_dock() + var prior := _bridge_status() + if not prior.is_empty(): + _present_error(str(prior.get("error", "The previous migration attempt did not finish."))) return - - ## Self-update extracts over the live addon and doesn't prune files that - ## disappeared from the new ZIP. Remove obsolete Logger-loader quarantine - ## files/folders once so upgraders match a fresh install. - _cleanup_legacy_logger_scripts() - - ## Register port overrides before spawn so `http_port()` / `ws_port()` - ## return the user's configured values (if any) when `_start_server` - ## builds the CLI args. - ClientConfigurator.ensure_settings_registered() - _startup_trace_phase("settings_registered") - - ## With the startup walk's blocking port resolution deferred to a worker - ## (#678), the Connection below dials before `_set_resolved_ws_port` - ## publishes. Seed the pre-resolution port from the configured - ## EditorSettings value (a cheap main-thread read, not a blocking probe) - ## so the first dial honors a `godot_ai/ws_port` override — without this - ## it targeted the compile-time default and the override only took - ## effect on the 1s retry. - _resolved_ws_port = _startup_ws_port_seed( - _ws_port_resolution_published, - _resolved_ws_port, - ClientConfigurator.ws_port(), - ) - - ## #691: pre-warm the env snapshot on the main thread before any worker - ## exists, so worker-thread env reads (dock refresh/action workers, the - ## #678 startup walk's discovery worker) serve from the snapshot and can - ## never race the spawn window's setenv/unsetenv around - ## OS.create_process. - ClientConfigurator.warm_env_snapshot() - - _log_buffer = LogBuffer.new() - ## Apply the persisted dock "Log" toggle before anything logs through the - ## buffer. Without this the choice only took effect after a manual toggle - ## and reset to noisy on every editor restart (#626). - _log_buffer.enabled = McpSettings.mcp_logging_enabled() - ## #678: in the real editor, run the startup path's blocking probes and - ## kill-drain waits off the main thread so a contended port can't freeze - ## plugin init/reload. Set here (not _init) so test fixtures — which - ## extend this plugin but never enter the tree — keep the synchronous - ## default and can call-then-assert. - _lifecycle.defer_blocking_work = true - ## A completed self-update means the user's Update click already - ## authorized replacing the previous-version backend. Armed BEFORE the - ## startup walk (a peek, not a drain — `_flush_pending_self_update_telemetry` - ## below still owns the read-and-clear) so the walk can weak-proof-kill - ## the stale server an attach bridge kept alive, and bounded retries - ## absorb the bridge-respawn port race, instead of latching INCOMPATIBLE - ## and waiting for a manual recovery click. - if _pending_self_update_succeeded(): - _lifecycle.authorize_stale_recovery() - _start_server() - _startup_trace_phase("server_start") - - _game_log_buffer = GameLogBuffer.new() - _editor_log_buffer = EditorLogBuffer.new() - _surfaced_error_tracker = SurfacedErrorTracker.new(_editor_log_buffer, _game_log_buffer) - _attach_editor_logger() - _dispatcher = Dispatcher.new(_log_buffer, _surfaced_error_tracker) - _dispatcher.mcp_logging = _log_buffer.enabled - _startup_trace_phase("core_objects") - - _connection = Connection.new() - _connection.log_buffer = _log_buffer - _connection.surfaced_error_tracker = _surfaced_error_tracker - _connection.ws_port = _resolved_ws_port - ## Restore the token before the first connect: after an editor restart - ## the static is empty but the managed-server record still names the - ## token the running server was spawned with (#690). A fresh spawn later - ## overwrites both via _set_ws_auth_token. - if _ws_auth_token.is_empty(): - _ws_auth_token = str(_read_managed_server_record().get("ws_token", "")) - _connection.auth_token = _ws_auth_token - ## Pause-depth restore boundary (#712): the dispatcher rebalances any - ## pause_processing level a crashed handler leaked. - _dispatcher.pause_target = _connection - _connection.connect_blocked = _lifecycle.is_connection_blocked() - _connection.connect_block_reason = _lifecycle.get_status_dict().get("message", "") - if ( - not _lifecycle.is_connection_blocked() - and not ServerStateScript.is_terminal_diagnosis(_lifecycle.get_state()) - ): - _arm_server_version_check() - ## Replay the custom-tool catalog on (re)connect so tools registered - ## before the initial connection or during a disconnect window reach - ## the server — send_event silently drops while _connected is false. - _connection.connection_state_changed.connect(_on_connection_state_changed) - - _telemetry = Telemetry.new(_connection) - - _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer, _surfaced_error_tracker) - _vision_routing = VisionRoutingScript.new() - _vision_routing.log_buffer = _log_buffer - _debugger_plugin.vision_routing = _vision_routing - add_debugger_plugin(_debugger_plugin) - _connection.debugger_plugin = _debugger_plugin - _ensure_game_helper_autoload() - - ## Lazy handler registration (#736): declare each handler's script path - ## and constructor args, then map every command to (handler_key, method). - ## The dispatcher load()s + constructs a handler at the first dispatch of - ## one of its commands and caches the instance, so this block is the - ## authoritative command list without pulling any handler script into the - ## boot-time compile closure. Constructor args are captured now (they are - ## all plugin-lifetime objects) and released by _dispatcher.clear() in - ## _exit_tree. - var undo := get_undo_redo() - _dispatcher.register_lazy_handler("editor", HANDLERS_DIR + "editor_handler.gd", [_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer, null, _surfaced_error_tracker, _vision_routing]) - _dispatcher.register_lazy_handler("scene", HANDLERS_DIR + "scene_handler.gd", [_connection]) - _dispatcher.register_lazy_handler("node", HANDLERS_DIR + "node_handler.gd", [undo]) - _dispatcher.register_lazy_handler("project", HANDLERS_DIR + "project_handler.gd", [_connection, _debugger_plugin, _editor_log_buffer]) - _dispatcher.register_lazy_handler( - "client", - HANDLERS_DIR + "client_handler.gd", - [_connection, ClientConfigurator.capture_launch_context()], - ) - _dispatcher.register_lazy_handler("script", HANDLERS_DIR + "script_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("resource", HANDLERS_DIR + "resource_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("api", HANDLERS_DIR + "api_handler.gd", []) - _dispatcher.register_lazy_handler("filesystem", HANDLERS_DIR + "filesystem_handler.gd", [_connection]) - _dispatcher.register_lazy_handler("signal", HANDLERS_DIR + "signal_handler.gd", [undo]) - _dispatcher.register_lazy_handler("autoload", HANDLERS_DIR + "autoload_handler.gd", []) - _dispatcher.register_lazy_handler("input", HANDLERS_DIR + "input_handler.gd", []) - _dispatcher.register_lazy_handler("test", HANDLERS_DIR + "test_handler.gd", [undo, _log_buffer, _dispatcher, _connection]) - _dispatcher.register_lazy_handler("batch", HANDLERS_DIR + "batch_handler.gd", [_dispatcher, undo]) - _dispatcher.register_lazy_handler("ui", HANDLERS_DIR + "ui_handler.gd", [undo]) - _dispatcher.register_lazy_handler("theme", HANDLERS_DIR + "theme_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("animation", HANDLERS_DIR + "animation_handler.gd", [undo]) - _dispatcher.register_lazy_handler("material", HANDLERS_DIR + "material_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("particle", HANDLERS_DIR + "particle_handler.gd", [undo]) - _dispatcher.register_lazy_handler("camera", HANDLERS_DIR + "camera_handler.gd", [undo]) - _dispatcher.register_lazy_handler("audio", HANDLERS_DIR + "audio_handler.gd", [undo]) - _dispatcher.register_lazy_handler("physics_shape", HANDLERS_DIR + "physics_shape_handler.gd", [undo]) - _dispatcher.register_lazy_handler("environment", HANDLERS_DIR + "environment_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("texture", HANDLERS_DIR + "texture_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("curve", HANDLERS_DIR + "curve_handler.gd", [undo, _connection]) - _dispatcher.register_lazy_handler("control_draw_recipe", HANDLERS_DIR + "control_draw_recipe_handler.gd", [undo]) - _dispatcher.register_lazy_handler("tilemap", HANDLERS_DIR + "tilemap_handler.gd", [undo]) - _dispatcher.register_lazy_handler("tileset", HANDLERS_DIR + "tileset_handler.gd", []) - _dispatcher.register_lazy_handler("gridmap", HANDLERS_DIR + "gridmap_handler.gd", [undo]) - _dispatcher.register_lazy_handler("csg", HANDLERS_DIR + "csg_handler.gd", [undo]) - - _dispatcher.register_lazy("get_editor_state", "editor", &"get_editor_state") - _dispatcher.register_lazy("get_scene_tree", "scene", &"get_scene_tree") - _dispatcher.register_lazy("get_open_scenes", "scene", &"get_open_scenes") - _dispatcher.register_lazy("find_nodes", "scene", &"find_nodes") - _dispatcher.register_lazy("create_scene", "scene", &"create_scene") - _dispatcher.register_lazy("open_scene", "scene", &"open_scene") - _dispatcher.register_lazy("save_scene", "scene", &"save_scene") - _dispatcher.register_lazy("save_scene_as", "scene", &"save_scene_as") - _dispatcher.register_lazy("get_selection", "editor", &"get_selection") - _dispatcher.register_lazy("create_node", "node", &"create_node") - _dispatcher.register_lazy("delete_node", "node", &"delete_node") - _dispatcher.register_lazy("reparent_node", "node", &"reparent_node") - _dispatcher.register_lazy("set_property", "node", &"set_property") - _dispatcher.register_lazy("rename_node", "node", &"rename_node") - _dispatcher.register_lazy("duplicate_node", "node", &"duplicate_node") - _dispatcher.register_lazy("move_node", "node", &"move_node") - _dispatcher.register_lazy("add_to_group", "node", &"add_to_group") - _dispatcher.register_lazy("remove_from_group", "node", &"remove_from_group") - _dispatcher.register_lazy("set_selection", "node", &"set_selection") - _dispatcher.register_lazy("get_node_properties", "node", &"get_node_properties") - _dispatcher.register_lazy("get_children", "node", &"get_children") - _dispatcher.register_lazy("get_groups", "node", &"get_groups") - _dispatcher.register_lazy("get_logs", "editor", &"get_logs") - _dispatcher.register_lazy("clear_logs", "editor", &"clear_logs") - _dispatcher.register_lazy("take_screenshot", "editor", &"take_screenshot") - _dispatcher.register_lazy("get_performance_monitors", "editor", &"get_performance_monitors") - _dispatcher.register_lazy("reload_plugin", "editor", &"reload_plugin") - _dispatcher.register_lazy("quit_editor", "editor", &"quit_editor") - _dispatcher.register_lazy("game_eval", "editor", &"game_eval") - _dispatcher.register_lazy("game_command", "editor", &"game_command") - _dispatcher.register_lazy("get_project_setting", "project", &"get_project_setting") - _dispatcher.register_lazy("set_project_setting", "project", &"set_project_setting") - _dispatcher.register_lazy("run_project", "project", &"run_project") - _dispatcher.register_lazy("stop_project", "project", &"stop_project") - _dispatcher.register_lazy("search_filesystem", "project", &"search_filesystem") - _dispatcher.register_lazy("configure_client", "client", &"configure_client") - _dispatcher.register_lazy("remove_client", "client", &"remove_client") - _dispatcher.register_lazy("check_client_status", "client", &"check_client_status") - _dispatcher.register_lazy("create_script", "script", &"create_script") - _dispatcher.register_lazy("patch_script", "script", &"patch_script") - _dispatcher.register_lazy("read_script", "script", &"read_script") - _dispatcher.register_lazy("attach_script", "script", &"attach_script") - _dispatcher.register_lazy("detach_script", "script", &"detach_script") - _dispatcher.register_lazy("find_symbols", "script", &"find_symbols") - _dispatcher.register_lazy("search_resources", "resource", &"search_resources") - _dispatcher.register_lazy("load_resource", "resource", &"load_resource") - _dispatcher.register_lazy("assign_resource", "resource", &"assign_resource") - _dispatcher.register_lazy("create_resource", "resource", &"create_resource") - _dispatcher.register_lazy("get_resource_info", "resource", &"get_resource_info") - _dispatcher.register_lazy("get_class_info", "api", &"get_class_info") - _dispatcher.register_lazy("read_file", "filesystem", &"read_file") - _dispatcher.register_lazy("write_file", "filesystem", &"write_file") - _dispatcher.register_lazy("reimport", "filesystem", &"reimport") - _dispatcher.register_lazy("scan_filesystem", "filesystem", &"scan_filesystem") - _dispatcher.register_lazy("list_signals", "signal", &"list_signals") - _dispatcher.register_lazy("connect_signal", "signal", &"connect_signal") - _dispatcher.register_lazy("disconnect_signal", "signal", &"disconnect_signal") - _dispatcher.register_lazy("list_autoloads", "autoload", &"list_autoloads") - _dispatcher.register_lazy("add_autoload", "autoload", &"add_autoload") - _dispatcher.register_lazy("remove_autoload", "autoload", &"remove_autoload") - _dispatcher.register_lazy("list_actions", "input", &"list_actions") - _dispatcher.register_lazy("add_action", "input", &"add_action") - _dispatcher.register_lazy("ensure_action", "input", &"ensure_action") - _dispatcher.register_lazy("remove_action", "input", &"remove_action") - _dispatcher.register_lazy("bind_event", "input", &"bind_event") - _dispatcher.register_lazy("ensure_binding", "input", &"ensure_binding") - _dispatcher.register_lazy("run_tests", "test", &"run_tests") - _dispatcher.register_lazy("get_test_results", "test", &"get_test_results") - _dispatcher.register_lazy("batch_execute", "batch", &"batch_execute") - _dispatcher.register_lazy("set_anchor_preset", "ui", &"set_anchor_preset") - _dispatcher.register_lazy("set_text", "ui", &"set_text") - _dispatcher.register_lazy("build_layout", "ui", &"build_layout") - _dispatcher.register_lazy("create_theme", "theme", &"create_theme") - _dispatcher.register_lazy("theme_set_color", "theme", &"set_color") - _dispatcher.register_lazy("theme_set_constant", "theme", &"set_constant") - _dispatcher.register_lazy("theme_set_font_size", "theme", &"set_font_size") - _dispatcher.register_lazy("theme_set_stylebox_flat", "theme", &"set_stylebox_flat") - _dispatcher.register_lazy("apply_theme", "theme", &"apply_theme") - _dispatcher.register_lazy("animation_player_create", "animation", &"create_player") - _dispatcher.register_lazy("animation_create", "animation", &"create_animation") - _dispatcher.register_lazy("animation_add_property_track", "animation", &"add_property_track") - _dispatcher.register_lazy("animation_add_method_track", "animation", &"add_method_track") - _dispatcher.register_lazy("animation_set_autoplay", "animation", &"set_autoplay") - _dispatcher.register_lazy("animation_play", "animation", &"play") - _dispatcher.register_lazy("animation_stop", "animation", &"stop") - _dispatcher.register_lazy("animation_list", "animation", &"list_animations") - _dispatcher.register_lazy("animation_get", "animation", &"get_animation") - _dispatcher.register_lazy("animation_create_simple", "animation", &"create_simple") - _dispatcher.register_lazy("animation_delete", "animation", &"delete_animation") - _dispatcher.register_lazy("animation_validate", "animation", &"validate_animation") - _dispatcher.register_lazy("animation_preset_fade", "animation", &"preset_fade") - _dispatcher.register_lazy("animation_preset_slide", "animation", &"preset_slide") - _dispatcher.register_lazy("animation_preset_shake", "animation", &"preset_shake") - _dispatcher.register_lazy("animation_preset_pulse", "animation", &"preset_pulse") - _dispatcher.register_lazy("material_create", "material", &"create_material") - _dispatcher.register_lazy("material_set_param", "material", &"set_param") - _dispatcher.register_lazy("material_set_shader_param", "material", &"set_shader_param") - _dispatcher.register_lazy("material_get", "material", &"get_material") - _dispatcher.register_lazy("material_list", "material", &"list_materials") - _dispatcher.register_lazy("material_assign", "material", &"assign_material") - _dispatcher.register_lazy("material_apply_to_node", "material", &"apply_to_node") - _dispatcher.register_lazy("material_apply_preset", "material", &"apply_preset") - _dispatcher.register_lazy("particle_create", "particle", &"create_particle") - _dispatcher.register_lazy("particle_set_main", "particle", &"set_main") - _dispatcher.register_lazy("particle_set_process", "particle", &"set_process") - _dispatcher.register_lazy("particle_set_draw_pass", "particle", &"set_draw_pass") - _dispatcher.register_lazy("particle_restart", "particle", &"restart_particle") - _dispatcher.register_lazy("particle_get", "particle", &"get_particle") - _dispatcher.register_lazy("particle_apply_preset", "particle", &"apply_preset") - _dispatcher.register_lazy("camera_create", "camera", &"create_camera") - _dispatcher.register_lazy("camera_configure", "camera", &"configure") - _dispatcher.register_lazy("camera_set_limits_2d", "camera", &"set_limits_2d") - _dispatcher.register_lazy("camera_set_damping_2d", "camera", &"set_damping_2d") - _dispatcher.register_lazy("camera_follow_2d", "camera", &"follow_2d") - _dispatcher.register_lazy("camera_get", "camera", &"get_camera") - _dispatcher.register_lazy("camera_list", "camera", &"list_cameras") - _dispatcher.register_lazy("camera_apply_preset", "camera", &"apply_preset") - _dispatcher.register_lazy("audio_player_create", "audio", &"create_player") - _dispatcher.register_lazy("audio_player_set_stream", "audio", &"set_stream") - _dispatcher.register_lazy("audio_player_set_playback", "audio", &"set_playback") - _dispatcher.register_lazy("audio_play", "audio", &"play") - _dispatcher.register_lazy("audio_stop", "audio", &"stop") - _dispatcher.register_lazy("audio_list", "audio", &"list_streams") - _dispatcher.register_lazy("physics_shape_autofit", "physics_shape", &"autofit") - _dispatcher.register_lazy("environment_create", "environment", &"create_environment") - _dispatcher.register_lazy("gradient_texture_create", "texture", &"create_gradient_texture") - _dispatcher.register_lazy("noise_texture_create", "texture", &"create_noise_texture") - _dispatcher.register_lazy("curve_set_points", "curve", &"set_points") - _dispatcher.register_lazy("control_draw_recipe", "control_draw_recipe", &"control_draw_recipe") - _dispatcher.register_lazy("tilemap_set_cell", "tilemap", &"set_cell") - _dispatcher.register_lazy("tilemap_set_cells_rect", "tilemap", &"set_cells_rect") - _dispatcher.register_lazy("tilemap_clear", "tilemap", &"clear_layer") - _dispatcher.register_lazy("tilemap_get_cells", "tilemap", &"get_used_cells") - _dispatcher.register_lazy("tileset_get_atlas_tiles", "tileset", &"get_atlas_tiles") - _dispatcher.register_lazy("tileset_get_atlas_image", "tileset", &"get_atlas_image") - _dispatcher.register_lazy("gridmap_set_item", "gridmap", &"set_item") - _dispatcher.register_lazy("gridmap_fill", "gridmap", &"fill") - _dispatcher.register_lazy("gridmap_clear", "gridmap", &"clear_layer") - _dispatcher.register_lazy("gridmap_get_used_cells", "gridmap", &"get_used_cells") - _dispatcher.register_lazy("gridmap_list_library_items", "gridmap", &"list_library_items") - _dispatcher.register_lazy("csg_create", "csg", &"create") - _dispatcher.register_lazy("csg_set_operation", "csg", &"set_operation") - - _connection.dispatcher = _dispatcher - add_child(_connection) - _startup_trace_phase("handlers_registered") - - # Custom tool registry - _custom_tool_service_locator = McpServiceLocator.new() - _custom_tool_service_locator.setup(_connection, _log_buffer) - _custom_tool_registry = McpToolRegistry.new() - _custom_tool_registry.setup(_dispatcher, _custom_tool_service_locator) - _custom_tool_registry.tools_changed.connect(_on_custom_tools_changed) - _custom_tool_registry.mark_ready() - _startup_trace_phase("custom_tools_ready") - - # Dock panel - _dock = Dock.new() - _dock.vision_routing = _vision_routing - _dock.name = "Godot AI" - _dock.setup(_connection, _log_buffer, self) - add_control_to_dock(DOCK_SLOT_RIGHT_BL, _dock) - _startup_trace_phase("dock_attached") - - _log_buffer.log("plugin loaded") - if _telemetry != null: - _telemetry.record_dock_startup() - _flush_pending_self_update_telemetry() - _telemetry.flush_pending_plugin_reload() - ## The startup-trace 'done' line is stamped by _start_server after the - ## (possibly suspended) walk completes — not here (#682 review). - - -## Public wrapper around the dev-server-toggle telemetry emit. Lets the -## dock (or any other caller) record without reaching into ``_telemetry`` -## directly — keeps the plugin's internal field encapsulated. The dev -## server is a Python subprocess unrelated to the plugin's own -## lifecycle, so emission can be synchronous (no EditorSettings persist -## dance like ``plugin_reload`` / ``self_update``). -func record_dev_server_toggle(action: String) -> void: - if _telemetry == null: + if not _editor_available(): + ## Headless imports and exports must never mutate the tree, not even + ## to restore final v3; the next interactive start handles it. + _present_state(EDITOR_REQUIRED, true, false) return - _telemetry.record_dev_server_toggle(action) - - -## Non-draining read of the runner's pending self-update marker. Peeked -## before `_start_server` (the drain in `_flush_pending_self_update_telemetry` -## runs later, after the dock attaches) so the startup walk knows an update -## just completed. Only a `status == "success"` marker counts: a failed -## install left the OLD plugin version enabled, and killing a same-version -## backend is not this path's business. -func _pending_self_update_succeeded() -> bool: - var settings := EditorInterface.get_editor_settings() - if settings == null: - return false - var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY - if not settings.has_setting(key): - return false - var raw := str(settings.get_setting(key)) - if raw.is_empty(): - return false - var parsed = JSON.parse_string(raw) - if typeof(parsed) != TYPE_DICTIONARY: - return false - return str(parsed.get("status", "")) == "success" - - -## Drain any self_update event written by `update_reload_runner` during the -## previous disable -> enable window. -func _flush_pending_self_update_telemetry() -> void: - var key := UPDATE_RELOAD_RUNNER_SCRIPT.PENDING_SELF_UPDATE_TELEMETRY_KEY - var parsed = Telemetry._drain_editor_setting_dict(key) - if parsed == null: + if not _meets_floor(): + print(FLOOR_REFUSAL) + ## The user's updater already overlaid the capsule over their v3 tree. + ## Put the final v3 back so nothing is lost; without the fallback (an + ## older capsule) the bridge can only wait for a newer Godot. + if MigrationFallback.available(): + _present_state(FLOOR_RESTORING, false, false) + var fallback: Node = MigrationFallback.new() + _parent_node().add_child(fallback) + fallback.start() + return + _present_state(FLOOR_MESSAGE, true, false) return - var status := str(parsed.get("status", "unknown")) - var error := str(parsed.get("error", "")) - ## Positional args: GDScript doesn't support keyword args in calls - ## (unlike Python). from/to versions ride the marker since the repin - ## gate landed; markers written by older runners simply lack them. - var from_version := str(parsed.get("from_version", "")) - var to_version := str(parsed.get("to_version", "")) - _telemetry.record_self_update(status, from_version, to_version, error) - ## After a successful update, previously-configured client entries still - ## pin the old server version; arm the dock's one-shot auto-repin so its - ## first healthy status sweep rewrites them without the user having to - ## click through the drift banner. The dock repins ONLY entries whose - ## sole drift is the old version pin — it needs `from_version` to render - ## that comparison, so a marker from an older runner (no version fields) - ## arms nothing and the drift banner stays the manual path. `has_method` - ## (not a typed call): the dock script is one of the files the update - ## just overwrote, and the untyped-reference convention applies across - ## that boundary. - if status == "success" and _dock != null and _dock.has_method("notify_self_update_success"): - _dock.notify_self_update_success(from_version) - - + _start_migration.call_deferred() func _exit_tree() -> void: - ## Registered before the headless guard in _enter_tree, so it must be - ## removed before the headless early-return here too. - if _export_plugin != null: - remove_export_plugin(_export_plugin) - _export_plugin = null - - if _headless_disabled: - _server_started_this_session = false - _headless_disabled = false - return - - if _custom_tool_registry != null: - _custom_tool_registry.clear() - _custom_tool_registry = null - - _custom_tool_service_locator = null - - ## Outer-to-inner teardown. Dispatcher Callables hold RefCounted handlers - ## alive past the point where Godot reloads their class_name scripts — the - ## first post-reload call into a typed-array-holding handler (e.g. - ## McpGameLogBuffer._storage) then SIGSEGVs against a stale class descriptor. - ## See issue #46. - - # Stop inbound work first so _process can't enqueue new commands or - # null-deref log_buffer on the next tick mid-teardown. - if _connection: - _connection.teardown() - - # Drop the dispatcher's Callables AND its lazily-constructed handler - # instances (#736: handlers live in the dispatcher's cache now). Handler - # destructors run here, while their scripts are still loaded. - if _dispatcher: - _dispatcher.clear() - if _vision_routing: - _vision_routing.shutdown() - _vision_routing = null - - if _dock: + _free_bridge() + if _coordinator != null and is_instance_valid(_coordinator): + ## The coordinator outlives this plugin on purpose; it disables the + ## capsule and keeps running. Only stop listening to it. + if _coordinator.state_changed.is_connected(_present_state): + _coordinator.state_changed.disconnect(_present_state) + _coordinator = null + if _dock != null and is_instance_valid(_dock): remove_control_from_docks(_dock) _dock.queue_free() - _dock = null - if _connection: - _connection.queue_free() - _connection = null - if _debugger_plugin: - remove_debugger_plugin(_debugger_plugin) - _debugger_plugin = null - - ## Detach the editor logger BEFORE nulling the buffer. After remove_logger - ## returns, Godot guarantees no further virtual calls — so the logger's - ## next access to `_buffer` (if any in flight) lands on a still-live - ## ref-counted buffer, not a freed one. - _detach_editor_logger() - - _dispatcher = null - _log_buffer = null - _game_log_buffer = null - _editor_log_buffer = null - _surfaced_error_tracker = null - - ## keep_server_on_exit (#800): the manager routes on the spawn-time - ## keep-alive flag (persisted in the managed-server record), NOT the - ## live setting — detach leaves the server for the next session (or a - ## same-session disable/enable cycle) to adopt. Explicit stops (dock - ## Restart, update reload) still kill via _stop_server. - _lifecycle.teardown_for_editor_exit() - ## Symmetric with prepare_for_update_reload: the static guard persists - ## across disable/enable within a single editor session, so the re-enabled - ## plugin instance's _start_server would short-circuit and never respawn. - ## Pre-#159 this was masked — the old kill path usually left Python alive - ## and the new instance adopted it on port 8000. Now that _stop_server is - ## deterministic, nothing is left to adopt and the reload hangs. - _server_started_this_session = false - print("MCP | plugin unloaded") + _dock = null -## Attach editor_logger.gd as a Godot logger so editor-process script -## errors (parse errors, @tool runtime errors, EditorPlugin errors, -## push_error/push_warning) flow into _editor_log_buffer for -## logs_read(source="editor"). -## -## Limitation called out in the issue: parse errors fired *before* the -## plugin's _enter_tree (e.g. during the editor's initial filesystem -## scan, or for scripts that fail on first project open) happen before -## add_logger is called and are not captured. There's no public API to -## drain the editor's already-emitted error history; rescanning the -## file would re-emit them but at the cost of disrupting the user's -## editing state, so we accept the gap. -func _attach_editor_logger() -> void: - _editor_logger = EditorLogger.new(_editor_log_buffer) - OS.add_logger(_editor_logger) +func _build_dock() -> void: + _dock = VBoxContainer.new() + _dock.name = "Godot AI" + _label = Label.new() + _label.text = "Preparing the signed Godot AI v4 migration…" + _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _label.add_theme_font_size_override("font_size", 15) + _dock.add_child(_label) + _retry = Button.new() + _retry.text = "Retry migration" + _retry.visible = false + _retry.pressed.connect(_on_retry) + _dock.add_child(_retry) + add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock) -## Remove old Logger-quarantine artifacts left by extract-over-live -## self-update. Idempotent: existence-guarded, so it's a no-op on fresh -## installs and symlinked dev checkouts. -func _cleanup_legacy_logger_scripts() -> void: - var legacy_files := [ - "res://addons/godot_ai/runtime/logger_loader.gd", - "res://addons/godot_ai/runtime/logger_loader.gd.uid", - "res://addons/godot_ai/testing/script_error_capture_loader.gd", - "res://addons/godot_ai/testing/script_error_capture_loader.gd.uid", - ] - for res_path in legacy_files: - if FileAccess.file_exists(res_path): - DirAccess.remove_absolute(ProjectSettings.globalize_path(res_path)) - var legacy_dirs := [ - "res://addons/godot_ai/runtime/loggers", - "res://addons/godot_ai/testing/loggers", - ] - for res_path in legacy_dirs: - var absolute := ProjectSettings.globalize_path(res_path) - if DirAccess.dir_exists_absolute(absolute): - _remove_dir_recursive_absolute(absolute) - - -static func _remove_dir_recursive_absolute(path: String) -> void: - var dir := DirAccess.open(path) - if dir == null: +func _start_migration() -> void: + if _bridge != null or _coordinator != null: return - dir.list_dir_begin() - var name := dir.get_next() - while not name.is_empty(): - var child := path.path_join(name) - if dir.current_is_dir(): - _remove_dir_recursive_absolute(child) - else: - DirAccess.remove_absolute(child) - name = dir.get_next() - dir.list_dir_end() - DirAccess.remove_absolute(path) + _clear_bridge_status() + _retry.visible = false + _label.text = "Preparing the signed Godot AI v4 migration…" + print("MCP | v3 bridge verifying and staging the signed v4 tree") + _bridge = MigrationBridge.new() + _bridge.state_changed.connect(_present_state) + _bridge.prepared.connect(_activate_prepared) + _parent_node().add_child(_bridge) + _bridge.start() -func _detach_editor_logger() -> void: - if _editor_logger != null: - OS.remove_logger(_editor_logger) - _editor_logger = null +func _present_state(message: String, failed: bool = false, record: bool = true) -> void: + if failed: + print("MCP | v3 bridge: %s" % message) + if record: + MigrationCoordinator.record_status(message) + _free_bridge() + _coordinator = null + if _label != null: + _label.text = message + if _retry != null: + _retry.visible = failed -## Register the game-side autoload on plugin enable. Runs the helper inside -## the game process so the editor-side debugger plugin can request -## framebuffer captures over EngineDebugger messages. Removed on -## _disable_plugin so disabling the plugin leaves project.godot clean. -func _enable_plugin() -> void: - if _mcp_disabled_for_headless_launch(): +func _present_error(message: String) -> void: + _present_state(message, true) + + +func _activate_prepared(package: Dictionary) -> void: + _free_bridge() + print("MCP | v3 bridge activating canonical v4 tree") + _coordinator = MigrationCoordinator.new() + _coordinator.state_changed.connect(_present_state) + _parent_node().add_child(_coordinator) + _coordinator.start(package) + + +func _on_retry() -> void: + if not _meets_floor(): + _present_state(FLOOR_MESSAGE, true, false) return - _ensure_game_helper_autoload() + if not _editor_available(): + _present_state(EDITOR_REQUIRED, true, false) + return + _start_migration() -static func _mcp_disabled_for_headless_launch() -> bool: - return _mcp_disabled_for_headless( - OS.get_cmdline_args(), - DisplayServer.get_name(), - OS.get_environment("GODOT_AI_ALLOW_HEADLESS") - ) +func _free_bridge() -> void: + if _bridge != null and is_instance_valid(_bridge): + _bridge.queue_free() + _bridge = null -static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: - if McpSettings.truthy(allow_value): +func _parent_node() -> Node: + var parent: Node = EditorInterface.get_base_control() + return parent if parent != null else get_tree().root + + +static func _meets_floor() -> bool: + ## Test-only: lets a scenario exercise the refusal path on a current Godot. + if OS.get_environment("GODOT_AI_TEST_GODOT_FLOOR") == "unmet": return false - return _args_request_headless(args) or display_name.to_lower() == "headless" + var version := Engine.get_version_info() + return int(version.get("major", 0)) == 4 and int(version.get("minor", 0)) >= 7 + + +## Updates are interactive-only: a headless import or an export run loads +## enabled plugins too, and must never swap the add-on tree or restart. +static func _editor_available() -> bool: + if not Engine.is_editor_hint(): + return false + if _truthy(OS.get_environment("GODOT_AI_ALLOW_HEADLESS")): + ## The override the v4 plugin honours too, so CI can drive the + ## crossing in a headless editor. Never set it for a real install. + return EditorInterface.get_base_control() != null + if DisplayServer.get_name().to_lower() == "headless": + return false + if _args_request_headless(OS.get_cmdline_args()): + return false + return EditorInterface.get_base_control() != null + + +static func _truthy(value: String) -> bool: + var lowered := value.strip_edges().to_lower() + return lowered == "1" or lowered == "true" or lowered == "yes" or lowered == "on" static func _args_request_headless(args: PackedStringArray) -> bool: @@ -773,1356 +186,17 @@ static func _args_request_headless(args: PackedStringArray) -> bool: return false - - -func _disable_plugin() -> void: - var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME - if not ProjectSettings.has_setting(key): - return - ProjectSettings.clear(key) - ProjectSettings.save() - - -func _ensure_game_helper_autoload() -> void: - ## Write the autoload directly to ProjectSettings and save immediately. - ## EditorPlugin.add_autoload_singleton only mutates in-memory settings — - ## the on-disk project.godot is only persisted when the editor saves - ## (e.g. on quit). CI spawns the game subprocess before any save fires, - ## so the child process never sees the autoload and the capture times - ## out. Mirror AutoloadHandler's pattern: set_setting + save(). - var key := "autoload/" + GAME_HELPER_AUTOLOAD_NAME - var value := "*" + GAME_HELPER_AUTOLOAD_PATH # "*" prefix = singleton - if ProjectSettings.get_setting(key, "") == value: - return ## already registered with the right target - ProjectSettings.set_setting(key, value) - ProjectSettings.set_initial_value(key, "") - ProjectSettings.set_as_basic(key, true) - var err := ProjectSettings.save() - if err != OK: - push_warning("MCP: failed to save project.godot after registering %s autoload (error %d)" - % [GAME_HELPER_AUTOLOAD_NAME, err]) - - -func _startup_trace_begin() -> void: - _startup_trace_enabled = ClientConfigurator.startup_trace_enabled() - if not _startup_trace_enabled: - return - _startup_trace_start_ms = Time.get_ticks_msec() - _startup_trace_last_ms = _startup_trace_start_ms - _startup_trace_netsh_start_count = WindowsPortReservation.netsh_query_count() - _startup_trace_counters.clear() - for counter in STARTUP_TRACE_COUNTER_NAMES: - _startup_trace_counters[counter] = 0 - print( - "MCP startup trace | begin platform=%s http_port=%d ws_port=%d" - % [ - OS.get_name(), - ClientConfigurator.http_port(), - ClientConfigurator.ws_port(), - ] - ) - - -func _startup_trace_count(counter: String, amount: int = 1) -> void: - if not _startup_trace_enabled: - return - _startup_trace_mutex.lock() - _startup_trace_counters[counter] = int(_startup_trace_counters.get(counter, 0)) + amount - _startup_trace_mutex.unlock() - - -func _startup_trace_phase(name: String) -> void: - if not _startup_trace_enabled: - return - var now := Time.get_ticks_msec() - print( - "MCP startup trace | phase=%s delta_ms=%d total_ms=%d" - % [name, now - _startup_trace_last_ms, now - _startup_trace_start_ms] - ) - _startup_trace_last_ms = now - - -func _startup_trace_finish(path: String) -> void: - if not _startup_trace_enabled: - return - var now := Time.get_ticks_msec() - ## Same lock as _startup_trace_count — a worker probe may still be - ## bumping counters while this reads/writes the shared dictionary. - _startup_trace_mutex.lock() - _startup_trace_counters["netsh"] = ( - WindowsPortReservation.netsh_query_count() - _startup_trace_netsh_start_count - ) - var counters_snapshot: Dictionary = _startup_trace_counters.duplicate() - _startup_trace_mutex.unlock() - print( - "MCP startup trace | done path=%s total_ms=%d counters=%s" - % [path, now - _startup_trace_start_ms, str(counters_snapshot)] - ) - - -func _start_server() -> void: - ## Fire-and-forget: the walk is a coroutine in production (#678). Its - ## completion continuation must NOT live in this method — a reload can - ## free this plugin while the walk is suspended, and resuming a freed - ## Node's coroutine errors out. The manager calls - ## `_finish_startup_trace_after_walk` on walk completion instead, - ## guarded by is_instance_valid. - _lifecycle.start_server() - - -## Called by the lifecycle manager when the (possibly suspended) startup -## walk completes — the point where the real startup outcome is known, so -## the trace 'done' line reports the true contended-port path and duration -## instead of a pre-walk placeholder (#682 review). -func _finish_startup_trace_after_walk() -> void: - var startup_path: String = str(_lifecycle.get_startup_path()) - _startup_trace_finish(startup_path if not startup_path.is_empty() else "loaded") - - -## Test-fixture shim — characterization tests in test_plugin_lifecycle -## reach for this instance method directly. Delegates to the manager's -## state-owning copy. -func _set_incompatible_server(live: Dictionary, expected_version: String, port: int) -> void: - _lifecycle._set_incompatible_server(live, expected_version, port) - - -## Static shim — kept on the plugin class because the characterization -## tests assert against `GodotAiPlugin._incompatible_server_message`. -## Implementation moved to ServerLifecycleManager. -static func _incompatible_server_message( - live: Dictionary, - expected_version: String, - port: int, - expected_ws_port: int -) -> String: - return ServerLifecycleManager._incompatible_server_message( - live, expected_version, port, expected_ws_port - ) - - -static func _server_version_compatibility( - actual_version: String, expected_version: String -) -> Dictionary: - return ServerLifecycleManager._server_version_compatibility( - actual_version, expected_version - ) - - -static func _server_status_compatibility( - actual_version: String, - expected_version: String, - actual_ws_port: int, - expected_ws_port: int, -) -> Dictionary: - return ServerLifecycleManager._server_status_compatibility( - actual_version, expected_version, actual_ws_port, expected_ws_port - ) - - -static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: - return ServerLifecycleManager._managed_record_has_version_drift(record_version, current_version) - - -static func _probe_live_server_status(port: int, timeout_ms: int = SERVER_STATUS_PROBE_TIMEOUT_MS) -> Dictionary: - var result := { - "reachable": false, - "version": "", - "name": "", - "ws_port": 0, - "status_code": 0, - "error": "", - } - var client := HTTPClient.new() - var err := client.connect_to_host("127.0.0.1", port) - if err != OK: - result["error"] = "connect_%d" % err - return result - var deadline := Time.get_ticks_msec() + timeout_ms - while client.get_status() == HTTPClient.STATUS_RESOLVING or client.get_status() == HTTPClient.STATUS_CONNECTING: - client.poll() - if Time.get_ticks_msec() >= deadline: - result["error"] = "connect_timeout" - return result - OS.delay_msec(10) - if client.get_status() != HTTPClient.STATUS_CONNECTED: - result["error"] = "connect_status_%d" % client.get_status() - return result - err = client.request(HTTPClient.METHOD_GET, SERVER_STATUS_PATH, ["Accept: application/json"]) - if err != OK: - result["error"] = "request_%d" % err - return result - var body := PackedByteArray() - while true: - var status := client.get_status() - if status == HTTPClient.STATUS_REQUESTING: - client.poll() - elif status == HTTPClient.STATUS_BODY: - client.poll() - var chunk := client.read_response_body_chunk() - if chunk.size() > 0: - body.append_array(chunk) - elif status == HTTPClient.STATUS_CONNECTED: - break - else: - result["error"] = "response_status_%d" % status - return result - if Time.get_ticks_msec() >= deadline: - result["error"] = "response_timeout" - return result - OS.delay_msec(10) - var response_code := client.get_response_code() - result["status_code"] = response_code - if response_code != 200: - result["error"] = "http_%d" % response_code - return result - var parsed = JSON.parse_string(body.get_string_from_utf8()) - if not (parsed is Dictionary): - result["error"] = "invalid_json" - return result - result.merge(_project_status_payload(parsed), true) - return result - - -## Project a parsed `/godot-ai/status` body into the probe's result shape. -## -## Extracted from the probe so it can be tested against a real payload. The -## probe is a whitelist — a field the server publishes does not reach callers -## unless it is copied here — and that is silent: the consumer just sees a -## missing key. #824's lease check shipped reading `active_lease_count` while -## this projection dropped it, so the branch was dead on every platform, and -## the tests could not see it because they hand-built the result dict this -## function is supposed to produce. Add new fields here, and cover them with a -## projection test rather than a fabricated `live_status`. -static func _project_status_payload(parsed: Dictionary) -> Dictionary: - var projected := { - "reachable": true, - "name": str(parsed.get("name", "")), - "version": _extract_server_version(parsed), - "ws_port": int(parsed.get("ws_port", 0)), - ## `package_path` was added in v2.4.4 (#416) so the dock's - ## "Incompatible server" banner can name the source of a version - ## skew. Older servers omit it; treat the missing field as "". - "package_path": str(parsed.get("package_path", "")), - } - ## #824: advisory attach-lease count, consumed by teardown to decide - ## detach-vs-kill. Absent stays absent rather than defaulting to 0, so - ## `ServerLifecycleManager.active_lease_count` keeps distinguishing "backend - ## too old to publish this" from "backend reports zero leases" — both stop - ## the server, but only one of them is a compatibility statement. - ## Anything that is not a finite whole number is dropped, for the same - ## reason the value is clamped downstream: a malformed count must not read - ## as occupancy and keep a server alive. Godot parses every JSON number as - ## a float, so the whole-number test is what distinguishes a real count - ## from junk — truncating 1.5 to 1 would manufacture a held lease. - var raw: Variant = parsed.get("active_lease_count") - if raw is int or raw is float: - var numeric := float(raw) - if is_finite(numeric) and numeric == floor(numeric): - projected["active_lease_count"] = int(numeric) - return projected - - -func _probe_live_server_status_for_port(port: int) -> Dictionary: - _startup_trace_count("http_status_probe") - return _probe_live_server_status(port) - - -static func _extract_server_version(payload: Dictionary) -> String: - var version := str(payload.get("server_version", "")) - if version.is_empty(): - version = str(payload.get("version", "")) - return version - - -static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: - return ServerLifecycleManager._live_status_identifies_godot_ai(live) - - -func _verified_status_version(live: Dictionary) -> String: - if not ServerLifecycleManager._live_status_identifies_godot_ai(live): - return "" - return str(live.get("version", "")) - - -func _verified_status_ws_port(live: Dictionary) -> int: - if not ServerLifecycleManager._live_status_identifies_godot_ai(live): - return 0 - return int(live.get("ws_port", 0)) - - -func _refresh_dock_client_statuses() -> bool: - if _dock == null: - return false - if not _dock.has_method("_refresh_all_client_statuses"): - return false - _dock.call("_refresh_all_client_statuses") - return true - - -## Test-fixture shim — characterization tests in test_plugin_lifecycle -## still drive the first-writer-wins terminal-diagnosis behaviour through -## this method. Delegates to the manager's `set_terminal_diagnosis` -## (which preserves the same first-writer-wins contract). -func _set_spawn_state(state: int) -> void: - _lifecycle.set_terminal_diagnosis(state) - - -## Arm the one-shot connection watcher. Called from `_start_server`'s -## FOREIGN_PORT branch: we flagged the diagnostic preemptively assuming -## the port holder doesn't speak MCP, but if it turns out to be another -## editor's server our WebSocket will open and we need to retract the -## diagnostic. -## -## We intentionally poll `_connection.is_connected` from `_process` -## instead of wiring a new signal on McpConnection. A signal added in the -## same release as a new consumer would be another shape-coupled update: -## old two-phase runners can parse the consumer while the McpConnection -## Script object still reflects v(N). Polling only reads `is_connected` -## (present on every shipped McpConnection), so old-runner upgrade windows -## do not depend on a same-release signal addition. -## -## The watch self-disarms after SPAWN_GRACE_MS so per-frame cost drops -## back to zero if it is ever armed by a legacy adoption path. -func _watch_for_adoption_confirmation() -> void: - _lifecycle.arm_adoption_watch() - _update_process_enabled() - - -func _arm_server_version_check() -> void: - ## `arm_version_check` resolves an empty expected via the plugin - ## version, so we can pass the raw field value through. - _lifecycle.arm_version_check(_connection, str(_lifecycle._server_expected_version)) - _update_process_enabled() - - -func _update_process_enabled() -> void: - if _lifecycle == null: - set_process(false) - return - set_process( - _lifecycle.get_adoption_watch_deadline_ms() > 0 - or _lifecycle.is_awaiting_server_version() - ) - - -func _process(_delta: float) -> void: - ## Guard: during script-reload / dual-plugin enable races `_lifecycle` - ## can be null while process is still armed — spam would otherwise flood - ## the Output dock every frame. - if _lifecycle == null: - set_process(false) - return - var now := Time.get_ticks_msec() - var version_check = _lifecycle.get_version_check() - if version_check != null: - version_check.tick(now) - _lifecycle.tick_adoption_watch(now) - _update_process_enabled() - - -## A WebSocket opening only proves the occupant speaks enough of the editor -## protocol to accept a session. Compatibility is decided by the server -## version in `handshake_ack`, so this only arms that check. -func _on_connection_established() -> void: - if _lifecycle.get_state() == ServerStateScript.FOREIGN_PORT: - _arm_server_version_check() - - -## Test-fixture shim — characterization tests poke the verified path -## directly. Delegates to the version-check seam; the manager resolves -## an empty expected version via `_resolve_expected_version`. -func _on_server_version_verified(version: String) -> void: - _lifecycle.handle_server_version_verified( - str(_lifecycle._server_expected_version), version - ) - _update_process_enabled() - - -## Test-fixture shim — same shape as `_on_server_version_verified`. -func _on_server_version_unverified() -> void: - _lifecycle.handle_server_version_unverified( - str(_lifecycle._server_expected_version) - ) - _update_process_enabled() - - -## Start a 1s-tick timer that watches the spawned server for up to -## SERVER_WATCH_MS. If the process dies inside the window we drain the -## captured pipes and mark the server as crashed so the dock can surface -## what went wrong. After the window expires we close the pipes so they -## don't pin file descriptors or fill their kernel buffers. See #146. -func _start_server_watch() -> void: - _stop_server_watch() - _server_watch_timer = Timer.new() - _server_watch_timer.wait_time = 1.0 - _server_watch_timer.one_shot = false - _server_watch_timer.timeout.connect(_check_server_health) - add_child(_server_watch_timer) - _server_watch_timer.start() - - -func _stop_server_watch() -> void: - if _server_watch_timer != null: - _server_watch_timer.stop() - _server_watch_timer.queue_free() - _server_watch_timer = null - - -func _check_server_health() -> void: - _lifecycle.check_server_health() - - -## True when the first spawn looks like a stale-uvx-index failure and we -## haven't already retried. Fail signal: launcher process already declared -## dead by the caller, pid-file was never written (Python never got to -## argparse), and we're on the uvx tier (the only tier where `--refresh` -## means anything). Bug #172 — after a fresh PyPI publish, uvx's local -## index metadata keeps saying the new version doesn't exist for ~10 min, -## which cascaded into an infinite reconnect loop pre-#171. Retry-at-spawn -## catches every entry path (Update, Reload Plugin, Reconnect, editor -## restart, crash recovery) — unlike the older Update-only precheck. -func _should_retry_with_refresh() -> bool: - return _retry_with_refresh_allowed( - _lifecycle._refresh_retried, - ClientConfigurator.get_server_launch_mode(), - _read_pid_file(), - ) - - -## Pure decision helper — environment-state readers stay in the instance -## method above, the logic lives here so tests can drive the three inputs -## directly without spoofing static caches or pid-files on disk. -static func _retry_with_refresh_allowed(already_retried: bool, launch_mode: String, pid_from_file: int) -> bool: - return ( - not already_retried - and launch_mode == "uvx" - and pid_from_file == 0 - ) - - -func _respawn_with_refresh() -> void: - _lifecycle.respawn_with_refresh() - - -## Snapshot of the server-spawn outcome for the dock. -## -## `state` is one of the `McpServerState.*` int constants; the dock owns -## the UI copy per state via its own `_crash_body_for_state`. `exit_ms` -## is only meaningful for `CRASHED`. -func get_server_status() -> Dictionary: - return _lifecycle.get_status_dict() - - -## Diagnostic accessor for the dock's ownership label. Positive = a PID this -## plugin instance spawned (or re-acquired via the managed record); -1 = an -## adopted external/attach-owned backend. Display only — adoption transfers -## end-of-life responsibility, so this value is never kill proof (#669). -func get_server_pid() -> int: - return _lifecycle.get_server_pid() - - -func get_resolved_ws_port() -> int: - return _resolved_ws_port - - -func _set_resolved_ws_port(port: int) -> void: - _ws_port_resolution_published = true - _resolved_ws_port = port - if _connection != null: - _connection.ws_port = port - - -## Pure decision helper — environment-state reads (the published flag, the -## EditorSettings port) stay in `_enter_tree`; the logic lives here so tests -## can drive the three inputs directly without mutating the shared statics. -static func _startup_ws_port_seed( - resolution_published: bool, - session_ws_port: int, - configured_ws_port: int -) -> int: - return session_ws_port if resolution_published else configured_ws_port - - -func _resolve_ws_port() -> int: - return PortResolver.resolve_ws_port( - ClientConfigurator.ws_port(), - ClientConfigurator.MAX_PORT, - _log_buffer, - ) - - -## Test-compat shim — characterization tests call this static directly. -static func _resolved_ws_port_for_existing_server( - record_ws_port: int, - record_version: String, - current_version: String, - fresh_resolved: int -) -> int: - return PortResolver.resolved_ws_port_for_existing_server( - record_ws_port, - record_version, - current_version, - fresh_resolved, - ) - - -static func _resolve_ws_port_from_output( - configured_port: int, - netsh_output: String, - span: int = 2048 -) -> int: - return PortResolver.resolve_ws_port_from_output( - configured_port, - netsh_output, - ClientConfigurator.MAX_PORT, - span, - ) - - -## Plugin-level shim around the resolver — keeps the startup-trace -## counter wiring and the `_ProofPlugin` override hook on the plugin. -## The scrape takes `_startup_trace_count` directly so the counter names -## track the scraper that actually ran (Windows can fall through netstat -## → PowerShell; the fallback used to hide under the `netstat` count). -func _is_port_in_use(port: int) -> bool: - if PortResolver.can_bind_local_port(port): - ## POSIX can still have an IPv6 wildcard listener on this port - ## even when an IPv4 loopback bind succeeds. Confirm through - ## lsof so startup and kill-path discovery agree. - if OS.get_name() != "Windows": - return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) - return false - return PortResolver.is_port_in_use_via_scrape(port, _startup_trace_count) - - -## Pass `_startup_trace_count` so the resolver bumps the right counter -## per scraper that actually ran (Windows can fall through netstat → -## PowerShell — counting both unconditionally would over-report). -func _find_pid_on_port(port: int) -> int: - return PortResolver.find_pid_on_port(port, _startup_trace_count) - - -func _find_all_pids_on_port(port: int) -> Array[int]: - return PortResolver.find_all_pids_on_port(port, _startup_trace_count) - - -static func _execute_windows_powershell(script: String, output: Array) -> int: - return PortResolver.execute_windows_powershell(script, output) - - -static func _windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: - return PortResolver.windows_listener_pids_from_execute_result(exit_code, output) - - -static func _windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: - return PortResolver.windows_listener_execute_result_in_use(exit_code, output) - - -static func _parse_lsof_pids(raw: String) -> Array[int]: - return PortResolver.parse_lsof_pids(raw) - - -static func _parse_pid_lines(raw: String) -> Array[int]: - return PortResolver.parse_pid_lines(raw) - - -## Find the managed server PID deterministically: prefer the pid-file -## the Python server writes on startup (see runtime_info.py), fall back -## to scraping `netstat -ano` / `lsof` only when the file is missing or -## stale. This is the replacement for raw port-scraping: on Windows the -## uvx launcher PID doesn't cover the Python child, and netstat parsing -## is fragile. -## -## Returns 0 when no server can be identified. -func _find_managed_pid(port: int) -> int: - var pid := _read_pid_file() - if pid > 0 and _pid_alive(pid): - return pid - return _find_pid_on_port(port) - - -## `live` is the result of a prior `_probe_live_server_status_for_port` -## call that the caller already has on hand. When non-empty it short- -## circuits the internal probe at the bottom of this helper, so a single -## `_start_server` invocation that probes once at the top can thread the -## same snapshot through compatibility check + recovery without paying -## for a second ~500 ms localhost HTTPClient poll loop. Default `{}` -## preserves the historical behavior for callers outside the spawn flow -## (`can_recover_incompatible_server`, the dock's UI buttons), where a -## fresh probe is the right thing. -## `record_override`: a managed-server record snapshot the caller already -## read. Non-empty skips the internal `_read_managed_server_record()` — -## required when this helper runs on a worker thread (#678), because the -## record lives in EditorSettings, which is main-thread-only. `{}` keeps -## the historical read-it-here behavior for synchronous callers -## (`_read_managed_server_record` never returns a bare `{}`, so the -## sentinel is unambiguous). -func _evaluate_strong_port_occupant_proof(port: int, live: Dictionary = {}, record_override: Dictionary = {}) -> Dictionary: - var result := {"proof": "", "pids": []} - var listener_pids := _find_all_pids_on_port(port) - if listener_pids.is_empty(): - return result - - var record: Dictionary = record_override if not record_override.is_empty() else _read_managed_server_record() - var record_pid := int(record.get("pid", 0)) - var record_version := str(record.get("version", "")) - - if record_pid > 1 and record_pid != OS.get_process_id(): - ## Brand-verify the recorded PID before trusting it as a kill target. - ## A recorded PID can outlive the server it named and be recycled by - ## the kernel for an unrelated process that happens to bind the same - ## port — without the cmdline brand gate (the same one the - ## `pidfile_listener` branch enforces) that process could be killed. - ## See #525. - if ( - listener_pids.has(record_pid) - and _pid_alive_for_proof(record_pid) - and _pid_cmdline_is_godot_ai_for_proof(record_pid) - ): - return {"proof": "managed_record", "pids": [record_pid]} - - var legacy_targets := _legacy_pidfile_kill_targets(port, listener_pids) - if not legacy_targets.is_empty(): - return {"proof": "pidfile_listener", "pids": legacy_targets} - - var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) - if ( - _live_status_identifies_godot_ai(current_live) - and not record_version.is_empty() - and str(current_live.get("version", "")) == record_version - ): - ## Brand-check every listener before returning it as a kill target - ## (#686): the /godot-ai/status match proves *a* godot-ai server owns - ## the port, but `listener_pids` is a raw scrape that can include an - ## unrelated process sharing the port number (e.g. a ::1-only - ## listener lsof reports alongside our IPv4 one). The other two tiers - ## brand-check every target (#525); this tier feeds the fully - ## automatic start_server drift-kill path, so it must too. - var branded_listeners: Array[int] = [] - for pid in listener_pids: - var listener_pid := int(pid) - if _pid_cmdline_is_godot_ai_for_proof(listener_pid): - branded_listeners.append(listener_pid) - if not branded_listeners.is_empty(): - return {"proof": "status_matches_record", "pids": branded_listeners} - - return result - - -## See `_evaluate_strong_port_occupant_proof` for the `live` and -## `record_override` contracts. Threads both through the strong-proof -## delegate so neither helper probes when the caller already knows the -## port-owner status, and so callers running this on a worker thread -## (#712) can inject the EditorSettings record read on the main thread. -func _evaluate_recovery_port_occupant_proof( - port: int, live: Dictionary = {}, record_override: Dictionary = {} -) -> Dictionary: - var proof := _evaluate_strong_port_occupant_proof(port, live, record_override) - if not str(proof.get("proof", "")).is_empty(): - return proof - - var current_live: Dictionary = live if not live.is_empty() else _probe_live_server_status_for_port(port) - if _live_status_identifies_godot_ai(current_live): - return {"proof": "status_name", "pids": _find_all_pids_on_port(port)} - - return {"proof": "", "pids": []} - - -## Seam over the static pre-warm so lifecycle recovery flows can fire it -## through the host (`_host._prewarm_server_package(...)`) and test stubs -## can record the call instead of spawning a real uvx process. Worker-safe: -## the static touches only CliFinder (mutex-guarded) and OS.create_process. -func _prewarm_server_package(version: String) -> int: - return ClientConfigurator.prewarm_server_package(version) - - -func _recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: - ## `await` because the manager method is a coroutine in production - ## (#678); with `defer_blocking_work` off it completes synchronously - ## and this await is a pass-through. - return await _lifecycle.recover_strong_port_occupant(port, wait_s, pre_kill_live) - - -func _legacy_pidfile_kill_targets(_port: int, listener_pids: Array[int]) -> Array[int]: - var targets: Array[int] = [] - var pidfile_pid := _read_pid_file_for_proof() - if pidfile_pid <= 1 or pidfile_pid == OS.get_process_id(): - return targets - ## An alive, branded pid-file PID is sufficient ownership proof. Under - ## `uvicorn --reload` the reloader writes the pid-file but a child worker - ## binds the port, so `listener_pids` never contains the reloader PID. - ## Requiring `listener_pids.has(pidfile_pid)` here used to silently skip - ## the kill path for the entire reload-shaped server family. The branded - ## listener loop below still does the per-PID brand check so we never - ## kill an unrelated process that happens to share the port. - if not _pid_alive_for_proof(pidfile_pid) or not _pid_cmdline_is_godot_ai_for_proof(pidfile_pid): - return targets - - for pid in listener_pids: - if pid <= 1 or pid == OS.get_process_id(): - continue - ## Reuse the brand result already proven above when this listener is - ## the same PID as the pidfile — saves a parent-chain walk and a - ## shell-out (PowerShell on Windows, /proc on Linux, ps on macOS) per - ## startup proof evaluation. - if pid == pidfile_pid or _pid_cmdline_is_godot_ai_for_proof(pid): - targets.append(pid) - ## Also kill the reloader/launcher itself when it isn't already a listener. - ## Without this, `--reload` workers would be killed but their parent would - ## immediately respawn a replacement and the port would never free. - if not targets.has(pidfile_pid): - targets.append(pidfile_pid) - return targets - - -func _read_pid_file_for_proof() -> int: - return _read_pid_file() - - -func _pid_alive_for_proof(pid: int) -> bool: - return _pid_alive(pid) - - -func _pid_cmdline_is_godot_ai_for_proof(pid: int) -> bool: - return _pid_cmdline_is_godot_ai(pid) - - -static func _parse_windows_netstat_pid(stdout: String, port: int) -> int: - return PortResolver.parse_windows_netstat_pid(stdout, port) - - -static func _parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: - return PortResolver.parse_windows_netstat_pids(stdout, port) - - -static func _parse_windows_netstat_listening(stdout: String, port: int) -> bool: - return PortResolver.parse_windows_netstat_listening(stdout, port) - - -static func _split_on_whitespace(s: String) -> PackedStringArray: - return PortResolver.split_on_whitespace(s) - - -static func _read_pid_file() -> int: - return PortResolver.read_pid_file() - - -static func _clear_pid_file() -> void: - PortResolver.clear_pid_file() - - -func _stop_server() -> void: - _lifecycle.stop_server() - - - - -## Clear the managed-server record and pid-file only if `port` is free. -## Returns true when state was cleared. Extracted from `_stop_server` so -## the "preserve on failed kill" contract is independently testable. -func _finalize_stop_if_port_free(port: int) -> bool: - if _is_port_in_use(port): - return false - _clear_managed_server_record() - _clear_pid_file() - return true - - -## Shared tail of the server CLI: transport, ports, and `--pid-file`. Both -## the initial spawn in `_start_server` and the `--refresh` retry in -## `_respawn_with_refresh` go through here so a new flag added in one place -## can't silently drop out of the other. -static func _build_server_flags(port: int, ws_port: int) -> Array[String]: - var flags: Array[String] = [] - flags.assign([ - "--transport", "streamable-http", - "--port", str(port), - "--ws-port", str(ws_port), - "--pid-file", ProjectSettings.globalize_path(SERVER_PID_FILE), - ]) - ## Append `--exclude-domains` only when the user has actually picked at - ## least one domain to drop. Skipping the empty case keeps spawns - ## compatible with older (pre-1.4.2) servers that don't know the flag — - ## relevant during staggered plugin/server upgrades in user-mode installs. - var excluded := ClientConfigurator.excluded_domains() - if not excluded.is_empty(): - flags.append("--exclude-domains") - flags.append(excluded) - ## LAN opt-in (#507, server core #421): pass `--allow-host` only when the - ## developer-mode Settings tab named at least one CIDR / bare IP. Skipping - ## the empty case keeps the default spawn byte-for-byte identical and - ## compatible with older servers that don't know the flag — same pattern - ## as `--exclude-domains` above. - var allow_hosts := ClientConfigurator.allow_hosts() - if not allow_hosts.is_empty(): - flags.append("--allow-host") - flags.append(allow_hosts) - return flags - - -## Returns true only when we can prove `pid`'s command line carries the -## `godot-ai` brand AND a server flag (`--pid-file` / `--transport`). Used by -## automatic kill paths (`_legacy_pidfile_kill_targets`) so a stale pidfile -## whose PID has been recycled by an unrelated listener can't hand us a -## kill target. If the OS lookup fails or returns an empty cmdline we -## conservatively return false — better to surface incompatible-server and -## let the user click Restart than to kill the wrong process. -func _pid_cmdline_is_godot_ai(pid: int) -> bool: - ## Walks up the parent chain so a uvicorn `--reload` worker whose - ## cmdline is just `multiprocessing.spawn` still matches when its - ## parent reloader carries the godot_ai brand. Bound the walk so a - ## hypothetical loop or runaway PPID can't stall the editor. - var current := pid - for _i in range(5): - if current <= 1: - return false - var cmd := "" - if OS.get_name() == "Windows": - cmd = _windows_pid_commandline(current) - else: - cmd = _posix_pid_commandline(current) - if _commandline_is_godot_ai_server(cmd): - return true - current = _pid_parent(current) - return false - - -func _pid_parent(pid: int) -> int: - if pid <= 1: - return 0 - if OS.get_name() == "Windows": - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " - + "Select-Object -ExpandProperty ParentProcessId" - ) % pid - _startup_trace_count("powershell") - if _execute_windows_powershell(script, output) != 0 or output.is_empty(): - return 0 - return int(str(output[0]).strip_edges()) - var output_posix: Array = [] - if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output_posix, true) != 0 or output_posix.is_empty(): - return 0 - return int(str(output_posix[0]).strip_edges()) - - -static func _commandline_is_godot_ai_server(cmd: String) -> bool: - if cmd.is_empty(): - return false - var lower := cmd.to_lower() - ## The server is invoked with `--pid-file /godot_ai_server.pid`, - ## so the path itself contains "godot_ai". A naive substring brand - ## search would falsely match an unrelated process whose cmdline - ## happens to reference a similarly-named pidfile path. Strip the - ## value (but leave the bare flag for the has_flag check) before - ## brand matching. - var brand_search := _strip_pidfile_value(lower) - var has_brand := brand_search.find("godot-ai") >= 0 or brand_search.find("godot_ai") >= 0 - var has_flag := lower.find("--pid-file") >= 0 or lower.find("--transport") >= 0 - return has_brand and has_flag - - -static func _strip_pidfile_value(cmd: String) -> String: - var rx := RegEx.new() - ## Match `--pid-file=` and `--pid-file `; keep the bare - ## flag so the flag-presence check still succeeds for a real server. - if rx.compile("--pid-file(?:=|\\s+)\\S+") != OK: - return cmd - return rx.sub(cmd, "--pid-file ", true) - - -func _windows_pid_commandline(pid: int) -> String: - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " - + "Select-Object -ExpandProperty CommandLine" - ) % pid - _startup_trace_count("powershell") - var exit_code := _execute_windows_powershell(script, output) - if exit_code != 0 or output.is_empty(): - return "" - return str(output[0]) - - -## POSIX command-line lookup. Linux exposes `/proc//cmdline` as -## NUL-separated argv — read it directly so we avoid a `ps` fork on Linux -## and get the full argv rather than the truncated/quoted form some `ps` -## builds emit. Falls back to `ps -ww -p -o args=` on macOS / *BSD, -## which lack a Linux-style `/proc//cmdline`. Returns "" on failure -## so callers conservatively reject the PID rather than killing it blind. -func _posix_pid_commandline(pid: int) -> String: - var proc_path := "/proc/%d/cmdline" % pid - if FileAccess.file_exists(proc_path): - var f := FileAccess.open(proc_path, FileAccess.READ) - if f != null: - ## procfs pseudo-files report length 0 (the kernel generates - ## content on read). `get_length()` therefore returns 0 and - ## `get_buffer(0)` reads nothing. Read in chunks until EOF - ## instead. Cap at ARG_MAX-class bound so a hypothetically - ## misbehaving file can never stall the editor frame. - var bytes := PackedByteArray() - var max_bytes := 1 << 20 # 1 MiB - while bytes.size() < max_bytes: - var chunk := f.get_buffer(4096) - if chunk.is_empty(): - break - bytes.append_array(chunk) - if f.eof_reached(): - break - f.close() - ## /proc cmdline is NUL-separated argv; convert NULs to spaces - ## so the substring fingerprint matches the same way it does on - ## the Windows path. Empty (kernel threads, exited processes) - ## bubbles up as "" via the strip below. - for i in range(bytes.size()): - if bytes[i] == 0: - bytes[i] = 0x20 - return bytes.get_string_from_utf8().strip_edges() - ## `-ww` removes ps's column-width truncation so trailing flags like - ## --pid-file / --transport aren't dropped from the args= field. - ## Both procps (Linux) and BSD ps (macOS / *BSD) accept the - ## double-w form. - var output: Array = [] - var exit_code := OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) - if exit_code != 0 or output.is_empty(): - return "" - return str(output[0]).strip_edges() - - -## True if the given PID corresponds to a live (non-zombie) process. -## POSIX uses `ps -o stat=` (see inline comment for the zombie rationale); -## Windows uses `tasklist`. Called by `_start_server` to distinguish a live -## managed server that outlived its editor from a stale EditorSettings -## record, and by `_check_server_health` to detect a fast-failing launcher. -static func _pid_alive(pid: int) -> bool: - return PortResolver.pid_alive(pid) - - -## Calls `_is_port_in_use` (not `PortResolver.wait_for_port_free`) so -## `_ProofPlugin` overrides keep driving the loop. -func _wait_for_port_free(port: int, timeout_s: float) -> void: - var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) - while _is_port_in_use(port): - if Time.get_ticks_msec() >= deadline: - push_warning("MCP | port %d still in use after %.1fs — proceeding anyway" % [port, timeout_s]) - return - OS.delay_msec(100) - - -func _read_managed_server_record() -> Dictionary: - var es := EditorInterface.get_editor_settings() - if es == null: - return {"pid": 0, "version": "", "ws_port": 0, "ws_token": "", "keep_alive": false} - var pid: int = 0 - if es.has_setting(MANAGED_SERVER_PID_SETTING): - pid = int(es.get_setting(MANAGED_SERVER_PID_SETTING)) - var version: String = "" - if es.has_setting(MANAGED_SERVER_VERSION_SETTING): - version = str(es.get_setting(MANAGED_SERVER_VERSION_SETTING)) - var ws_port: int = 0 - if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): - ws_port = int(es.get_setting(MANAGED_SERVER_WS_PORT_SETTING)) - var ws_token: String = "" - if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): - ws_token = str(es.get_setting(MANAGED_SERVER_WS_TOKEN_SETTING)) - var keep_alive := false - if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): - keep_alive = bool(es.get_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING)) - return { - "pid": pid, - "version": version, - "ws_port": ws_port, - "ws_token": ws_token, - "keep_alive": keep_alive, - } - - -func _write_managed_server_record(pid: int, version: String, keep_alive: bool = false) -> void: - var es := EditorInterface.get_editor_settings() - if es == null: - return - es.set_setting(MANAGED_SERVER_PID_SETTING, pid) - es.set_setting(MANAGED_SERVER_VERSION_SETTING, version) - es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, _resolved_ws_port) - es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, _ws_auth_token) - es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, keep_alive) - - -## Keep the in-memory token, the connection's handshake field, and (via the -## next _write_managed_server_record) the persisted record in one place so -## the three can't drift. Empty token = "send no auth_token field". -func _set_ws_auth_token(token: String) -> void: - _ws_auth_token = token - if _connection != null: - _connection.auth_token = token - - -func _clear_managed_server_record() -> void: - ## Drop the in-memory token together with the persisted one: a cleared - ## record means "no managed server", and a surviving static would make - ## the next handshake send a stale token — the exact present-but-wrong - ## shape a newer spawned server rejects with 4003. (Runs before the - ## es == null early return on purpose: the in-memory scrub must not - ## depend on EditorSettings being available.) - _set_ws_auth_token("") - var es := EditorInterface.get_editor_settings() - if es == null: - return - if es.has_setting(MANAGED_SERVER_PID_SETTING): - es.set_setting(MANAGED_SERVER_PID_SETTING, 0) - if es.has_setting(MANAGED_SERVER_VERSION_SETTING): - es.set_setting(MANAGED_SERVER_VERSION_SETTING, "") - if es.has_setting(MANAGED_SERVER_WS_PORT_SETTING): - es.set_setting(MANAGED_SERVER_WS_PORT_SETTING, 0) - if es.has_setting(MANAGED_SERVER_WS_TOKEN_SETTING): - es.set_setting(MANAGED_SERVER_WS_TOKEN_SETTING, "") - if es.has_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING): - es.set_setting(MANAGED_SERVER_KEEP_ALIVE_SETTING, false) - - -func prepare_for_update_reload() -> void: - if _dispatcher != null: - # Stop accepting handler work and hand any live status worker to its - # frame-polled teardown coroutine. _exit_tree() calls clear() again; the - # second call is intentionally inert because the caches are empty. - _dispatcher.clear() - _lifecycle.prepare_for_update_reload() - - -func _adopt_compatible_server( - record_version: String, - current_version: String, - owner: int, - record_owns_listener: bool = false -) -> String: - return _lifecycle.adopt_compatible_server( - record_version, - current_version, - owner, - record_owns_listener - ) - - -static func _compatible_adoption_log_message( - owner_label: String, - owned_pid: int, - observed_owner_pid: int, - live_version: String, - live_ws_port: int, - current_version: String -) -> String: - if owner_label == "managed": - return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ - owned_pid, - live_version, - live_ws_port, - current_version - ] - return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ - observed_owner_pid, - live_version, - live_ws_port, - current_version - ] - - -## Hand the self-update over to a tiny runner that is not owned by this -## EditorPlugin. The runner keeps the editor process alive, but disables this -## plugin before extracting/scanning the new scripts so every plugin-owned -## instance tears down on pre-update bytecode and pre-update field storage. -func install_downloaded_update(zip_path: String, temp_dir: String, source_dock: Control) -> void: - prepare_for_update_reload() - - var detached_dock = null - if _dock != null and is_instance_valid(_dock): - detached_dock = _dock - remove_control_from_docks(_dock) - _dock = null - elif source_dock != null and is_instance_valid(source_dock): - detached_dock = source_dock - remove_control_from_docks(source_dock) - - var runner = UPDATE_RELOAD_RUNNER_SCRIPT.new() - var parent: Node = EditorInterface.get_base_control() - if parent == null: - parent = get_tree().root - parent.add_child(runner) - runner.start(zip_path, temp_dir, detached_dock) - - -func can_recover_incompatible_server() -> bool: - return _lifecycle.can_recover_incompatible_server() - - -func _resume_connection_after_recovery() -> void: - if _connection == null: - return - var state: int = _lifecycle.get_state() - if ( - _lifecycle.is_connection_blocked() - or ( - state != ServerStateScript.SPAWNING - and state != ServerStateScript.READY - ) - ): - return - _connection.connect_blocked = false - _connection.connect_block_reason = "" - _connection.server_version = "" - _connection.set_process(true) - _arm_server_version_check() - - -func recover_incompatible_server(user_initiated: bool = true) -> bool: - ## A user's click (the dock's Restart) authorizes the bounded - ## stale-occupant retry for this episode, so a bridge respawning the old - ## version and winning the post-kill bind race gets re-killed - ## automatically instead of dead-ending the click in a terminal state. - ## The automatic triggers (post-update handshake mismatch, fast-exit - ## re-walk) call with `user_initiated=false` and only SPEND from the - ## already-authorized budget — re-arming there would unbound the - ## kill/respawn loop against a persistent respawner. - if user_initiated: - _lifecycle.authorize_stale_recovery() - ## `await` because the manager's recovery is a coroutine in production - ## (#678): `_resume_connection_after_recovery` gates on the post-walk - ## state, so it must not run until the respawn walk has completed. With - ## `defer_blocking_work` off this completes synchronously. - if not await _lifecycle.recover_incompatible_server(): - return false - _resume_connection_after_recovery() - return true - - -## Kill whichever process is holding `http_port()` right now — by resolving -## the port-owning PID via pid-file / netstat / lsof, independent of whether -## we ever set the manager's `_server_pid` — then clear ownership state -## and respawn via the lifecycle manager. The dock's version-mismatch -## banner wires here when the plugin adopted a foreign server whose -## `server_version` drifts from the current plugin version. -func force_restart_server() -> void: - _lifecycle.force_restart_server() - - -## Single entry point for the dock's primary "Restart Dev Server" button. -## The user clicking Restart is explicit consent to take over the HTTP port, -## so this is aggressive: any PID holding the port gets killed (managed, -## branded-dev, or orphan multiprocessing.spawn workers whose parent died -## so brand detection misses them). After the port frees we spawn a fresh -## --reload dev server. Returns true if a kill happened, false if the port -## was already free and we just spawned. -func force_restart_or_start_dev_server() -> bool: - var port := ClientConfigurator.http_port() - var killed := false - if has_managed_server(): - _lifecycle.reset_for_force_restart() - if _is_port_in_use(port): - _kill_processes_and_windows_spawn_children(_find_all_pids_on_port(port)) - killed = true - if killed: - ## OS.kill returns synchronously but uvicorn's listener can take - ## longer to release the port. Without this wait, start_dev_server's - ## fixed 500ms timer races the old shutdown and the new --reload - ## spawn fails to bind. - _wait_for_port_free(port, 5.0) - start_dev_server() - return killed - - -func start_dev_server() -> void: - ## Start a dev server with --reload that survives plugin reloads. - ## Kills any managed server first, waits for the port to free, then spawns. - ## - ## PYTHONPATH handling: when `res://` sits inside a checkout that owns a - ## `src/godot_ai/` (root repo or a git worktree), prepend that `src/` to - ## PYTHONPATH so `import godot_ai` and uvicorn's `reload_dirs` both pick - ## up *this* tree's source rather than the root repo's editable install. - ## On the root repo the path matches the installed package, so this is a - ## no-op; in a worktree it's what makes `--reload` actually watch the - ## worktree's Python. See #84. - _stop_server() - get_tree().create_timer(0.5).timeout.connect(func(): - var server_cmd := ClientConfigurator.get_server_command() - if server_cmd.is_empty(): - push_warning("MCP | could not find server command for dev server") - return - - var cmd: String = server_cmd[0] - _set_resolved_ws_port(_resolve_ws_port()) - var inner_args: Array[String] = [] - inner_args.assign(server_cmd.slice(1)) - inner_args.append_array([ - "--transport", "streamable-http", - "--port", str(ClientConfigurator.http_port()), - "--ws-port", str(_resolved_ws_port), - "--reload", - ]) - - var worktree_src := ClientConfigurator.find_worktree_src_dir(ProjectSettings.globalize_path("res://")) - var prev_pythonpath := OS.get_environment("PYTHONPATH") - if not worktree_src.is_empty(): - var sep := ";" if OS.get_name() == "Windows" else ":" - var new_pp := worktree_src if prev_pythonpath.is_empty() else worktree_src + sep + prev_pythonpath - OS.set_environment("PYTHONPATH", new_pp) - - var injected_telemetry: bool = _lifecycle._inject_telemetry_env() - var pid := OS.create_process(cmd, inner_args) - if injected_telemetry: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - - ## Restore PYTHONPATH immediately — the spawned child has already - ## copied the env, so the editor's own process state returns to - ## baseline. Leaving it set would leak to any later OS.create_process - ## from unrelated paths. - if not worktree_src.is_empty(): - if prev_pythonpath.is_empty(): - OS.unset_environment("PYTHONPATH") - else: - OS.set_environment("PYTHONPATH", prev_pythonpath) - - if pid > 0: - ## Match `server_lifecycle.gd::start_server`'s log wording — - ## "prefix" since we prepended to any pre-existing PYTHONPATH, - ## not replaced it. See #429 review. - var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" - print("MCP | started dev server with --reload (PID %d): %s %s%s" % [pid, cmd, " ".join(inner_args), suffix]) - else: - push_warning("MCP | failed to start dev server") - ) - - -func stop_dev_server() -> void: - ## Stop any server running on the HTTP port (by port, not PID). - ## Used for dev servers whose PID we don't track across reloads. - if _lifecycle.get_server_pid() > 0: - # We have a managed server — use normal stop - _stop_server() - return - ## A suspended startup walk holds pre-kill probe results; without this - ## it can resume against the listener we are about to kill and adopt a - ## dead server. - _lifecycle._invalidate_async_startup() - var port := ClientConfigurator.http_port() - var candidates: Array[int] = [] - for pid in _find_all_pids_on_port(port): - var candidate := int(pid) - if _pid_cmdline_is_godot_ai(candidate): - candidates.append(candidate) - var killed := _kill_processes_and_windows_spawn_children(candidates) - if not killed.is_empty(): - print("MCP | stopped dev server on port %d" % port) - - -## `verify_brand`: re-check `pid_alive` + the godot-ai cmdline brand -## immediately before the kill (#686). Pass true when the proof that -## nominated `pids` was evaluated in an earlier scheduling window (e.g. -## `recover_strong_port_occupant`'s proof runs in one `_run_blocking` task -## and the kill in a second, with main-thread frames in between) — a branded -## target that exits in that gap can have its PID recycled to an innocent -## process. Default false preserves the intentionally-unbranded call sites -## (the dock's explicit-consent Restart button, orphan spawn workers whose -## parent died so brand detection misses them). -func _kill_processes_and_windows_spawn_children(pids: Array[int], verify_brand: bool = false) -> Array[int]: - var unique: Array[int] = [] - for pid in pids: - if pid <= 0 or unique.has(pid): - continue - if verify_brand and not (_pid_alive_for_proof(pid) and _pid_cmdline_is_godot_ai_for_proof(pid)): - continue - unique.append(pid) - if OS.get_name() == "Windows": - for child_pid in _find_windows_spawn_children(unique): - if not unique.has(child_pid): - unique.append(child_pid) - var killed: Array[int] = [] - for pid in unique: - if OS.get_name() == "Windows": - var output: Array = [] - var exit_code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) - if exit_code == 0 or not _pid_alive(pid): - killed.append(pid) - else: - ## Mirror the Windows branch: only report the PID as killed if - ## the kill succeeded or the process is verifiably gone. - if OS.kill(pid) == OK or not _pid_alive(pid): - killed.append(pid) - return killed - - -func _find_windows_spawn_children(parent_pids: Array[int]) -> Array[int]: - if parent_pids.is_empty(): - var empty: Array[int] = [] - return empty - var found: Array[int] = [] - for parent_pid in parent_pids: - var output: Array = [] - var script := ( - "Get-CimInstance Win32_Process | " - + "Where-Object { $_.CommandLine -like '*spawn_main(parent_pid=%d*' } | " - + "ForEach-Object { $_.ProcessId }" - ) % parent_pid - _startup_trace_count("powershell") - var exit_code := _execute_windows_powershell(script, output) - if exit_code != 0 or output.is_empty(): - continue - for pid in _parse_pid_lines(str(output[0])): - if not found.has(pid): - found.append(pid) - return found - - -func is_dev_server_running() -> bool: - ## Returns true if a branded dev server is running on the HTTP port - ## that we didn't start as managed. - if _lifecycle.get_server_pid() > 0: - return false - for pid in _find_all_pids_on_port(ClientConfigurator.http_port()): - if _pid_cmdline_is_godot_ai(int(pid)): - return true - return false - - -func has_managed_server() -> bool: - ## Returns true if the plugin is currently managing a server process it spawned. - return _lifecycle.has_managed_server() - - -func can_restart_managed_server() -> bool: - ## Restart is allowed only when we have ownership proof. A live PID - ## means this plugin spawned/adopted a managed server; a non-empty - ## managed record is the cross-session proof used by the drift branch. - return _lifecycle.can_restart_managed_server() - - -func _on_custom_tools_changed() -> void: - if _connection == null: - push_warning("MCP | connection isn't established") - return - var tool_list: Array[Dictionary] = [] - ## Send all definitions plus their state: the server hides disabled tools - ## from fresh tools/list responses but retains a callable tombstone so a - ## client using a cached promoted name receives CUSTOM_TOOL_DISABLED. - for spec in _custom_tool_registry.all(): - tool_list.append({ - "name": spec.name, - "description": spec.description, - "params_schema": spec.params_schema, - "source": spec.source, - "deferred": spec.deferred, - "timeout_ms": spec.timeout_ms, - "requires_writable": spec.requires_writable, - "undoable": spec.undoable, - "promoted": spec.promoted, - "enabled": _custom_tool_registry.is_tool_enabled(spec.name) - }) - _connection.send_event("custom_tools_changed", {"tools": tool_list}) - - -## On (re)connect, replay the current custom-tool catalog so tools -## registered before the initial connection or during a disconnect -## window reach the server. send_event silently drops while -## _connected is false (connection.gd::_send_json), so without this -## replay those tools only surface on the next registry mutation. -func _on_connection_state_changed(is_open: bool) -> void: - if is_open and _custom_tool_registry != null: - _on_custom_tools_changed() +static func _bridge_status() -> Dictionary: + var settings := EditorInterface.get_editor_settings() + var key := MigrationCoordinator.status_setting() + if settings == null or not settings.has_setting(key): + return {} + var parsed: Variant = JSON.parse_string(str(settings.get_setting(key))) + return parsed if parsed is Dictionary else {} + + +static func _clear_bridge_status() -> void: + var settings := EditorInterface.get_editor_settings() + var key := MigrationCoordinator.status_setting() + if settings != null and settings.has_setting(key): + settings.erase(key) diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid index 4c550dd..33cf809 100644 --- a/addons/godot_ai/plugin.gd.uid +++ b/addons/godot_ai/plugin.gd.uid @@ -1 +1 @@ -uid://d3ui3yx6vdigl +uid://b5w7nw8pfnjww diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd deleted file mode 100644 index 8204bd7..0000000 --- a/addons/godot_ai/runtime/draw_recipe.gd +++ /dev/null @@ -1,86 +0,0 @@ -@tool -extends Control - -## Runtime helper attached by control_draw_recipe. -## Reads an array of op dicts from node metadata under key "_ops" and dispatches -## each to a CanvasItem draw call in _draw(). The ops list is set by the handler -## via set_meta; this script is deterministic — re-setting meta + queue_redraw -## is enough to update the visuals. - -const META_KEY := "_ops" - - -func _ready() -> void: - queue_redraw() - - -func _draw() -> void: - if not has_meta(META_KEY): - return - var ops: Variant = get_meta(META_KEY) - if typeof(ops) != TYPE_ARRAY: - return - for op in ops: - if typeof(op) != TYPE_DICTIONARY: - continue - match op.get("draw", ""): - "line": - draw_line( - op.from, - op.to, - op.color, - float(op.get("width", 1.0)), - bool(op.get("antialiased", false)) - ) - "rect": - # Godot warns if `width` is passed when `filled` is true — - # width has no effect on filled rects. Split the call so we - # only pass width when stroking an outline. - var filled := bool(op.get("filled", true)) - if filled: - draw_rect(op.rect, op.color, true) - else: - draw_rect( - op.rect, - op.color, - false, - float(op.get("width", 1.0)) - ) - "arc": - draw_arc( - op.center, - float(op.radius), - float(op.start_angle), - float(op.end_angle), - int(op.get("point_count", 32)), - op.color, - float(op.get("width", 1.0)), - bool(op.get("antialiased", false)) - ) - "circle": - draw_circle(op.center, float(op.radius), op.color) - "polyline": - draw_polyline( - op.points, - op.color, - float(op.get("width", 1.0)), - bool(op.get("antialiased", false)) - ) - "polygon": - var colors: PackedColorArray = ( - op.colors if op.has("colors") else PackedColorArray([op.color]) - ) - draw_polygon(op.points, colors) - "string": - var font: Font = get_theme_default_font() - if font == null: - continue - draw_string( - font, - op.position, - str(op.text), - int(op.get("align", HORIZONTAL_ALIGNMENT_LEFT)), - float(op.get("max_width", -1.0)), - int(op.get("font_size", 16)), - op.color - ) diff --git a/addons/godot_ai/runtime/draw_recipe.gd.uid b/addons/godot_ai/runtime/draw_recipe.gd.uid deleted file mode 100644 index 5de2df2..0000000 --- a/addons/godot_ai/runtime/draw_recipe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://da3fqfqv6gtgm diff --git a/addons/godot_ai/runtime/editor_logger.gd b/addons/godot_ai/runtime/editor_logger.gd deleted file mode 100644 index 2d19daa..0000000 --- a/addons/godot_ai/runtime/editor_logger.gd +++ /dev/null @@ -1,139 +0,0 @@ -@tool -extends Logger - -## Editor-process Logger subclass. -## -## NOTE: deliberately no `class_name`. Registered from plugin.gd::_enter_tree -## so we can intercept editor-process script errors — parse errors, @tool -## runtime errors, EditorPlugin errors, push_error/push_warning — and surface -## them via `logs_read(source="editor")`. Without this, the LLM sees nothing -## in `logs_read` while the same errors show in red lines in Godot's Output -## panel. -## -## Why only `_log_error` and not `_log_message`: -## `_log_message(msg, error)` covers print() and printerr(), which is the -## firehose path — running editors print thousands of internal info lines -## a session. The issue (#231) explicitly asks to filter so the buffer -## isn't drowned. Errors and warnings flow through `_log_error` (parse -## errors, push_error/push_warning, runtime errors), which is what -## debugging callers actually need. If we discover @tool printerr() is a -## valuable source later, _log_message can be added behind the same filter. -## -## Logger virtuals can be called from any thread (e.g. async script -## loaders push parse errors off the main thread). McpEditorLogBuffer is -## mutex-protected so we can append directly without an intermediate queue. - -const ADDON_PATH_MARKER := "/addons/godot_ai/" - -## Resolve McpLogBacktrace by path, not by the `McpLogBacktrace` class_name. -## A bare class_name reference depends on the global class-name table being populated -## at compile time, which isn't guaranteed on a cold editor enable mid-scan. -## `const preload` resolves at compile time independent of the registry — -## matches game_logger.gd's deliberate choice for the same reason. -const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") - -## Constructor-injected so the hot path doesn't need a per-call null check. -var _buffer - - -func _init(buffer = null) -> void: - _buffer = buffer - - -func _log_error( - function: String, - file: String, - line: int, - code: String, - rationale: String, - _editor_notify: bool, - error_type: int, - script_backtraces: Array, -) -> void: - if _buffer == null: - return - ## Cheap reject for the firehose: when `file` is already non-user (the - ## bulk of editor-internal C++ chatter), there's no backtrace to remap - ## from, and the message doesn't name a project resource, the resolved - ## path can only stay non-user — drop without paying for resolve_error's - ## call frame + dict allocation. - var message := rationale if not rationale.is_empty() else code - var message_res_path := _extract_user_res_path(message) - if not _is_user_script(file) and script_backtraces.is_empty() and message_res_path.is_empty(): - return - var resolved := _LogBacktrace.resolve_error( - function, file, line, code, rationale, error_type, script_backtraces, - ) - if not _is_user_script(resolved.path): - if message_res_path.is_empty(): - return - resolved.path = message_res_path - resolved.line = 0 - resolved.function = function - _update_resolved_details(resolved) - if _is_in_godot_ai_addon(resolved.path): - return - if not message_res_path.is_empty() and _is_in_godot_ai_addon(message_res_path): - return - var details: Dictionary = resolved.get("details", {}) - _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) - - -static func _update_resolved_details(resolved: Dictionary) -> void: - var details: Dictionary = resolved.get("details", {}) - if details.is_empty(): - return - details["resolved"] = { - "path": resolved.get("path", ""), - "line": resolved.get("line", 0), - "function": resolved.get("function", ""), - } - resolved["details"] = details - - -## Predicate broken out so tests can drive the path-filter logic without -## constructing real Logger calls. -static func _is_user_script(path: String) -> bool: - if path.is_empty(): - return false - ## Match .gd / .cs (case-insensitively to handle .GD on case-insensitive - ## filesystems). C# scripts compile elsewhere but the parser path can - ## still surface .cs files for assembly load failures. - var lower := path.to_lower() - return lower.ends_with(".gd") or lower.ends_with(".cs") - - -## Path-substring check works for both `res://addons/godot_ai/foo.gd` and -## globalized absolute paths (`/Users/.../addons/godot_ai/foo.gd`) that -## Godot can also report depending on where the error originated. -static func _is_in_godot_ai_addon(path: String) -> bool: - if path.begins_with("res://addons/godot_ai/"): - return true - return path.find(ADDON_PATH_MARKER) >= 0 - - -## Some engine-origin errors have no ScriptBacktrace even though they are -## project-relevant, notably ResourceLoader failures: -## `Failed loading resource: res://does/not/exist.tres.`. Capture these by -## extracting a named `res://` path from the message while keeping editor -## internals and this addon's own resources filtered. -static func _extract_user_res_path(message: String) -> String: - var start := message.find("res://") - if start < 0: - return "" - var end := message.length() - var quote_end := message.find("'", start) - if quote_end >= 0: - end = mini(end, quote_end) - quote_end = message.find("\"", start) - if quote_end >= 0: - end = mini(end, quote_end) - quote_end = message.find("`", start) - if quote_end >= 0: - end = mini(end, quote_end) - var path := message.substr(start, end - start).strip_edges() - while not path.is_empty() and path.substr(path.length() - 1, 1) in [".", ",", ";", ":", ")"]: - path = path.substr(0, path.length() - 1) - if path.is_empty() or _is_in_godot_ai_addon(path): - return "" - return path diff --git a/addons/godot_ai/runtime/editor_logger.gd.uid b/addons/godot_ai/runtime/editor_logger.gd.uid deleted file mode 100644 index 9db6035..0000000 --- a/addons/godot_ai/runtime/editor_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cxfregddqj5b8 diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd deleted file mode 100644 index ff4fb4e..0000000 --- a/addons/godot_ai/runtime/game_helper.gd +++ /dev/null @@ -1,1317 +0,0 @@ -extends Node - -## Godot AI MCP — game-process helper. -## -## Registered as an autoload by plugin.gd when the Godot AI plugin is enabled. -## Runs in the running game process (separate from the editor) so the plugin -## can request the game's framebuffer over the editor-debugger channel. -## -## The editor never has direct access to the game's pixels: even when "Embed -## Game Mode" is on, the game is still a separate OS child process whose -## window is reparented into the editor via Win32 SetParent / X11 -## XReparentWindow / macOS remote layer (Godot PR godotengine/godot#99010). -## So viewport-texture capture on the editor side never contains game pixels. -## This autoload solves that by replying to "mcp:take_screenshot" debug -## messages with a PNG of Viewport.get_texture() from inside the game. -## -## No-ops in the editor (Engine.is_editor_hint) and silently sits idle -## when the debugger channel is inactive (e.g. exported release builds) -## — register_message_capture is safe to call either way, it's -## send_message that requires an active channel. - -const CAPTURE_PREFIX := "mcp" -## Cap per-frame flush so a runaway print loop can't blow the debugger's -## packet budget in a single send. Surplus stays queued for the next frame. -const FLUSH_BATCH_LIMIT := 200 -## How long take_screenshot waits for the game's first real presentation -## before reading the viewport texture back. The "mcp" capture registers in -## this autoload's _ready(), which runs BEFORE the main scene enters the tree -## and before the renderer has presented anything — so a request arriving -## right after mcp:hello would otherwise read back the clear-color -## framebuffer (observed as a uniform RGB(77,77,77) PNG on GitHub's -## GPU-less paravirtualized macOS runners, where the first present lags -## seconds behind boot). MUST stay below the editor-side reply timer -## (DEFAULT_TIMEOUT_SEC = 8.0 in debugger/mcp_debugger_plugin.gd) so a -## game that genuinely can't render falls through to the existing -## texture/image error replies before the editor gives up with its -## generic timeout. -const FIRST_FRAME_WAIT_SEC := 6.0 -## #777: how long the main loop can go without ticking _process before -## _handle_take_screenshot treats it as frozen and commits a synchronous -## stale-frame capture instead of awaiting frames that will never come. -## A backgrounded/minimized play-in-editor game stops iterating its main -## loop entirely, so any real threshold works; 1s keeps a merely-slow game -## (heavy frame, low FPS) on the fresh-frame await path. -const MAIN_LOOP_STALL_MSEC := 1000 -## How long frames_drawn can stay flat before _handle_take_screenshot treats -## rendering as suppressed and commits the synchronous stale-frame capture. -## On Windows, minimizing the game window freezes frame presentation but NOT -## the main loop — _process keeps ticking, so the MAIN_LOOP_STALL_MSEC beacon -## never trips and every capture used to burn the full FIRST_FRAME_WAIT_SEC -## await before replying stale (issue #794 smoke, item 1b). Larger than the -## loop threshold so a heavy-but-rendering game (~1 FPS frame gaps) stays on -## the fresh-frame await path; a sub-0.7 FPS game that trips this still gets -## an honestly stale-flagged image immediately instead of a 6s wait. -const RENDER_STALL_MSEC := 1500 - -const GameLogger := preload("res://addons/godot_ai/runtime/game_logger.gd") -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") -## Shared with the editor-side copy in editor_handler.gd (#716). Preload by -## path, not class_name: this autoload runs in the game process and must not -## depend on the editor's global-class cache being warm. -const ScreenshotEncode := preload("res://addons/godot_ai/utils/screenshot_encode.gd") - -var _registered := false -## Captures game-process print, warning, and error output for the editor. -var _logger: Logger -var _logger_attached := false -## Entries drained from the logger but not yet sent over the debugger -## channel. Holds the tail of one drain() so we can bleed it out across -## frames at FLUSH_BATCH_LIMIT per frame rather than blasting the whole -## queue in a single _process tick. -var _pending_outbound: Array = [] -## #490: in-flight evals, keyed by request_id (multiple deferred game_evals -## can run at once). Each entry: {node:Node, token:String, baseline:int}. -## `token` names this eval's unique wrapper function so a runtime error is -## attributed only to the eval that actually raised it — not an unrelated -## background game error, and not a sibling overlapping eval. `baseline` is the -## logger's script-error seq just before this eval ran. The editor's eval_check -## probe (and #488's in-flight poll loop, when the game is focused) consult -## these to report a runtime error that aborted execute() before the reply. -var _inflight_evals: Dictionary = {} -var _eval_token_counter: int = 0 -## #777: last time _process ran, in ticks msec. The debugger message capture -## stays live while a backgrounded game's main loop is frozen, so this is how -## _handle_take_screenshot (running inside that capture) detects the freeze -## synchronously. -1 until the first tick. -var _last_loop_tick_msec: int = -1 -## Rendering-freeze beacon for the Windows-minimize state (#794 smoke, 1b): -## the frames_drawn value last observed in _process, and when it last -## advanced. -1 until the first observed advance, so a booting or -## render-less game (frames_drawn stuck at 0) can never read as -## render-stalled and keeps the fresh-frame await path's error replies. -var _last_frames_drawn_seen: int = -1 -var _last_frames_advance_msec: int = -1 -## #859: test/diagnostic seam for the synchronous eval liveness reply. The -## debugger capture remains callable while the game loop is parked, so the -## reply reports whether the _process beacon is actually advancing rather -## than merely proving that the capture was registered once. -var _last_eval_liveness_reply: Dictionary = {} - - -func _ready() -> void: - ## Only run in the game process, not in the editor. Use is_editor_hint - ## — NOT OS.has_feature("editor"), which is a BUILD-config check - ## (TOOLS_ENABLED) and returns true in the game subprocess too because - ## the game is spawned with the same editor binary. is_editor_hint is - ## the runtime-context check: true only inside the editor GUI, false - ## in play-from-editor. The earlier has_feature check was causing us - ## to skip registration in the game and time out every capture. - if Engine.is_editor_hint(): - return - ## Keep ticking while the tree is paused: _process both ferries game logs - ## and timestamps main-loop liveness for the stalled-loop screenshot - ## fallback (#777). A paused game still iterates its loop and renders, and - ## must not be misread as frozen. - process_mode = Node.PROCESS_MODE_ALWAYS - ## register_message_capture is safe to call before the debugger - ## handshake completes; the capture sits until a message arrives. - EngineDebugger.register_message_capture(CAPTURE_PREFIX, _on_debug_message) - _registered = true - ## Capture print() / printerr() / push_error() / push_warning() and - ## ferry them to the editor in mcp:log_batch messages flushed from - ## _process. - _logger = GameLogger.new() - OS.add_logger(_logger) - _logger_attached = true - ## Routed to the editor's Output panel via Godot's remote-stdout - ## forwarder — handy when diagnosing why capture timed out. - print("[godot_ai game_helper] registered mcp capture (debugger active=%s, logger=%s)" - % [EngineDebugger.is_active(), _logger_attached]) - ## Boot beacon so the editor side can confirm the autoload ran even - ## if no screenshot was ever requested. - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:hello", []) - - -func _process(_delta: float) -> void: - ## #777: liveness beacon for _handle_take_screenshot's stalled-loop check. - ## Recorded before the early returns below so the signal stays truthful - ## even when the logger or debugger channel is unavailable. - _last_loop_tick_msec = Time.get_ticks_msec() - ## Rendering beacon: on Windows a minimized game keeps ticking _process - ## while presentation stops, so frames_drawn stagnation — not loop - ## silence — is the observable freeze signal there (#794 smoke, 1b). - var frames_now := Engine.get_frames_drawn() - if frames_now != _last_frames_drawn_seen: - _last_frames_drawn_seen = frames_now - _last_frames_advance_msec = _last_loop_tick_msec - ## Drain the logger queue on the main thread (Logger virtuals can fire - ## from any thread; EngineDebugger.send_message is only safe from main). - ## Send at most one FLUSH_BATCH_LIMIT-sized batch per frame so a runaway - ## print loop can't stall the game by shoving thousands of entries - ## through the debugger packet path in a single tick. Surplus stays in - ## `_pending_outbound` and bleeds out across subsequent frames. - if not _logger_attached or _logger == null: - return - if not EngineDebugger.is_active(): - return - if _pending_outbound.is_empty(): - if not _logger.has_pending(): - return - _pending_outbound = _logger.drain() - var batch := _pending_outbound.slice(0, FLUSH_BATCH_LIMIT) - _pending_outbound = _pending_outbound.slice(FLUSH_BATCH_LIMIT) - EngineDebugger.send_message("mcp:log_batch", [batch]) - - -func _exit_tree() -> void: - if _registered: - EngineDebugger.unregister_message_capture(CAPTURE_PREFIX) - _registered = false - if _logger_attached and _logger != null: - OS.remove_logger(_logger) - _logger_attached = false - _logger = null - - -## Dispatched for messages prefixed "mcp:" on the debugger channel. -## Godot passes the full message ("mcp:take_screenshot") to the capture -## callable; trim defensively so tests can still call the helper with either -## form. -func _on_debug_message(message: String, data: Array) -> bool: - var action := message.trim_prefix("mcp:") - match action: - "take_screenshot": - _handle_take_screenshot(data) - return true - "eval_liveness": - _reply_eval_liveness(data) - return true - "eval": - _handle_eval(data) - return true - "eval_check": - _handle_eval_check(data) - return true - "game_command": - _handle_game_command(data) - return true - return false - - -## #859: answer from the debugger capture without awaiting a game frame. The -## capture can still run while a backgrounded/frozen game's main loop cannot, -## so the _process beacon is the liveness signal that matters for game_eval. -func _reply_eval_liveness(data: Array) -> void: - var request_id: String = data[0] if data.size() > 0 else "" - var loop_live := not _main_loop_appears_stalled() - _last_eval_liveness_reply = {"request_id": request_id, "loop_live": loop_live} - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:eval_liveness_response", [request_id, loop_live]) - - -func _handle_take_screenshot(data: Array) -> void: - var request_id: String = data[0] if data.size() > 0 else "" - var max_resolution: int = int(data[1]) if data.size() > 1 else 0 - - var tree := get_tree() - var viewport := tree.root if tree != null else null - if viewport == null: - _reply_error(request_id, "No game root viewport available") - return - - ## #777: this function runs inside the debugger message capture, which - ## stays live even when a backgrounded/minimized play-in-editor game has - ## frozen its main loop. In that state awaiting `process_frame` parks - ## this coroutine forever — no reply is ever sent, and the game side - ## cannot self-timeout because timers need the same frozen loop. Commit a - ## synchronous capture of the last rendered frame instead: stale, but a - ## real image, flagged as such in the reply. Only fall through to the - ## fresh-frame awaits when the loop is demonstrably alive. - if _should_capture_stale_sync( - _main_loop_appears_stalled(), - _rendering_appears_stalled(), - tree.current_scene != null, - Engine.get_frames_drawn() - ): - _capture_and_reply(request_id, viewport, max_resolution, Engine.get_frames_drawn()) - return - - ## Wait (bounded — see FIRST_FRAME_WAIT_SEC) until the main scene is in - ## the tree and at least one frame has been drawn after this request, so - ## the readback never precedes the first real present. Past the deadline, - ## fall through anyway: current_scene stays null under a custom main - ## loop, and frames_drawn never advances in a render-less game — both - ## are handled by the texture/image error replies below. - var deadline := Time.get_ticks_msec() + int(FIRST_FRAME_WAIT_SEC * 1000.0) - while tree.current_scene == null and Time.get_ticks_msec() < deadline: - await tree.process_frame - var frames_at_request := Engine.get_frames_drawn() - while Engine.get_frames_drawn() <= frames_at_request and Time.get_ticks_msec() < deadline: - await tree.process_frame - - _capture_and_reply(request_id, viewport, max_resolution, frames_at_request) - - -## #777: pure decision for the synchronous stale-frame path. Sync capture is -## only worth committing when awaiting can't produce a fresh frame — the main -## loop is frozen (macOS/suspend), or the loop still ticks but presentation -## is suppressed (Windows minimize, #794 smoke 1b) — AND the viewport -## plausibly holds a real frame: the main scene is in the tree and at least -## one frame was presented. Without those, the stale readback would be the -## boot clear-color framebuffer — worse than the honest timeout. -static func _should_capture_stale_sync( - loop_stalled: bool, render_stalled: bool, has_current_scene: bool, frames_drawn: int -) -> bool: - return (loop_stalled or render_stalled) and has_current_scene and frames_drawn > 0 - - -## #777: true when _process hasn't ticked within MAIN_LOOP_STALL_MSEC — -## i.e. the main loop is frozen (backgrounded window) or has never run. -func _main_loop_appears_stalled() -> bool: - ## The hello→probe round trip normally follows at least one _process tick. - ## Treat the never-ticked sentinel as stalled defensively rather than - ## dispatching an eval into a game whose main loop has not started. - if _last_loop_tick_msec < 0: - return true - return Time.get_ticks_msec() - _last_loop_tick_msec > MAIN_LOOP_STALL_MSEC - - -## True when frames_drawn has sat flat past RENDER_STALL_MSEC while _process -## kept ticking — Windows minimize suppresses presentation without freezing -## the loop, so the loop beacon alone misses it (#794 smoke, 1b). False until -## the first observed frame advance: a game that has never presented has no -## trustworthy frame to return, and must fall through to the await path's -## texture/image error replies instead. -func _rendering_appears_stalled() -> bool: - if _last_frames_advance_msec < 0: - return false - return Time.get_ticks_msec() - _last_frames_advance_msec > RENDER_STALL_MSEC - - -## Read back the viewport texture and reply — fully synchronous, so it is -## safe to call from the debugger capture while the main loop is frozen. -## `frames_at_request` is Engine.get_frames_drawn() at request receipt: if no -## further frame was drawn by capture time, the image predates the request -## and the reply is flagged stale. -func _capture_and_reply( - request_id: String, viewport: Viewport, max_resolution: int, frames_at_request: int -) -> void: - var texture := viewport.get_texture() - if texture == null: - _reply_error(request_id, "Root viewport has no texture (headless?)") - return - - var image := texture.get_image() - if image == null or image.is_empty(): - _reply_error(request_id, "Captured an empty image from game viewport") - return - - var encoded: Dictionary = ScreenshotEncode.downscale_and_encode(image, max_resolution) - var frames_drawn := Engine.get_frames_drawn() - var stale := frames_drawn <= frames_at_request - - _last_screenshot_reply = { - "kind": "response", - "request_id": request_id, - "frames_drawn": frames_drawn, - "stale": stale, - "width": encoded.width, - "height": encoded.height, - } - if EngineDebugger.is_active(): - ## Fields 7+8 are new in #777; older editors read the first six and - ## ignore the rest. - EngineDebugger.send_message("mcp:screenshot_response", [ - request_id, - encoded.base64, - encoded.width, - encoded.height, - encoded.original_width, - encoded.original_height, - frames_drawn, - stale, - ]) - - -## Testing seam: the last screenshot reply (response or error), recorded -## before hitting the EngineDebugger channel (inactive in the editor-side -## test harness). Mirrors _last_eval_reply. -var _last_screenshot_reply: Dictionary = {} - - -func _reply_error(request_id: String, message: String) -> void: - _last_screenshot_reply = {"kind": "error", "request_id": request_id, "message": message} - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:screenshot_error", [request_id, message]) - - -## --- game_command: curated runtime inspection and input --- - -func _handle_game_command(data: Array) -> void: - var request_id: String = data[0] if data.size() > 0 else "" - var op: String = data[1] if data.size() > 1 else "" - var params_json: String = data[2] if data.size() > 2 else "{}" - - if request_id.is_empty(): - return - if op.is_empty(): - _reply_game_command_error(request_id, "No op provided") - return - - var json := JSON.new() - var parse_err := json.parse(params_json) - if parse_err != OK or not (json.data is Dictionary): - _reply_game_command_error(request_id, "Invalid params JSON") - return - - var result: Dictionary - match op: - "get_scene_tree": - result = _game_get_scene_tree(json.data) - "get_node_info": - result = _game_get_node_info(json.data) - "get_ui_elements": - result = _game_get_ui_elements(json.data) - "input_key": - result = _game_input_key(json.data) - "input_mouse": - result = _game_input_mouse(json.data) - "input_gamepad": - result = _game_input_gamepad(json.data) - "input_action": - result = _game_input_action(json.data) - "input_state": - result = _game_input_state(json.data) - "input_sequence": - ## Async: steps frames and replies itself (deferred), so bail out - ## before the synchronous send below — same shape as the eval and - ## screenshot capture paths. - _run_input_sequence(request_id, json.data) - return - _: - _reply_game_command_error(request_id, "Unknown game op: %s" % op) - return - - result["source"] = "game" - result["op"] = op - EngineDebugger.send_message("mcp:game_command_response", - [request_id, JSON.stringify(_variant_to_json(result))]) - - -func _reply_game_command_error(request_id: String, message: String) -> void: - EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) - - -func _game_get_scene_tree(params: Dictionary) -> Dictionary: - var depth := maxi(0, int(params.get("depth", 10))) - var root := _resolve_runtime_node(str(params.get("root_path", ""))) - if root == null: - return {"root": "", "nodes": [], "total_count": 0, "not_found": params.get("root_path", "")} - - var nodes: Array[Dictionary] = [] - _collect_runtime_nodes(root, 0, depth, nodes) - return { - "root": _runtime_path(root), - "nodes": nodes, - "total_count": nodes.size(), - } - - -func _collect_runtime_nodes(node: Node, current_depth: int, max_depth: int, out: Array[Dictionary]) -> void: - out.append({ - "name": node.name, - "type": node.get_class(), - "path": _runtime_path(node), - "children_count": node.get_child_count(), - }) - if current_depth >= max_depth: - return - for child in node.get_children(): - if child is Node: - _collect_runtime_nodes(child, current_depth + 1, max_depth, out) - - -func _game_get_node_info(params: Dictionary) -> Dictionary: - var path := str(params.get("path", "")) - var node := _resolve_runtime_node(path) - if node == null: - return {"path": path, "found": false} - - var info := { - "path": _runtime_path(node), - "name": node.name, - "type": node.get_class(), - "children_count": node.get_child_count(), - "groups": node.get_groups(), - "found": true, - } - if bool(params.get("include_properties", true)): - info["properties"] = _runtime_node_properties(node) - return info - - -func _game_get_ui_elements(params: Dictionary) -> Dictionary: - var max_depth := maxi(0, int(params.get("max_depth", 10))) - var include_hidden := bool(params.get("include_hidden", false)) - var include_disabled := bool(params.get("include_disabled", true)) - var root_path := str(params.get("root_path", "")) - var root := _resolve_runtime_node(root_path) - if root == null: - return {"root": "", "elements": [], "total_count": 0, "not_found": root_path} - - var elements: Array[Dictionary] = [] - _collect_ui_elements(root, 0, max_depth, include_hidden, include_disabled, elements) - return { - "root": _runtime_path(root), - "elements": elements, - "total_count": elements.size(), - } - - -func _collect_ui_elements( - node: Node, - current_depth: int, - max_depth: int, - include_hidden: bool, - include_disabled: bool, - out: Array[Dictionary] -) -> void: - if node is Control: - var control := node as Control - var visible := _control_visible_in_tree(control) - var disabled := _control_disabled(control) - if (include_hidden or visible) and (include_disabled or not disabled): - out.append(_ui_element_info(control, visible, disabled)) - - if current_depth >= max_depth: - return - for child in node.get_children(): - if child is Node: - _collect_ui_elements( - child, - current_depth + 1, - max_depth, - include_hidden, - include_disabled, - out - ) - - -func _ui_element_info(control: Control, visible: bool, disabled: bool) -> Dictionary: - var info := { - "path": _runtime_path(control), - "name": control.name, - "type": control.get_class(), - "visible": visible, - "disabled": disabled, - "rect": _variant_to_json(control.get_rect()), - "global_rect": _variant_to_json(control.get_global_rect()), - } - if _object_has_property(control, "text"): - info["text"] = str(control.get("text")) - return info - - -func _control_disabled(control: Control) -> bool: - if _object_has_property(control, "disabled"): - return bool(control.get("disabled")) - return false - - -func _control_visible_in_tree(control: Control) -> bool: - if not control.visible: - return false - var parent := control.get_parent() - while parent != null: - if parent is CanvasItem and not (parent as CanvasItem).visible: - return false - parent = parent.get_parent() - if Engine.is_editor_hint(): - return true - return control.is_visible_in_tree() - - -static var _property_name_cache: Dictionary = {} - - -func _object_has_property(obj: Object, property_name: String) -> bool: - var key := _property_cache_key(obj) - if not _property_name_cache.has(key): - var names := {} - for prop in obj.get_property_list(): - names[str(prop.get("name", ""))] = true - _property_name_cache[key] = names - return (_property_name_cache[key] as Dictionary).has(property_name) - - -func _property_cache_key(obj: Object) -> String: - var script = obj.get_script() - if script == null: - return obj.get_class() - var script_id := str(script.get_instance_id()) - if not script.resource_path.is_empty(): - script_id = script.resource_path - return "%s:%s" % [obj.get_class(), script_id] - - -func _runtime_node_properties(node: Node) -> Dictionary: - var props := {} - for p in node.get_property_list(): - var name := str(p.get("name", "")) - var usage := int(p.get("usage", 0)) - if name.is_empty() or (usage & PROPERTY_USAGE_EDITOR) == 0: - continue - props[name] = _variant_to_json(node.get(name)) - return props - - -func _resolve_runtime_node(path: String) -> Node: - var scene_root := _current_scene_root() - if scene_root == null: - return null - if path.is_empty() or path == "/": - return scene_root - - if path.begins_with("/root/"): - return get_tree().root.get_node_or_null(path.trim_prefix("/root/")) - - var scene_path := path.trim_prefix("/") - if scene_path == str(scene_root.name): - return scene_root - var prefix := str(scene_root.name) + "/" - if scene_path.begins_with(prefix): - scene_path = scene_path.substr(prefix.length()) - return scene_root.get_node_or_null(scene_path) - - -func _runtime_path(node: Node) -> String: - var scene_root := _current_scene_root() - if scene_root == null: - return str(node.get_path()) - if node == scene_root: - return "/" + str(scene_root.name) - return "/" + str(scene_root.name) + "/" + str(scene_root.get_path_to(node)) - - -func _current_scene_root() -> Node: - var tree := get_tree() - if tree == null: - return null - var scene_root := tree.current_scene - if scene_root == null and Engine.is_editor_hint(): - # Look the editor singleton up by name rather than referencing the bare - # `EditorInterface` identifier: that identifier is compiled out of export - # templates, so the GDScript parser rejects it ("Identifier - # "EditorInterface" not declared in the current scope") in an exported - # build even though `Engine.is_editor_hint()` would never run it there. - # That parse failure stops this autoload from loading in every export. - var editor := Engine.get_singleton(&"EditorInterface") - if editor: - scene_root = editor.get_edited_scene_root() - return scene_root - - -func _game_input_key(params: Dictionary) -> Dictionary: - var key_name := str(params.get("key", "")) - var keycode := OS.find_keycode_from_string(key_name) - if keycode == KEY_NONE: - return {"sent": false, "error": "Unknown key: %s" % key_name} - var ev := InputEventKey.new() - ev.keycode = keycode - ev.physical_keycode = keycode - ev.pressed = bool(params.get("pressed", true)) - ev.echo = bool(params.get("echo", false)) - Input.parse_input_event(ev) - return {"sent": true, "key": key_name, "pressed": ev.pressed} - - -func _game_input_mouse(params: Dictionary) -> Dictionary: - var event := str(params.get("event", "button")) - var pos_result := _resolve_mouse_position(params.get("position")) - if pos_result.has("error"): - return {"sent": false, "event": event, "error": pos_result.error} - var pos: Vector2 = pos_result.position - match event: - "motion": - var motion := InputEventMouseMotion.new() - motion.position = pos - motion.global_position = pos - Input.parse_input_event(motion) - return {"sent": true, "event": "motion", "position": _variant_to_json(pos)} - "button": - var button_event := InputEventMouseButton.new() - button_event.position = pos - button_event.global_position = pos - button_event.button_index = _mouse_button_index(str(params.get("button", "left"))) - button_event.pressed = bool(params.get("pressed", true)) - Input.parse_input_event(button_event) - return { - "sent": true, - "event": "button", - "button": params.get("button", "left"), - "pressed": button_event.pressed, - "position": _variant_to_json(pos), - } - return {"sent": false, "error": "Invalid mouse event: %s" % event} - - -func _game_input_gamepad(params: Dictionary) -> Dictionary: - var device := int(params.get("device", 0)) - var control := str(params.get("control", "button")) - match control: - "button": - var button := InputEventJoypadButton.new() - button.device = device - button.button_index = int(params.get("index", 0)) - button.pressed = bool(params.get("pressed", true)) - Input.parse_input_event(button) - return {"sent": true, "control": "button", "device": device, "index": button.button_index, "pressed": button.pressed} - "axis": - var axis := InputEventJoypadMotion.new() - axis.device = device - axis.axis = int(params.get("index", 0)) - axis.axis_value = float(params.get("value", 0.0)) - Input.parse_input_event(axis) - return {"sent": true, "control": "axis", "device": device, "index": axis.axis, "value": axis.axis_value} - return {"sent": false, "error": "Invalid gamepad control: %s" % control} - - -func _game_input_action(params: Dictionary) -> Dictionary: - var action := str(params.get("action", "")) - if action.is_empty(): - return {"sent": false, "error": "Missing action"} - if not InputMap.has_action(action): - return {"sent": false, "action": action, "error": "Unknown action: %s" % action} - var pressed := bool(params.get("pressed", true)) - var strength := clampf(float(params.get("strength", 1.0)), 0.0, 1.0) - if pressed: - Input.action_press(action, strength) - else: - Input.action_release(action) - return { - "sent": true, - "action": action, - "pressed": pressed, - "strength": strength, - "delivery": "action_state", - } - - -func _game_input_state(params: Dictionary) -> Dictionary: - var actions: Array = params.get("actions", []) - if actions.is_empty(): - actions = InputMap.get_actions() - var states := {} - for action in actions: - var name := str(action) - states[name] = Input.is_action_pressed(name) - return {"actions": states} - - -## --- input_sequence: frame-timed action timeline (deferred) --- -## -## Per-step round-trips can't hit a target frame — network jitter lands each -## input on whatever frame its reply happens to arrive on, so a jump arc or a -## timed combo is unreproducible (#814). input_sequence takes the whole -## timeline in one call and drives it game-side, applying each step's action on -## its scheduled frame, then replies once (deferred). Frame count (not ms) is -## the timing basis: it's what reproduces identically across runs. - -## Hard caps mirrored by the server-side schema (see game handlers). The game -## side re-checks them so a malformed direct message can't park the coroutine -## on an unbounded await; the server rejects the same cases up front with a -## clearer error. -## -## The frame cap bounds the sequence in *frames*, which is a wall-clock time -## only at a given FPS: 600 frames is ~10s at 60fps but longer under load or on -## a throttled runner. It is not sized to the ~30s deferred budget -## (editor_handler.INPUT_SEQUENCE_TIMEOUT_SEC) — the two are independent -## safeguards. If a genuinely slow run exceeds the budget, the dispatcher -## returns a clean DEFERRED_TIMEOUT rather than hanging, so the cap can stay a -## simple frame count. -const MAX_SEQUENCE_STEPS := 256 -const MAX_SEQUENCE_FRAMES := 600 - -## Testing seam: the last input_sequence reply (response or error), recorded -## before the EngineDebugger channel (inactive in the editor-side test -## harness). Mirrors _last_screenshot_reply / _last_eval_reply. -var _last_game_command_reply: Dictionary = {} - -## Testing seam: overrides the per-frame wait in _run_input_sequence. Left -## invalid in production (real `process_frame` awaits). A test sets it to a -## synchronously-returning Callable so the multi-frame loop runs to completion -## in one call — the editor test runner invokes tests synchronously and never -## pumps `process_frame`, so a real frame-await would suspend and record zero -## assertions. Timing itself (one frame per step) is engine-guaranteed; this -## seam covers the scheduling/application/reply logic layered on top. -var _frame_waiter: Callable = Callable() - - -## Validate + normalize an input_sequence request. Pure (no engine state), so -## the ordering/cap/shape rules are unit-testable without a running game. -## Returns {"error": String} or {"steps": Array, "end_frame": int}. -func _plan_input_sequence(params: Dictionary) -> Dictionary: - var raw_steps: Variant = params.get("steps", null) - if not (raw_steps is Array): - return {"error": "steps must be an array"} - var steps_arr: Array = raw_steps - if steps_arr.is_empty(): - return {"error": "steps must not be empty"} - if steps_arr.size() > MAX_SEQUENCE_STEPS: - return {"error": "steps exceeds cap of %d (got %d)" % [MAX_SEQUENCE_STEPS, steps_arr.size()]} - - ## Validate field *kinds* rather than coercing them: the server already - ## rejects bad shapes, but this planner is also the backstop for a - ## malformed direct debugger message, so it must not silently turn - ## pressed="false" into true or at_frame="oops" into 0. _is_number accepts - ## int or float (JSON round-trips whole numbers as either) but not bool or - ## string, so this stays consistent with the server without tripping on - ## JSON's number typing. - var settle_raw: Variant = params.get("settle_frames", 0) - if not _is_number(settle_raw): - return {"error": "settle_frames must be a number"} - var settle_frames := int(settle_raw) - if settle_frames < 0: - return {"error": "settle_frames must be >= 0"} - - var normalized: Array = [] - var prev_frame := -1 - for i in steps_arr.size(): - var raw: Variant = steps_arr[i] - if not (raw is Dictionary): - return {"error": "steps[%d] must be an object" % i} - var step: Dictionary = raw - if not (step.get("action", "") is String) or str(step.get("action", "")).is_empty(): - return {"error": "steps[%d].action is required" % i} - var action: String = step["action"] - var at_frame_raw: Variant = step.get("at_frame", 0) - if not _is_number(at_frame_raw): - return {"error": "steps[%d].at_frame must be a number" % i} - var at_frame := int(at_frame_raw) - if at_frame < 0: - return {"error": "steps[%d].at_frame must be >= 0" % i} - if at_frame < prev_frame: - return {"error": "steps must be ordered by at_frame (steps[%d]=%d < previous %d)" % [i, at_frame, prev_frame]} - prev_frame = at_frame - var pressed_raw: Variant = step.get("pressed", true) - if not (pressed_raw is bool): - return {"error": "steps[%d].pressed must be a boolean" % i} - var strength_raw: Variant = step.get("strength", 1.0) - if not _is_number(strength_raw): - return {"error": "steps[%d].strength must be a number" % i} - normalized.append({ - "at_frame": at_frame, - "action": action, - "pressed": pressed_raw, - "strength": clampf(float(strength_raw), 0.0, 1.0), - }) - - var end_frame: int = int(normalized[-1]["at_frame"]) + settle_frames - if end_frame > MAX_SEQUENCE_FRAMES: - return {"error": "sequence spans %d frames, exceeds cap of %d" % [end_frame, MAX_SEQUENCE_FRAMES]} - return {"steps": normalized, "end_frame": end_frame} - - -## Async: apply each step's action on its scheduled frame, awaiting one -## process_frame per frame, then reply (deferred). Bails out before applying -## anything if the plan is invalid or any action is unknown to the running -## game's InputMap — a half-applied timeline leaves inputs in an undefined -## state, so it's all-or-nothing on the pre-checks. -func _run_input_sequence(request_id: String, params: Dictionary) -> void: - var plan := _plan_input_sequence(params) - if plan.has("error"): - _reply_input_sequence_error(request_id, plan["error"]) - return - - var steps: Array = plan["steps"] - var end_frame: int = plan["end_frame"] - - ## Resolve action names against the *game's* InputMap up front — the server - ## can't see it, so this is the first place unknown actions surface. - for step in steps: - if not InputMap.has_action(step["action"]): - _reply_input_sequence_error(request_id, "Unknown action: %s" % step["action"]) - return - - var tree := get_tree() - if tree == null: - _reply_input_sequence_error(request_id, "No SceneTree available for input sequence") - return - - var applied: Array = [] - var step_i := 0 - for f in range(0, end_frame + 1): - while step_i < steps.size() and int(steps[step_i]["at_frame"]) == f: - var step: Dictionary = steps[step_i] - _game_input_action(step) - applied.append({"at_frame": f, "action": step["action"], "pressed": step["pressed"]}) - step_i += 1 - if f < end_frame: - if _frame_waiter.is_valid(): - await _frame_waiter.call() - else: - await tree.process_frame - - _reply_input_sequence_ok(request_id, { - "completed": true, - "steps_applied": applied.size(), - "frames_elapsed": end_frame, - "applied": applied, - "actions_pressed_at_end": _actions_pressed_at_end(steps), - }) - - -## Distinct actions the sequence touched that are still held at the end, so the -## caller knows what it must release (a press with no matching release leaves -## the action stuck on across the next frames). -func _actions_pressed_at_end(steps: Array) -> Array: - var seen := {} - var pressed: Array = [] - for step in steps: - var action: String = step["action"] - if seen.has(action): - continue - seen[action] = true - if Input.is_action_pressed(action): - pressed.append(action) - return pressed - - -func _reply_input_sequence_ok(request_id: String, result: Dictionary) -> void: - result["source"] = "game" - result["op"] = "input_sequence" - _last_game_command_reply = {"kind": "response", "op": "input_sequence", "result": result} - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:game_command_response", - [request_id, JSON.stringify(_variant_to_json(result))]) - - -func _reply_input_sequence_error(request_id: String, message: String) -> void: - _last_game_command_reply = {"kind": "error", "op": "input_sequence", "message": message} - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:game_command_error", [request_id, message]) - - -## Resolve a mouse-position param. Absent (null, or an empty {}) falls back to -## the live cursor position — a deliberate default. A present but wrong-shaped -## value is rejected instead of silently substituting the cursor, which -## previously hid caller bugs (#635). Accepts a {x, y} dict or an [x, y] array; -## returns {position: Vector2} or {error: String}. -func _resolve_mouse_position(value: Variant) -> Dictionary: - var viewport := get_viewport() - var fallback := viewport.get_mouse_position() if viewport != null else Vector2.ZERO - if value == null: - return {"position": fallback} - if value is Dictionary: - var dict: Dictionary = value - if dict.is_empty(): - return {"position": fallback} - # A non-empty dict that carries neither coordinate is a caller mistake, - # not "use the default" — reject rather than silently substitute. - if not dict.has("x") and not dict.has("y"): - return {"error": "position object must have an 'x' and/or 'y' key (got keys %s)" % str(dict.keys())} - var x_val: Variant = dict.get("x", fallback.x) - var y_val: Variant = dict.get("y", fallback.y) - if not _is_number(x_val) or not _is_number(y_val): - return {"error": "position x/y must be numbers (got x=%s, y=%s)" % [type_string(typeof(x_val)), type_string(typeof(y_val))]} - return {"position": Vector2(float(x_val), float(y_val))} - if value is Array: - var arr: Array = value - if arr.size() != 2: - return {"error": "position array must be [x, y] (got %d elements)" % arr.size()} - if not _is_number(arr[0]) or not _is_number(arr[1]): - return {"error": "position array elements must be numbers (got [%s, %s])" % [type_string(typeof(arr[0])), type_string(typeof(arr[1]))]} - return {"position": Vector2(float(arr[0]), float(arr[1]))} - return {"error": "position must be a {x, y} object or [x, y] array (got %s)" % type_string(typeof(value))} - - -func _is_number(v: Variant) -> bool: - return typeof(v) == TYPE_INT or typeof(v) == TYPE_FLOAT - - -func _mouse_button_index(name: String) -> int: - match name: - "right": - return MOUSE_BUTTON_RIGHT - "middle": - return MOUSE_BUTTON_MIDDLE - "wheel_up": - return MOUSE_BUTTON_WHEEL_UP - "wheel_down": - return MOUSE_BUTTON_WHEEL_DOWN - return MOUSE_BUTTON_LEFT - - -## --- game_eval: execute arbitrary GDScript in the running game --- - -## Wall-clock ceiling for a single game_eval. Evaluated code that awaits -## something which never completes (a signal that never fires, a timer on a -## paused tree) would otherwise pin the request open until the dispatcher's -## 15s deferred budget / the server's 15s command timeout fires it as an -## opaque INTERNAL_ERROR — with the temp eval Node leaked into the tree. -## Bounding it here lets us free the node and reply with an actionable -## message instead. See hi-godot/godot-ai#487. -## -## TIMEOUT ORDERING — load-bearing across three files: this value MUST stay -## below the editor-side fallback timer in -## `debugger/mcp_debugger_plugin.gd::request_game_eval` (`timeout_sec`, -## default 10.0), which in turn stays below the dispatcher's `game_eval` -## budget in `dispatcher.gd` (15000 ms). So: game 8s < editor 10s < -## dispatcher 15s. Only this game-side guard emits the specific -## "Eval exceeded 8s" message (both it and the editor backstop now carry the -## EVAL_HUNG code, #518, but the editor's message can't name the cause). -## Raise this at/above the editor timer (or drop that timer below this) and -## the less specific editor message wins the race, silently losing the -## diagnostic this fix exists to provide. The cross-file contract is enforced -## by tests/unit/test_game_eval_timeout_ordering.py. -## -## NOTE: this catches a hung `await`, not a CPU-bound loop with no `await` — -## a tight `while true:` with no yield blocks the main thread, so nothing -## (including this poll) runs until it yields. That case is out of scope. -const EVAL_TIMEOUT_SEC := 8.0 - - -func _handle_eval(data: Array) -> void: - var request_id: String = data[0] if data.size() > 0 else "" - var code: String = data[1] if data.size() > 1 else "" - - if code.is_empty(): - _reply_eval_error(request_id, "No code provided") - return - - ## Wrap user code in an execute() coroutine (so it can `await` internally) - ## whose inner function is uniquely named per eval. A runtime error's - ## backtrace then carries `_mcp_run_`, letting us attribute it to - ## THIS eval — not an unrelated background game error, and not a sibling - ## overlapping eval. (#490) - _eval_token_counter += 1 - var token := str(_eval_token_counter) - var run_fn := "_mcp_run_%s" % token - var script_source := ( - "extends Node\n" - + "func execute():\n" - + "\treturn await %s()\n\n" % run_fn - + "func %s():\n" % run_fn - + _indent_eval_code(code) - ) - - ## Snapshot the logger's script-error seq BEFORE running so we only attribute - ## errors raised by this eval. In a debug build a parse error aborts reload() - ## and a runtime error aborts execute() — either way this function may never - ## reach its reply: the editor infers a compile error from the missing - ## mcp:eval_compiled beacon, and a runtime error is reported (via the - ## eval_check probe / the in-flight poll loop) once a logged error past this - ## baseline carries this eval's token. - var baseline: int = _logger.script_error_seq() if _logger != null else 0 - - var script: GDScript = GDScript.new() - script.source_code = script_source - ## #490: ack BEFORE reload(). A parse error aborts this function at reload() - ## without a return code in a debug build, so this is our only chance to tell - ## the editor "received + about to compile." The editor uses that to tell a - ## real parse error (acked, never compiled) apart from a message it simply - ## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace. - EngineDebugger.send_message("mcp:eval_ack", [request_id]) - ## reload() ABORTS this function on a parse error in a debug build (it does - ## not return a non-OK code there), so the lines below only run when the - ## source compiled. Keep reload() INLINE — moving it behind a timer/await - ## poisons subsequent evals (#490). The err branch still matters for the - ## editor process (handler unit tests), where reload() does return. - var err: int = script.reload() - if err != OK: - _reply_eval_error(request_id, - "Failed to compile GDScript (error %d). Check syntax." % err) - return - - ## Compiled OK — tell the editor so its grace timer doesn't flag a compile - ## error and so it begins probing for a runtime error. - EngineDebugger.send_message("mcp:eval_compiled", [request_id]) - - var temp_node := Node.new() - temp_node.set_script(script) - temp_node.process_mode = Node.PROCESS_MODE_ALWAYS - add_child(temp_node) - - if not temp_node.has_method("execute"): - temp_node.queue_free() - _reply_eval_error(request_id, "Internal error: eval wrapper is missing execute().") - return - - ## Register in-flight BEFORE running: a runtime error aborts execute() (and - ## may unwind this function) before we could record it afterward, and the - ## editor probe / poll loop need the entry to attribute and report the error. - _inflight_evals[request_id] = {"node": temp_node, "token": token, "baseline": baseline} - - ## Drive execute() as a fire-and-forget coroutine that records its outcome - ## into `holder`, then poll frames until it finishes or the deadline passes - ## (#488's hung-await guard). A plain `await temp_node.execute()` has no - ## escape hatch: if user code never returns, we never reach the reply/cleanup - ## below and the request hangs with the node leaked. - var holder := {"done": false, "value": null, "abandoned": false} - _drive_eval(temp_node, holder) - - var tree := get_tree() - var deadline_ms := int(EVAL_TIMEOUT_SEC * 1000.0) - var start_ms := Time.get_ticks_msec() - while not holder["done"] and (Time.get_ticks_msec() - start_ms) < deadline_ms: - ## #490 focused fast path: a runtime error aborts _drive_eval (holder - ## never completes), so check each frame whether THIS eval's token now - ## appears in a logged error and report it immediately. (Backgrounded, - ## this loop is frozen and the editor probe does the same job.) - if _try_report_eval_runtime_error(request_id): - holder["abandoned"] = true - return - await tree.process_frame - - if not holder["done"]: - ## Past the 8s deadline. Disambiguate a runtime error (its token is in a - ## logged error) from a genuine hung await before the generic timeout. - holder["abandoned"] = true - if _try_report_eval_runtime_error(request_id): - return - _inflight_evals.erase(request_id) - if is_instance_valid(temp_node): - remove_child(temp_node) - _reply_eval_error(request_id, - ("Eval exceeded %ds and was aborted — the code likely awaits " - + "something that never completes (a signal that never fires, a timer on " - + "a paused tree) or loops forever. Check logs_read(source='game').") - % int(EVAL_TIMEOUT_SEC), - ErrorCodes.EVAL_HUNG) - return - - ## Clean finish. - _inflight_evals.erase(request_id) - temp_node.queue_free() - _reply_eval_response(request_id, holder["value"]) - - -## Run the compiled eval node's execute() and stash the result. Kept -## separate from _handle_eval so the latter can race it against a deadline -## via frame polling. If the eval was abandoned (timed out) before this -## resumes, drop the result and free the now-detached node — _handle_eval -## has already replied. -## -## RESIDUAL LEAK (accepted): if the awaited thing *never* fires, this -## coroutine never resumes, so the `node` it holds is detached (via -## _handle_eval's remove_child) but never freed — one orphaned Node per such -## timeout, for the game-process lifetime. GDScript has no way to cancel a -## suspended coroutine, so this is the best achievable in-process. It is still -## strictly better than the pre-#487 behavior, where the node leaked *into* -## the live tree and the request hung to the 15s ceiling. -func _drive_eval(node: Node, holder: Dictionary) -> void: - var value = await node.execute() - if holder.get("abandoned", false): - if is_instance_valid(node): - node.queue_free() - return - holder["value"] = value - holder["done"] = true - - -## #518: cap on the serialized eval result. Godot's remote-debugger TCP peer -## silently discards any single message over ~8 MiB, so a bigger reply never -## reaches the editor and the request rides to the 10s backstop as a phantom -## "hang". (Results over the editor↔server WebSocket buffer cap of 4 MiB fail -## there with their own explicit error; this game-side cap only needs to stay -## under the debugger peer's drop threshold to keep the failure visible.) -const EVAL_RESULT_MAX_BYTES := 6 * 1024 * 1024 - -## Testing seam: the last eval reply, recorded before hitting the -## EngineDebugger channel (inactive in the editor-side test harness). -var _last_eval_reply: Dictionary = {} - - -## `code` (optional) rides as a third payload element so the editor can map -## the reply to a specific error code instead of the generic INTERNAL_ERROR; -## the editor allowlists the value (see mcp_debugger_plugin._on_eval_error). -func _reply_eval_error(request_id: String, message: String, code: String = "") -> void: - _last_eval_reply = {"kind": "error", "request_id": request_id, - "message": message, "code": code} - var payload := [request_id, message] - if not code.is_empty(): - payload.append(code) - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:eval_error", payload) - - -func _reply_eval_response(request_id: String, value: Variant) -> void: - var serialized := JSON.stringify(_variant_to_json(value)) - var serialized_bytes := serialized.to_utf8_buffer().size() - if serialized_bytes > EVAL_RESULT_MAX_BYTES: - _reply_eval_error(request_id, - ("Eval result too large to return (%d bytes serialized, limit %d). " - + "Return a smaller slice instead — e.g. counts, node paths, or a " - + "truncated substring.") % [serialized_bytes, EVAL_RESULT_MAX_BYTES], - ErrorCodes.EVAL_RESULT_TOO_LARGE) - return - _last_eval_reply = {"kind": "response", "request_id": request_id} - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:eval_response", [request_id, serialized]) - - -## #490: if a logged script error past THIS eval's baseline carries its unique -## wrapper-function token, a runtime error aborted it before it could reply — -## report it with the real text + line. Returns true if it reported. Called -## from the editor's eval_check probe (the reliable path when a backgrounded -## game's idle loop is frozen — the debugger capture callback still runs) and -## from _handle_eval's poll loop (the focused fast path). Token + baseline -## matching means an unrelated background error, or a sibling overlapping -## eval's error, can never fail this request. -func _try_report_eval_runtime_error(request_id: String) -> bool: - if _logger == null: - return false - var entry = _inflight_evals.get(request_id) - if entry == null: - return false - var text: String = _logger.find_script_error_since( - int(entry["baseline"]), "_mcp_run_%s" % str(entry["token"])) - if text.is_empty(): - return false - _inflight_evals.erase(request_id) - var node: Node = entry["node"] - if node != null and is_instance_valid(node): - node.queue_free() - if EngineDebugger.is_active(): - EngineDebugger.send_message("mcp:eval_runtime_error", [request_id, text]) - return true - - -## #490: answer an editor eval_check probe. The editor polls this once the -## eval has compiled but not yet replied. This runs in the debugger capture -## callback, which stays live even when the backgrounded game's _process is -## frozen — so it's the reliable channel for reporting a runtime error that -## aborted the eval. Report if one is detected for this request, else stay -## silent (the editor keeps polling until the real reply or the hang timeout). -func _handle_eval_check(data: Array) -> void: - var request_id: String = data[0] if data.size() > 0 else "" - if request_id.is_empty(): - return - _try_report_eval_runtime_error(request_id) - - -func _indent_eval_code(code: String) -> String: - var lines: PackedStringArray = code.split("\n") - var out := "" - for line in lines: - out += "\t" + line + "\n" - return out - - -## Serialize any Godot Variant to a JSON-safe dictionary/array/primitive. -## Ported from godot-mcp's mcp_interaction_server.gd. -func _variant_to_json(value: Variant) -> Variant: - if value == null: - return null - if value is bool or value is int or value is float or value is String: - return value - if value is Vector2: - return {"x": value.x, "y": value.y} - if value is Vector3: - return {"x": value.x, "y": value.y, "z": value.z} - if value is Vector4: - return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} - if value is Vector2i: - return {"x": value.x, "y": value.y} - if value is Vector3i: - return {"x": value.x, "y": value.y, "z": value.z} - if value is Vector4i: - return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} - if value is Color: - return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} - if value is Quaternion: - return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} - if value is Basis: - return { - "x": _variant_to_json(value.x), - "y": _variant_to_json(value.y), - "z": _variant_to_json(value.z), - } - if value is Transform3D: - return { - "basis": _variant_to_json(value.basis), - "origin": _variant_to_json(value.origin), - } - if value is Transform2D: - return { - "x": _variant_to_json(value.x), - "y": _variant_to_json(value.y), - "origin": _variant_to_json(value.origin), - } - if value is Rect2: - return { - "position": _variant_to_json(value.position), - "size": _variant_to_json(value.size), - } - if value is Rect2i: - return { - "position": _variant_to_json(value.position), - "size": _variant_to_json(value.size), - } - if value is AABB: - return { - "position": _variant_to_json(value.position), - "size": _variant_to_json(value.size), - } - if value is NodePath or value is StringName: - return str(value) - if value is Plane: - return { - "normal": _variant_to_json(value.normal), - "d": value.d, - } - if value is Projection: - return { - "x": _variant_to_json(value.x), - "y": _variant_to_json(value.y), - "z": _variant_to_json(value.z), - "w": _variant_to_json(value.w), - } - ## Packed arrays - if value is PackedByteArray: - var arr: Array = [] - for item in value: arr.append(item) - return arr - if value is PackedInt32Array or value is PackedInt64Array: - var arr: Array = [] - for item in value: arr.append(item) - return arr - if value is PackedFloat32Array or value is PackedFloat64Array: - var arr: Array = [] - for item in value: arr.append(item) - return arr - if value is PackedStringArray: - var arr: Array = [] - for item in value: arr.append(item) - return arr - if value is PackedVector2Array: - var arr: Array = [] - for item in value: arr.append({"x": item.x, "y": item.y}) - return arr - if value is PackedVector3Array: - var arr: Array = [] - for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z}) - return arr - if value is PackedVector4Array: - var arr: Array = [] - for item in value: arr.append({"x": item.x, "y": item.y, "z": item.z, "w": item.w}) - return arr - if value is PackedColorArray: - var arr: Array = [] - for item in value: arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a}) - return arr - ## Generic arrays and dictionaries — recurse - if value is Array: - var arr: Array = [] - for item in value: - arr.append(_variant_to_json(item)) - return arr - if value is Dictionary: - var dict: Dictionary = {} - for key in value.keys(): - dict[str(key)] = _variant_to_json(value[key]) - return dict - ## Fallback: string representation - return str(value) diff --git a/addons/godot_ai/runtime/game_helper.gd.uid b/addons/godot_ai/runtime/game_helper.gd.uid deleted file mode 100644 index 7224a50..0000000 --- a/addons/godot_ai/runtime/game_helper.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gfybkdtsclti diff --git a/addons/godot_ai/runtime/game_logger.gd b/addons/godot_ai/runtime/game_logger.gd deleted file mode 100644 index 6384960..0000000 --- a/addons/godot_ai/runtime/game_logger.gd +++ /dev/null @@ -1,142 +0,0 @@ -@tool -extends Logger - -## Game-process Logger subclass. -## -## NOTE: deliberately no `class_name`. Registered from inside the running -## game so we can intercept print(), printerr(), push_error(), and -## push_warning() and ferry them back to the editor over the EngineDebugger -## channel — the same bridge PR #76 uses for screenshots. -## -## Logger virtuals can be called from any thread (e.g. async loaders push -## errors off the main thread). We accumulate into _pending under a Mutex -## and the host (game_helper.gd) flushes once per frame from the main -## thread, where EngineDebugger.send_message is safe to call. - -## `McpLogBacktrace` is published as a `class_name` on log_backtrace.gd, but a -## freshly-launched game subprocess (no prior editor scan; e.g. CI launching -## `--headless --path`) hits this autoload before the global class_name table -## is populated, and parsing this script fails with -## "Identifier 'McpLogBacktrace' not declared in the current scope". Using -## `const preload` resolves the path at parse time and is independent of the -## class_name registry — matches the project convention in CLAUDE.md -## ("Internals … skip class_name entirely and load via const preload"). -const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") - -var _pending: Array = [] -var _mutex := Mutex.new() -## #490: a monotonic sequence + a small ring of recent GDScript runtime -## (script-type) errors, each with its text AND the function names in its -## backtrace. game_helper uses this to attribute a runtime error to the -## *specific* eval that raised it: each eval's wrapper has a uniquely named -## inner function, and game_helper asks find_script_error_since() whether any -## error past its pre-eval baseline carries that function in its stack. This -## avoids failing an eval on an unrelated background game error that merely -## advanced a global counter, and keeps overlapping evals from cross- -## attributing. Gated on ERROR_TYPE_SCRIPT (2) so push_error()/push_warning() -## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread. -const _ERROR_TYPE_SCRIPT := 2 -const _MAX_RECENT_SCRIPT_ERRORS := 64 -var _script_error_seq: int = 0 -var _recent_script_errors: Array = [] - - -func _log_message(message: String, error: bool) -> void: - ## `error` is true for printerr(), false for print(). - var level := "error" if error else "info" - _append(level, message) - - -func _log_error( - function: String, - file: String, - line: int, - code: String, - rationale: String, - _editor_notify: bool, - error_type: int, - script_backtraces: Array, -) -> void: - ## EngineDebugger's payload shape is `[level, text]` — the source - ## location has nowhere structured to land for the game side, so we - ## inline it into `text`. editor_logger keeps the resolved fields - ## as structured columns instead. - var resolved := _LogBacktrace.resolve_error( - function, file, line, code, rationale, error_type, script_backtraces, - ) - var loc := "" - if not resolved.path.is_empty(): - loc = "%s:%d @ %s" % [resolved.path, resolved.line, resolved.function] if not resolved.function.is_empty() else "%s:%d" % [resolved.path, resolved.line] - var text: String = "%s (%s)" % [resolved.message, loc] if not loc.is_empty() else resolved.message - var details: Dictionary = resolved.get("details", {}) - _append(resolved.level, text, details) - if error_type == _ERROR_TYPE_SCRIPT: - ## Collect every function name in the first non-empty backtrace so - ## game_helper can match its eval's uniquely named wrapper function. - var funcs := PackedStringArray() - for bt: RefCounted in script_backtraces: - if bt != null and bt.get_frame_count() > 0: - for i: int in bt.get_frame_count(): - funcs.append(bt.get_frame_function(i)) - break - _mutex.lock() - _script_error_seq += 1 - _recent_script_errors.append({"seq": _script_error_seq, "text": text, "funcs": funcs}) - if _recent_script_errors.size() > _MAX_RECENT_SCRIPT_ERRORS: - _recent_script_errors.remove_at(0) - _mutex.unlock() - - -func _append(level: String, text: String, details: Dictionary = {}) -> void: - _mutex.lock() - if details.is_empty(): - _pending.append([level, text]) - else: - _pending.append([level, text, details.duplicate(true)]) - _mutex.unlock() - - -## Drain the pending queue and return entries as [[level, text], ...]. -## Called from the main thread by game_helper each frame. -func drain() -> Array: - _mutex.lock() - var out := _pending - _pending = [] - _mutex.unlock() - return out - - -func has_pending() -> bool: - _mutex.lock() - var any := not _pending.is_empty() - _mutex.unlock() - return any - - -## #490: monotonic count of script-type runtime errors seen this run. -## game_helper snapshots this before an eval to use as the `since_seq` -## baseline for find_script_error_since(). Mutex-guarded. -func script_error_seq() -> int: - _mutex.lock() - var v := _script_error_seq - _mutex.unlock() - return v - - -## #490: text of the most recent script error with seq > since_seq whose -## backtrace includes `function_name`, or "" if none. Lets game_helper -## attribute a runtime error to the exact eval whose uniquely named wrapper -## function appears in the stack — ignoring unrelated game errors and errors -## from before the eval started. Mutex-guarded. -func find_script_error_since(since_seq: int, function_name: String) -> String: - _mutex.lock() - var found := "" - for i in range(_recent_script_errors.size() - 1, -1, -1): - var rec: Dictionary = _recent_script_errors[i] - if int(rec["seq"]) <= since_seq: - break - if (rec["funcs"] as PackedStringArray).has(function_name): - found = rec["text"] - break - _mutex.unlock() - return found diff --git a/addons/godot_ai/runtime/game_logger.gd.uid b/addons/godot_ai/runtime/game_logger.gd.uid deleted file mode 100644 index d4e263e..0000000 --- a/addons/godot_ai/runtime/game_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwcs6d1y7vhqi diff --git a/addons/godot_ai/runtime/validation_logger.gd b/addons/godot_ai/runtime/validation_logger.gd deleted file mode 100644 index 672b11f..0000000 --- a/addons/godot_ai/runtime/validation_logger.gd +++ /dev/null @@ -1,43 +0,0 @@ -@tool -extends Logger - -## Short-lived Logger used only for per-write validation loads. -## -## Unlike editor_logger.gd this deliberately has no addon feedback-loop filter: -## the caller attaches it around one ResourceLoader.load() call, reads its -## private buffer, and immediately removes it. The shared editor logger should -## still drop these validation-load errors so logs_read(source="editor") stays -## clean. - -const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd") - -var _buffer - - -func _init(buffer = null) -> void: - _buffer = buffer - - -func _log_error( - function: String, - file: String, - line: int, - code: String, - rationale: String, - _editor_notify: bool, - error_type: int, - script_backtraces: Array, -) -> void: - if _buffer == null: - return - var resolved := _LogBacktrace.resolve_error( - function, - file, - line, - code, - rationale, - error_type, - script_backtraces, - ) - var details: Dictionary = resolved.get("details", {}) - _buffer.append(resolved.level, resolved.message, resolved.path, resolved.line, resolved.function, details) diff --git a/addons/godot_ai/runtime/validation_logger.gd.uid b/addons/godot_ai/runtime/validation_logger.gd.uid deleted file mode 100644 index 4e693b4..0000000 --- a/addons/godot_ai/runtime/validation_logger.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b2ff3aot6t2l4 diff --git a/addons/godot_ai/telemetry.gd b/addons/godot_ai/telemetry.gd deleted file mode 100644 index dc90bf9..0000000 --- a/addons/godot_ai/telemetry.gd +++ /dev/null @@ -1,198 +0,0 @@ -## Plugin-side telemetry helper. -## -## Relays plugin-only events (dock startup, self-update outcome, plugin -## reload, dev-server toggle) to the Python MCP server via the existing -## `send_event("plugin_event", {...})` channel. The server's -## `transport/websocket.py` allowlists event names and forwards into the -## central telemetry pipeline — meaning opt-out, endpoint, customer_uuid -## and the bounded-queue worker stay in one place (Python), not -## duplicated in GDScript. -## -## Opt-out options priority: -## 1. `GODOT_AI_DISABLE_TELEMETRY` / `DISABLE_TELEMETRY` env vars — -## checked first so CI / operators can force-disable without touching -## EditorSettings. -## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the -## MCP dock and persisted between sessions. -## -## When telemetry is disabled, events are never buffered or sent. Only a -## *truthy* env var force-disables; a falsey or absent env var falls through -## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled. -## -## Buffering: events recorded before the WebSocket is connected go into -## a small bounded buffer and flush on the next `record_event` call once -## connected. The buffer is intentionally small (`_MAX_BUFFER`); plugin -## events are sparse, and a flood means something is misconfigured. - -extends RefCounted - -## Allowlist mirrored on the Python side in -## `src/godot_ai/transport/websocket.py::_PLUGIN_EVENT_NAMES`. Update -## both together. -const _ALLOWED_EVENTS := [ - "dock_startup", - "plugin_reload", - "self_update", - "dev_server_toggle", -] - -const _MAX_BUFFER := 32 - -## EditorSetting key used to defer a ``plugin_reload`` event across the -## disable -> enable boundary. Callers that trigger plugin reload (the -## dock reload button, ``editor_reload_plugin`` MCP-tool path) write -## here *before* the disable kills the live WebSocket; the new -## plugin's ``_enter_tree`` flushes via ``flush_pending_plugin_reload``. -const PENDING_PLUGIN_RELOAD_KEY := "godot_ai/pending_plugin_reload_event" - - -## Persist a ``plugin_reload`` event so the re-enabled plugin instance -## can emit it once its new WebSocket is up. Static so callers without -## a telemetry instance handle (e.g. ``editor_handler.reload_plugin``) -## can use it via the preloaded const alias. -static func record_pending_plugin_reload(source: String) -> void: - var settings := EditorInterface.get_editor_settings() - if settings == null: - return - settings.set_setting( - PENDING_PLUGIN_RELOAD_KEY, - JSON.stringify({"source": source, "success": true}), - ) - - -## Read + clear an EditorSetting JSON-encoded event payload. Returns -## the parsed dict, or ``null`` if the key is absent / empty / -## malformed. Used by ``flush_pending_plugin_reload`` (below) and by -## ``plugin.gd::_flush_pending_self_update_telemetry``. Centralising -## the read-and-clear dance keeps both flush sites symmetric with the -## ``record_pending_*`` writers and prevents the "key gets stuck" -## class of bug if a future flush helper forgets the clear step. -static func _drain_editor_setting_dict(key: String): - var settings := EditorInterface.get_editor_settings() - if settings == null: - return null - if not settings.has_setting(key): - return null - var raw := str(settings.get_setting(key)) - settings.set_setting(key, "") - if raw == "": - return null - var parsed = JSON.parse_string(raw) - if typeof(parsed) != TYPE_DICTIONARY: - return null - return parsed - -var _connection -var _disabled: bool = false -var _pending: Array = [] # of {name: String, data: Dictionary} - -func _init(connection) -> void: - _connection = connection - _disabled = not McpSettings.telemetry_enabled() - ## Subscribe to ``connection_state_changed`` so events buffered before - ## the WebSocket handshake (e.g. ``record_dock_startup`` from - ## ``plugin._enter_tree``) actually leave the editor. Without this, - ## the buffer only drained on the next ``record_event`` call — when - ## that call never came (the common single-session case), the very - ## events we cared about most sat in the queue forever. - if _connection != null and _connection.has_signal("connection_state_changed"): - _connection.connection_state_changed.connect(_on_connection_state_changed) - - -func record_event(name: String, data: Dictionary = {}) -> void: - if _disabled: - return - if not _ALLOWED_EVENTS.has(name): - ## Drop silently — matches the server's behavior for unknown - ## names, and avoids editor yellow-bar noise from third-party - ## callers or stale event names mid-rollout. - return - if _connection != null and _connection.is_connected: - _flush() - _send_one(name, data) - return - ## Pre-handshake: stash in a small bounded buffer. Drained on the - ## first ``connection_state_changed(true)`` after this point (see - ## ``_on_connection_state_changed``). Falling back to "drain on the - ## next record_event" is a footgun: the most useful plugin events - ## (``dock_startup``, pending ``self_update``) fire from - ## ``plugin._enter_tree`` before the handshake, and a single-session - ## editor may never emit a second event — so without the signal- - ## driven flush they sat buffered forever. - if _pending.size() >= _MAX_BUFFER: - _pending.pop_front() - _pending.append({"name": name, "data": data}) - - -func _on_connection_state_changed(is_open: bool) -> void: - if is_open: - _flush() - -func _flush() -> void: - if _pending.is_empty(): - return - var to_send := _pending.duplicate() - _pending.clear() - for entry in to_send: - _send_one(entry["name"], entry["data"]) - -func _send_one(name: String, data: Dictionary) -> void: - if _connection == null: - return - _connection.send_event("plugin_event", {"name": name, "data": data}) - -# --- convenience emitters -------------------------------------------------- - -func record_dock_startup(extra: Dictionary = {}) -> void: - record_event("dock_startup", extra) - -func record_self_update( - status: String, - from_version: String = "", - to_version: String = "", - error: String = "", -) -> void: - var data := {"status": status} - if from_version != "": - data["from_version"] = from_version - if to_version != "": - data["to_version"] = to_version - if error != "": - data["error"] = error.substr(0, 200) - record_event("self_update", data) - -func record_dev_server_toggle(action: String) -> void: - record_event("dev_server_toggle", {"action": action}) - - -## Drain a pending ``plugin_reload`` event written by the previous -## instance before it disabled itself. Pending events are currently -## always success=true — ``record_pending_plugin_reload`` above is the -## only writer and hardcodes it (a reload that fails never reaches the -## flush anyway; there is no new instance to drain the key). The -## error/success parsing below stays tolerant for forward compat with -## a writer that records failures. -func flush_pending_plugin_reload() -> void: - var parsed = _drain_editor_setting_dict(PENDING_PLUGIN_RELOAD_KEY) - if parsed == null: - return - var data := { - "success": bool(parsed.get("success", true)), - "source": str(parsed.get("source", "unknown")), - } - var error := str(parsed.get("error", "")) - if error != "": - data["error"] = error.substr(0, 200) - record_event("plugin_reload", data) - -# --- test seam ------------------------------------------------------------- - -## Inject a fake connection or force the disabled flag for unit tests -## that don't have a live WebSocket. Production code does not call this. -func _test_set_state(connection, disabled: bool) -> void: - _connection = connection - _disabled = disabled - _pending.clear() - -func _test_pending_count() -> int: - return _pending.size() diff --git a/addons/godot_ai/telemetry.gd.uid b/addons/godot_ai/telemetry.gd.uid deleted file mode 100644 index 6f46be6..0000000 --- a/addons/godot_ai/telemetry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlul2gculiy1p diff --git a/addons/godot_ai/testing/script_error_capture.gd b/addons/godot_ai/testing/script_error_capture.gd deleted file mode 100644 index 92c53ed..0000000 --- a/addons/godot_ai/testing/script_error_capture.gd +++ /dev/null @@ -1,49 +0,0 @@ -@tool -extends Logger - -## Captures GDScript runtime errors emitted while a test is running. -## -## Deliberately no class_name: this is an internal test helper. -## -## Only ERROR_TYPE_SCRIPT is captured. push_error(), push_warning(), and -## engine-internal ERR_FAIL_* checks are often valid negative-path assertions and -## should not abort the test. - -var _mutex := Mutex.new() -var _capturing := false -var _errors := PackedStringArray() - - -func begin_capture() -> void: - _mutex.lock() - _capturing = true - _errors.clear() - _mutex.unlock() - - -func end_capture() -> PackedStringArray: - _mutex.lock() - var captured := _errors.duplicate() - _capturing = false - _errors.clear() - _mutex.unlock() - return captured - - -func _log_error( - function: String, - file: String, - line: int, - code: String, - rationale: String, - _editor_notify: bool, - error_type: int, - _script_backtraces: Array, -) -> void: - if error_type != ERROR_TYPE_SCRIPT: - return - _mutex.lock() - if _capturing: - var text := rationale if not rationale.is_empty() else code - _errors.append("%s (%s:%d in %s)" % [text, file, line, function]) - _mutex.unlock() diff --git a/addons/godot_ai/testing/script_error_capture.gd.uid b/addons/godot_ai/testing/script_error_capture.gd.uid deleted file mode 100644 index 6b2d712..0000000 --- a/addons/godot_ai/testing/script_error_capture.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://crk8w2nei087v diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd deleted file mode 100644 index 1aa0041..0000000 --- a/addons/godot_ai/testing/test_runner.gd +++ /dev/null @@ -1,441 +0,0 @@ -@tool -class_name McpTestRunner -extends RefCounted - -## Lightweight test runner for MCP plugin tests. Discovers test_* methods -## on McpTestSuite instances, runs them, and collects structured results. - -const ScriptErrorCapture := preload("res://addons/godot_ai/testing/script_error_capture.gd") - -var _results: Array[Dictionary] = [] -var _last_run_ms: int = 0 -var _script_error_capture: ScriptErrorCapture = null -var _capture_registered := false - - -func _notification(what: int) -> void: - if what == NOTIFICATION_PREDELETE and _capture_registered and _script_error_capture != null: - OS.remove_logger(_script_error_capture) - _capture_registered = false - - -func run_suite(suite: McpTestSuite, test_filter: String = "", exclude_test_filter: String = "") -> void: - var owns_capture := not _capture_registered - if owns_capture: - _register_capture() - - _run_suite_tests(suite, test_filter, exclude_test_filter, Callable(), 0, {}) - - if owns_capture: - _unregister_capture() - - -## Shared per-test loop for both the legacy synchronous path and the -## serviced driver. Returns "" when the loop completed, or a terminal -## outcome ("timeout" / "transport_lost" / "paused") when a checkpoint -## aborted it. Checkpoints run BETWEEN tests — an atomic test body is -## never preempted (see docs/test-run-transport-starvation-plan.md). -func _run_suite_tests( - suite: McpTestSuite, - test_filter: String, - exclude_test_filter: String, - service_cb: Callable, - deadline_ticks_ms: int, - run_state: Dictionary, -) -> String: - var name := suite.suite_name() - var methods := _get_test_methods(suite) - var exclusions := _parse_exclusions(exclude_test_filter) - - for method_name in methods: - if not test_filter.is_empty() and method_name.find(test_filter) == -1: - continue - _selected_processed += 1 - if _matches_any_exclusion(method_name, exclusions): - _results.append({ - "suite": name, - "test": method_name, - "passed": true, - "skipped": true, - "message": "Excluded by exclude_test_name filter", - "assertion_count": 0, - "duration_ms": 0, - }) - continue - - var test_start := Time.get_ticks_msec() - var entry := _run_one_test(suite, name, method_name) - entry["duration_ms"] = Time.get_ticks_msec() - test_start - _results.append(entry) - - var stop := _checkpoint(service_cb, deadline_ticks_ms, run_state) - if not stop.is_empty(): - return stop - return "" - - -## Execute one test method and return its result entry (not yet appended; -## the caller stamps `duration_ms`). Extracted from run_suite so the legacy -## and serviced drivers share one execution core — behavior must stay -## byte-identical to the pre-refactor loop body. -func _run_one_test(suite: McpTestSuite, name: String, method_name: String) -> Dictionary: - suite._reset() - _begin_script_error_capture() - suite.setup() - suite.call(method_name) - suite.teardown() - var script_errors := suite._unexpected_script_errors(_end_script_error_capture()) - suite._free_tracked() - - ## Issue #19 defence: free any `_McpTest*` nodes the test created, even - ## nested ones. If the scene gets auto-saved mid-test while one of these - ## exists, the reference bakes into main.tscn and breaks the next open - ## with a "missing dependency" error. Runs after every test, not just at - ## suite boundaries, so a test that fails mid-flow can't leave a trap - ## for the next test or for scene autosave. - var scene_root_for_cleanup := _edited_scene_root() - if scene_root_for_cleanup != null and scene_root_for_cleanup.is_inside_tree(): - _free_mcp_test_nodes_recursive(scene_root_for_cleanup) - - if not script_errors.is_empty(): - var abort_message := "Aborted by SCRIPT ERROR: %s" % "; ".join(script_errors) - if suite._failed: - abort_message += " (after assertion failure: %s)" % suite._message - return { - "suite": name, - "test": method_name, - "passed": false, - "message": abort_message, - "assertion_count": suite._assertion_count, - } - - ## A failed assertion always wins over a later skip(): a test that - ## fails and then hits a skip-guard must report the failure, not - ## park itself as green-skipped. - if suite._skipped and not suite._failed: - return { - "suite": name, - "test": method_name, - "passed": true, - "skipped": true, - "message": suite._skip_reason, - "assertion_count": 0, - } - - var passed := not suite._failed - var msg := suite._message - - ## Warn about zero-assertion tests (likely silently skipped logic). - if passed and suite._assertion_count == 0: - passed = false - msg = "Test completed with 0 assertions (likely skipped its logic)" - - return { - "suite": name, - "test": method_name, - "passed": passed, - "message": msg, - "assertion_count": suite._assertion_count, - } - - -func run_suites(suites: Array, suite_filter: String = "", test_filter: String = "", ctx: Dictionary = {}, verbose: bool = false, exclude_test_filter: String = "") -> Dictionary: - ## Legacy synchronous API — unchanged signature and semantics for direct - ## callers, unit-test fixtures, and any batch context. No servicing, no - ## ceiling: with an invalid Callable and deadline 0 every checkpoint is a - ## no-op and the outcome is always "completed". - var run := run_suites_serviced(suites, suite_filter, test_filter, ctx, verbose, exclude_test_filter) - return run["results"] - - -## Serviced driver for live MCP runs. Between tests and at suite -## boundaries it (a) aborts once `deadline_ticks_ms` passes and (b) calls -## `service_cb` (McpConnection.service_transport_during_exclusive_run) so -## the WebSocket heartbeat stays alive while the suite monopolizes the -## main thread. Returns: -## { -## "outcome": "completed" | "timeout" | "transport_lost" | "paused", -## "results": , -## "tests_not_run": , -## } -## Every outcome path — including aborts — runs the current suite's -## teardown + leak cleanup, restores console echo, and unregisters the -## capture logger. `run_state` is caller-owned and threaded through to the -## service callback (cumulative packet counter lives there). -func run_suites_serviced( - suites: Array, - suite_filter: String = "", - test_filter: String = "", - ctx: Dictionary = {}, - verbose: bool = false, - exclude_test_filter: String = "", - service_cb: Callable = Callable(), - deadline_ticks_ms: int = 0, - run_state: Dictionary = {}, -) -> Dictionary: - _results.clear() - _selected_processed = 0 - var selected_total := _count_selected_tests(suites, suite_filter, test_filter) - var start := Time.get_ticks_msec() - var outcome := "" - - ## Silence the plugin's ring-buffer console echo while tests run. Negative- - ## path suites deliberately fill the ring with 500 lines and log malformed- - ## result errors; echoing all of that buries an all-green run in scary - ## console output. The ring contents tests assert on are untouched, and - ## the flag is restored after the run so live logging resumes. - var _prev_console_echo := McpLogBuffer.console_echo - McpLogBuffer.console_echo = false - - ## If a prior run was interrupted after registering the logger but before - ## normal teardown, remove that stale registration before starting fresh. - _unregister_capture() - _register_capture() - - for suite: McpTestSuite in suites: - if not suite_filter.is_empty() and suite.suite_name() != suite_filter: - continue - - ## Snapshot scene children before the suite so we can clean up leaks. - var scene_root := _edited_scene_root() - var before_children: Array[Node] = [] - if scene_root != null: - before_children = _get_children_snapshot(scene_root) - - suite._reset_suite_state() - suite.suite_setup(ctx.duplicate(true)) - - ## fail_setup() / skip_suite() gives suites a clean way to bail out of - ## suite_setup without leaving N tests to fail with "0 assertions". We - ## emit ONE suite-level result and skip individual tests entirely. - if suite._suite_failed: - _results.append({ - "suite": suite.suite_name(), - "test": "", - "passed": false, - "message": "suite_setup() failed: %s (subsequent tests not run)" % suite._suite_failed_message, - "assertion_count": 0, - }) - ## The bailed suite's tests are accounted for, not "not run". - _selected_processed += _count_selected_tests([suite], "", test_filter) - elif suite._suite_skipped: - _results.append({ - "suite": suite.suite_name(), - "test": "", - "passed": true, - "skipped": true, - "message": "suite_setup() skipped: %s" % suite._suite_skipped_reason, - "assertion_count": 0, - }) - _selected_processed += _count_selected_tests([suite], "", test_filter) - else: - outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state) - if outcome.is_empty(): - outcome = _run_suite_tests( - suite, test_filter, exclude_test_filter, - service_cb, deadline_ticks_ms, run_state - ) - ## Suite epilogue runs on EVERY path, including aborts: the suite has - ## begun, so its teardown and leak cleanup must not be skipped. - suite.suite_teardown() - suite._free_tracked() - - ## Remove any nodes the suite left behind (failed undo, missing cleanup). - if scene_root != null and scene_root.is_inside_tree(): - _cleanup_leaked_nodes(scene_root, before_children) - - if not outcome.is_empty(): - break - - outcome = _checkpoint(service_cb, deadline_ticks_ms, run_state) - if not outcome.is_empty(): - break - - _last_run_ms = Time.get_ticks_msec() - start - McpLogBuffer.console_echo = _prev_console_echo - _unregister_capture() - if outcome.is_empty(): - outcome = "completed" - return { - "outcome": outcome, - "results": get_results(verbose), - "tests_not_run": maxi(0, selected_total - _selected_processed), - } - - -## Count of selected (filter-surviving) tests already looped over this run, -## including exclusion-skips and bailed-suite accounting. Drives the -## `tests_not_run` estimate on aborted runs. -var _selected_processed := 0 - - -func _count_selected_tests(suites: Array, suite_filter: String, test_filter: String) -> int: - var count := 0 - for suite: McpTestSuite in suites: - if not suite_filter.is_empty() and suite.suite_name() != suite_filter: - continue - for method_name in _get_test_methods(suite): - if not test_filter.is_empty() and method_name.find(test_filter) == -1: - continue - count += 1 - return count - - -## Between-phase checkpoint: "" to continue, or a terminal outcome. -## Delegates to the shared mapping so the discovery checkpoints in -## test_handler.gd can never drift from the between-test ones (plan §9 Q2). -func _checkpoint(service_cb: Callable, deadline_ticks_ms: int, run_state: Dictionary) -> String: - return McpConnection.exclusive_run_checkpoint(service_cb, deadline_ticks_ms, run_state) - - -func _register_capture() -> void: - if _capture_registered: - return - if _script_error_capture == null: - _script_error_capture = ScriptErrorCapture.new() - if _script_error_capture == null: - return - OS.add_logger(_script_error_capture) - _capture_registered = true - - -func _unregister_capture() -> void: - if not _capture_registered: - return - if _script_error_capture == null: - _capture_registered = false - return - OS.remove_logger(_script_error_capture) - _capture_registered = false - - -func _begin_script_error_capture() -> void: - if _script_error_capture != null and _capture_registered: - _script_error_capture.begin_capture() - - -func _end_script_error_capture() -> PackedStringArray: - if _script_error_capture == null or not _capture_registered: - return PackedStringArray() - return _script_error_capture.end_capture() - - -static func _edited_scene_root() -> Node: - if not Engine.is_editor_hint(): - return null - return EditorInterface.get_edited_scene_root() - - -func get_results(verbose: bool = false) -> Dictionary: - var passed := 0 - var failed := 0 - var skipped := 0 - var failures: Array[Dictionary] = [] - var suites_seen := {} - for r in _results: - suites_seen[r.suite] = true - if r.get("skipped", false): - skipped += 1 - elif r.passed: - passed += 1 - else: - failed += 1 - failures.append(r) - - var result := { - "passed": passed, - "failed": failed, - "skipped": skipped, - "total": _results.size(), - "duration_ms": _last_run_ms, - "suites_run": suites_seen.keys(), - "suite_count": suites_seen.size(), - } - - if not failures.is_empty(): - result["failures"] = failures - - if verbose: - result["results"] = _results - - return result - - -func clear() -> void: - _results.clear() - _last_run_ms = 0 - - -func _get_test_methods(obj: Object) -> Array[String]: - var methods: Array[String] = [] - for m in obj.get_method_list(): - var name: String = m.get("name", "") - if name.begins_with("test_"): - methods.append(name) - methods.sort() - return methods - - -func _get_children_snapshot(node: Node) -> Array[Node]: - var children: Array[Node] = [] - for child in node.get_children(): - children.append(child) - return children - - -## Remove any nodes in scene_root that weren't present before the suite ran, -## plus any _McpTest* named nodes anywhere in the tree (catches nested leaks). -## NOTE: this bypasses EditorUndoRedoManager by design — the test runner -## owns these leaks and needs to clear them unconditionally. Don't Ctrl-Z in -## the editor immediately after a test run that triggered cleanup; the undo -## stack may reference freed nodes. -func _cleanup_leaked_nodes(scene_root: Node, before: Array[Node]) -> void: - var before_set := {} - for n in before: - before_set[n] = true - for child in scene_root.get_children(): - if not before_set.has(child): - scene_root.remove_child(child) - child.queue_free() - - -## Recursively free every node whose name starts with `_McpTest`, anywhere in -## the scene. Intentionally bypasses undo — these are test leaks, not user -## work. Walk breadth-first so we can collect victims before mutating the tree. -func _free_mcp_test_nodes_recursive(root: Node) -> void: - var victims: Array[Node] = [] - var queue: Array[Node] = [root] - while not queue.is_empty(): - var node: Node = queue.pop_back() - for child in node.get_children(): - if str(child.name).begins_with("_McpTest"): - victims.append(child) - else: - queue.append(child) - for v in victims: - if v.get_parent() != null: - v.get_parent().remove_child(v) - v.queue_free() - - -## Split the `exclude_test_name` filter into individual substring matchers. -## Comma-separated so the CI smoke harness can list multiple flaky tests -## without shipping a richer schema (single names still work — same string, -## no comma, same one-element list). Whitespace around each name is stripped -## so `"a, b"` and `"a,b"` behave identically. -static func _parse_exclusions(filter: String) -> Array[String]: - var out: Array[String] = [] - if filter.is_empty(): - return out - for part in filter.split(","): - var trimmed := part.strip_edges() - if not trimmed.is_empty(): - out.append(trimmed) - return out - - -static func _matches_any_exclusion(method_name: String, exclusions: Array[String]) -> bool: - for ex in exclusions: - if method_name.find(ex) != -1: - return true - return false diff --git a/addons/godot_ai/testing/test_runner.gd.uid b/addons/godot_ai/testing/test_runner.gd.uid deleted file mode 100644 index c0befb0..0000000 --- a/addons/godot_ai/testing/test_runner.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://367b77qh5grt diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd deleted file mode 100644 index 57b3bef..0000000 --- a/addons/godot_ai/testing/test_suite.gd +++ /dev/null @@ -1,330 +0,0 @@ -@tool -class_name McpTestSuite -extends RefCounted - -## Base class for MCP test suites. Provides assertion methods and -## lifecycle hooks. Subclass this, add test_* methods, and drop the -## script in res://tests/. - -## Override to return a short name for this suite (e.g. "scene", "node"). -func suite_name() -> String: - return "unnamed" - - -## Called once before the suite runs. Override to create handlers. -func suite_setup(_ctx: Dictionary) -> void: - pass - - -## Called before each test method. -func setup() -> void: - pass - - -## Called after each test method. -func teardown() -> void: - pass - - -## Called once after the suite finishes. -func suite_teardown() -> void: - pass - - -# ----- tracked allocations (freed by the runner after each test) ----- - -var _tracked_objects: Array[Object] = [] - - -## Register a manually-managed Object (plain Object or out-of-tree Node) so -## the runner frees it after the current test. RefCounted instances are -## accepted but ignored because they manage their own lifetime. -func track(obj: Object) -> Object: - if obj != null and not obj is RefCounted: - _tracked_objects.append(obj) - return obj - - -## Free everything registered via track(). Called by the runner after each -## test's teardown() and again after suite_teardown(). -func _free_tracked() -> void: - for obj in _tracked_objects: - if not is_instance_valid(obj) or (obj is Node and obj.is_queued_for_deletion()): - continue - if obj is Node: - var parent := (obj as Node).get_parent() - if parent != null: - parent.remove_child(obj) - obj.free() - _tracked_objects.clear() - - -# ----- assertion state (managed by McpTestRunner) ----- - -var _failed: bool = false -var _message: String = "" -var _assertion_count: int = 0 -var _skipped: bool = false -var _skip_reason: String = "" -var _expected_script_error_substrings: Array[String] = [] - -# ----- suite-level state (managed by McpTestRunner) ----- - -var _suite_failed: bool = false -var _suite_failed_message: String = "" -var _suite_skipped: bool = false -var _suite_skipped_reason: String = "" - - -func _reset() -> void: - _failed = false - _message = "" - _assertion_count = 0 - _skipped = false - _skip_reason = "" - _expected_script_error_substrings.clear() - - -func _reset_suite_state() -> void: - _suite_failed = false - _suite_failed_message = "" - _suite_skipped = false - _suite_skipped_reason = "" - - -## Mark the current test as skipped. Use when a precondition isn't met -## (e.g. no scene open, no Node3D in scene) and the test can't run. -## Skipped tests count separately from passed/failed. -func skip(reason: String = "") -> void: - _skipped = true - _skip_reason = reason - - -## Bail out of suite_setup() with a failure. Subsequent tests in this suite -## are not run; the runner reports a single suite-level failure with the -## given reason instead of N zero-assertion noise lines per test. -## -## Example: -## func suite_setup(ctx): -## var arena = preload("res://game/arena.gd").new() -## if arena == null: -## fail_setup("arena.gd failed to instantiate in @tool scope") -## return -func fail_setup(reason: String) -> void: - _suite_failed = true - _suite_failed_message = reason - - -## Bail out of suite_setup() because a precondition isn't met (no scene open, -## no game running, etc.). Subsequent tests are not run and the runner emits -## a single suite-level skip rather than per-test skip noise. -func skip_suite(reason: String) -> void: - _suite_skipped = true - _suite_skipped_reason = reason - - -## Mark the current test as skipped when the running Godot is older than -## `min_version` (a "major.minor" string like "4.6"). Use for tests that -## exercise an engine API or behavior that only exists on newer Godot. -## Returns true when the test was skipped, so callers can `return` from -## the test body. -## -## Example: -## func test_uses_46_only_api() -> void: -## if skip_on_godot_lt("4.6", "example API requires Godot 4.6+"): -## return -## ... -func skip_on_godot_lt(min_version: String, reason: String = "") -> bool: - var v := Engine.get_version_info() - var current_major := int(v.get("major", 0)) - var current_minor := int(v.get("minor", 0)) - var parts := min_version.split(".") - var want_major := int(parts[0]) if parts.size() > 0 else 0 - var want_minor := int(parts[1]) if parts.size() > 1 else 0 - if ( - current_major < want_major - or (current_major == want_major and current_minor < want_minor) - ): - var msg := reason if not reason.is_empty() else "requires Godot %s+" % min_version - skip(msg + " (running %d.%d)" % [current_major, current_minor]) - return true - return false - - -## Allow one captured SCRIPT ERROR whose text contains `substring`. -## Use only for negative-path tests that intentionally compile or execute -## invalid GDScript and assert on the resulting diagnostics. -func expect_script_error_containing(substring: String) -> void: - _expected_script_error_substrings.append(substring) - - -func _unexpected_script_errors(captured: PackedStringArray) -> PackedStringArray: - var unexpected := PackedStringArray() - var remaining := _expected_script_error_substrings.duplicate() - for error in captured: - var matched_index := -1 - for i in range(remaining.size()): - if error.find(remaining[i]) != -1: - matched_index = i - break - if matched_index == -1: - unexpected.append(error) - else: - remaining.remove_at(matched_index) - return unexpected - - -## Trigger an undo against whichever history (scene or global) holds the most -## recent action. `EditorUndoRedoManager` in Godot 4.x doesn't expose `.undo()` -## directly — you resolve the history's underlying UndoRedo and call it there. -## Actions registered via `add_do_method(self, …)` with a non-scene target land -## in GLOBAL_HISTORY, while actions on scene nodes land in the scene's history, -## so we try both (matches the pattern in batch_handler.gd). -func editor_undo(undo_redo: EditorUndoRedoManager) -> bool: - for ur in _collect_histories(undo_redo): - if ur.undo(): - return true - return false - - -## Mirror of `editor_undo` for redo. -func editor_redo(undo_redo: EditorUndoRedoManager) -> bool: - for ur in _collect_histories(undo_redo): - if ur.redo(): - return true - return false - - -func _collect_histories(undo_redo: EditorUndoRedoManager) -> Array: - var out: Array = [] - if undo_redo == null: - return out - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root != null: - var scene_id := undo_redo.get_object_history_id(scene_root) - var scene_ur := undo_redo.get_history_undo_redo(scene_id) - if scene_ur != null: - out.append(scene_ur) - var global_ur := undo_redo.get_history_undo_redo(EditorUndoRedoManager.GLOBAL_HISTORY) - if global_ur != null and not global_ur in out: - out.append(global_ur) - return out - - -# ----- assertions ----- - -func assert_true(condition: bool, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if not condition: - _failed = true - _message = msg if msg else "Expected true" - - -func assert_false(condition: bool, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if condition: - _failed = true - _message = msg if msg else "Expected false" - - -func assert_eq(actual: Variant, expected: Variant, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if actual != expected: - _failed = true - _message = msg if msg else "Expected %s, got %s" % [str(expected), str(actual)] - - -func assert_ne(actual: Variant, not_expected: Variant, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if actual == not_expected: - _failed = true - _message = msg if msg else "Expected value != %s" % str(not_expected) - - -func assert_gt(actual: Variant, threshold: Variant, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if not (actual > threshold): - _failed = true - _message = msg if msg else "Expected %s > %s" % [str(actual), str(threshold)] - - -func assert_has_key(dict: Variant, key: String, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if not dict is Dictionary: - _failed = true - _message = msg if msg else "Expected Dictionary, got %s" % type_string(typeof(dict)) - return - if not dict.has(key): - _failed = true - _message = msg if msg else "Missing key: %s (keys: %s)" % [key, str(dict.keys())] - - -func assert_contains(haystack: Variant, needle: Variant, msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if haystack is String: - if haystack.find(str(needle)) == -1: - _failed = true - _message = msg if msg else "'%s' not found in '%s'" % [str(needle), haystack] - elif haystack is Array: - if not haystack.has(needle): - _failed = true - _message = msg if msg else "%s not found in array" % str(needle) - else: - _failed = true - _message = msg if msg else "assert_contains requires String or Array" - - -func assert_is_error(result: Dictionary, expected_code: String = "", msg: String = "") -> void: - _assertion_count += 1 - if _failed: - return - if not result.has("error"): - _failed = true - _message = msg if msg else "Expected error response, got: %s" % str(result.keys()) - return - if expected_code and result.error.get("code", "") != expected_code: - _failed = true - _message = msg if msg else "Expected error code %s, got %s" % [expected_code, result.error.get("code", "")] - - -# ----- scene helpers (shared across suites that create/remove Controls) ----- - -## Add a Control under the scene root. Creates a Panel if ctl is null. -## Returns the scene path, or "" when no scene is open — in which case a -## caller-supplied ctl is freed to prevent leaks. -func _add_control(ctl_name: String, ctl: Control = null) -> String: - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - if ctl != null: - ctl.queue_free() - return "" - if ctl == null: - ctl = Panel.new() - ctl.name = ctl_name - scene_root.add_child(ctl) - ctl.owner = scene_root - return "/" + scene_root.name + "/" + ctl_name - - -func _remove_control(path: String) -> void: - var scene_root := EditorInterface.get_edited_scene_root() - if scene_root == null: - return - var node := McpScenePath.resolve(path, scene_root) - if node != null: - node.get_parent().remove_child(node) - node.queue_free() diff --git a/addons/godot_ai/testing/test_suite.gd.uid b/addons/godot_ai/testing/test_suite.gd.uid deleted file mode 100644 index b75e726..0000000 --- a/addons/godot_ai/testing/test_suite.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dlrq2s7jhp71s diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd deleted file mode 100644 index d1e8b4a..0000000 --- a/addons/godot_ai/tool_catalog.gd +++ /dev/null @@ -1,110 +0,0 @@ -@tool -class_name McpToolCatalog -extends RefCounted - -## Mirror of src/godot_ai/tools/domains.py — drives the dock's Tools tab -## so the UI can render checkboxes, tool counts, and tooltips without -## round-tripping to a running server. -## -## DO NOT EDIT by hand. tests/unit/test_tool_domains.py verifies this file -## against actual tool registration and fails CI when they drift; the -## failure message prints the up-to-date catalog body for paste-over. -## -## The four core tools are always registered and cannot be excluded — they -## render as a single grayed-out "Core" row in the UI. Each non-core domain -## now exposes one or two named verbs plus a single rolled-up -## `_manage` tool. - -const CORE_TOOLS := [ - "editor_state", - "node_get_properties", - "scene_get_hierarchy", - "session_activate", -] - -## Non-core tools that live in a NON-excludable domain (only `session` -## today), so they appear in no DOMAINS row yet are always registered. -## Counted alongside CORE_TOOLS so the dock's totals match the real -## server surface. -const ALWAYS_ON_TOOLS := [ - "session_manage", -] - -## Ordered list of user-toggleable domains. Each entry: -## id: matches the name passed to `--exclude-domains` -## label: human-friendly display (same as id for now, kept separate so -## a future renaming doesn't break the setting) -## count: number of NON-CORE tools in this domain -## tools: flat list of tool names registered by this domain (non-core only) -const DOMAINS := [ - {"id": "animation", "label": "animation", "count": 2, "tools": ["animation_create", "animation_manage"]}, - {"id": "api", "label": "api", "count": 1, "tools": ["api_manage"]}, - {"id": "audio", "label": "audio", "count": 1, "tools": ["audio_manage"]}, - {"id": "autoload", "label": "autoload", "count": 1, "tools": ["autoload_manage"]}, - {"id": "batch", "label": "batch", "count": 1, "tools": ["batch_execute"]}, - {"id": "camera", "label": "camera", "count": 1, "tools": ["camera_manage"]}, - {"id": "client", "label": "client", "count": 1, "tools": ["client_manage"]}, - {"id": "custom", "label": "custom", "count": 1, "tools": ["custom_manage"]}, - {"id": "editor", "label": "editor", "count": 4, "tools": ["editor_manage", "editor_reload_plugin", "editor_screenshot", "logs_read"]}, - {"id": "filesystem", "label": "filesystem", "count": 1, "tools": ["filesystem_manage"]}, - {"id": "game", "label": "game", "count": 1, "tools": ["game_manage"]}, - {"id": "input_map", "label": "input_map", "count": 1, "tools": ["input_map_manage"]}, - {"id": "material", "label": "material", "count": 1, "tools": ["material_manage"]}, - {"id": "node", "label": "node", "count": 4, "tools": ["node_create", "node_find", "node_manage", "node_set_property"]}, - {"id": "particle", "label": "particle", "count": 1, "tools": ["particle_manage"]}, - {"id": "project", "label": "project", "count": 2, "tools": ["project_manage", "project_run"]}, - {"id": "resource", "label": "resource", "count": 1, "tools": ["resource_manage"]}, - {"id": "scene", "label": "scene", "count": 3, "tools": ["scene_manage", "scene_open", "scene_save"]}, - {"id": "script", "label": "script", "count": 4, "tools": ["script_attach", "script_create", "script_manage", "script_patch"]}, - {"id": "signal", "label": "signal", "count": 1, "tools": ["signal_manage"]}, - {"id": "testing", "label": "testing", "count": 2, "tools": ["test_manage", "test_run"]}, - {"id": "theme", "label": "theme", "count": 1, "tools": ["theme_manage"]}, - {"id": "tilemap", "label": "tilemap", "count": 1, "tools": ["tilemap_manage"]}, - {"id": "tileset", "label": "tileset", "count": 1, "tools": ["tileset_manage"]}, - {"id": "gridmap", "label": "gridmap", "count": 1, "tools": ["gridmap_manage"]}, - {"id": "csg", "label": "csg", "count": 1, "tools": ["csg_manage"]}, - {"id": "ui", "label": "ui", "count": 1, "tools": ["ui_manage"]}, -] - - -## Whether `id` is a real, excludable domain in this plugin version. Used to -## drop stale names (e.g. a domain removed since the setting was written) so -## they never reach the server's `--exclude-domains`, whose `parse_exclude_list` -## hard-fails on unknown names. -static func is_excludable_domain(id: String) -> bool: - for d in DOMAINS: - if d["id"] == id: - return true - return false - - -## Total tool count when no domains are excluded. Used for the "Enabled: N / M" -## readout in the Tools tab without looping the catalog on every repaint. -static func total_tool_count() -> int: - var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() - for d in DOMAINS: - n += int(d["count"]) - return n - - -## Tool count remaining after excluding the given set of domain ids. -static func enabled_tool_count(excluded: PackedStringArray) -> int: - var n := CORE_TOOLS.size() + ALWAYS_ON_TOOLS.size() - for d in DOMAINS: - if excluded.find(d["id"]) == -1: - n += int(d["count"]) - return n - - -## Canonical comma-separated string for a set of domain ids — sorted and -## deduplicated so two equivalent settings (entered in different orders) -## hash to the same EditorSetting value. Matches `excluded_domains()` in -## client_configurator.gd. -static func canonical(excluded: PackedStringArray) -> String: - var seen := PackedStringArray() - for e in excluded: - var t := e.strip_edges() - if not t.is_empty() and seen.find(t) == -1: - seen.append(t) - seen.sort() - return ",".join(seen) diff --git a/addons/godot_ai/tool_catalog.gd.uid b/addons/godot_ai/tool_catalog.gd.uid deleted file mode 100644 index c0c179b..0000000 --- a/addons/godot_ai/tool_catalog.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d1vqyt4uyo378 diff --git a/addons/godot_ai/update_reload_runner.gd b/addons/godot_ai/update_reload_runner.gd deleted file mode 100644 index 7761e40..0000000 --- a/addons/godot_ai/update_reload_runner.gd +++ /dev/null @@ -1,590 +0,0 @@ -@tool -extends Node - -## EditorSetting key used to defer a self_update telemetry event across the -## disable -> enable boundary. The runner runs while the plugin is disabled, -## so it can't send WebSocket events directly; it writes the outcome here -## and the re-enabled plugin's `_enter_tree` flushes it. See -## `plugin.gd::_flush_pending_self_update_telemetry`. -const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event" - -## Self-update runner. Owns the install-and-reload sequence from -## `start(zip_path, temp_dir, detached_dock)` onward: extract files into -## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem, -## re-enable the plugin, and clean up the detached dock. -## -## Single-phase install: writes the full `_new_file_paths + -## _existing_file_paths` set before issuing exactly one -## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one -## consistent v(N+1) snapshot, so new files and existing files can resolve -## each other's same-release API changes regardless of parse order. -## -## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI -## (in `mcp_dock.gd`), or server stop prep (called by -## `plugin.gd::install_downloaded_update` before this runner starts via -## `_lifecycle.prepare_for_update_reload()`). -## -## This node is deliberately tiny and not parented under the EditorPlugin: -## it survives `set_plugin_enabled(false)`, extracts the downloaded release, -## waits for Godot's filesystem scan, then enables the plugin again. The old -## dock is detached before this runner starts, kept alive while deferred -## Callables drain, and freed only after the new plugin instance is loaded. - -const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg" -const PRE_DISABLE_DRAIN_FRAMES := 8 -const POST_DISABLE_DRAIN_FRAMES := 2 -const POST_ENABLE_FREE_FRAMES := 8 -const INSTALL_BASE_PATH := "res://" -const ZIP_ADDON_PREFIX := "addons/godot_ai/" -const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp" -const INSTALL_BACKUP_SUFFIX := ".update_backup" - -## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced. -## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously -## written file was rolled back to its vN content (or removed, if the file -## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons -## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable -## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss -## scenario this guards against. -enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED } - -var _zip_path := "" -## Old plugin.cfg version, read from disk in `start()` before the extract -## replaces it. Rides the pending self-update marker. -var _from_version := "" -var _temp_dir := "" -var _detached_dock = null -var _started := false -var _next_step := "" -var _frames_remaining := 0 -var _waiting_for_scan := false -var _scan_next_step := "" -## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed` -## signal never fires (slow disk, NFS, AV holding the just-extracted addon -## files open), the runner used to hang in `_waiting_for_scan = true` -## forever and the dock stayed disabled. After this timeout we disconnect -## the signal and proceed anyway — worst case the new files aren't visible -## on the first frame, but they get picked up on the next scan. See -## audit-v2 finding #9 (issue #353). Untyped to match the codebase's -## defensive pattern for state that survives `fs.scan()` during update. -const SCAN_WATCHDOG_SECS := 30.0 -var _scan_watchdog_timer = null -## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent -## `_start_filesystem_scan` calls in the same update bypass connect+scan -## so a delayed `filesystem_changed` emission from the timed-out scan -## can't fire on a freshly-armed listener for the next scan and falsely -## settle it before that scan actually completed. See PR #381 review for -## the cross-scan race this guards against. -var _scan_timed_out := false -## Keep Array fields untyped: this runner survives fs.scan() during update, -## and typed Variant storage is part of the hot-reload crash class. -var _new_file_paths = [] -var _existing_file_paths = [] -## Per-file install records accumulated during install so a later failure -## can roll back files already replaced earlier in the same update. -## Each entry is an untyped Dictionary with target_path / backup_path / -## had_original keys. Cleared by `_finalize_install_success` on full success -## and by `_rollback_paths_written` on failure. -var _paths_written = [] -## Set true if `_install_zip_file`'s inner restore-from-backup couldn't -## complete (backup gone, copy failed). The failed file is NOT recorded in -## `_paths_written` because the function bails at that point — without this -## flag, `_rollback_paths_written` would walk only the prior records, all -## restore cleanly, and report FAILED_CLEAN even though the current target -## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses -## to re-enable the plugin against a half-installed tree. -var _restore_failed := false -## Test-only opt-out for the scan-watchdog `push_warning` lines. The -## watchdog unit tests in `test_update_reload_runner.gd` invoke -## `_on_scan_watchdog_timeout()` and the post-timeout -## `_start_filesystem_scan` bypass branch directly to pin their behavior -## — but those code paths' `push_warning` calls then appear as yellow -## console noise in every `test_run`, training reviewers to ignore the -## runner's real production warnings. Tests set this true; production -## leaves it false so genuine scan timeouts during a real self-update -## still surface loudly. See issue #413. -var _suppress_scan_warnings := false - - -func start(zip_path: String, temp_dir: String, detached_dock) -> void: - if _started: - return - _started = true - _zip_path = zip_path - _temp_dir = temp_dir - _detached_dock = detached_dock - ## Captured before the extract replaces plugin.cfg: the success marker - ## carries from/to versions so the re-enabled plugin can (a) report a - ## complete self_update telemetry event and (b) scope the post-update - ## client repin to entries whose ONLY drift is the old version pin. Pure - ## file IO on purpose — the runner must not call into plugin scripts - ## during the disable window. - _from_version = _read_plugin_cfg_version() - _wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin") - - -## Read `version="X.Y.Z"` from the on-disk plugin.cfg. Returns "" when the -## file or key is missing — consumers treat an empty version as "unknown" -## and degrade (telemetry sends empty fields, the repin gate skips). -static func _read_plugin_cfg_version() -> String: - var file := FileAccess.open(PLUGIN_CFG_PATH, FileAccess.READ) - if file == null: - return "" - var text := file.get_as_text() - file.close() - var re := RegEx.new() - if re.compile("(?m)^version=\"([^\"]+)\"") != OK: - return "" - var found := re.search(text) - return found.get_string(1) if found != null else "" - - -func _process(_delta: float) -> void: - if _frames_remaining <= 0: - set_process(false) - return - - _frames_remaining -= 1 - if _frames_remaining <= 0: - var step := _next_step - _next_step = "" - set_process(false) - call(step) - - -func _wait_frames(frame_count: int, next_step: String) -> void: - _next_step = next_step - _frames_remaining = max(1, frame_count) - set_process(true) - - -func _disable_old_plugin() -> void: - ## Disable before writing or scanning new scripts. This avoids both the - ## Dict/Array field-storage hot-reload crash (#245) and cached handler - ## constructor shape mismatches (#247) for plugin-owned instances. - print("MCP | update runner disabling old plugin") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false) - _wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan") - - -func _extract_and_scan() -> void: - if not _read_update_manifest(): - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - return - - var install_paths := [] - install_paths.append_array(_new_file_paths) - install_paths.append_array(_existing_file_paths) - - var status := _install_zip_paths(install_paths) - if status != InstallStatus.OK: - _handle_install_failure(status) - return - - _finalize_install_success() - _cleanup_update_temp() - ## One scan covers both dependency directions: plugin.gd's preloads of - ## new files resolve because those files are already present, and new - ## files' references to new members or static-ness changes on existing - ## load-surface scripts resolve because those existing files are also - ## already at v(N+1). The goal is a consistent snapshot before scan, not - ## a tree-atomic install; per-file writes still use `.tmp` + rename and - ## rollback on failure. - _start_filesystem_scan("_enable_new_plugin") - - -func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void: - var fs := EditorInterface.get_resource_filesystem() - var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin" - if fs == null: - call_deferred(deferred_step) - return - - ## Bypass: a previous scan in this update already watchdog'd, so the - ## editor's filesystem is unresponsive. Re-arming a `filesystem_changed` - ## listener now would race with a delayed emission from the timed-out - ## scan: that single emission would fire whichever listener is currently - ## connected to the shared signal, falsely settling this scan before it - ## actually completed. Skip the wait; Godot's normal background scan - ## catches up after the plugin re-enables. See PR #381 review. - if _scan_timed_out: - if not _suppress_scan_warnings: - push_warning( - "MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)" - % deferred_step - ) - call_deferred(deferred_step) - return - - _waiting_for_scan = true - _scan_next_step = deferred_step - if not fs.filesystem_changed.is_connected(_on_filesystem_changed): - fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT) - _arm_scan_watchdog() - fs.scan() - - -func _arm_scan_watchdog() -> void: - if _scan_watchdog_timer == null: - _scan_watchdog_timer = Timer.new() - _scan_watchdog_timer.one_shot = true - _scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout) - add_child(_scan_watchdog_timer) - _scan_watchdog_timer.start(SCAN_WATCHDOG_SECS) - - -func _stop_scan_watchdog() -> void: - if _scan_watchdog_timer != null: - _scan_watchdog_timer.stop() - - -func _on_scan_watchdog_timeout() -> void: - ## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the - ## filesystem scan is blocked behind a slow disk / NFS / AV scanner - ## still reading the just-extracted addon files. - ## Set the sticky `_scan_timed_out` flag so any subsequent - ## `_start_filesystem_scan` in this update skips its connect+scan - ## (otherwise a delayed emission from this scan would falsely settle - ## the next scan's listener — see PR #381 review). - ## Disconnect the current listener too, so this scan's listener can't - ## double-call `_finish_scan_wait` if the signal arrives quickly after - ## the timeout fires. `_finish_scan_wait` is idempotent on - ## `_waiting_for_scan == false`. - if not _waiting_for_scan: - return - if not _suppress_scan_warnings: - push_warning( - "MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation" - % int(SCAN_WATCHDOG_SECS) - ) - _scan_timed_out = true - var fs := EditorInterface.get_resource_filesystem() - if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed): - fs.filesystem_changed.disconnect(_on_filesystem_changed) - _finish_scan_wait() - - -func _read_update_manifest() -> bool: - var zip_path := ProjectSettings.globalize_path(_zip_path) - var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) - - var reader := ZIPReader.new() - if reader.open(zip_path) != OK: - print("MCP | update extract failed: could not open %s" % zip_path) - return false - - _new_file_paths.clear() - _existing_file_paths.clear() - var has_plugin_cfg := false - var has_plugin_script := false - var files := reader.get_files() - for file_path in files: - if not file_path.begins_with(ZIP_ADDON_PREFIX): - continue - var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) - ## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand- - ## built archives) emit zero-byte directory entries like - ## `addons/godot_ai/`. Skip those before the safety check; the - ## empty-segment guard in `_is_safe_zip_addon_file` would otherwise - ## flag the bare prefix as unsafe and abort the extract. Current - ## release.yml passes `-D` to strip them, but installed runners must - ## still tolerate older or manually built zips. - if rel_path.is_empty() or file_path.ends_with("/"): - continue - if not _is_safe_zip_addon_file(file_path): - print("MCP | update extract failed: unsafe zip path %s" % file_path) - reader.close() - return false - if rel_path == "plugin.cfg": - has_plugin_cfg = true - elif rel_path == "plugin.gd": - has_plugin_script = true - var target_path := install_base.path_join(file_path) - if FileAccess.file_exists(target_path): - _existing_file_paths.append(file_path) - else: - _new_file_paths.append(file_path) - reader.close() - if not has_plugin_cfg: - print("MCP | update extract failed: zip is missing plugin.cfg") - return false - if not has_plugin_script: - print("MCP | update extract failed: zip is missing plugin.gd") - return false - return true - - -func _handle_install_failure(status: int) -> void: - _record_pending_self_update({ - "status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean", - }) - if status == InstallStatus.FAILED_MIXED: - ## Half-installed addon tree on disk: re-enabling the plugin would - ## load a mix of vN and vN+1 files. Print a load-bearing diagnostic - ## and bail without re-enabling — user must restore manually. See - ## issue #297 finding #9 for the data-loss scenario. - push_error( - "MCP | self-update failed mid-install AND rollback could not" - + " restore the previous addons/godot_ai/ contents. The plugin" - + " is left disabled. Inspect addons/godot_ai/ for" - + " *.update_backup / *.godot_ai_update_tmp files and restore" - + " manually before re-enabling the plugin." - ) - print( - "MCP | self-update aborted: addons/godot_ai/ is in a mixed state;" - + " plugin left disabled (manual intervention required)." - ) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - return - ## FAILED_CLEAN: rollback restored every previously-written file. Safe - ## to re-enable the previous plugin version. - print("MCP | self-update rolled back; re-enabling previous plugin version") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - - -func _is_safe_zip_addon_file(file_path: String) -> bool: - if file_path.is_absolute_path() or file_path.contains("\\"): - return false - if not file_path.begins_with(ZIP_ADDON_PREFIX): - return false - var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX) - if rel_path.is_empty() or rel_path.ends_with("/"): - return false - ## Reserved install-machinery suffixes (#713): an entry named like the - ## runner's own staging/backup files would collide with the temp file - ## `_install_zip_file` writes, or overwrite / later be deleted with the - ## rollback snapshots `_finalize_install_success` cleans up — corrupting - ## the very rollback set protecting this install. - if rel_path.ends_with(TEMP_FILE_SUFFIX) or rel_path.ends_with(INSTALL_BACKUP_SUFFIX): - return false - for segment in rel_path.split("/", true): - if segment.is_empty() or segment == "." or segment == "..": - return false - return true - - -func _install_zip_paths(paths: Array) -> int: - if paths.is_empty(): - return InstallStatus.OK - - var zip_path := ProjectSettings.globalize_path(_zip_path) - var reader := ZIPReader.new() - if reader.open(zip_path) != OK: - print("MCP | update extract failed: could not reopen %s" % zip_path) - ## Nothing else can be written, but earlier files from this update - ## may have landed on disk; roll those back too. - return _rollback_paths_written() - - var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH) - for file_path in paths: - var record := _install_zip_file(reader, String(file_path), install_base) - if record.is_empty(): - reader.close() - return _rollback_paths_written() - _paths_written.append(record) - reader.close() - return InstallStatus.OK - - -func _install_zip_file( - reader: ZIPReader, file_path: String, install_base: String -) -> Dictionary: - var target_path := install_base.path_join(file_path) - var dir := target_path.get_base_dir() - if DirAccess.make_dir_recursive_absolute(dir) != OK: - print("MCP | update extract failed: could not create %s" % dir) - return {} - - var temp_path := target_path + TEMP_FILE_SUFFIX - DirAccess.remove_absolute(temp_path) - var content := reader.read_file(file_path) - var f := FileAccess.open(temp_path, FileAccess.WRITE) - if f == null: - print("MCP | update extract failed: could not write %s (error %d)" % [ - temp_path, - FileAccess.get_open_error(), - ]) - return {} - ## `store_buffer` reports a short write only via its return value — it - ## does NOT set the last-error state `get_error()` reads — and the write - ## is stdio-buffered, so disk-full surfaces at flush/close, not here. - ## Capture the return and re-verify the on-disk size after close() so a - ## disk-full "succeeds" write can't rename a truncated file over the - ## live target (#687). - var stored := f.store_buffer(content) - f.flush() - var write_error := f.get_error() - f.close() - ## get_length() on a read handle, not get_file_as_bytes().size() — the - ## latter re-reads the whole file into memory per extracted entry just - ## to learn its length. - var written_size := -1 - var verify := FileAccess.open(temp_path, FileAccess.READ) - if verify != null: - written_size = verify.get_length() - verify.close() - if not stored or write_error != OK or written_size != content.size(): - print("MCP | update extract failed: write validation failed (error %d) for %s (stored=%s size=%d expected=%d)" % [ - write_error, - temp_path, - stored, - written_size, - content.size(), - ]) - DirAccess.remove_absolute(temp_path) - return {} - - ## Back up the original via COPY (not rename) so the source of truth - ## stays in place if a later step fails. Rolled back via - ## `_rollback_paths_written` if a subsequent file in this batch — or a - ## later batch — can't be installed. - var had_original := FileAccess.file_exists(target_path) - var backup_path := target_path + INSTALL_BACKUP_SUFFIX - if had_original: - DirAccess.remove_absolute(backup_path) - if DirAccess.copy_absolute(target_path, backup_path) != OK: - DirAccess.remove_absolute(temp_path) - print("MCP | update extract failed: could not back up %s" % target_path) - return {} - - if DirAccess.rename_absolute(temp_path, target_path) != OK: - ## POSIX and APFS replace atomically. Some filesystems reject - ## rename-over-existing; keep a fallback so the update can still - ## proceed, but the common path never exposes a truncated target. - DirAccess.remove_absolute(target_path) - if DirAccess.rename_absolute(temp_path, target_path) != OK: - DirAccess.remove_absolute(temp_path) - ## Target was removed above; restore from the COPY backup so the - ## addons dir is left in its vN state before we surface failure. - ## Only delete the backup if the restore copy actually succeeded - ## — if it didn't, target_path is missing, and `_restore_failed` - ## tells `_rollback_paths_written` to surface FAILED_MIXED so the - ## runner refuses to re-enable the plugin. Leaving the backup on - ## disk also gives the user a manual recovery path. Without this - ## guard the failed file isn't tracked anywhere (we return `{}`, - ## not appended to `_paths_written`) and the caller would - ## erroneously see FAILED_CLEAN. - if had_original: - if ( - FileAccess.file_exists(backup_path) - and DirAccess.copy_absolute(backup_path, target_path) == OK - ): - DirAccess.remove_absolute(backup_path) - else: - _restore_failed = true - print("MCP | update extract failed: could not replace %s" % target_path) - return {} - return { - "target_path": target_path, - "backup_path": backup_path, - "had_original": had_original, - } - - -## Restore (or remove) every file already touched in this update. Safe to -## call after a partial install — entries are processed in reverse so a -## given target is restored before the next earlier write of the same path -## could resurrect a stale value. Returns FAILED_CLEAN if every entry was -## restored AND no in-flight `_install_zip_file` left a target stranded -## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT -## re-enable the plugin in the MIXED case. -func _rollback_paths_written() -> int: - var any_failed := false - var i := _paths_written.size() - 1 - while i >= 0: - var record = _paths_written[i] - var target := String(record.get("target_path", "")) - var backup := String(record.get("backup_path", "")) - var had_original := bool(record.get("had_original", false)) - if had_original: - if not FileAccess.file_exists(backup): - print("MCP | update rollback failed: backup missing for %s" % target) - any_failed = true - else: - DirAccess.remove_absolute(target) - if DirAccess.copy_absolute(backup, target) != OK: - print("MCP | update rollback failed: could not restore %s" % target) - any_failed = true - else: - DirAccess.remove_absolute(backup) - else: - if FileAccess.file_exists(target): - if DirAccess.remove_absolute(target) != OK: - print( - "MCP | update rollback failed: could not delete %s" % target - ) - any_failed = true - i -= 1 - _paths_written.clear() - if any_failed or _restore_failed: - return InstallStatus.FAILED_MIXED - return InstallStatus.FAILED_CLEAN - - -## Discard accumulated backups after the combined install succeeds. Backups -## are best-effort: a failure here doesn't compromise the new install, just -## leaves stray *.update_backup files for the user to clean up. -func _finalize_install_success() -> void: - for record in _paths_written: - if record.get("had_original", false): - DirAccess.remove_absolute(String(record.get("backup_path", ""))) - _paths_written.clear() - ## plugin.cfg on disk is the NEW version by now — read it back rather - ## than trusting any in-memory value, so the marker describes what was - ## actually installed. - _record_pending_self_update({ - "status": "success", - "from_version": _from_version, - "to_version": _read_plugin_cfg_version(), - }) - - -## Persist a self_update event description so the re-enabled plugin can -## emit it once its WebSocket is connected. Survives the disable -> enable -## window where the runner cannot send anything itself. -func _record_pending_self_update(data: Dictionary) -> void: - var settings := EditorInterface.get_editor_settings() - if settings == null: - return - settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data)) - - -func _cleanup_update_temp() -> void: - DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path)) - DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir)) - - -func _on_filesystem_changed() -> void: - _finish_scan_wait() - - -func _finish_scan_wait() -> void: - if not _waiting_for_scan: - return - _waiting_for_scan = false - _stop_scan_watchdog() - var next_step := _scan_next_step - _scan_next_step = "" - set_process(false) - if next_step.is_empty(): - next_step = "_enable_new_plugin" - call_deferred(next_step) - - -func _enable_new_plugin() -> void: - print("MCP | update runner enabling new plugin") - EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true) - _wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish") - - -func _cleanup_and_finish() -> void: - _cleanup_detached_dock() - queue_free() - - -func _cleanup_detached_dock() -> void: - if _detached_dock != null and is_instance_valid(_detached_dock): - _detached_dock.queue_free() - _detached_dock = null diff --git a/addons/godot_ai/update_reload_runner.gd.uid b/addons/godot_ai/update_reload_runner.gd.uid deleted file mode 100644 index 9cc6615..0000000 --- a/addons/godot_ai/update_reload_runner.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cu6c75n3x2pik diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd deleted file mode 100644 index a34c964..0000000 --- a/addons/godot_ai/utils/allow_hosts.gd +++ /dev/null @@ -1,170 +0,0 @@ -@tool -class_name McpAllowHosts -extends RefCounted - -## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core -## in #421). Pure static functions only — no EditorSettings, no sockets — -## so the settings-value → launch-args plumbing and the manual-command LAN -## URL builder are deterministically testable without a live editor. -## -## Accepted syntax mirrors the server's `parse_allow_hosts` -## (src/godot_ai/transport/origin_guard.py): each token is a bare IP -## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are -## tolerated server-side (`strict=False`), so we only validate the IP part -## and the prefix length here — anything else fails loudly at server -## startup, which the dock-side validation exists to pre-empt. - - -## Canonicalize a comma-separated allow-host value: whitespace-stripped, -## deduplicated, sorted. Returns "" for a value with no usable tokens so -## callers can skip appending `--allow-host` entirely (keeps spawns -## compatible with pre-#421 servers — same contract as -## `ClientConfigurator.excluded_domains()`). -static func normalize(raw: String) -> String: - var parts := PackedStringArray() - for p in raw.split(","): - var t := p.strip_edges() - if not t.is_empty() and parts.find(t) == -1: - parts.append(t) - parts.sort() - return ",".join(parts) - - -## Whether a single token is a bare IP or a CIDR the server will accept. -static func token_is_valid(token: String) -> bool: - var t := token.strip_edges() - if t.is_empty(): - return false - var ip := t - var prefix := 0 - ## Track slash presence separately from the prefix value: reusing -1 as - ## the "no slash" sentinel let an explicit negative prefix like - ## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review. - var has_prefix := false - var slash := t.find("/") - if slash != -1: - ip = t.substr(0, slash) - var prefix_text := t.substr(slash + 1) - ## Explicit signs are rejected by the server's parse_allow_hosts - ## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts - ## them — keep the mirror honest. - if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"): - return false - if not prefix_text.is_valid_int(): - return false - prefix = int(prefix_text) - has_prefix = true - if not ip.is_valid_ip_address(): - return false - var max_prefix := 128 if ip.contains(":") else 32 - return not has_prefix or (prefix >= 0 and prefix <= max_prefix) - - -## Every token in `raw` that fails `token_is_valid` — the dock surfaces -## these inline so a typo is caught before it aborts the server spawn. -static func invalid_tokens(raw: String) -> PackedStringArray: - var bad := PackedStringArray() - for p in raw.split(","): - var t := p.strip_edges() - if t.is_empty(): - continue - if not token_is_valid(t) and bad.find(t) == -1: - bad.append(t) - return bad - - -## True when the allowlist names at least one non-loopback range — i.e. -## the server is actually reachable off this machine and the manual -## command should surface a LAN URL. -static func is_lan_allowlist_active(value: String) -> bool: - for p in value.split(","): - var t := p.strip_edges() - if t.is_empty(): - continue - var ip := t.substr(0, t.find("/")) if t.contains("/") else t - if not _is_loopback(ip): - return true - return false - - -## Choose the LAN address to show in the manual command from the -## machine's local addresses (caller passes `IP.get_local_addresses()` -## so this stays pure). Loopback and link-local addresses are dropped; -## the first private-range IPv4 wins, then any remaining IPv4, then -## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}` -## — `ambiguous` flags multiple viable candidates so the note can tell -## the user to pick the interface on their trusted network. -static func pick_lan_address(addresses: PackedStringArray) -> Dictionary: - var candidates := PackedStringArray() - for a in addresses: - var addr := String(a).strip_edges() - if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr): - continue - candidates.append(addr) - if candidates.is_empty(): - return {"address": "", "ambiguous": false} - var chosen := "" - for addr in candidates: - if _is_private_ipv4(addr): - chosen = addr - break - if chosen.is_empty(): - for addr in candidates: - if addr.contains("."): - chosen = addr - break - if chosen.is_empty(): - chosen = candidates[0] - return {"address": chosen, "ambiguous": candidates.size() > 1} - - -## Informational LAN-URL note appended to the manual command when the -## allowlist is active (#507). Never changes what gets WRITTEN to client -## configs — loopback stays the write target; this is copy-paste help for -## pointing a remote agent at the right address. -static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String: - if not is_lan_allowlist_active(allow_hosts_value): - return "" - var pick := pick_lan_address(addresses) - var addr := String(pick.get("address", "")) - if addr.is_empty(): - return ( - "LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine." - % allow_hosts_value - ) - var host := "[%s]" % addr if addr.contains(":") else addr - var note := ( - "LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp" - % [allow_hosts_value, host, http_port] - ) - if bool(pick.get("ambiguous", false)): - note += "\n(multiple network interfaces detected — pick the address on the network you allowed)" - return note - - -static func _is_loopback(addr: String) -> bool: - var a := addr.to_lower() - return a.begins_with("127.") or a == "::1" or a == "localhost" - - -static func _is_link_local(addr: String) -> bool: - var a := addr.to_lower() - if a.begins_with("169.254."): - return true - ## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not - ## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must - ## also be excluded from LAN-URL candidates). - if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab": - return true - return false - - -static func _is_private_ipv4(addr: String) -> bool: - if not addr.contains("."): - return false - if addr.begins_with("10.") or addr.begins_with("192.168."): - return true - if addr.begins_with("172."): - var second := int(addr.get_slice(".", 1)) - return second >= 16 and second <= 31 - return false diff --git a/addons/godot_ai/utils/allow_hosts.gd.uid b/addons/godot_ai/utils/allow_hosts.gd.uid deleted file mode 100644 index 02fecd1..0000000 --- a/addons/godot_ai/utils/allow_hosts.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b7qk3vw2nxr4d diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd deleted file mode 100644 index 3076740..0000000 --- a/addons/godot_ai/utils/class_introspection.gd +++ /dev/null @@ -1,259 +0,0 @@ -@tool -extends RefCounted - -## Builds stable, JSON-safe metadata for any class registered in ClassDB. - -const VariantSerializer := preload("res://addons/godot_ai/utils/variant_serializer.gd") - -## Sections returned when the caller does not name any. Deliberately narrow: -## a bare `get_class` is almost always "what properties does X have", and the -## full five-section dump for a large class (e.g. Node, Control) costs an agent -## thousands of tokens it rarely wanted. Callers opt into the rest by name, or -## request the lot with the "all" keyword (see `_sections`). -const DEFAULT_SECTIONS: Array[String] = ["properties"] -## The full documentation-shaped section set (excludes the heavier, separately -## gated "inheritors"). Expanded from the "all" keyword. -const ALL_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants"] -const KNOWN_SECTIONS: Array[String] = ["properties", "methods", "signals", "enums", "constants", "inheritors"] -## Tokens a caller may legitimately pass in `sections` — the known sections plus -## the "all" meta-keyword. Used for error suggestions so a typo like "al" can -## resolve to "all"; "all" is NOT a section (it expands in `_sections`), so it -## stays out of KNOWN_SECTIONS which gates validity. -const SUGGESTABLE_SECTION_TOKENS: Array[String] = [ - "properties", "methods", "signals", "enums", "constants", "inheritors", "all" -] -const MAX_DEFAULT_ITEMS := 100 - - -static func build(type_name: String, options: Dictionary = {}) -> Dictionary: - var sections := _sections(options.get("sections", DEFAULT_SECTIONS)) - var include_inherited := bool(options.get("include_inherited", false)) - var include_inheritors := bool(options.get("include_inheritors", false)) - var offset := max(0, int(options.get("offset", 0))) - var limit := int(options.get("limit", MAX_DEFAULT_ITEMS)) - if limit < 0: - limit = MAX_DEFAULT_ITEMS - var can_instantiate := ClassDB.can_instantiate(type_name) - - var data := { - "class_name": type_name, - "engine_version": Engine.get_version_info().get("string", ""), - "parent_class": str(ClassDB.get_parent_class(type_name)), - "inheritance_chain": _inheritance_chain(type_name), - "can_instantiate": can_instantiate, - "is_singleton": Engine.has_singleton(type_name), - "include_inherited": include_inherited, - "offset": offset, - "limit": limit, - } - if include_inheritors or sections.has("inheritors"): - _add_paged(data, "inheritor", "inheritors", _inheritors(type_name, false), offset, limit) - _add_paged( - data, - "concrete_inheritor", - "concrete_inheritors", - _inheritors(type_name, true), - offset, - limit - ) - if sections.has("properties"): - _add_paged(data, "property", "properties", _properties(type_name, include_inherited), offset, limit) - if sections.has("methods"): - _add_paged(data, "method", "methods", _methods(type_name, include_inherited), offset, limit) - if sections.has("signals"): - _add_paged(data, "signal", "signals", _signals(type_name, include_inherited), offset, limit) - if sections.has("enums"): - _add_paged(data, "enum", "enums", _enums(type_name, include_inherited), offset, limit) - if sections.has("constants"): - _add_paged( - data, - "constant", - "constants", - _unscoped_constants(type_name, include_inherited), - offset, - limit - ) - return data - - -static func validate_sections(raw_sections: Variant) -> Dictionary: - var sections := _sections(raw_sections) - var invalid: Array[String] = [] - for section in sections: - if not KNOWN_SECTIONS.has(section): - invalid.append(section) - return {"sections": sections, "invalid": invalid} - - -static func _inheritance_chain(type_name: String) -> Array[String]: - var chain: Array[String] = [] - var current := type_name - while not current.is_empty(): - chain.append(current) - current = str(ClassDB.get_parent_class(current)) - return chain - - -static func _sections(raw_sections: Variant) -> Array[String]: - var result: Array[String] = [] - var values: Array = [] - if raw_sections is String: - values = raw_sections.split(",", false) - elif raw_sections is Array: - values = raw_sections - else: - values = DEFAULT_SECTIONS - for raw_section in values: - var section := str(raw_section).strip_edges().to_lower() - if section == "all": - for expanded in ALL_SECTIONS: - if not result.has(expanded): - result.append(expanded) - continue - if not section.is_empty() and not result.has(section): - result.append(section) - if result.is_empty(): - result.assign(DEFAULT_SECTIONS) - return result - - -static func _add_paged( - data: Dictionary, - singular: String, - key: String, - items: Array, - offset: int, - limit: int -) -> void: - var end := items.size() if limit == 0 else min(items.size(), offset + limit) - var page: Array = [] - if offset < items.size(): - page = items.slice(offset, end) - data[key] = page - data["%s_count" % singular] = items.size() - data["%s_returned_count" % singular] = page.size() - - -static func _inheritors(type_name: String, concrete_only: bool) -> Array[String]: - var result: Array[String] = [] - for inheritor in ClassDB.get_inheriters_from_class(type_name): - var inheritor_name := str(inheritor) - if concrete_only and not ClassDB.can_instantiate(inheritor_name): - continue - result.append(inheritor_name) - result.sort() - return result - - -static func _properties(type_name: String, include_inherited: bool) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - for raw_prop in ClassDB.class_get_property_list(type_name, not include_inherited): - var prop: Dictionary = raw_prop - var usage := int(prop.get("usage", 0)) - if not (usage & PROPERTY_USAGE_EDITOR): - continue - var prop_name := str(prop.get("name", "")) - result.append({ - "name": prop_name, - "type": type_string(int(prop.get("type", TYPE_NIL))), - "class_name": str(prop.get("class_name", "")), - "hint": int(prop.get("hint", PROPERTY_HINT_NONE)), - "hint_string": str(prop.get("hint_string", "")), - "usage": usage, - "default": VariantSerializer.serialize( - ClassDB.class_get_property_default_value(type_name, prop_name) - ), - }) - result.sort_custom(func(a, b): return a.name < b.name) - return result - - -static func _methods(type_name: String, include_inherited: bool) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - for raw_method in ClassDB.class_get_method_list(type_name, not include_inherited): - var method: Dictionary = raw_method - var args: Array[Dictionary] = [] - for raw_arg in method.get("args", []): - args.append(_argument_info(raw_arg)) - var defaults: Array = [] - for value in method.get("default_args", []): - defaults.append(VariantSerializer.serialize(value)) - result.append({ - "name": str(method.get("name", "")), - "arguments": args, - "default_arguments": defaults, - "return": _argument_info(method.get("return", {})), - "flags": int(method.get("flags", 0)), - }) - result.sort_custom(func(a, b): return a.name < b.name) - return result - - -static func _signals(type_name: String, include_inherited: bool) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - for raw_signal in ClassDB.class_get_signal_list(type_name, not include_inherited): - var signal_info: Dictionary = raw_signal - var args: Array[Dictionary] = [] - for raw_arg in signal_info.get("args", []): - args.append(_argument_info(raw_arg)) - var defaults: Array = [] - for value in signal_info.get("default_args", []): - defaults.append(VariantSerializer.serialize(value)) - result.append({ - "name": str(signal_info.get("name", "")), - "arguments": args, - "default_arguments": defaults, - "flags": int(signal_info.get("flags", 0)), - }) - result.sort_custom(func(a, b): return a.name < b.name) - return result - - -static func _argument_info(raw_info: Variant) -> Dictionary: - var info: Dictionary = raw_info if raw_info is Dictionary else {} - return { - "name": str(info.get("name", "")), - "type": type_string(int(info.get("type", TYPE_NIL))), - "class_name": str(info.get("class_name", "")), - "hint": int(info.get("hint", PROPERTY_HINT_NONE)), - "hint_string": str(info.get("hint_string", "")), - "usage": int(info.get("usage", 0)), - } - - -static func _enums(type_name: String, include_inherited: bool) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - var enum_names: Array[String] = [] - for enum_name in ClassDB.class_get_enum_list(type_name, not include_inherited): - enum_names.append(str(enum_name)) - enum_names.sort() - for enum_name in enum_names: - var values: Array[Dictionary] = [] - for constant_name in ClassDB.class_get_enum_constants(type_name, enum_name, not include_inherited): - values.append({ - "name": str(constant_name), - "value": ClassDB.class_get_integer_constant(type_name, constant_name), - }) - values.sort_custom(func(a, b): return a.name < b.name) - result.append({ - "name": enum_name, - "is_bitfield": ClassDB.is_class_enum_bitfield(type_name, enum_name, not include_inherited), - "values": values, - }) - return result - - -static func _unscoped_constants(type_name: String, include_inherited: bool) -> Array[Dictionary]: - var result: Array[Dictionary] = [] - for constant_name in ClassDB.class_get_integer_constant_list(type_name, not include_inherited): - var enum_name := str( - ClassDB.class_get_integer_constant_enum(type_name, constant_name, not include_inherited) - ) - if not enum_name.is_empty(): - continue - result.append({ - "name": str(constant_name), - "value": ClassDB.class_get_integer_constant(type_name, constant_name), - }) - result.sort_custom(func(a, b): return a.name < b.name) - return result diff --git a/addons/godot_ai/utils/class_introspection.gd.uid b/addons/godot_ai/utils/class_introspection.gd.uid deleted file mode 100644 index 3eda5ca..0000000 --- a/addons/godot_ai/utils/class_introspection.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://caedbsmsl6fk4 diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd deleted file mode 100644 index 22441ad..0000000 --- a/addons/godot_ai/utils/diagnostics_capture.gd +++ /dev/null @@ -1,66 +0,0 @@ -@tool -class_name McpDiagnosticsCapture -extends RefCounted - -## Small helper for scoped validation-log capture windows. Callers snapshot a -## private log cursor, perform a deliberate validation action, then only report -## new diagnostics whose original source location is the target file. - - -static func capture_this_file(log_buffer: McpEditorLogBuffer, target_path: String, action: Callable) -> Dictionary: - var cursor := 0 - if log_buffer != null: - cursor = log_buffer.appended_total() - - var action_result = action.call() - var diagnostics: Array[Dictionary] = [] - var truncated := false - - if log_buffer != null: - var captured: Dictionary = log_buffer.get_since(cursor) - truncated = captured.get("truncated", false) - diagnostics = _diagnostics_for_target(captured.get("entries", []), target_path) - - return { - "action": action_result if action_result is Dictionary else {}, - "diagnostics": diagnostics, - "diagnostics_detail": "log_capture" if not diagnostics.is_empty() else "none", - "diagnostics_scope": "this_file", - "diagnostics_status": "partial" if truncated else "checked", - } - - -static func _diagnostics_for_target(entries: Array, target_path: String) -> Array[Dictionary]: - var out: Array[Dictionary] = [] - for raw_entry in entries: - if not raw_entry is Dictionary: - continue - var entry: Dictionary = raw_entry - if not _entry_matches_target(entry, target_path): - continue - out.append(_normalize_entry(entry, target_path)) - return out - - -static func _entry_matches_target(entry: Dictionary, target_path: String) -> bool: - var source := _source_location(entry) - return str(source.get("path", "")) == target_path - - -static func _normalize_entry(entry: Dictionary, target_path: String) -> Dictionary: - var normalized := entry.duplicate(true) - var source := _source_location(entry) - normalized["path"] = str(source.get("path", target_path)) - normalized["line"] = int(source.get("line", normalized.get("line", 0))) - normalized["function"] = str(source.get("function", normalized.get("function", ""))) - if normalized.has("details") and normalized.details is Dictionary: - normalized["details"] = normalized.details.duplicate(true) - return normalized - - -static func _source_location(entry: Dictionary) -> Dictionary: - if entry.get("details") is Dictionary: - var details: Dictionary = entry.details - if details.get("source") is Dictionary: - return details.source - return {} diff --git a/addons/godot_ai/utils/diagnostics_capture.gd.uid b/addons/godot_ai/utils/diagnostics_capture.gd.uid deleted file mode 100644 index 95973c9..0000000 --- a/addons/godot_ai/utils/diagnostics_capture.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3npxxpuobbc2 diff --git a/addons/godot_ai/utils/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd deleted file mode 100644 index 0ea44e7..0000000 --- a/addons/godot_ai/utils/editor_log_buffer.gd +++ /dev/null @@ -1,127 +0,0 @@ -@tool -class_name McpEditorLogBuffer -extends McpStructuredLogRing - -## Ring buffer for editor-process script errors and warnings (parse errors, -## @tool runtime errors, EditorPlugin errors, push_error/push_warning) captured -## by editor_logger.gd's Logger subclass. -## -## Smaller cap than McpGameLogBuffer (500 vs 2000) — the editor only emits errors, -## not the full println firehose a game can produce. No run_id rotation: editor -## errors persist across project_run cycles (they're about *editing* state, not -## about the playing game). -## -## Mutex-protected because Logger virtuals can fire from any thread (e.g. -## async script-loader threads emitting parse errors), and the buffer is -## read on the main thread by EditorHandler.get_logs. Each public method -## wraps the base ring's lockless helpers in `_mutex.lock()/unlock()` — -## the base stays lockless so McpGameLogBuffer's hot path doesn't pay an -## unused mutex cost. -## -## Entry shape: {source: "editor", level: "info"|"warn"|"error", -## text, path, line, function} — `path/line/function` may be empty/zero -## when the source location wasn't recoverable (e.g. printerr from a -## thread without a script context). - -const MAX_LINES := 500 - -var _mutex := Mutex.new() -var _error_appended_total := 0 -var _warn_appended_total := 0 - - -func _init() -> void: - super._init(MAX_LINES) - - -func append(level: String, text: String, path: String = "", line: int = 0, function: String = "", details: Dictionary = {}) -> void: - var coerced_level := _coerce_level(level) - var entry := { - "source": "editor", - "level": coerced_level, - "text": text, - "path": path, - "line": line, - "function": function, - } - if not details.is_empty(): - entry["details"] = details.duplicate(true) - _mutex.lock() - _append_entry(entry) - if coerced_level == "error": - _error_appended_total += 1 - elif coerced_level == "warn": - _warn_appended_total += 1 - _mutex.unlock() - - -func get_range(offset: int, count: int) -> Array[Dictionary]: - _mutex.lock() - var out := _get_range_unlocked(offset, count) - _mutex.unlock() - return out - - -func get_recent(count: int) -> Array[Dictionary]: - ## Single-lock so the size we compute `start` from can't race against - ## a concurrent append between the size read and the slice copy. - _mutex.lock() - var size := _total_count_unlocked() - var start := maxi(0, size - count) - var out := _get_range_unlocked(start, size - start) - _mutex.unlock() - return out - - -func get_since(since_seq: int, limit: int = -1) -> Dictionary: - ## Single-lock so the cursor snapshot and slice copy can't race against a - ## Logger-thread append. - _mutex.lock() - var out := _get_since_unlocked(since_seq, limit) - _mutex.unlock() - return out - - -func total_count() -> int: - _mutex.lock() - var n := _total_count_unlocked() - _mutex.unlock() - return n - - -func dropped_count() -> int: - _mutex.lock() - var n := _dropped_count_unlocked() - _mutex.unlock() - return n - - -func appended_total() -> int: - _mutex.lock() - var n := _appended_total_unlocked() - _mutex.unlock() - return n - - -func error_appended_total() -> int: - _mutex.lock() - var n := _error_appended_total - _mutex.unlock() - return n - - -func warn_appended_total() -> int: - _mutex.lock() - var n := _warn_appended_total - _mutex.unlock() - return n - - -func clear() -> int: - _mutex.lock() - var n := _total_count_unlocked() - _clear_storage() - _error_appended_total = 0 - _warn_appended_total = 0 - _mutex.unlock() - return n diff --git a/addons/godot_ai/utils/editor_log_buffer.gd.uid b/addons/godot_ai/utils/editor_log_buffer.gd.uid deleted file mode 100644 index 8c8d823..0000000 --- a/addons/godot_ai/utils/editor_log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b6ynms0856hhq diff --git a/addons/godot_ai/utils/error_codes.gd b/addons/godot_ai/utils/error_codes.gd deleted file mode 100644 index 5dc3466..0000000 --- a/addons/godot_ai/utils/error_codes.gd +++ /dev/null @@ -1,163 +0,0 @@ -@tool -class_name McpErrorCodes -extends RefCounted - -## Error code constants shared across handlers. Mirrors protocol/errors.py. -## -## This `class_name` shipped in v2.3.2 and earlier and must stay reachable -## through self-update. v2.4.1 dropped it and triggered a "Could not resolve -## script" cascade for every user upgrading from any earlier version; v2.4.2 -## restored it as a hot-fix. The cascade fires because Godot keeps stale -## registry entries during the disable -> extract -> enable window when a -## previously-registered class_name disappears, and that failure mode is -## independent of the runner's install ordering. See CLAUDE.md's -## never-delete-published-class_name policy for the shape-aware shim path -## that retirement (if ever needed) must follow. -## -## All consumers use the preload-alias pattern -## (`const ErrorCodes := preload(...)`) introduced in #412. The alias is -## stylistic; both `McpErrorCodes.X` and `ErrorCodes.X` resolve through the -## same Script object cache, so the alias is not a parse-safety boundary -## under the single-phase runner. - -const INVALID_PARAMS := "INVALID_PARAMS" -const EDITED_SCENE_MISMATCH := "EDITED_SCENE_MISMATCH" -const EDITOR_NOT_READY := "EDITOR_NOT_READY" -const UNKNOWN_COMMAND := "UNKNOWN_COMMAND" -const INTERNAL_ERROR := "INTERNAL_ERROR" -const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT" -## Python-originated attach bridge codes. GDScript has no emit path, but the -## public registry intentionally mirrors protocol/errors.py. -const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN" -const NEW_CLIENT_SESSION_REQUIRED := "NEW_CLIENT_SESSION_REQUIRED" -const ATTACH_LOCK_TIMEOUT := "ATTACH_LOCK_TIMEOUT" -const ATTACH_LOCK_ERROR := "ATTACH_LOCK_ERROR" -const ATTACH_RUNTIME_DIR_ERROR := "ATTACH_RUNTIME_DIR_ERROR" -const PORT_OCCUPIED := "PORT_OCCUPIED" -const BACKEND_START_FAILED := "BACKEND_START_FAILED" -const BACKEND_START_TIMEOUT := "BACKEND_START_TIMEOUT" -# game_eval failure codes (#490) — keep in sync with protocol/errors.py -const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR" -const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR" -## #518/#859: the play session is up (EditorInterface.is_playing_scene() is true, -## so editor_handler's EDITOR_NOT_READY "game is not running" gate already -## passed) but the game cannot service evals: the helper did not register within -## EVAL_READY_WAIT_SEC, its main-loop beacon is stale, or its debugger session -## closed mid-eval. Carved out of INTERNAL_ERROR so these fast, -## caller-actionable failures do not burn the opaque 10s eval backstop. -const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY" -## #518: the eval genuinely never finished inside the timeout ladder — the -## game-side 8s deadline aborted a hung await, or the editor-side 10s backstop -## fired because the game never replied at all (CPU-bound loop, frozen / -## backgrounded idle loop). Carved out of INTERNAL_ERROR — the last big -## still-unlabeled bucket from #487/#488 — so "your eval code never finished" -## stops reading as an internal fault in telemetry and agent-facing errors. -const EVAL_HUNG := "EVAL_HUNG" -## #518: the eval completed but its serialized result is too large for the -## debugger + WebSocket pipeline. Without this the reply is dropped silently -## (the debugger TCP peer discards messages over ~8 MiB) and the request rides -## to the 10s backstop as a phantom "hang". Failing fast game-side with the -## real byte count makes the failure actionable (return a smaller slice). -const EVAL_RESULT_TOO_LARGE := "EVAL_RESULT_TOO_LARGE" -## #777: a game-side request (currently editor_screenshot source="game") -## reached a live, registered game helper but no reply came back before the -## editor-side timer fired. Every editor gate already passed -## (is_playing_scene, helper hello) so this is a TOP-LEVEL code, not an -## EDITOR_NOT_READY sub-code: the game process itself failed to respond — -## backgrounded with a frozen main loop and nothing rendered to fall back -## on, main thread blocked, or the helper died mid-run. Carved out of -## INTERNAL_ERROR (the largest opaque timeout bucket fleet-wide) so the -## residual timeout is attributable and actionable. -const GAME_HELPER_TIMEOUT := "GAME_HELPER_TIMEOUT" -## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471 -## INVALID_PARAMS sites so agents can distinguish recoverable input -## errors from structural ones. INVALID_PARAMS stays for genuinely -## catch-all input errors that don't fit any of the buckets below. -## -## - NODE_NOT_FOUND: scene-tree/autoload node lookup failed (path didn't -## resolve to a Node). -## - RESOURCE_NOT_FOUND: a `res://` path lookup failed (file/.tres/ -## .gdshader/.tscn etc. doesn't exist or couldn't load). Distinct from -## NODE_NOT_FOUND because the recovery path differs — agents need to -## know whether to fix a node path vs. create/import a resource. -## - PROPERTY_NOT_ON_CLASS: property/signal/method/uniform/slot lookup -## failed on a known instance (path resolved, but the requested -## member doesn't exist on that class). -## - VALUE_OUT_OF_RANGE: numeric/index bound violation OR enum value -## not in the allowed set. -## - WRONG_TYPE: input was a value (or a loaded resource) of the wrong -## type — the param was provided, but `typeof` or `is X` failed. -## - MISSING_REQUIRED_PARAM: required input field was absent or empty. -const NODE_NOT_FOUND := "NODE_NOT_FOUND" -const RESOURCE_NOT_FOUND := "RESOURCE_NOT_FOUND" -const PROPERTY_NOT_ON_CLASS := "PROPERTY_NOT_ON_CLASS" -const VALUE_OUT_OF_RANGE := "VALUE_OUT_OF_RANGE" -const WRONG_TYPE := "WRONG_TYPE" -const MISSING_REQUIRED_PARAM := "MISSING_REQUIRED_PARAM" -const CUSTOM_TOOL_NOT_UNDOABLE := "CUSTOM_TOOL_NOT_UNDOABLE" -const CUSTOM_TOOL_DISABLED := "CUSTOM_TOOL_DISABLED" - -## #651 stage 1: EDITOR_NOT_READY sub-codes. These travel in -## `error.data.sub_code`, NEVER as the top-level `error.code` — existing -## callers and dashboards key on EDITOR_NOT_READY, so the top-level code is -## frozen. Each sub-code names the concrete editor state at rejection time, -## limited to states EditorInterface/EditorFileSystem can report -## deterministically. States we cannot observe (script compilation, -## resource reload, modal dialogs) intentionally get NO sub-code: a bare -## EDITOR_NOT_READY stays the honest fallback rather than a guessed label. -## Keep in sync with protocol/errors.py::EditorNotReadySubCode — enforced -## by tests/unit/test_editor_not_ready_hint_contract.py. -const SUB_EDITOR_IMPORTING := "EDITOR_IMPORTING" -const SUB_EDITOR_PLAYING := "EDITOR_PLAYING" -const SUB_EDITOR_NO_SCENE := "EDITOR_NO_SCENE" -const SUB_EDITOR_GAME_NOT_RUNNING := "EDITOR_GAME_NOT_RUNNING" -const SUB_EDITOR_VIEWPORT_UNAVAILABLE := "EDITOR_VIEWPORT_UNAVAILABLE" -const SUB_EDITOR_VIEWPORT_NOT_3D := "EDITOR_VIEWPORT_NOT_3D" -const SUB_EDITOR_VIEWPORT_EMPTY := "EDITOR_VIEWPORT_EMPTY" -const SUB_EDITOR_UNAVAILABLE := "EDITOR_UNAVAILABLE" -## Emitted only by the exclusive-run transport servicing path: a command -## arrived while a synchronous test run holds the main thread, and was -## rejected (not buffered) so it can't replay stale after its server-side -## future expires. See connection.gd::service_transport_during_exclusive_run. -const SUB_EDITOR_TEST_RUNNING := "EDITOR_TEST_RUNNING" - -## Terminal code for a test run that hit its between-test abort ceiling -## before finishing. error.data carries the partial summary; full partial -## results stay retrievable via get_test_results. -const TEST_RUN_TIMEOUT := "TEST_RUN_TIMEOUT" - - -## Build a standard error response dictionary. -static func make(code: String, message: String) -> Dictionary: - return {"status": "error", "error": {"code": code, "message": message}} - - -## Build an EDITOR_NOT_READY error carrying the #651 stage-1 attribution -## payload: `data.sub_code` + `retryable` + `hint`. Mirrors the shape -## scene_path.gd::require_edited_scene established (editor_state/retryable/ -## hint). `hint` may be empty when `message` already IS the recovery hint — -## the server's GodotCommandError string-appends every data key, so -## duplicating the message into data would double the agent-visible text. -static func make_not_ready( - sub_code: String, message: String, retryable: bool, hint: String = "" -) -> Dictionary: - var err := make(EDITOR_NOT_READY, message) - var data := {"sub_code": sub_code, "retryable": retryable} - if not hint.is_empty(): - data["hint"] = hint - err["error"]["data"] = data - return err - - -## Return a NEW error dict with the original code and a prefixed message. -## Prefer this over mutating `err["error"]["message"]` in place — callers -## that want to add context ("Property '%s': …") shouldn't need to know -## the internal shape of the dict returned by `make`. Empty `prefix` -## returns `err` unchanged so callers don't need their own guard. -static func prefix_message(err: Dictionary, prefix: String) -> Dictionary: - if prefix.is_empty(): - return err - var inner: Dictionary = err.get("error", {}) - var code: String = inner.get("code", INTERNAL_ERROR) - var message: String = inner.get("message", "") - return make(code, "%s: %s" % [prefix, message]) diff --git a/addons/godot_ai/utils/error_codes.gd.uid b/addons/godot_ai/utils/error_codes.gd.uid deleted file mode 100644 index fcc1e82..0000000 --- a/addons/godot_ai/utils/error_codes.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2klnglf5p861 diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd b/addons/godot_ai/utils/fuzzy_suggestions.gd deleted file mode 100644 index de7cc19..0000000 --- a/addons/godot_ai/utils/fuzzy_suggestions.gd +++ /dev/null @@ -1,39 +0,0 @@ -@tool -extends RefCounted - -## Shared fuzzy ranking for typo suggestions. - - -static func rank( - needle: String, - candidates: Array, - limit: int = 5, - threshold: float = 0.4, - substring_bonus: float = 0.5, - prefix_bonus: float = 1.0 -) -> Array[String]: - if needle.is_empty() or candidates.is_empty(): - return [] - var needle_lower := needle.to_lower() - var scored: Array = [] - for raw_candidate in candidates: - var candidate := str(raw_candidate) - var candidate_lower := candidate.to_lower() - var score := needle.similarity(candidate) - if prefix_bonus != 0.0 and candidate_lower.begins_with(needle_lower): - score += prefix_bonus - elif substring_bonus != 0.0 and ( - candidate_lower.contains(needle_lower) or needle_lower.contains(candidate_lower) - ): - score += substring_bonus - if score >= threshold: - scored.append([score, candidate]) - scored.sort_custom(func(a, b): - if a[0] == b[0]: - return a[1] < b[1] - return a[0] > b[0] - ) - var result: Array[String] = [] - for index in range(min(limit, scored.size())): - result.append(scored[index][1]) - return result diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd.uid b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid deleted file mode 100644 index c663d01..0000000 --- a/addons/godot_ai/utils/fuzzy_suggestions.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxwaws6w0xw60 diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd deleted file mode 100644 index 2f1b3b0..0000000 --- a/addons/godot_ai/utils/game_log_buffer.gd +++ /dev/null @@ -1,105 +0,0 @@ -@tool -class_name McpGameLogBuffer -extends McpStructuredLogRing - -## Ring buffer for game-process log lines (print, push_warning, push_error) -## ferried back from the playing game over the EngineDebugger channel. -## -## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id` -## rotates at play-start, giving agents a stable cursor for "lines from -## this run" even when the game never reaches the mcp:hello boot beacon. -## -## Single-threaded — game_helper.gd drains its logger from `_process` and -## calls `append` from the main thread, so this subclass can use the base -## ring's lockless reads/writes directly. - -const MAX_LINES := 2000 - -var _run_id := "" -var _run_seq := 0 -var _error_warn_total := 0 -var _error_total := 0 - - -func _init() -> void: - super._init(MAX_LINES) - - -func append(level: String, text: String, details: Dictionary = {}) -> void: - var coerced_level := _coerce_level(level) - var entry := { - "source": "game", - "level": coerced_level, - "text": text, - "run_id": _run_id, - } - if not details.is_empty(): - entry["details"] = details.duplicate(true) - _append_entry(entry) - if coerced_level in ["warn", "error"]: - _error_warn_total += 1 - if coerced_level == "error": - _error_total += 1 - - -## Rotate the run identifier without dropping buffered entries. Called at -## play-start so even no-hello parse failures get a fresh current-run identity. -## Historical lines stay tagged with their original run_id and can still be -## queried explicitly. -func clear_for_new_run() -> String: - _run_id = _generate_run_id() - _error_warn_total = 0 - _error_total = 0 - return _run_id - - -func run_id() -> String: - return _run_id - - -func error_warn_total() -> int: - return _error_warn_total - - -func error_total() -> int: - return _error_total - - -## Warn-level lines for the current run: the combined error+warn tally minus -## the error-only tally. Feeds the `game_warn` watermark component so a run -## that only emitted push_warning is no longer reported as clean. -func warn_total() -> int: - return _error_warn_total - _error_total - - -func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]: - return get_run_page(run_id, offset, count).entries - - -func get_run_page(run_id: String, offset: int, count: int) -> Dictionary: - var entries := _entries_for_run(run_id) - var start := mini(maxi(0, offset), entries.size()) - var stop := mini(entries.size(), start + maxi(0, count)) - var out: Array[Dictionary] = [] - for i in range(start, stop): - out.append(entries[i]) - return { - "entries": out, - "total_count": entries.size(), - } - - -func _entries_for_run(run_id: String) -> Array[Dictionary]: - var out: Array[Dictionary] = [] - for entry in get_range(0, total_count()): - if str(entry.get("run_id", "")) == run_id: - out.append(entry) - return out - - -func _generate_run_id() -> String: - ## Opaque to agents — they only check equality. Time-based is plenty - ## unique within a single editor session; the local sequence protects - ## fast back-to-back test runs within the same millisecond. - _run_seq += 1 - return "r%d-%d" % [Time.get_ticks_msec(), _run_seq] diff --git a/addons/godot_ai/utils/game_log_buffer.gd.uid b/addons/godot_ai/utils/game_log_buffer.gd.uid deleted file mode 100644 index 6c7ce7d..0000000 --- a/addons/godot_ai/utils/game_log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://biojw0xl64haw diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd deleted file mode 100644 index d745d02..0000000 --- a/addons/godot_ai/utils/json_values.gd +++ /dev/null @@ -1,92 +0,0 @@ -@tool -class_name McpJsonValues -extends RefCounted - -## Canonical JSON→Variant parsers for the wire shapes agents send. -## -## One parser family instead of five drifted per-handler copies (#714) — -## the canonical color set is the maintainer decision recorded on that -## issue. parse_color accepts: Color passthrough; "#rrggbb"/"#rrggbbaa" -## hex or named-color strings (two-sentinel Color.from_string -## validation); {r,g,b[,a]} dicts; [r,g,b[,a]] arrays. parse_vector2/3 -## accept the Vector passthrough, {x,y[,z]} dicts, and [x,y[,z]] arrays. -## -## Strict WITHIN each shape (the #123/#126 contract): wrong dict keys, -## wrong array lengths, or non-numeric components return null instead of -## guessing zeros — callers turn null into their own typed error. - -const COLOR_KEYS: Array[String] = ["r", "g", "b"] -const VECTOR2_KEYS: Array[String] = ["x", "y"] -const VECTOR3_KEYS: Array[String] = ["x", "y", "z"] - - -static func parse_color(value: Variant) -> Variant: - if value is Color: - return value - if value is String: - ## Color.from_string returns the fallback on parse failure — call - ## twice with distinct sentinels; agreement means a real parse. - var a := Color.from_string(value, Color(0, 0, 0, 0)) - var b := Color.from_string(value, Color(1, 1, 1, 1)) - if a != b: - return null - return a - if value is Dictionary: - var d: Dictionary = value - if not d.has_all(COLOR_KEYS): - return null - var alpha: Variant = d.get("a", 1.0) - if not (_is_number(d.r) and _is_number(d.g) and _is_number(d.b) and _is_number(alpha)): - return null - return Color(float(d.r), float(d.g), float(d.b), float(alpha)) - if value is Array: - var arr: Array = value - if arr.size() != 3 and arr.size() != 4: - return null - for item in arr: - if not _is_number(item): - return null - var a4 := float(arr[3]) if arr.size() == 4 else 1.0 - return Color(float(arr[0]), float(arr[1]), float(arr[2]), a4) - return null - - -static func parse_vector2(value: Variant) -> Variant: - if value is Vector2: - return value - if value is Dictionary: - var d: Dictionary = value - if not d.has_all(VECTOR2_KEYS) or not (_is_number(d.x) and _is_number(d.y)): - return null - return Vector2(float(d.x), float(d.y)) - if value is Array: - var arr: Array = value - if arr.size() != 2 or not (_is_number(arr[0]) and _is_number(arr[1])): - return null - return Vector2(float(arr[0]), float(arr[1])) - return null - - -static func parse_vector3(value: Variant) -> Variant: - if value is Vector3: - return value - if value is Dictionary: - var d: Dictionary = value - if not d.has_all(VECTOR3_KEYS): - return null - if not (_is_number(d.x) and _is_number(d.y) and _is_number(d.z)): - return null - return Vector3(float(d.x), float(d.y), float(d.z)) - if value is Array: - var arr: Array = value - if arr.size() != 3: - return null - for item in arr: - if not _is_number(item): - return null - return Vector3(float(arr[0]), float(arr[1]), float(arr[2])) - return null - - -static func _is_number(v: Variant) -> bool: - return v is int or v is float diff --git a/addons/godot_ai/utils/json_values.gd.uid b/addons/godot_ai/utils/json_values.gd.uid deleted file mode 100644 index b3eeba9..0000000 --- a/addons/godot_ai/utils/json_values.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://4wbf83hwckms diff --git a/addons/godot_ai/utils/log_backtrace.gd b/addons/godot_ai/utils/log_backtrace.gd deleted file mode 100644 index 3fdf838..0000000 --- a/addons/godot_ai/utils/log_backtrace.gd +++ /dev/null @@ -1,113 +0,0 @@ -@tool -class_name McpLogBacktrace -extends RefCounted - -## Helpers for interpreting Godot's `_log_error` virtual arguments. -## (Named `McpLogBacktrace`, not `ScriptBacktrace`: Godot ships a built-in -## `ScriptBacktrace` class — the type of `script_backtraces[i]` entries -## — so class_name'ing ours the same would collide. Verified against -## the engine's `--doctool` output in 4.6.) -## -## Both `editor_logger.gd` and `game_logger.gd` need to: -## - Map `error_type` (0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER) to a -## two-bucket "error" / "warn" string so callers can filter without -## consulting the enum. -## - Fall back to `code` when `rationale` is empty — single-arg -## `push_error("msg")` leaves rationale empty and stuffs the user's -## string into `code`; without the fallback the user message is -## silently lost. The two-arg form `push_error(code, rationale)` -## populates both and rationale wins. -## - Remap the source location to the first frame of `script_backtraces[0]` -## when present. `push_error` / `push_warning` always report -## `file=core/variant/variant_utility.cpp`; the actual user GDScript -## caller is in the backtrace. -## -## Centralising the rules keeps the next push_error semantics shift -## (already happened once between 4.5 and 4.6, see PR #78) a one-place -## fix instead of a two-place hunt. - - -## Coalesce the per-virtual-arg shape Godot hands `_log_error` into a -## flat record. Always walks `script_backtraces` for the first non-empty -## frame; loggers that need to filter by source path call this first and -## then check the resolved `path` field. -## -## Returns: `{level, message, path, line, function, details}` -## - `level`: "error" or "warn" (warn iff `error_type == 1`). -## - `message`: `rationale` when non-empty, else `code`. -## - `path` / `line` / `function`: first backtrace frame when one is -## available; otherwise the original `file` / `line` / `function`. -## - `details`: original `_log_error` fields plus the first non-empty -## backtrace as frames, mirroring the debugger Errors tab context. -const ERROR_TYPE_NAMES := { - 0: "error", - 1: "warning", - 2: "script", - 3: "shader", -} - - -static func resolve_error( - function: String, - file: String, - line: int, - code: String, - rationale: String, - error_type: int, - script_backtraces: Array, -) -> Dictionary: - var src_file := file - var src_line := line - var src_function := function - var frames: Array[Dictionary] = [] - ## First non-empty frame wins, not just `script_backtraces[0]` — - ## chained errors can leave the leading entry empty with the actual - ## user frame in `script_backtraces[1]`. - for bt in script_backtraces: - if bt != null and bt.get_frame_count() > 0: - frames = _frames_from_backtrace(bt) - src_file = str(frames[0].get("path", "")) - src_line = int(frames[0].get("line", 0)) - src_function = str(frames[0].get("function", "")) - break - var message := rationale if not rationale.is_empty() else code - return { - "level": "warn" if error_type == 1 else "error", - "message": message, - "path": src_file, - "line": src_line, - "function": src_function, - "details": { - "message": message, - "code": code, - "rationale": rationale, - "error_type": error_type, - "error_type_name": _error_type_name(error_type), - "source": { - "path": file, - "line": line, - "function": function, - }, - "resolved": { - "path": src_file, - "line": src_line, - "function": src_function, - }, - "frames": frames, - }, - } - - -static func _frames_from_backtrace(bt) -> Array[Dictionary]: - var frames: Array[Dictionary] = [] - for i in bt.get_frame_count(): - frames.append({ - "path": bt.get_frame_file(i), - "line": bt.get_frame_line(i), - "function": bt.get_frame_function(i), - }) - return frames - - -static func _error_type_name(error_type: int) -> String: - return str(ERROR_TYPE_NAMES.get(error_type, "unknown")) diff --git a/addons/godot_ai/utils/log_backtrace.gd.uid b/addons/godot_ai/utils/log_backtrace.gd.uid deleted file mode 100644 index 4845ad8..0000000 --- a/addons/godot_ai/utils/log_backtrace.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8t9kznr2pqxa diff --git a/addons/godot_ai/utils/log_buffer.gd b/addons/godot_ai/utils/log_buffer.gd deleted file mode 100644 index 34bd2b5..0000000 --- a/addons/godot_ai/utils/log_buffer.gd +++ /dev/null @@ -1,67 +0,0 @@ -@tool -class_name McpLogBuffer -extends RefCounted - -## Ring buffer for MCP log lines. Also prints to Godot console. - -const MAX_LINES := 500 - -## When false, `log()` still records into the ring buffer but does not echo the -## line to the Godot console. The test runner flips this off for the duration -## of a run so negative-path suites (which intentionally drive a 500-line ring -## fill and malformed-result error logging) don't bury an all-green run in -## console noise. Ring *contents* — what tests assert on via `get_recent()` / -## `total_logged()` — are unaffected. Engine-level C++ errors raised by -## negative-path tests are not routed through here and still surface. -static var console_echo := true - -var _lines: Array[String] = [] -## Monotonic count of every line ever passed to `log()` since the last -## `clear()`. Distinct from `_lines.size()`, which is bounded at MAX_LINES. -## Consumers that need to detect "new lines arrived" (e.g. `LogViewer.tick`) -## must track this rather than the bounded size — once the ring fills, the -## size stays at MAX_LINES on every subsequent append, so a size-based -## cursor would freeze and the consumer would stop seeing new entries. -var _total_logged: int = 0 -var enabled := true - - -## `echo=false` records the line into the ring (so the dock's log panel shows -## it) without printing to the Godot console. Used for high-frequency -## machine-driven lines like readiness flips, which spammed the console of -## every install (#626) — filesystem scans toggle readiness on each import. -func log(msg: String, echo: bool = true) -> void: - var line := "MCP | %s" % msg - if enabled and console_echo and echo: - print(line) - _lines.append(line) - if _lines.size() > MAX_LINES: - _lines = _lines.slice(-MAX_LINES) - _total_logged += 1 - - -func get_recent(count: int = 50) -> Array[String]: - var start := maxi(0, _lines.size() - count) - var result: Array[String] = [] - result.assign(_lines.slice(start)) - return result - - -func clear() -> void: - _lines.clear() - ## Reset the monotonic counter so a viewer's `seq < _last_seq` shrink - ## detection still recognizes the clear. Callers that want a cumulative - ## ever-produced count across clears can wrap their own counter. - _total_logged = 0 - - -func total_count() -> int: - return _lines.size() - - -## Monotonic sequence — number of lines ever appended via `log()` since -## the last `clear()`. Strictly increases per append, even once the ring -## has filled and `total_count()` is pinned at MAX_LINES. See `_total_logged` -## for rationale. -func total_logged() -> int: - return _total_logged diff --git a/addons/godot_ai/utils/log_buffer.gd.uid b/addons/godot_ai/utils/log_buffer.gd.uid deleted file mode 100644 index 58341ca..0000000 --- a/addons/godot_ai/utils/log_buffer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ddkslse7511e6 diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd deleted file mode 100644 index 3114037..0000000 --- a/addons/godot_ai/utils/mcp_adoption_label.gd +++ /dev/null @@ -1,23 +0,0 @@ -@tool -class_name McpAdoptionLabel -extends RefCounted - -## Outcome flag for `McpServerLifecycleManager.adopt_compatible_server`. -## Distinguishes a same-version managed adoption (we own the PID, can -## restart it) from an external compatible adoption (some other plugin -## instance / dev server owns the process; we just rendezvoused with it). -## -## Was a free-form string in PR 5; promoted to constants here because -## the seam now spans `server_lifecycle.gd`, `plugin.gd`'s log helper, -## the dock's restart-button gating, and the test suite. Stable strings -## keep log scrapes and characterization fixtures unaffected. - -## We have a PID we spawned (or re-acquired by reading the managed -## record + verifying liveness). `force_restart_server` and -## `prepare_for_update_reload` may target this PID. -const MANAGED := "managed" - -## A compatible godot-ai server is on the port but we don't own its -## PID — likely another plugin instance's spawn, or a developer-run -## `godot-ai --reload` server. We reuse it but won't kill it on stop. -const EXTERNAL := "external" diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd.uid b/addons/godot_ai/utils/mcp_adoption_label.gd.uid deleted file mode 100644 index aee58fe..0000000 --- a/addons/godot_ai/utils/mcp_adoption_label.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://klhsu1cuhcue diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd deleted file mode 100644 index fbc0b84..0000000 --- a/addons/godot_ai/utils/mcp_client_refresh_state.gd +++ /dev/null @@ -1,107 +0,0 @@ -@tool -class_name McpClientRefreshState -extends RefCounted - -## State machine for the dock's client-status refresh sweep. Single -## source of truth — supersedes the seven booleans + deadline previously -## scattered across `mcp_dock.gd` (`_client_status_refresh_in_flight`, -## `_client_status_refresh_pending`, `_client_status_refresh_pending_force`, -## `_client_status_refresh_timed_out`, `_client_status_refresh_started_msec`, -## `_client_status_refresh_deferred_until_filesystem_ready`, -## `_client_status_refresh_deferred_force`, -## `_client_status_refresh_deferred_initial`, -## `_client_status_refresh_shutdown_requested`). -## -## The ints are stable for tests; reordering is a breaking change. - -## No worker running, no pending request. Default state. -const IDLE := 0 -## A refresh request landed but the editor filesystem is busy -## (`EditorInterface.get_resource_filesystem().is_scanning()` is true); -## the dock parks the request and retries on the next `_process` after -## the scan settles. Held alongside two flags (force / initial) for -## what kind of refresh to retry; those live next to the state, not -## inside it, because they're requests not state. -const DEFERRED_FOR_FILESYSTEM := 1 -## Worker thread is alive and probing client status off-main. The -## dock paints "(checking...)" in the clients summary and accepts -## additional requests as `pending`. -const RUNNING := 2 -## Worker has been alive past CLIENT_STATUS_REFRESH_TIMEOUT_MSEC. The -## dock paints "(client probe still running)" and a forced refresh is -## allowed to abandon the worker into the orphan list and start a new -## sweep. The state stays RUNNING after a forced abandon-and-restart. -const RUNNING_TIMED_OUT := 3 -## `_exit_tree` / `_install_update` is draining workers. New refresh -## requests are rejected outright. Set once and not cleared (the dock -## instance is being torn down). -const SHUTTING_DOWN := 4 - -const _NAMES := { - IDLE: "idle", - DEFERRED_FOR_FILESYSTEM: "deferred_for_filesystem", - RUNNING: "running", - RUNNING_TIMED_OUT: "running_timed_out", - SHUTTING_DOWN: "shutting_down", -} - - -static func name_of(state: int) -> String: - return _NAMES.get(state, "unknown(%d)" % state) - - -## True when a worker thread should be alive in this state. Combined -## state — RUNNING or RUNNING_TIMED_OUT both have a worker running, but -## the timed-out flavor allows a force-refresh to abandon it. -static func has_worker_alive(state: int) -> bool: - return state == RUNNING or state == RUNNING_TIMED_OUT - - -## True while the status worker is still within its healthy budget. Once a -## refresh has timed out, the dock keeps the warning badge but must let users -## retry Configure / Configure all instead of stranding the controls behind an -## orphaned, uninterruptible worker. -static func should_disable_client_actions(state: int) -> bool: - return state == RUNNING - - -## True when the dock should reject new refresh spawns. Used by the -## dock's two refresh-spawn guards (the deferred refresh entrypoint and -## the status-refresh scheduler). -static func is_blocked_for_spawn(state: int) -> bool: - return state == SHUTTING_DOWN - - -## True when the summary label should show the in-flight badge. -static func should_show_checking_badge(state: int) -> bool: - return state == RUNNING or state == RUNNING_TIMED_OUT - - -## Transition table. Same shape as McpServerState — illegal transitions -## return false; callers `push_warning` and no-op. -static func can_transition(from: int, to: int) -> bool: - if from == to: - return true - ## Shutdown is sticky. - if from == SHUTTING_DOWN: - return false - ## Anything → SHUTTING_DOWN is legal (drain on _exit_tree / install). - if to == SHUTTING_DOWN: - return true - match from: - IDLE: - return to == RUNNING or to == DEFERRED_FOR_FILESYSTEM - DEFERRED_FOR_FILESYSTEM: - ## When the filesystem scan settles we either spawn a worker - ## (RUNNING) or roll back to IDLE if no rows need probing. - return to == RUNNING or to == IDLE - RUNNING: - ## Worker finishes -> IDLE. Worker outlives budget -> - ## RUNNING_TIMED_OUT. Forced respawn after orphan abandon - ## stays in RUNNING (covered by from == to above). - return to == IDLE or to == RUNNING_TIMED_OUT - RUNNING_TIMED_OUT: - ## Late-arriving worker result drops back to IDLE; forced - ## abandon-and-respawn drops back to RUNNING. - return to == IDLE or to == RUNNING - return false diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid deleted file mode 100644 index 9e4129c..0000000 --- a/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dv4tukg6eioww diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd deleted file mode 100644 index 24597f4..0000000 --- a/addons/godot_ai/utils/mcp_server_state.gd +++ /dev/null @@ -1,182 +0,0 @@ -@tool -class_name McpServerState -extends RefCounted - -## State machine for the plugin's server-spawn / adopt / version-verify -## lifecycle. Single source of truth — supersedes the boolean-flag thicket -## (`_server_started_this_session`, `_awaiting_server_version`, -## `_server_version_deadline_ms`, `_connection_blocked`, -## `_can_recover_incompatible`, `_refresh_retried`, -## `_adoption_watch_deadline_ms`) and the older terminal-only -## McpSpawnState string union. -## -## The integer values matter — they're what `get_server_status()` -## surfaces, what the dock pattern-matches on, and what the test suites -## assert against. Reordering the enum is a breaking change. -## -## The transitions are documented in `can_transition()`. The lifecycle -## manager calls `set_state()` which: -## 1. Validates the transition (logs a warning + no-ops on illegal). -## 2. Preserves first-writer-wins among terminal diagnoses so a late -## CRASHED from the watch loop can't clobber an earlier -## PORT_EXCLUDED from the proactive Windows reservation check. - -## Fresh plugin instance, `_start_server` has not run yet. Default state. -const UNINITIALIZED := 0 -## Process spawned via OS.create_process; watch loop is observing the -## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack -## verifies a compatible version), CRASHED (process died early), or -## INCOMPATIBLE (handshake reported a mismatch). -const SPAWNING := 1 -## (slot 2 reserved — keep wire-compat for clients pattern-matching -## numeric `editor_state.state` values; do not reuse.) -## Server is healthy and version-verified. Happy path. Includes both -## "spawned fresh" and "adopted compatible existing server" flavors — -## adoption flavor is recorded separately via `McpAdoptionLabel`. -const READY := 3 -## Live server on the HTTP port returned a version that doesn't match -## what this plugin expects, OR returned no `handshake_ack` inside the -## timeout. Connection is blocked; recovery requires a kill+respawn -## click via `recover_incompatible_server`. -const INCOMPATIBLE := 4 -## Spawned process exited inside the SPAWN_GRACE_MS window. Python -## traceback went to Godot's output log. Terminal — reload the plugin -## or restart the editor to retry. -const CRASHED := 5 -## No server command resolved: no `.venv` Python, no `uvx` on PATH, no -## system `godot-ai`. Terminal — install guidance shown in dock. -const NO_COMMAND := 6 -## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion -## range. Caught proactively before bind. Terminal — port picker shown. -const PORT_EXCLUDED := 7 -## HTTP port held by a process we didn't spawn (no matching managed -## record). Plugin armed an adoption-confirmation watcher; if the foreign -## occupant turns out to be a compatible godot-ai server, -## `handle_server_version_verified` transitions to READY. If the -## adoption deadline expires without a connection, the watcher self- -## disarms but the state stays at FOREIGN_PORT — the dock keeps showing -## "port held by another process" until the user reloads. The version- -## check seam (separate from the adoption deadline) is what fires -## INCOMPATIBLE on a positive-but-mismatched handshake. -const FOREIGN_PORT := 8 -## Static re-entrancy guard fired (`_server_started_this_session` was -## already true). The plugin is being re-enabled within the same editor -## session; the previous instance still owns the spawn. Terminal — does -## NOT block READY paths, just records that this enable cycle no-op'd. -const GUARDED := 9 -## stop_server / prepare_for_update_reload in progress. Transitional — -## next state is STOPPED. -const STOPPING := 10 -## stop_server completed; `_server_pid` reset to -1, port may or may -## not be free. From here a fresh `start_server` call moves back through -## SPAWNING / READY. -const STOPPED := 11 - -const _NAMES := { - UNINITIALIZED: "uninitialized", - SPAWNING: "spawning", - READY: "ready", - INCOMPATIBLE: "incompatible", - CRASHED: "crashed", - NO_COMMAND: "no_command", - PORT_EXCLUDED: "port_excluded", - FOREIGN_PORT: "foreign_port", - GUARDED: "guarded", - STOPPING: "stopping", - STOPPED: "stopped", -} - - -## Human-readable label. Used in startup-trace logs and transition -## warnings. Falls back to `unknown()` for unrecognised values so -## a future enum addition won't crash the formatter. -static func name_of(state: int) -> String: - return _NAMES.get(state, "unknown(%d)" % state) - - -## True for any state the dock should render as a non-OK diagnostic -## panel. Used as the "should we hide the spawn-failure panel?" gate. -static func is_terminal_diagnosis(state: int) -> bool: - return ( - state == CRASHED - or state == NO_COMMAND - or state == PORT_EXCLUDED - or state == INCOMPATIBLE - or state == FOREIGN_PORT - ) - - -## True when the dock should consider the server unsuitable for client -## health checks (incompatible tool surface). Currently just INCOMPATIBLE -## — FOREIGN_PORT is transitional and may resolve to READY if the -## foreign occupant turns out to speak our handshake. -static func blocks_client_health(state: int) -> bool: - return state == INCOMPATIBLE - - -## Transition validation table. Returns true when `from -> to` is a -## legal transition the lifecycle manager should accept. Illegal -## transitions are silently no-op'd at the call site (with a -## `push_warning` log) — this preserves the first-writer-wins contract -## that prevents a late CRASHED from the watch loop overwriting an -## earlier PORT_EXCLUDED diagnosis. -static func can_transition(from: int, to: int) -> bool: - if from == to: - return true - ## Stop is always legal — teardown / install reload short-circuits - ## any in-flight state. - if to == STOPPING: - return true - if to == STOPPED and from == STOPPING: - return true - ## STOPPED can also be reached directly when `_server_pid <= 0` and - ## stop_server early-returns; treat it as legal from any state to - ## keep the teardown path forgiving. - if to == STOPPED: - return true - ## STOPPED -> any (re-arm via restart paths). - if from == STOPPED: - return true - ## GUARDED is sticky for the rest of this enable cycle; only stop is - ## legal out of it. Already covered by the stop checks above. - if from == GUARDED: - return false - ## Terminal diagnoses freeze further forward transitions. Recovery - ## goes through STOPPING (covered above), so any other target is - ## rejected — this is the first-writer-wins contract. - if ( - from == CRASHED - or from == NO_COMMAND - or from == PORT_EXCLUDED - or from == INCOMPATIBLE - ): - return false - ## UNINITIALIZED is the boot state — any target except STOPPING is - ## reachable directly (start_server's early branches set - ## terminal states without going through SPAWNING). - if from == UNINITIALIZED: - return true - ## In-flight forward transitions. - match from: - SPAWNING: - return ( - to == READY - or to == CRASHED - or to == FOREIGN_PORT - or to == INCOMPATIBLE - ) - FOREIGN_PORT: - return to == READY or to == INCOMPATIBLE - READY: - ## Late incompatibility detection (e.g. version verifier - ## re-arms after a foreign-port reconnect that turns out - ## to be incompatible after all). - return to == INCOMPATIBLE or to == CRASHED - STOPPING: - ## Recovery rollback: kill-then-respawn paths that fail to - ## free the port re-latch INCOMPATIBLE (so the dock keeps - ## the diagnostic UI) or fall back to UNINITIALIZED (clean - ## baseline for a follow-up `_set_incompatible_server`). - ## STOPPING -> STOPPED is handled by the early checks above. - return to == INCOMPATIBLE or to == UNINITIALIZED - return false diff --git a/addons/godot_ai/utils/mcp_server_state.gd.uid b/addons/godot_ai/utils/mcp_server_state.gd.uid deleted file mode 100644 index dc5bef9..0000000 --- a/addons/godot_ai/utils/mcp_server_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d3ial4erjonlq diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd deleted file mode 100644 index 130b8e1..0000000 --- a/addons/godot_ai/utils/mcp_startup_path.gd +++ /dev/null @@ -1,34 +0,0 @@ -@tool -class_name McpStartupPath -extends RefCounted - -## Branch-tag enum for `McpServerLifecycleManager.start_server`. Records -## which arm of the spawn / adopt / drift / recover decision tree the -## current `_enter_tree` walked. Surfaced via the startup trace log so -## a Windows port-reservation issue or a stale-record kill can be -## reconstructed from the editor output. -## -## Single-file constants, not an int enum, because the values land in -## startup-trace text and the strings are stable across releases (the -## CLAUDE.md "tool surface" entry references them by name). - -const UNSET := "" -## Re-entrancy guard fired; this enable cycle did not spawn or adopt. -const GUARDED := "guarded" -## Adopted a compatible existing server (managed or external). -const ADOPTED := "adopted" -## Spawned a fresh server process. -const SPAWNED := "spawned" -## OS.create_process returned -1 or proactive Windows reservation -## detected. Either way the spawn never produced a live process. -const CRASHED := "crashed" -## Windows port-exclusion check fired — port is blocked at the OS layer. -const RESERVED := "reserved" -## Server-command discovery returned an empty list — no .venv, no uvx, -## no system godot-ai. -const NO_COMMAND := "no_command" -## Drift-recovery kill fell through; we set INCOMPATIBLE and stayed. -const INCOMPATIBLE := "incompatible" -## Port was free at start; this is the prelude to SPAWNED but kept as -## a distinct path so adopt-vs-spawn is unambiguous in the trace. -const FREE := "free" diff --git a/addons/godot_ai/utils/mcp_startup_path.gd.uid b/addons/godot_ai/utils/mcp_startup_path.gd.uid deleted file mode 100644 index fd01066..0000000 --- a/addons/godot_ai/utils/mcp_startup_path.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cikdvq2x4vs4x diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd deleted file mode 100644 index a199dc5..0000000 --- a/addons/godot_ai/utils/path_validator.gd +++ /dev/null @@ -1,178 +0,0 @@ -@tool -class_name McpPathValidator -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Validates `res://`-rooted paths against directory-traversal escape. -## -## Issue #347 (audit-v2 #3): handlers were accepting `res://../etc/passwd.gd` -## because the only check was `path.begins_with("res://")`. LLM-driven path -## generation (prompt injection, agent typos, untrusted issue/PR text in -## context) can produce traversal payloads for the write tools that produce -## arbitrary disk content (`script_create`, `filesystem_write_text`, -## `patch_script`) and for the matching reads (info disclosure surface). -## -## Two entry points: -## * `validate_resource_path` — for paths that name a `res://` disk file the -## plugin will read or (with `for_write`) write. This is the strict one. -## * `validate_loadable_path` — for paths handed to `ResourceLoader`, which -## also accepts `uid://` (an opaque resource-DB id that cannot express -## traversal) and `user://` (the per-project user data sandbox). Load -## handlers must use this so `uid://` references copied out of `.tscn` -## ExtResource / `.uid` sidecars and `user://` runtime assets keep loading. -## -## Error wrapping: callers should use `path_error` / `loadable_error`, which -## return a ready `ErrorCodes.make(VALUE_OUT_OF_RANGE, …)` dict (or null). A -## bad path is a value-domain error, and funneling every site through one -## wrapper keeps the error code consistent across all handlers. -## -## Known limitation: containment is lexical (`globalize_path` + `simplify_path` -## prefix match). It does NOT resolve symlinks — GDScript exposes no realpath. -## A symlink *inside* the project that points outside it can therefore defeat -## the under-root check. This matches the engine's own `res://` resolution and -## is accepted; the loopback trust boundary is the primary control. - - -# Cached project / user roots. `globalize_path` is stable across the editor's -# lifetime — caching avoids redundant resolution on every call. Matters most -# for `reimport`, which loops the validator over each path in a batch. -# Lazy-init on first call so static-load timing can't see a half-initialised -# ProjectSettings. -static var _cached_res_root: String = "" -static var _cached_user_root: String = "" - - -static func _res_root() -> String: - if _cached_res_root.is_empty(): - _cached_res_root = ProjectSettings.globalize_path("res://").simplify_path() - return _cached_res_root - - -static func _user_root() -> String: - if _cached_user_root.is_empty(): - _cached_user_root = ProjectSettings.globalize_path("user://").simplify_path() - return _cached_user_root - - -## Returns "" when the path is a safe `res://`-rooted reference inside the -## project root. Returns a human-readable error message otherwise. -## Prefer `path_error` over calling this directly — it wraps the message in the -## canonical error code. -## -## Pass `for_write = true` for any handler that creates/overwrites the file -## (write_file, create_script, patch_script, ResourceSaver-backed saves, -## scene saves). Write callers additionally refuse the project manifest and -## startup override, plus the `.godot/` metadata dir. Reads default to -## `for_write = false`, which permits inspecting those files. -static func validate_resource_path(path: String, for_write: bool = false) -> String: - if path.is_empty(): - return "Missing required param: path" - ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines - ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and - ## reject every path. A String that can't hold a null can't smuggle one. - var nul := String.chr(0) - if not nul.is_empty() and path.contains(nul): - return "Path must not contain null bytes" - if not path.begins_with("res://"): - return "Path must start with res://" - var confine_err := _confine_under(path, _res_root(), "res://") - if not confine_err.is_empty(): - return confine_err - if for_write: - return _reject_sensitive_write(path) - return "" - - -## Returns "" when `path` is safe to hand to `ResourceLoader.load` / `.exists`. -## Accepts, in addition to confined `res://` paths: -## * `uid://` — an opaque 64-bit resource id; it cannot express a path -## and the engine only ever resolves it to a resource already in the -## project, so there is nothing to confine. -## * `user://…` — the per-project user data dir, confined under its root the -## same way `res://` is (so `user://../…` can't escape the sandbox). -static func validate_loadable_path(path: String) -> String: - if path.is_empty(): - return "Missing required param: path" - ## Guard the sentinel: on builds where String.chr(0) yields "" (some engines - ## normalize embedded nulls away, e.g. 4.3), contains("") would be true and - ## reject every path. A String that can't hold a null can't smuggle one. - var nul := String.chr(0) - if not nul.is_empty() and path.contains(nul): - return "Path must not contain null bytes" - if path.begins_with("uid://"): - return "" - if path.begins_with("user://"): - return _confine_under(path, _user_root(), "user://") - if path.begins_with("res://"): - return _confine_under(path, _res_root(), "res://") - return "Path must start with res://, uid://, or user://" - - -## Shared traversal + under-root containment. `root` must already be simplified. -static func _confine_under(path: String, root: String, label: String) -> String: - if ".." in path: - return "Path must not contain '..' (path traversal not allowed)" - var globalized := ProjectSettings.globalize_path(path).simplify_path() - # Append a separator so `/proj_evil/...` can't pretend to be inside `/proj` - # via prefix match. `globalized == root` covers the bare `res://` / `user://`. - if globalized != root and not globalized.begins_with(root + "/"): - return "Path must resolve under %s root" % label - return "" - - -## Refuse writes that would clobber project-critical files. The path is already -## confirmed `res://`-rooted and traversal-free by the caller. -## -## Comparisons are case-folded: macOS (APFS) and Windows (NTFS) are -## case-insensitive by default, so `res://Project.godot` resolves to the real -## `project.godot` and must be refused too. -## -## `.import` sidecars are deliberately NOT blocked — editing an asset's import -## options then re-importing is a legitimate, recoverable workflow (the file is -## source-controlled). The blocked set is the startup-execution surface only: -## the manifest, its `override.cfg` shadow, and the `.godot/` cache dir. -static func _reject_sensitive_write(path: String) -> String: - var file_lower := path.get_file().to_lower() - if file_lower == "project.godot": - return "Refusing to write res://project.godot (project manifest)" - if file_lower == "override.cfg": - return "Refusing to write res://override.cfg (startup config override)" - # Reject the `.godot/` editor-metadata dir at any depth. Split drops empty - # segments so a trailing slash can't hide a segment from the check. - var segments := path.trim_prefix("res://").split("/", false) - for segment in segments: - if segment.to_lower() == ".godot": - return "Refusing to write under res://.godot/ (editor metadata)" - # Reject the currently-loaded plugin's own script tree. Overwriting a - # loaded .gd here (plus the immediate reimport triggered by update_file()) - # can SIGABRT the editor mid-call, or silently corrupt the installed - # plugin so the next enable fails to resolve scripts. - if segments.size() >= 2 and segments[0].to_lower() == "addons" and segments[1].to_lower() == "godot_ai": - return "Refusing to write under res://addons/godot_ai/ (overwriting a loaded plugin script can crash the editor)" - return "" - - -## Validate a write/read `res://` path and return a ready error dict, or null -## when the path is fine. The single wrapper every handler should use so the -## error code (VALUE_OUT_OF_RANGE — a bad path is a value-domain error) stays -## consistent. `param_name` is prefixed onto the message for context. -static func path_error(path: String, param_name: String = "path", for_write: bool = false) -> Variant: - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) - var err := validate_resource_path(path, for_write) - if err.is_empty(): - return null - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) - - -## Same as `path_error` but for paths handed to `ResourceLoader` (allows -## `uid://` / `user://`). Returns a ready error dict or null. An empty path is -## reported as MISSING_REQUIRED_PARAM rather than a value error. -static func loadable_error(path: String, param_name: String = "path") -> Variant: - if path.is_empty(): - return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: %s" % param_name) - var err := validate_loadable_path(path) - if err.is_empty(): - return null - return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "%s: %s" % [param_name, err]) diff --git a/addons/godot_ai/utils/path_validator.gd.uid b/addons/godot_ai/utils/path_validator.gd.uid deleted file mode 100644 index 8ed4acf..0000000 --- a/addons/godot_ai/utils/path_validator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://blxntmd65ljyu diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd index 789cfc6..9ef32e9 100644 --- a/addons/godot_ai/utils/port_resolver.gd +++ b/addons/godot_ai/utils/port_resolver.gd @@ -9,7 +9,37 @@ extends RefCounted ## Canonical pid-file path. plugin.gd::SERVER_PID_FILE re-exports this so ## external readers and tests can use either name. const SERVER_PID_FILE := "user://godot_ai_server.pid" +## Where a plugin-spawned server reports a failure that happens before it +## publishes its capability record (`--startup-report`). Removed before every +## spawn, so a report that exists afterwards belongs to that launch. +const SERVER_STARTUP_REPORT := "user://godot_ai_server_startup.json" const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +static var _process_spawn_mutex := Mutex.new() + + +## Serialize plugin-owned subprocess creation across the brief process-global +## environment override used for server capabilities. Callers must keep this +## window to environment mutation + one spawn and release it immediately. +static func lock_process_spawn() -> void: + _process_spawn_mutex.lock() + + +static func unlock_process_spawn() -> void: + _process_spawn_mutex.unlock() + + +## A managed Linux server needs a listener PID tool to prove ownership. +## Query each launch so installing the missing tool makes Retry work. +static func listener_tools_problem() -> String: + if OS.get_name() != "Linux": + return "" + var output: Array = [] + var available := OS.execute("/bin/sh", ["-c", + "command -v lsof >/dev/null 2>&1 || command -v ss >/dev/null 2>&1" + ], output, true) + if available == 0: + return "" + return "Cannot verify Linux listener ownership: neither lsof nor ss is available on the editor's PATH. Install lsof or iproute2 (which provides ss), then retry starting the server." static func can_bind_local_port(port: int) -> bool: @@ -60,7 +90,16 @@ static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) - return not find_listener_pids_windows(port).is_empty() _trace(trace, "lsof") var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) - return exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty() + if exit_code == 0 and output.size() > 0 and not output[0].strip_edges().is_empty(): + return true + if OS.get_name() != "Linux": + return false + _trace(trace, "ss") + output.clear() + return ( + OS.execute("ss", ["-H", "-ltnp"], output, true) == 0 + and not parse_linux_ss_pids(str(output[0]) if not output.is_empty() else "", port).is_empty() + ) ## Return the PID currently listening on the given TCP port, or 0 if @@ -76,7 +115,7 @@ static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: ## both bind the same port. ## ## `trace` is an optional Callable that fires once per OS invocation with -## a counter name (`"netstat"` / `"powershell"` / `"lsof"`) so the plugin +## a counter name (`"netstat"` / `"powershell"` / `"lsof"` / `"ss"`) so the plugin ## can keep its cold-start trace accurate. The Windows path may fall ## through netstat → PowerShell, and a wrapping caller can't see which ## scraper actually ran without the hook. @@ -105,10 +144,19 @@ static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Ar var output: Array = [] _trace(trace, "lsof") var exit_code := OS.execute("lsof", ["-ti:%d" % port, "-sTCP:LISTEN"], output, true) - if exit_code != 0 or output.is_empty(): + if exit_code == 0 and not output.is_empty(): + var lsof_pids := parse_lsof_pids(str(output[0])) + if not lsof_pids.is_empty() or OS.get_name() != "Linux": + return lsof_pids + elif OS.get_name() != "Linux": var empty: Array[int] = [] return empty - return parse_lsof_pids(str(output[0])) + _trace(trace, "ss") + output.clear() + if OS.execute("ss", ["-H", "-ltnp"], output, true) != 0 or output.is_empty(): + var empty: Array[int] = [] + return empty + return parse_linux_ss_pids(str(output[0]), port) static func _trace(trace: Callable, counter: String) -> void: @@ -139,6 +187,14 @@ static func execute_windows_powershell(script: String, output: Array) -> int: static func windows_powershell_candidates() -> Array[String]: var candidates: Array[String] = [] + ## Prefer an already-installed PowerShell 7 at its fixed machine path. + ## Missing or failing installations retain every existing fallback below. + for variable in ["ProgramW6432", "ProgramFiles"]: + var program_files := OS.get_environment(variable).replace("\\", "/").trim_suffix("/") + if not program_files.is_empty(): + var pwsh := program_files.path_join("PowerShell/7/pwsh.exe") + if FileAccess.file_exists(pwsh) and not candidates.has(pwsh): + candidates.append(pwsh) var system_root := OS.get_environment("SystemRoot") if system_root.is_empty(): system_root = "C:/Windows" @@ -161,16 +217,32 @@ static func windows_listener_execute_result_in_use(exit_code: int, output: Array ## Pure parser for `lsof -ti` output — newline-separated decimal PIDs. -## Empty lines and non-numeric tokens are dropped. Duplicates pass -## through (uvicorn reloader + worker can produce the same PID twice -## across runs but typically two distinct PIDs). +## Empty lines, non-positive values, non-numeric tokens, and duplicate +## IPv4/IPv6 rows for the same listener are dropped. static func parse_lsof_pids(raw: String) -> Array[int]: - var pids: Array[int] = [] - for line in raw.strip_edges().split("\n", false): - var stripped := line.strip_edges() - if stripped.is_valid_int(): - pids.append(int(stripped)) - return pids + return parse_pid_lines(raw) + + +## Parse Linux `ss -H -ltnp` without trusting process names or localized +## state text. The local endpoint is the fourth whitespace-delimited field; +## every `pid=` value on an exact-port row is retained for later brand, +## lineage, and fingerprint checks. +static func parse_linux_ss_pids(raw: String, port: int) -> Array[int]: + var result: Array[int] = [] + if port < 1 or port > 65535: + return result + var expression := RegEx.new() + if expression.compile("(?:^|,)pid=([0-9]+)(?:,|\\))") != OK: + return result + for line in raw.split("\n", false): + var fields := line.split(" ", false) + if fields.size() < 4 or not str(fields[3]).ends_with(":%d" % port): + continue + for matched in expression.search_all(line): + var pid := int(matched.get_string(1)) + if pid > 0 and not result.has(pid): + result.append(pid) + return result static func parse_pid_lines(raw: String) -> Array[int]: @@ -262,10 +334,10 @@ static func split_on_whitespace(s: String) -> PackedStringArray: return out -static func read_pid_file() -> int: - if not FileAccess.file_exists(SERVER_PID_FILE): +static func read_pid_file(path := SERVER_PID_FILE) -> int: + if not FileAccess.file_exists(path): return 0 - var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ) + var f := FileAccess.open(path, FileAccess.READ) if f == null: return 0 var content := f.get_as_text().strip_edges() @@ -286,9 +358,11 @@ static func clear_pid_file() -> void: ## uvx launcher lingers as a zombie forever and `kill -0` would block ## the spawn-failure branch in check_server_health from firing. Use ## `ps -o stat=` instead. State codes: R/S/D/I/T (live), Z (zombie). #172. -static func pid_alive(pid: int) -> bool: +static func pid_alive(pid: int, snapshot: Variant = null) -> bool: if pid <= 0: return false + if snapshot != null: + return not _process_snapshot_row(snapshot, pid).is_empty() if OS.get_name() == "Windows": var output: Array = [] var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) @@ -306,11 +380,353 @@ static func pid_alive(pid: int) -> bool: return not stat.is_empty() and not stat.begins_with("Z") +## Read-only process identity helpers. Lifecycle authority is granted only +## after capturing a fingerprint and every later stop re-reads the fingerprint +## before acting, so PID reuse fails closed. +## One Windows query captures the target and at most fifteen ancestors. The +## dictionary belongs to one proof boundary, never a cache: callers must take +## a fresh snapshot when closing the capture window or authorizing a kill. +## Non-Windows callers receive null and keep the existing live-query path. +## The process enumeration stays inside PowerShell; only the bounded target +## ancestry crosses back into Godot, without logging command-line metadata. +static func capture_process_snapshot(pid: int) -> Variant: + if OS.get_name() != "Windows": + return null + if pid <= 1: + return {"capture_error": true} + var script := _windows_process_snapshot_script(pid) + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return {"capture_error": true} + return parse_process_snapshot(str(output[0]), pid) + + +static func _windows_process_snapshot_script(pid: int) -> String: + return ( + "$rows = @(); $current = %d; $seen = @{}; $capture_failed = $false; " + + "$processes = @{}; try { Get-CimInstance Win32_Process -ErrorAction Stop | " + + "ForEach-Object { $processes[[int]$_.ProcessId] = $_ } } catch {}; " + + "for ($depth = 0; $depth -lt 16 -and $current -gt 1; $depth++) { " + + "if ($seen.ContainsKey($current)) { break }; $seen[$current] = $true; " + + "$p = $processes[$current]; " + + "if ($null -eq $p) { " + + "try { $identity = [string](Get-Process -Id $current -ErrorAction Stop).StartTime.ToFileTimeUtc(); " + + "$rows += @{pid=$current;parent_pid=0;identity=$identity;commandline=''} } catch { if ($_.CategoryInfo.Category -ne 'ObjectNotFound') { $capture_failed = $true } }; break }; " + + "$rows += @{pid=[int]$p.ProcessId;parent_pid=[int]$p.ParentProcessId; " + + "identity=([string]$p.CreationDate + '|' + [string]$p.CommandLine);commandline=[string]$p.CommandLine}; " + + "$current = [int]$p.ParentProcessId }; " + + "if ($capture_failed) { 'null' } else { ConvertTo-Json -InputObject @($rows) -Compress -Depth 3 }" + ) % pid + + +## Independent CIM maps in separate scriptblock scopes are +## captured within one shell invocation. No snapshot crosses a grant call. +static func _capture_windows_process_snapshot_pair(pid: int) -> Array: + var query := _windows_process_snapshot_script(pid) + var script := ( + "$first = & { %s }; $final = & { %s }; " + + "ConvertTo-Json -InputObject @([string]$first,[string]$final) -Compress" + ) % [query, query] + var output: Array = [] + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return [{"capture_error": true}, {"capture_error": true}] + return _parse_process_snapshot_pair(str(output[0]), pid) + + +static func _parse_process_snapshot_pair(raw: String, pid: int) -> Array: + if raw.length() > 4 * 1024 * 1024 + 16: + return [{"capture_error": true}, {"capture_error": true}] + var json := JSON.new() + if json.parse(raw) != OK: + return [{"capture_error": true}, {"capture_error": true}] + var pair: Variant = json.data + if not (pair is Array) or pair.size() != 2 or not (pair[0] is String and pair[1] is String): + return [{"capture_error": true}, {"capture_error": true}] + return [parse_process_snapshot(pair[0], pid), parse_process_snapshot(pair[1], pid)] + + +static func parse_process_snapshot(raw: String, expected_pid: int) -> Dictionary: + if raw.length() > 1024 * 1024: + return {"capture_error": true} + var json := JSON.new() + if json.parse(raw) != OK: + return {"capture_error": true} + var rows: Variant = json.data + if not (rows is Array) or rows.size() > 16: + return {"capture_error": true} + var snapshot := {} + var next_pid := expected_pid + for row in rows: + if not (row is Dictionary): + return {"capture_error": true} + var value: Variant = row.get("pid") + if ( + not (value is int or value is float) or not is_finite(float(value)) + or float(value) <= 1.0 or float(value) > 4294967295.0 + or float(value) != float(int(value)) + ): + return {"capture_error": true} + var pid := int(value) + if pid <= 1 or pid != next_pid or snapshot.has(pid): + return {"capture_error": true} + snapshot[pid] = row + if _process_snapshot_row(snapshot, pid).is_empty(): + return {"capture_error": true} + next_pid = int(row.parent_pid) + if snapshot.has(next_pid): + return {"capture_error": true} + return snapshot + + +## An unavailable capture is not evidence that its PID has exited. +static func capture_failed(snapshot: Variant) -> bool: + return snapshot is Dictionary and bool(snapshot.get("capture_error", false)) + + +static func _process_snapshot_row(snapshot: Variant, pid: int) -> Dictionary: + if not (snapshot is Dictionary) or capture_failed(snapshot) or pid <= 1: + return {} + var row: Variant = snapshot.get(pid) + if not (row is Dictionary) or row.size() != 4: + return {} + var parent: Variant = row.get("parent_pid") + if ( + row.get("pid") != pid + or not (parent is int or parent is float) + or not is_finite(float(parent)) or float(parent) < 0.0 or float(parent) > 4294967295.0 + or float(parent) != float(int(parent)) + or not (row.get("identity") is String) or str(row.identity).strip_edges().is_empty() + or not (row.get("commandline") is String) + ): + return {} + var identity := str(row.identity) + var commandline := str(row.commandline) + var separator := identity.find("|") + if separator >= 0: + if separator == 0 or identity.substr(separator + 1) != commandline: + return {} + elif not identity.is_valid_int() or not commandline.is_empty() or int(parent) != 0: + return {} + return row + + +static func process_commandline(pid: int, snapshot: Variant = null) -> String: + if pid <= 1: + return "" + if snapshot != null: + return str(_process_snapshot_row(snapshot, pid).get("commandline", "")) + if OS.get_name() == "Windows": + var output: Array = [] + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty CommandLine" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return "" + return str(output[0]).strip_edges() + var proc_path := "/proc/%d/cmdline" % pid + if FileAccess.file_exists(proc_path): + var file := FileAccess.open(proc_path, FileAccess.READ) + if file != null: + var bytes := PackedByteArray() + while bytes.size() < (1 << 20): + var chunk := file.get_buffer(4096) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if file.eof_reached(): + break + file.close() + for index in range(bytes.size()): + if bytes[index] == 0: + bytes[index] = 0x20 + return bytes.get_string_from_utf8().strip_edges() + var output: Array = [] + if OS.execute("ps", ["-ww", "-p", str(pid), "-o", "args="], output, true) != 0: + return "" + return str(output[0]).strip_edges() if not output.is_empty() else "" + + +static func process_parent(pid: int, snapshot: Variant = null) -> int: + if pid <= 1: + return 0 + if snapshot != null: + return int(_process_snapshot_row(snapshot, pid).get("parent_pid", 0)) + var output: Array = [] + if OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "Select-Object -ExpandProperty ParentProcessId" + ) % pid + if execute_windows_powershell(script, output) != 0 or output.is_empty(): + return 0 + else: + if OS.execute("ps", ["-o", "ppid=", "-p", str(pid)], output, true) != 0 or output.is_empty(): + return 0 + var raw := str(output[0]).strip_edges() + return int(raw) if raw.is_valid_int() else 0 + + +static func process_descends_from(pid: int, ancestor_pid: int, snapshot: Variant = null) -> bool: + if pid <= 1 or ancestor_pid <= 1: + return false + if snapshot != null and _process_snapshot_row(snapshot, pid).is_empty(): + return false + var current := pid + for _depth in range(16): + if snapshot != null and _process_snapshot_row(snapshot, current).is_empty(): + return false + if current == ancestor_pid: + return true + var parent := process_parent(current, snapshot) + if parent <= 1 or parent == current: + return false + current = parent + return false + + +static func commandline_is_godot_ai_server(commandline: String) -> bool: + if commandline.is_empty(): + return false + var lower := commandline.to_lower() + var expression := RegEx.new() + var brand_search := lower + ## Paths we choose ourselves must not satisfy the brand: strip the pid-file + ## and startup-report values (they live under user:// and carry our name) + ## before searching for it. + if expression.compile("--(pid-file|startup-report)(?:=|\\s+)\\S+") == OK: + brand_search = expression.sub(lower, "--$1 ", true) + var branded := brand_search.contains("godot-ai") or brand_search.contains("godot_ai") + return branded and (lower.contains("--pid-file") or lower.contains("--transport")) + + +static func pid_cmdline_is_godot_ai(pid: int, snapshot: Variant = null) -> bool: + var current := pid + for _depth in range(5): + if current <= 1: + return false + if commandline_is_godot_ai_server(process_commandline(current, snapshot)): + return true + current = process_parent(current, snapshot) + return false + + +static func process_fingerprint(pid: int, snapshot: Variant = null) -> String: + if not pid_alive(pid, snapshot): + return "" + var output: Array = [] + var identity := "" + if snapshot != null: + identity = str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges() + elif OS.get_name() == "Windows": + var script := ( + "Get-CimInstance Win32_Process -Filter 'ProcessId = %d' | " + + "ForEach-Object { [string]$_.CreationDate + '|' + [string]$_.CommandLine }" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + ## Some restricted Windows hosts expose process start time through + ## Get-Process while CIM returns no Win32_Process projection. PID + + ## creation time is the reuse-resistant identity; command-line brand + ## is checked independently before destructive authority is minted. + output.clear() + script = ( + "(Get-Process -Id %d -ErrorAction Stop).StartTime.ToFileTimeUtc()" + ) % pid + if execute_windows_powershell(script, output) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + else: + if OS.execute( + "ps", ["-ww", "-p", str(pid), "-o", "lstart=", "-o", "args="], output, true + ) == 0 and not output.is_empty(): + identity = str(output[0]).strip_edges() + if identity.is_empty(): + return "" + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update((str(pid) + "|" + identity).to_utf8_buffer()) + return context.finish().hex_encode() + + +## Capture the process identity that a later destructive call must present. +## A PID or command-line brand alone is never kill authority: either can refer +## to a different process by the time a worker reaches the effect boundary. +## `diagnostics`, when given, receives one word naming the check that +## refused the grant. It costs no extra probe and never changes the result. +static func capture_process_kill_grant( + pid: int, require_brand := false, diagnostics: Array = [] +) -> Dictionary: + if pid <= 1 or pid == OS.get_process_id(): + diagnostics.append("invalid_pid") + return {} + var pair: Array = _capture_windows_process_snapshot_pair(pid) if OS.get_name() == "Windows" else [] + var first: Variant = pair[0] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(first): + diagnostics.append("identity_unavailable") + return {} + if not pid_alive(pid, first): + diagnostics.append("not_alive") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, first): + diagnostics.append("unbranded") + return {} + var fingerprint := process_fingerprint(pid, first) + if fingerprint.is_empty(): + diagnostics.append("fingerprint_unavailable") + return {} + ## Close the capture window: both identity and optional lineage/brand must + ## still describe the same process in the independently collected final snapshot. + var final: Variant = pair[1] if not pair.is_empty() else capture_process_snapshot(pid) + if capture_failed(final): + diagnostics.append("identity_unavailable") + return {} + if process_fingerprint(pid, final) != fingerprint: + diagnostics.append("fingerprint_changed") + return {} + if require_brand and not pid_cmdline_is_godot_ai(pid, final): + diagnostics.append("brand_changed") + return {} + return {"pid": pid, "fingerprint": fingerprint} + + +## Kill only exact process grants and re-read identity immediately before the +## OS call. On Windows, taskkill /T already handles real descendants; do not +## append command-line lookalikes such as spoofed multiprocessing children. +static func kill_exact_processes( + grants: Array[Dictionary], require_brand := false, require_tree_proof := false +) -> Array[int]: + var killed: Array[int] = [] + var seen: Array[int] = [] + for grant in grants: + if grant.keys().size() != 2 or not grant.has("pid") or not grant.has("fingerprint"): + continue + var pid := int(grant.get("pid", 0)) + var fingerprint := str(grant.get("fingerprint", "")) + if pid <= 1 or pid == OS.get_process_id() or seen.has(pid) or fingerprint.is_empty(): + continue + var snapshot: Variant = capture_process_snapshot(pid) + if ( + process_fingerprint(pid, snapshot) != fingerprint + or (require_brand and not pid_cmdline_is_godot_ai(pid, snapshot)) + ): + continue + seen.append(pid) + if OS.get_name() == "Windows": + var output: Array = [] + var code := OS.execute("taskkill", ["/PID", str(pid), "/T", "/F"], output, true) + ## A vanished direct PID is enough for ordinary exact-process callers. + ## Timeout safety needs stronger evidence: only taskkill /T success + ## proves that Windows accepted termination of the whole child tree. + if code == 0 or (not require_tree_proof and not pid_alive(pid)): + killed.append(pid) + elif OS.kill(pid) == OK or not pid_alive(pid): + killed.append(pid) + return killed + + ## Poll until the given port is no longer bound, or the timeout elapses. ## Used after `OS.kill` so we don't race the port-in-use check on rebind. -## NOTE: plugin.gd::_wait_for_port_free (and _is_port_in_use) is a -## deliberate line-for-line fork of this pair kept for _ProofPlugin -## isolation — keep the two in sync when editing either. static func wait_for_port_free(port: int, timeout_s: float) -> void: var deadline := Time.get_ticks_msec() + int(timeout_s * 1000.0) while is_port_in_use(port): @@ -338,24 +754,6 @@ static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) - return resolved -## Trust the cached ws_port from the managed record only when the record -## is current ownership proof — i.e. record version matches the installed -## plugin. Otherwise a stale record from an older install (e.g. a 9500 -## value pre-Windows-reservation collision) would mislead the -## compatibility check into killing an unrelated external process. #259. -static func resolved_ws_port_for_existing_server( - record_ws_port: int, - record_version: String, - current_version: String, - fresh_resolved: int -) -> int: - if record_ws_port <= 0: - return fresh_resolved - if current_version.is_empty() or record_version != current_version: - return fresh_resolved - return record_ws_port - - static func resolve_ws_port_from_output( configured_port: int, netsh_output: String, diff --git a/addons/godot_ai/utils/release_verifier.gd b/addons/godot_ai/utils/release_verifier.gd new file mode 100644 index 0000000..77ce158 --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd @@ -0,0 +1,876 @@ +@tool +class_name McpReleaseVerifier +extends RefCounted + +## Pure, static verification of a signed v4 release: the canonical manifest, +## its RSA signature, the archive, and the tree hash the installer compares +## after extraction and again after restart. +## +## Every rule here mirrors `src/godot_ai/release_verify.py`. The two must stay +## byte-for-byte compatible: the release pipeline produces the manifest with +## Python's `_canonical()` and the installed-tree hash with Python's +## `hash_tree()`, and this file re-derives both from raw bytes. Every function +## fails closed with a specific error string and never raises. + +const REPOSITORY := "hi-godot/godot-ai" +const CHANNEL := "stable" +const ASSET_NAME := "godot-ai-v4-plugin.zip" +const PLUGIN_PREFIX := "addons/godot_ai/" +const PLUGIN_CONFIG := "addons/godot_ai/plugin.cfg" +const SCHEMA_VERSION := 1 +const MAX_FILES := 4096 +const MAX_FILE_SIZE := 64 * 1024 * 1024 +const MAX_ARCHIVE_SIZE := 64 * 1024 * 1024 +const MAX_TREE_SIZE := MAX_ARCHIVE_SIZE +const MAX_MANIFEST_SIZE := 1024 * 1024 +## Signature length for the production RSA-4096 key. The check itself is +## derived from the supplied key's modulus so a smaller injected test key +## still verifies its own (shorter) signatures. +const SIGNATURE_SIZE := 512 +const MIN_RSA_MODULUS_BYTES := 256 +## Largest integer JSON can round-trip through Godot's double-based parser. +const MAX_SAFE_INTEGER := 9007199254740992 +const READ_CHUNK := 1024 * 1024 + +const ROOT_KEYS := [ + "asset", "channel", "inventory", "repository", "schema_version", "source_commit", "tag", "version", +] +const ASSET_KEYS := ["name", "sha256", "size"] +const ROW_KEYS := ["path", "sha256", "size"] +const RESERVED_NAMES := [ + "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "COM¹", "COM²", "COM³", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "LPT¹", "LPT²", "LPT³", +] +const _UNSAFE_PATH_CHARS := "<>:\"|?*" +## DER-encoded AlgorithmIdentifier for rsaEncryption with NULL parameters. +const _RSA_ALGORITHM := [0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00] + + +# ----- public API ------------------------------------------------------- + + +## Authenticate raw manifest bytes against a detached signature and the +## caller's explicit expectations. `expected` carries `repository` and +## `channel`, optionally `version` (must equal the manifest's), and optionally +## `current_version` (the manifest must be strictly newer). Returns +## `{"ok": bool, "error": String, "manifest": Dictionary}`; the returned +## manifest has integer sizes normalised to `int`. +static func verify_manifest( + manifest_bytes: PackedByteArray, + signature: PackedByteArray, + expected: Dictionary, + public_key_pem: String, +) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + if manifest_bytes.is_empty() or manifest_bytes.size() > MAX_MANIFEST_SIZE: + result.error = "manifest: expected 1..%d bytes, got %d" % [MAX_MANIFEST_SIZE, manifest_bytes.size()] + return result + if not is_valid_utf8(manifest_bytes): + result.error = "manifest: not valid UTF-8" + return result + var json := JSON.new() + var parse_error := json.parse(manifest_bytes.get_string_from_utf8()) + if parse_error != OK: + result.error = "manifest: invalid JSON: %s (line %d)" % [json.get_error_message(), json.get_error_line()] + return result + var canonical := canonical_json(json.data) + if not bool(canonical.ok): + result.error = "manifest: encoding is not canonical JSON: %s" % str(canonical.error) + return result + if canonical.bytes != manifest_bytes: + result.error = "manifest: encoding is not canonical JSON" + return result + if not json.data is Dictionary: + result.error = "manifest: expected a JSON object" + return result + var validated := validate_manifest_dict(json.data) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var manifest: Dictionary = validated.manifest + var identity_error := _check_identity(manifest, expected) + if not identity_error.is_empty(): + result.error = identity_error + return result + var signature_error := verify_signature(manifest_bytes, signature, public_key_pem) + if not signature_error.is_empty(): + result.error = signature_error + return result + result.ok = true + result.manifest = manifest + return result + + +## Check a zip on disk against an already-verified manifest: whole-file size +## and SHA-256 against `manifest.asset`, then the entry list against the +## inventory (exact names, exact order, no directory entries), then every +## entry's size and SHA-256 against its row. Returns `{"ok", "error"}`. +static func verify_archive(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + var validated := validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var asset: Dictionary = normalised["asset"] + var hashed := sha256_file(zip_path) + if not bool(hashed.ok): + result.error = "archive: %s" % str(hashed.error) + return result + if int(hashed["size"]) != int(asset["size"]): + result.error = "archive: size %d differs from signed manifest size %d" % [int(hashed["size"]), int(asset["size"])] + return result + if str(hashed.sha256) != str(asset["sha256"]): + result.error = "archive: SHA-256 differs from signed manifest" + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "archive: cannot open zip: %s" % error_string(open_error) + return result + var entries := reader.get_files() + var rows: Array = normalised["inventory"] + var expected_paths: Array[String] = [] + var expected_rows := {} + for row in rows: + expected_paths.append(str(row["path"])) + expected_rows[str(row["path"])] = row + var listing_error := _check_entry_names(entries, expected_paths, expected_rows) + if not listing_error.is_empty(): + reader.close() + result.error = listing_error + return result + var total := 0 + var config := PackedByteArray() + for entry in entries: + var row: Dictionary = expected_rows[entry] + var data := reader.read_file(entry) + if data.size() != int(row["size"]): + reader.close() + result.error = "archive entry %s: size %d differs from inventory size %d" % [entry, data.size(), int(row["size"])] + return result + if sha256_bytes(data) != str(row["sha256"]): + reader.close() + result.error = "archive entry %s: content differs from inventory" % entry + return result + total += data.size() + if total > MAX_TREE_SIZE: + reader.close() + result.error = "archive: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if entry == PLUGIN_CONFIG: + config = data + reader.close() + var config_version := plugin_cfg_version(config) + if config_version != str(normalised["version"]): + result.error = "archive: plugin.cfg version %s differs from signed manifest version %s" % [ + config_version if not config_version.is_empty() else "(unreadable)", str(normalised["version"]), + ] + return result + result.ok = true + return result + + +## Hash every regular file under `root` recursively. Returns +## `{"ok", "error", "files": {relative_path: {"size", "sha256"}}, "tree_sha256"}`. +## +## Tree-hash algorithm (shared verbatim with Python `release_verify.hash_tree`): +## relative POSIX paths sorted by UTF-8 byte order; for each file one line +## `sha256 + " " + str(size) + " " + path + "\n"`; `tree_sha256` is the hex +## SHA-256 of those lines concatenated. Symlinks anywhere in the tree fail. +static func hash_tree(root: String) -> Dictionary: + var result := {"ok": false, "error": "", "files": {}, "tree_sha256": ""} + var root_abs := _absolute(root) + if not DirAccess.dir_exists_absolute(root_abs): + result.error = "tree: directory does not exist: %s" % root_abs + return result + var files := {} + var pending: Array[String] = [""] + var total := 0 + while not pending.is_empty(): + var relative: String = pending.pop_back() + var directory_abs := root_abs if relative.is_empty() else root_abs.path_join(relative) + var directory := DirAccess.open(directory_abs) + if directory == null: + result.error = "tree: cannot open %s: %s" % [directory_abs, error_string(DirAccess.get_open_error())] + return result + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_directories(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + pending.append(name if relative.is_empty() else relative + "/" + name) + for name in directory.get_files(): + var child_abs := directory_abs.path_join(name) + if directory.is_link(child_abs): + result.error = "tree: link is not allowed: %s" % child_abs + return result + var hashed := sha256_file(child_abs) + if not bool(hashed.ok): + result.error = "tree: %s" % str(hashed.error) + return result + total += int(hashed["size"]) + if files.size() >= MAX_FILES or total > MAX_TREE_SIZE: + result.error = "tree: exceeds %d files or %d bytes" % [MAX_FILES, MAX_TREE_SIZE] + return result + var relative_path := name if relative.is_empty() else relative + "/" + name + files[relative_path] = {"size": int(hashed["size"]), "sha256": str(hashed.sha256)} + var sorted_paths: Array[String] = [] + for path in files: + sorted_paths.append(str(path)) + sorted_paths.sort() + var ordered := {} + for path in sorted_paths: + ordered[path] = files[path] + result.files = ordered + result.tree_sha256 = tree_hash_from_files(ordered) + result.ok = true + return result + + +## The tree hash a correct extraction of `manifest.inventory` must produce: +## the same line algorithm as `hash_tree` over the inventory rows with the +## `addons/godot_ai/` prefix stripped. Only `inventory` is read, and row +## order does not matter. Returns "" for a malformed inventory or a row +## outside `addons/godot_ai/`, which can never equal a real hash. +static func inventory_tree_hash(manifest: Dictionary) -> String: + if not manifest.has("inventory") or not manifest["inventory"] is Array: + return "" + var files := {} + for value in manifest["inventory"]: + if not value is Dictionary: + return "" + var row: Dictionary = value + if not _check_keys(row, ROW_KEYS, "row").is_empty(): + return "" + var size := _integer(row["size"], "row.size", MAX_FILE_SIZE) + if not row["path"] is String or not bool(size.ok) or not row["sha256"] is String: + return "" + var path := str(row["path"]) + var digest := str(row["sha256"]) + if not path.begins_with(PLUGIN_PREFIX) or path.length() == PLUGIN_PREFIX.length() or not _is_hex(digest, 64): + return "" + var relative := path.substr(PLUGIN_PREFIX.length()) + if files.has(relative): + return "" + files[relative] = {"size": int(size.value), "sha256": digest} + if files.is_empty(): + return "" + return tree_hash_from_files(files) + + +## Apply the tree-hash line algorithm to a `{relative_path: {size, sha256}}` +## dictionary (the `files` shape `hash_tree` returns). +static func tree_hash_from_files(files: Dictionary) -> String: + var paths: Array[String] = [] + for path in files: + paths.append(str(path)) + paths.sort() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + for path in paths: + var entry: Dictionary = files[path] + var line := "%s %d %s\n" % [str(entry["sha256"]), int(entry["size"]), path] + context.update(line.to_utf8_buffer()) + return context.finish().hex_encode() + + +## Hex SHA-256 of a byte buffer. +static func sha256_bytes(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + if not bytes.is_empty(): + context.update(bytes) + return context.finish().hex_encode() + + +## Chunked SHA-256 of a file. Returns `{"ok", "error", "size", "sha256"}`; +## files beyond MAX_FILE_SIZE fail without being read to the end. +static func sha256_file(path: String) -> Dictionary: + var result := {"ok": false, "error": "", "size": 0, "sha256": ""} + var absolute := _absolute(path) + var file := FileAccess.open(absolute, FileAccess.READ) + if file == null: + result.error = "cannot read %s: %s" % [absolute, error_string(FileAccess.get_open_error())] + return result + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + var size := 0 + while true: + var chunk := file.get_buffer(READ_CHUNK) + if chunk.is_empty(): + break + size += chunk.size() + if size > MAX_FILE_SIZE: + file.close() + result.error = "%s exceeds %d-byte bound" % [absolute, MAX_FILE_SIZE] + return result + context.update(chunk) + var read_error := file.get_error() + file.close() + if read_error != OK and read_error != ERR_FILE_EOF: + result.error = "cannot read %s: %s" % [absolute, error_string(read_error)] + return result + result.size = size + result.sha256 = context.finish().hex_encode() + result.ok = true + return result + + +## Serialise `value` exactly as Python's +## `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"` +## encodes it: sorted keys, no whitespace, integers (including integral +## floats, which is what Godot's JSON parser yields for them) as integers, +## non-ASCII unescaped. Returns `{"ok", "error", "bytes"}`. Non-integral +## numbers fail: the manifest schema has none and Godot's float formatting +## is not guaranteed to match Python's. +static func canonical_json(value: Variant) -> Dictionary: + var result := {"ok": false, "error": "", "bytes": PackedByteArray()} + var text := _canonical_value(value, result) + if not str(result.error).is_empty(): + return result + result.bytes = (text + "\n").to_utf8_buffer() + result.ok = true + return result + + +## Strict UTF-8 validation: rejects overlong forms, surrogates, and code +## points beyond U+10FFFF, the same input Python's `bytes.decode` rejects. +static func is_valid_utf8(bytes: PackedByteArray) -> bool: + var index := 0 + var size := bytes.size() + while index < size: + var lead := bytes[index] + if lead < 0x80: + index += 1 + continue + var need := 0 + var code := 0 + if lead >= 0xC2 and lead <= 0xDF: + need = 1 + code = lead & 0x1F + elif lead >= 0xE0 and lead <= 0xEF: + need = 2 + code = lead & 0x0F + elif lead >= 0xF0 and lead <= 0xF4: + need = 3 + code = lead & 0x07 + else: + return false + if index + need >= size: + return false + for offset in range(1, need + 1): + var continuation := bytes[index + offset] + if (continuation & 0xC0) != 0x80: + return false + code = (code << 6) | (continuation & 0x3F) + if need == 2 and code < 0x800: + return false + if need == 3 and (code < 0x10000 or code > 0x10FFFF): + return false + if code >= 0xD800 and code <= 0xDFFF: + return false + index += need + 1 + return true + + +## Validate a parsed manifest's schema, bounds, and inventory path safety +## without checking identity or the signature. Returns +## `{"ok", "error", "manifest"}` where `manifest` is a normalised copy with +## integer fields as `int`. +static func validate_manifest_dict(root: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "manifest": {}} + var root_error := _check_keys(root, ROOT_KEYS, "manifest") + if not root_error.is_empty(): + result.error = root_error + return result + var schema := _integer(root["schema_version"], "manifest.schema_version", SCHEMA_VERSION) + if not bool(schema.ok): + result.error = str(schema.error) + return result + if int(schema.value) != SCHEMA_VERSION: + result.error = "manifest.schema_version: expected %d" % SCHEMA_VERSION + return result + var manifest := {"schema_version": SCHEMA_VERSION} + for key in ["repository", "channel", "tag", "version", "source_commit"]: + var text := _string(root[key], "manifest.%s" % key) + if not bool(text.ok): + result.error = str(text.error) + return result + manifest[key] = str(text.value) + if not root["asset"] is Dictionary: + result.error = "manifest.asset: expected object" + return result + var asset: Dictionary = root["asset"] + var asset_error := _check_keys(asset, ASSET_KEYS, "manifest.asset") + if not asset_error.is_empty(): + result.error = asset_error + return result + if not asset["name"] is String or str(asset["name"]) != ASSET_NAME: + result.error = "manifest.asset.name: expected %s" % ASSET_NAME + return result + var asset_size := _integer(asset["size"], "manifest.asset.size", MAX_ARCHIVE_SIZE) + if not bool(asset_size.ok): + result.error = str(asset_size.error) + return result + if not asset["sha256"] is String or not _is_hex(str(asset["sha256"]), 64): + result.error = "manifest.asset.sha256: expected 64 lowercase hex characters" + return result + manifest["asset"] = {"name": ASSET_NAME, "size": int(asset_size.value), "sha256": str(asset["sha256"])} + if not root["inventory"] is Array: + result.error = "manifest.inventory: expected array" + return result + var rows: Array = root["inventory"] + var inventory: Array = [] + var paths: Array[String] = [] + var total := 0 + for index in rows.size(): + var context := "manifest.inventory[%d]" % index + if not rows[index] is Dictionary: + result.error = "%s: expected object" % context + return result + var row: Dictionary = rows[index] + var row_error := _check_keys(row, ROW_KEYS, context) + if not row_error.is_empty(): + result.error = row_error + return result + var path := _string(row["path"], context + ".path") + if not bool(path.ok): + result.error = str(path.error) + return result + var size := _integer(row["size"], context + ".size", MAX_FILE_SIZE) + if not bool(size.ok): + result.error = str(size.error) + return result + if not row["sha256"] is String or not _is_hex(str(row["sha256"]), 64): + result.error = "%s.sha256: expected 64 lowercase hex characters" % context + return result + total += int(size.value) + paths.append(str(path.value)) + inventory.append({"path": str(path.value), "size": int(size.value), "sha256": str(row["sha256"])}) + var paths_error := _validate_paths(paths, "manifest.inventory") + if not paths_error.is_empty(): + result.error = paths_error + return result + if total > MAX_TREE_SIZE: + result.error = "manifest.inventory: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE + return result + if not paths.has(PLUGIN_CONFIG): + result.error = "manifest.inventory: missing %s" % PLUGIN_CONFIG + return result + manifest["inventory"] = inventory + result.manifest = manifest + result.ok = true + return result + + +## Verify a PKCS#1 v1.5 SHA-256 signature over `message` with a PEM +## SubjectPublicKeyInfo. Returns "" on success, otherwise the reason. +static func verify_signature(message: PackedByteArray, signature: PackedByteArray, public_key_pem: String) -> String: + var modulus_size := rsa_modulus_size(public_key_pem) + if modulus_size < MIN_RSA_MODULUS_BYTES: + return "public key: expected a PEM RSA SubjectPublicKeyInfo of at least %d bits" % (MIN_RSA_MODULUS_BYTES * 8) + if signature.size() != modulus_size: + return "manifest signature: expected exactly %d bytes, got %d" % [modulus_size, signature.size()] + var key := CryptoKey.new() + if key.load_from_string(public_key_pem, true) != OK: + return "public key: could not be loaded" + var crypto := Crypto.new() + var digest := PackedByteArray() + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(message) + digest = context.finish() + if not crypto.verify(HashingContext.HASH_SHA256, digest, signature, key): + return "manifest signature: RSA PKCS#1 v1.5 SHA-256 verification failed" + return "" + + +## Byte length of the RSA modulus inside a PEM SubjectPublicKeyInfo, or 0 +## when the PEM is not a well-formed rsaEncryption public key. +static func rsa_modulus_size(public_key_pem: String) -> int: + var lines := public_key_pem.replace("\r\n", "\n").strip_edges().split("\n") + if ( + lines.size() < 3 + or lines[0].strip_edges() != "-----BEGIN PUBLIC KEY-----" + or lines[lines.size() - 1].strip_edges() != "-----END PUBLIC KEY-----" + ): + return 0 + var encoded := "" + for index in range(1, lines.size() - 1): + encoded += lines[index].strip_edges() + if encoded.is_empty() or not _is_base64(encoded): + return 0 + var der := Marshalls.base64_to_raw(encoded) + if der.is_empty(): + return 0 + var outer := _der_item(der, 0, 0x30) + if not bool(outer.ok) or int(outer.end) != der.size(): + return 0 + var body: PackedByteArray = outer.value + var algorithm := _der_item(body, 0, 0x30) + if not bool(algorithm.ok) or algorithm.value != PackedByteArray(_RSA_ALGORITHM): + return 0 + var bits := _der_item(body, int(algorithm.end), 0x03) + if not bool(bits.ok) or int(bits.end) != body.size(): + return 0 + var bit_string: PackedByteArray = bits.value + if bit_string.is_empty() or bit_string[0] != 0: + return 0 + var key := _der_item(bit_string, 1, 0x30) + if not bool(key.ok) or int(key.end) != bit_string.size(): + return 0 + var key_body: PackedByteArray = key.value + var modulus := _der_item(key_body, 0, 0x02) + if not bool(modulus.ok): + return 0 + var raw: PackedByteArray = modulus.value + if raw.is_empty() or raw[0] & 0x80: + return 0 + if raw.size() > 1 and raw[0] == 0: + if not (raw[1] & 0x80): + return 0 + raw = raw.slice(1) + var exponent := _der_item(key_body, int(modulus.end), 0x02) + if not bool(exponent.ok) or int(exponent.end) != key_body.size() or exponent.value.is_empty(): + return 0 + return raw.size() + + +## The single quoted `version="..."` line of a plugin.cfg, or "" when the +## file does not carry exactly one. +static func plugin_cfg_version(data: PackedByteArray) -> String: + if not is_valid_utf8(data): + return "" + var expression := RegEx.new() + if expression.compile("(?m)^\\s*version\\s*=\\s*\"([^\"]+)\"\\s*$") != OK: + return "" + var matches := expression.search_all(data.get_string_from_utf8()) + if matches.size() != 1: + return "" + return matches[0].get_string(1) + + +## Parse a dotted `major.minor.patch` version into three ints, or [] when +## it is not exactly three unsigned decimal components. +static func version_parts(version: String) -> Array[int]: + var parts := version.split(".") + if parts.size() != 3: + return [] + var numbers: Array[int] = [] + for part in parts: + if not _all_digits(part): + return [] + numbers.append(int(part)) + return numbers + + +## True when `candidate` is strictly newer than `current` by tuple order. +static func is_newer_version(candidate: String, current: String) -> bool: + var left := version_parts(candidate) + var right := version_parts(current) + if left.is_empty() or right.is_empty(): + return false + for index in 3: + if left[index] != right[index]: + return left[index] > right[index] + return false + + +# ----- identity --------------------------------------------------------- + + +static func _check_identity(manifest: Dictionary, expected: Dictionary) -> String: + var repository := str(manifest["repository"]) + if repository != REPOSITORY or repository != str(expected.get("repository", REPOSITORY)): + return "manifest.repository: expected %s" % REPOSITORY + var channel := str(manifest["channel"]) + if channel != CHANNEL or channel != str(expected.get("channel", CHANNEL)): + return "manifest.channel: v4 releases are stable-only" + var version := str(manifest["version"]) + var parts := version_parts(version) + if parts.is_empty() or parts[0] != 4: + return "manifest.version: expected a 4.x.y version" + if str(manifest["tag"]) != "v" + version: + return "manifest.tag: expected v%s" % version + if not _is_hex(str(manifest["source_commit"]), 40): + return "manifest.source_commit: expected 40 lowercase hex characters" + var expected_version := str(expected.get("version", "")) + if not expected_version.is_empty() and expected_version != version: + return "manifest.version: %s does not match the expected release %s" % [version, expected_version] + var expected_tag := str(expected.get("tag", "")) + if not expected_tag.is_empty() and expected_tag != str(manifest["tag"]): + return "manifest.tag: %s does not match the expected tag %s" % [str(manifest["tag"]), expected_tag] + var expected_commit := str(expected.get("source_commit", "")) + if not expected_commit.is_empty() and expected_commit != str(manifest["source_commit"]): + return "manifest.source_commit: does not match the expected commit" + var current_version := str(expected.get("current_version", "")) + if not current_version.is_empty(): + if version_parts(current_version).is_empty(): + return "current_version: %s is not a dotted major.minor.patch version" % current_version + if not is_newer_version(version, current_version): + return "manifest.version: %s is not newer than the installed %s" % [version, current_version] + return "" + + +# ----- archive listing -------------------------------------------------- + + +static func _check_entry_names( + entries: PackedStringArray, expected_paths: Array[String], expected_rows: Dictionary +) -> String: + var seen := {} + for entry in entries: + if entry.ends_with("/"): + return "archive: directory entry is not allowed: %s" % entry + if not expected_rows.has(entry): + return "archive: entry is not in the signed inventory: %s" % entry + if seen.has(entry): + return "archive: duplicate entry: %s" % entry + seen[entry] = true + for path in expected_paths: + if not seen.has(path): + return "archive: inventory file is missing from the zip: %s" % path + if Array(entries) != Array(expected_paths): + return "archive: entries are not in the sorted inventory order" + return "" + + +# ----- path safety ------------------------------------------------------ + + +static func _validate_paths(paths: Array[String], context: String) -> String: + if paths.is_empty() or paths.size() > MAX_FILES: + return "%s: expected 1..%d paths" % [context, MAX_FILES] + var sorted := paths.duplicate() + sorted.sort() + var exact := {} + var identities := {} + for index in paths.size(): + var path := paths[index] + var path_error := _check_path(path, "%s[%d]" % [context, index]) + if not path_error.is_empty(): + return path_error + if exact.has(path): + return "%s: duplicate path %s" % [context, path] + exact[path] = true + var identity := path.to_lower() + if identities.has(identity): + return "%s: case-colliding paths %s and %s" % [context, str(identities[identity]), path] + identities[identity] = path + if paths != sorted: + return "%s: paths are not in bytewise order" % context + for path in paths: + var parent := path.get_base_dir() + while parent.contains("/"): + if exact.has(parent): + return "%s: file/ancestor collision at %s" % [context, parent] + parent = parent.get_base_dir() + return "" + + +static func _check_path(path: String, context: String) -> String: + if not path.begins_with(PLUGIN_PREFIX) or path.ends_with("/") or path.contains("\\"): + return "%s: path must be a file beneath %s: %s" % [context, PLUGIN_PREFIX, path] + for part in path.split("/"): + if part.is_empty() or part == "." or part == ".." or part.ends_with(" ") or part.ends_with("."): + return "%s: unsafe path component in %s" % [context, path] + for index in part.length(): + var code := part.unicode_at(index) + if code < 32 or _UNSAFE_PATH_CHARS.contains(char(code)): + return "%s: unsafe path character in %s" % [context, path] + var stem := part.split(".", true, 1)[0] + if RESERVED_NAMES.has(stem.to_upper()): + return "%s: reserved path component in %s" % [context, path] + return "" + + +# ----- schema helpers --------------------------------------------------- + + +static func _check_keys(value: Dictionary, expected_keys: Array, context: String) -> String: + var keys := value.keys() + for key in keys: + if not key is String: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + keys.sort() + if keys != expected_keys: + return "%s: expected object with exactly keys %s" % [context, str(expected_keys)] + return "" + + +static func _string(value: Variant, context: String) -> Dictionary: + if not value is String or str(value).is_empty(): + return {"ok": false, "error": "%s: expected non-empty string" % context, "value": ""} + return {"ok": true, "error": "", "value": str(value)} + + +## Accept `int`, or the integral `float` Godot's JSON parser yields, within +## `[0, maximum]`. Booleans are rejected. +static func _integer(value: Variant, context: String, maximum: int) -> Dictionary: + var failure := {"ok": false, "error": "%s: expected integer in [0, %d]" % [context, maximum], "value": 0} + var number := 0 + if value is int: + number = value + elif value is float: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + return failure + number = int(real) + else: + return failure + if number < 0 or number > maximum: + return failure + return {"ok": true, "error": "", "value": number} + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _all_digits(text: String) -> bool: + if text.is_empty(): + return false + for index in text.length(): + var code := text.unicode_at(index) + if code < 48 or code > 57: + return false + return true + + +static func _is_base64(text: String) -> bool: + for index in text.length(): + var code := text.unicode_at(index) + var ok := ( + (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or (code >= 48 and code <= 57) + or code == 43 or code == 47 or code == 61 + ) + if not ok: + return false + return true + + +# ----- canonical JSON --------------------------------------------------- + + +static func _canonical_value(value: Variant, result: Dictionary) -> String: + match typeof(value): + TYPE_NIL: + return "null" + TYPE_BOOL: + return "true" if value else "false" + TYPE_INT: + return String.num_int64(value) + TYPE_FLOAT: + var real: float = value + if real != floor(real) or absf(real) > MAX_SAFE_INTEGER: + result.error = "non-integer number %s" % str(real) + return "" + return String.num_int64(int(real)) + TYPE_STRING, TYPE_STRING_NAME: + return _escape_json_string(str(value)) + TYPE_ARRAY: + var items: Array[String] = [] + for item in value: + items.append(_canonical_value(item, result)) + if not str(result.error).is_empty(): + return "" + return "[" + ",".join(items) + "]" + TYPE_DICTIONARY: + var dictionary: Dictionary = value + var keys: Array = dictionary.keys() + for key in keys: + if not key is String: + result.error = "non-string object key" + return "" + keys.sort() + var members: Array[String] = [] + for key in keys: + var member := _canonical_value(dictionary[key], result) + if not str(result.error).is_empty(): + return "" + members.append(_escape_json_string(str(key)) + ":" + member) + return "{" + ",".join(members) + "}" + _: + result.error = "unsupported JSON value type %s" % type_string(typeof(value)) + return "" + + +static func _escape_json_string(text: String) -> String: + var out := "\"" + for index in text.length(): + var code := text.unicode_at(index) + match code: + 0x22: + out += "\\\"" + 0x5C: + out += "\\\\" + 0x0A: + out += "\\n" + 0x0D: + out += "\\r" + 0x09: + out += "\\t" + 0x08: + out += "\\b" + 0x0C: + out += "\\f" + _: + if code < 0x20: + out += "\\u%04x" % code + else: + out += char(code) + return out + "\"" + + +# ----- DER -------------------------------------------------------------- + + +static func _der_item(data: PackedByteArray, offset: int, tag: int) -> Dictionary: + var failure := {"ok": false, "value": PackedByteArray(), "end": 0} + var size := data.size() + if offset >= size or data[offset] != tag: + return failure + offset += 1 + if offset >= size: + return failure + var first := data[offset] + offset += 1 + var length := 0 + if first < 0x80: + length = first + else: + var count := first & 0x7F + if count < 1 or count > 4 or offset + count > size or data[offset] == 0: + return failure + for index in count: + length = (length << 8) | data[offset + index] + offset += count + if length < 0x80: + return failure + var end := offset + length + if end > size: + return failure + return {"ok": true, "value": data.slice(offset, end), "end": end} + + +# ----- paths ------------------------------------------------------------ + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/release_verifier.gd.uid b/addons/godot_ai/utils/release_verifier.gd.uid new file mode 100644 index 0000000..34abbfe --- /dev/null +++ b/addons/godot_ai/utils/release_verifier.gd.uid @@ -0,0 +1 @@ +uid://cvjv1388502ne diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd deleted file mode 100644 index cfac90f..0000000 --- a/addons/godot_ai/utils/resource_io.gd +++ /dev/null @@ -1,277 +0,0 @@ -@tool -class_name McpResourceIO -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Shared helpers for "save a Resource to .tres" and the mutually-exclusive -## path-vs-resource_path param validation that every resource-authoring -## handler needs. Extracted to remove 4-way duplication across -## resource_handler, environment_handler, texture_handler, and curve_handler. -## Also home to the shared write-a-text-file path + deferred import-settle -## completion used by both script_handler.create_script and -## filesystem_handler.write_file (#714). - -# Bounded settle window for `ResourceLoader.exists(path)` after a fresh text -# write registers with the filesystem, so an agent calling -# create_script/write_file -> attach_script back-to-back doesn't race the -# editor's import pipeline (#261, extended to write_file by #714). Polled once -# per frame, with an elapsed-time cap below the dispatcher's deferred timeouts -# for both commands. If import is still not visible at the cap, we still -# return committed data instead of letting the already-written file surface -# as DEFERRED_TIMEOUT. -const IMPORT_SETTLE_MAX_FRAMES := 300 -const IMPORT_SETTLE_MAX_MSEC := 3500 - - -## Validate that exactly one of {path, resource_path} is provided. -## -## When `require_property` is true (default), also requires a non-empty -## `property` param when `path` is given — this matches the semantics of -## "assign a resource to node.property" (resource_create, texture tools, -## curve_set_points). Pass false for tools where the path itself IS the -## target (environment_create assigning to WorldEnvironment.environment). -## -## Returns null on success or an error dict on failure. -static func validate_home(params: Dictionary, require_property: bool = true) -> Variant: - var node_path: String = params.get("path", "") - var property: String = params.get("property", "") - var resource_path: String = params.get("resource_path", "") - var has_node_target := not node_path.is_empty() - var has_file_target := not resource_path.is_empty() - - if has_node_target and has_file_target: - var both_msg := "Provide either path+property or resource_path, not both" if require_property else "Provide either path or resource_path, not both" - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, both_msg) - if not has_node_target and not has_file_target: - var none_msg := "Must provide either path+property (assign inline) or resource_path (save .tres)" if require_property else "Must provide either path or resource_path" - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, none_msg) - if require_property and has_node_target and property.is_empty(): - return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Missing required param: property (required when path is given)") - return null - - -## Save `res` to `resource_path` as a .tres/.res file. -## -## Handles: res:// prefix validation, overwrite check, parent-directory -## creation, ResourceSaver.save error reporting, and the post-save -## EditorFileSystem.update_file() so the dock picks up the change. -## -## `label` is the human-readable resource-kind for error messages (e.g. -## "Environment", "Gradient texture", "Curve"). `extra_fields` is merged -## into the success response alongside the standard fields -## (`resource_path`, `overwritten`, `undoable: false`, `reason`). Passing -## a `reason` key in `extra_fields` overrides the default — useful for -## tools that edit existing files rather than creating fresh ones. -## -## `pause_target` should be the handler's `McpConnection`. When supplied, -## `pause_processing` is flipped on around `ResourceSaver.save()` so the -## dispatcher's WebSocket pump can't re-enter while Godot pumps -## `Main::iteration()` for the resource-save's progress UI / script-class -## update task. Without this guard a queued command landing during the -## save can trigger another `save_to_disk` that tries to add the same -## `update_scripts_classes` editor task — "Task already exists" → null -## deref → SIGSEGV. Same family of bug as godotengine/godot#118545 and -## the same mitigation as `SceneHandler`'s `save_scene*` wraps. See -## issue #288. -## -## Returns either an error dict or a {"data": {...}} success dict — ready -## for the handler to return directly. -static func save_to_disk( - res: Resource, - resource_path: String, - overwrite: bool, - label: String, - extra_fields: Dictionary = {}, - pause_target: McpConnection = null, -) -> Dictionary: - var path_err = McpPathValidator.path_error(resource_path, "resource_path", true) - if path_err != null: - return path_err - - var existed_before := FileAccess.file_exists(resource_path) - if existed_before and not overwrite: - return ErrorCodes.make( - ErrorCodes.INVALID_PARAMS, - "%s already exists at %s (pass overwrite=true to replace)" % [label, resource_path] - ) - # Captured BEFORE the overwrite below so a resave of an already-uid'd file - # (overwrite=true) can restore its own uid instead of losing it — see - # ensure_uid's doc comment. - var prior_uid := ResourceLoader.get_resource_uid(resource_path) if existed_before else ResourceUID.INVALID_ID - - var dir_path := resource_path.get_base_dir() - var mkdir_err := DirAccess.make_dir_recursive_absolute(dir_path) - if mkdir_err != OK and mkdir_err != ERR_ALREADY_EXISTS: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to create directory %s: %s" % [dir_path, error_string(mkdir_err)] - ) - - if pause_target != null: - pause_target.pause_processing = true - var save_err := ResourceSaver.save(res, resource_path) - if pause_target != null: - pause_target.pause_processing = false - if save_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Failed to save %s to %s: %s" % [label, resource_path, error_string(save_err)] - ) - var uid_err := ensure_uid(resource_path, prior_uid) - if uid_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "%s saved to %s but failed to write its uid: %s" % [label, resource_path, error_string(uid_err)] - ) - - var efs := EditorInterface.get_resource_filesystem() - if efs != null: - efs.update_file(resource_path) - - var data := { - "resource_path": resource_path, - "overwritten": existed_before, - "undoable": false, - "reason": "File creation is persistent; delete the file manually to revert", - } - attach_cleanup_hint(data, existed_before, [resource_path]) - # merge with overwrite=true so callers (e.g. curve_set_points editing an - # existing .tres) can supply a domain-specific `reason`. - data.merge(extra_fields, true) - return {"data": data} - - -## Save `res` to `resource_path` with the same `pause_processing` re-entrancy -## guard as `save_to_disk` (see its doc for the #288 SIGSEGV background), for -## call sites that need to pick their own error handling / overwrite policy -## instead of `save_to_disk`'s full validate+mkdir+overwrite-guard bundle -## (undo/redo callables reloading-mutating-resaving an existing resource, -## `apply_to_node`'s inline-then-save branch). Returns the raw -## `ResourceSaver.save` error code. -static func guarded_save(res: Resource, resource_path: String, pause_target: McpConnection) -> int: - var prior_uid := ResourceLoader.get_resource_uid(resource_path) if FileAccess.file_exists(resource_path) else ResourceUID.INVALID_ID - if pause_target != null: - pause_target.pause_processing = true - var save_err := ResourceSaver.save(res, resource_path) - if pause_target != null: - pause_target.pause_processing = false - if save_err != OK: - return save_err - return ensure_uid(resource_path, prior_uid) - - -## Make `resource_path` carry a stable uid after a successful -## `ResourceSaver.save()`, matching what Godot's own "New Scene"/"New -## Resource" editor flows always embed. A bare `ResourceSaver.save()` call -## does neither on its own: a brand-new file gets no `uid=` at all, and -## resaving a file that already had one silently drops it (#737). Call this -## immediately after every successful save. -## -## `prior_uid` is whatever `ResourceLoader.get_resource_uid(resource_path)` -## returned BEFORE this save overwrote the file (pass `ResourceUID.INVALID_ID` -## for a brand-new path). Reusing the prior id — instead of always minting a -## fresh one — keeps any `uid://...` references elsewhere in the project -## resolving to the same file. -## -## Returns the `Error` from `ResourceSaver.set_uid()` so callers can surface a -## uid-write failure instead of silently reporting success on a file that -## didn't end up with the uid it was supposed to get. -static func ensure_uid(resource_path: String, prior_uid: int) -> Error: - var id := prior_uid - if id == ResourceUID.INVALID_ID: - id = ResourceUID.create_id() - return ResourceSaver.set_uid(resource_path, id) - - -## Attach a `cleanup.rm` hint listing `paths` to `data` — only when the call -## just created a new file (`existed_before == false`). On overwrite the field -## is omitted because the caller already had the file on disk, and handing -## them a cleanup list would invite dropping user content instead of just -## scratch artifacts. Used by write-and-return handlers (create_script, -## filesystem_write_text, resource_create/save_to_disk) so callers running -## transient smoke tests can rm artifacts without tracking paths. See #82. -static func attach_cleanup_hint(data: Dictionary, existed_before: bool, paths: Array) -> void: - if existed_before: - return - data["cleanup"] = {"rm": paths} - - -## Shared write-a-text-file path (#714): parent-directory mkdir, write + -## flush with an explicit error check so a truncated write (disk full, -## permission flip mid-write) surfaces as an error instead of plain success. -## Deliberately does NOT call `EditorFileSystem.update_file()` — callers -## register the file themselves after assembling their response fields, so -## the registration comment (the dsarno/godot#6 scan-stacking rationale) -## stays next to the call. Returns null on success or an error dict ready -## to return from the handler. -static func write_text_to_disk(path: String, content: String) -> Variant: - var dir_path := path.get_base_dir() - if not DirAccess.dir_exists_absolute(dir_path): - var err := DirAccess.make_dir_recursive_absolute(dir_path) - if err != OK: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to create directory: %s" % dir_path) - - var file := FileAccess.open(path, FileAccess.WRITE) - if file == null: - return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to open file for writing: %s" % path) - - file.store_string(content) - file.flush() - var write_err := file.get_error() - file.close() - if write_err != OK: - return ErrorCodes.make( - ErrorCodes.INTERNAL_ERROR, - "Write failed for %s (%s); file may be truncated" % [path, error_string(write_err)] - ) - return null - - -# `static` is load-bearing: the deferred completion captures no `self`, so the -# coroutine survives even if the calling handler RefCounted is freed mid-await. -# Under concurrent create storms with editor_reload_plugin fired during the -# burst, an instance-method coroutine is otherwise GC'd between `await` and -# resume, producing "Resumed function ... after await, but class instance is -# gone" errors and dropping the response. Keep this function static and -# parameterise everything it needs explicitly — do not reference instance -# state. Shared by create_script and write_file's fresh-`.gd` path (#714). -static func finish_text_write_deferred( - connection: McpConnection, - request_id: String, - path: String, - data: Dictionary, -) -> void: - if not is_instance_valid(connection): - return - var tree := connection.get_tree() - if tree == null: - return - var deadline_ms := Time.get_ticks_msec() + IMPORT_SETTLE_MAX_MSEC - # Let _dispatch() return DEFERRED_RESPONSE and register the request before - # this coroutine can send a committed result. ResourceLoader.exists(path) - # may already be true on fast imports; without this handoff the connection - # treats the response as late/unregistered and drops it, then the dispatcher - # times out a file that was already written (#324). The deadline starts - # before this await so a slow handoff frame is counted against the bounded - # settle window. - await tree.process_frame - var frames := 0 - while ( - frames < IMPORT_SETTLE_MAX_FRAMES - and Time.get_ticks_msec() < deadline_ms - and not ResourceLoader.exists(path) - ): - await tree.process_frame - frames += 1 - # If the plugin tears down (_exit_tree frees the connection) during the - # await, is_instance_valid() goes false and we drop the response silently — - # the server's request timeout will surface the failure to the caller. - if not is_instance_valid(connection): - return - var payload := data.duplicate() - var settled := ResourceLoader.exists(path) - payload["import_settled"] = settled - payload["import_settle"] = "settled" if settled else "timeout" - payload["import_pending"] = not settled - connection.send_deferred_response(request_id, {"data": payload}) diff --git a/addons/godot_ai/utils/resource_io.gd.uid b/addons/godot_ai/utils/resource_io.gd.uid deleted file mode 100644 index 94da4c9..0000000 --- a/addons/godot_ai/utils/resource_io.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://de2rwdoa4wabf diff --git a/addons/godot_ai/utils/scene_path.gd b/addons/godot_ai/utils/scene_path.gd deleted file mode 100644 index 782aea1..0000000 --- a/addons/godot_ai/utils/scene_path.gd +++ /dev/null @@ -1,155 +0,0 @@ -@tool -class_name McpScenePath -extends RefCounted - -const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") - -## Utility for converting between Godot internal node paths and clean -## scene-relative paths like /Main/Camera3D. - - -## Return a clean path relative to the scene root (e.g. /Main/Camera3D). -## Returns "" when `node` is not the scene root or a descendant of it — -## without the ancestry guard, get_path_to() returns an empty NodePath that -## concatenates into a plausible-looking but invalid "/Main/". -static func from_node(node: Node, scene_root: Node) -> String: - if scene_root == null or node == null: - return "" - if node == scene_root: - return "/" + scene_root.name - if not scene_root.is_ancestor_of(node): - return "" - var relative := scene_root.get_path_to(node) - return "/" + scene_root.name + "/" + str(relative) - - -## Resolve a clean scene path like "/Main/Camera3D" to the actual node. -## -## Accepts forms relative to the edited scene root: -## "/Main" — explicit root prefix (canonical) -## "/Main/Camera3D" — descendant path -## "Camera3D" — bare relative to scene_root -## "World/Ground" — nested bare relative to scene_root -## -## Also accepts SceneTree-style "/root/[/...]" as an alias for -## the edited scene root. Agents reach for /root/Foo right after creating a -## scene because that's where scenes live at runtime; we honor it so the call -## doesn't fail with a confusing "not found" error. The alias only kicks in -## when the segment after /root matches the scene root's name — paths like -## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor) -## fall through to the absolute-path fallback unchanged. -static func resolve(scene_path: String, scene_root: Node) -> Node: - if scene_root == null: - return null - - ## Bare "/" alias: the most natural first guess for "the scene root". - ## There is exactly one edited-scene root, so the alias is unambiguous; - ## without it, "/" falls through to get_node_or_null("/") → null and - ## every call costs the agent a NODE_NOT_FOUND round trip (issue #624). - if scene_path == "/": - return scene_root - - ## /root/[/...] alias: strip the /root prefix and recurse. - ## Match the scene root by name explicitly so we don't capture editor- - ## internal paths that legitimately live under /root. - var alias_prefix := "/root/" + scene_root.name - if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"): - return resolve(scene_path.substr(5), scene_root) # keep leading slash - - var root_prefix := "/" + scene_root.name - if scene_path == root_prefix: - return scene_root - if scene_path.begins_with(root_prefix + "/"): - var relative := scene_path.substr(root_prefix.length() + 1) - return scene_root.get_node_or_null(relative) - - # Try as-is (relative path, or absolute SceneTree path). - return scene_root.get_node_or_null(scene_path) - - -## Return the edited scene root, or an error dict if the editor has no open -## scene or the open scene doesn't match `expected_scene_file`. -## -## `expected_scene_file` is the caller's `scene_file` parameter — an empty -## string means "target whatever is currently edited" (current behaviour, -## no guard). A non-empty value must match `scene_file_path` on the current -## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the -## caller can re-open the right scene. -## -## Shape on success: {"node": }. Shape on error matches -## `ErrorCodes.make()` so callers can propagate the result directly. -static func require_edited_scene(expected_scene_file: String) -> Dictionary: - var root := EditorInterface.get_edited_scene_root() - if root == null: - # Mirrors the structured payload that the Python-side require_writable - # gate attaches for `playing` / `importing`. Together these cover the - # three recoverable editor *states* (playing / importing / no_scene) - # — the EDITOR_NOT_READY paths an AI caller can act on. Other - # EDITOR_NOT_READY callsites describing internal-state failures - # ("EditorFileSystem not available" etc.) carry sub_code + retryable - # via ErrorCodes.make_not_ready (#651 stage 1) but intentionally - # omit the hint — there's no useful caller action to name. - var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open") - err["error"]["data"] = { - "sub_code": ErrorCodes.SUB_EDITOR_NO_SCENE, - "editor_state": "no_scene", - "retryable": false, - "hint": ( - "No scene is open. Call scene_open with a scene path " - + "(e.g. \"res://main.tscn\") before issuing scene-mutating tools." - ), - } - return err - if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file: - var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "" - return ErrorCodes.make( - ErrorCodes.EDITED_SCENE_MISMATCH, - ( - "Expected edited scene \"%s\" but \"%s\" is active. " - + "Call scene_open(\"%s\") first, or omit scene_file to target the active scene." - ) % [expected_scene_file, actual, expected_scene_file], - ) - return {"node": root} - - -## Format a "parent not found" error that names the path convention. -## Agents routinely try /root/Foo or absolute SceneTree paths; the bare -## "Parent not found: X" gave them no hint that paths are scene-relative. -## Wording is generic ("Paths are relative...") so the helper works for any -## param name (parent_path, new_parent, …). -static func format_parent_error(path: String, scene_root: Node) -> String: - if scene_root == null: - return "Parent not found: %s. No edited scene is open." % path - var root_name := str(scene_root.name) - return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name] - - -## Format a "node not found" error that names the path convention and, when -## possible, suggests a corrected path. Agents routinely pass /root/Foo -## (runtime SceneTree) or unprefixed names; the bare "Node not found: X" -## gives no hint that paths are edited-scene-relative. -## -## Suggestion logic (highest-confidence first): -## 1. /root/[/...] where is not the scene root → suggest //[/...] -## 2. path doesn't start with "/" → suggest "//" -## 3. otherwise no concrete "did you mean", just the convention reminder. -static func format_node_error(path: String, scene_root: Node) -> String: - if scene_root == null: - return "Node not found: %s. No edited scene is open." % path - var root_name := str(scene_root.name) - var suggestion := "" - - if path.begins_with("/root/"): - var after_root := path.substr(6) # "/root/" is 6 chars - # Only suggest if the segment after /root/ isn't already the scene root - # (resolve() handles /root//... as an alias, so a failure - # with that prefix means a deeper segment is wrong — no clean rewrite). - var first_seg := after_root.split("/")[0] - if first_seg != root_name and not first_seg.is_empty(): - suggestion = "/" + root_name + "/" + after_root - elif not path.begins_with("/") and not path.is_empty(): - suggestion = "/" + root_name + "/" + path - - if suggestion.is_empty(): - return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name] - return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name] diff --git a/addons/godot_ai/utils/scene_path.gd.uid b/addons/godot_ai/utils/scene_path.gd.uid deleted file mode 100644 index 85795a9..0000000 --- a/addons/godot_ai/utils/scene_path.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1irdrss0amex diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd deleted file mode 100644 index e3b0210..0000000 --- a/addons/godot_ai/utils/screenshot_encode.gd +++ /dev/null @@ -1,38 +0,0 @@ -@tool -class_name McpScreenshotEncode -extends RefCounted - -## Shared downscale + PNG + base64 block for the screenshot paths (#716). -## -## Two call sites straddle the editor/game process boundary — the editor's -## take_screenshot (editor_handler) and the game-process autoload -## (runtime/game_helper) — and were maintained as manually synchronized -## copies. Pure static, no editor APIs, so it loads safely in the game -## process too. - - -## Downscale `image` in place so its longest edge is at most -## `max_resolution` (0 = no cap), then PNG-encode. Returns -## {base64, width, height, original_width, original_height}. -static func downscale_and_encode(image: Image, max_resolution: int) -> Dictionary: - var original_width := image.get_width() - var original_height := image.get_height() - - if max_resolution > 0: - var longest := maxi(original_width, original_height) - if longest > max_resolution: - var scale := float(max_resolution) / float(longest) - ## Clamp to 1px min: extreme aspect ratios at very small - ## max_resolution could otherwise compute a zero dimension and - ## crash image.resize(). - var new_w := maxi(1, int(original_width * scale)) - var new_h := maxi(1, int(original_height * scale)) - image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) - - return { - "base64": Marshalls.raw_to_base64(image.save_png_to_buffer()), - "width": image.get_width(), - "height": image.get_height(), - "original_width": original_width, - "original_height": original_height, - } diff --git a/addons/godot_ai/utils/screenshot_encode.gd.uid b/addons/godot_ai/utils/screenshot_encode.gd.uid deleted file mode 100644 index 0bcdac2..0000000 --- a/addons/godot_ai/utils/screenshot_encode.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cl0xhoxwsbmow diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd deleted file mode 100644 index ca7bdb0..0000000 --- a/addons/godot_ai/utils/server_lifecycle.gd +++ /dev/null @@ -1,2061 +0,0 @@ -@tool -class_name McpServerLifecycleManager -extends RefCounted - -## Server spawn / stop / respawn / adopt / recover orchestration plus the -## update-reload handoff. Owns the server-state machine -## (`McpServerState`), version-check seam (`McpServerVersionCheck`), -## adoption metadata, and connection-blocked / dev-mismatch flags. -## -## State previously lived on plugin.gd; PR 6 (#297) moved it here so -## PR 7 (UpdateManager extraction) can absorb the same encapsulation -## pattern. The plugin still owns the physical editor surfaces -## (Connection, Dock, Timer, EditorSettings I/O) and exposes them via -## `_host.()` shims; the test fixtures override those shims to -## drive the manager without touching the editor. -## -## `_host` is untyped to honor the self-update field-storage policy -## plugin.gd calls out near `_connection`. -var _host - -const UvCacheCleanup := preload("res://addons/godot_ai/utils/uv_cache_cleanup.gd") -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") -const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") -const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") -const McpServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") -const McpStartupPathScript := preload("res://addons/godot_ai/utils/mcp_startup_path.gd") -const McpAdoptionLabelScript := preload("res://addons/godot_ai/utils/mcp_adoption_label.gd") -const McpServerVersionCheckScript := preload("res://addons/godot_ai/utils/server_version_check.gd") - -# ---- State (owned here, was on plugin.gd through PR 5) --------------- - -## Single source of truth for the server-spawn/adopt/version lifecycle. -## See `McpServerState` for the transition table. -var _server_state: int = McpServerStateScript.UNINITIALIZED - -## OS-level state populated only when WE spawned the process. -var _server_pid: int = -1 -## keep_server_on_exit (#800): whether the RUNNING server was launched with -## the keep-alive env opt-outs (no owner pid, NO_IDLE_EXIT staged). Editor -## teardown routes on this, never on the live setting — a server spawned -## without the opt-outs must die with the editor even if the user enabled -## the setting mid-session, or the owner-PID watchdog reaps it seconds -## later and the preserved record goes stale (the #774 scenario). Set at -## spawn, recovered from the managed-server record on adoption. -var _server_keep_alive := false -var _server_spawn_ms: int = 0 -var _server_exit_ms: int = 0 -## Elapsed-since-spawn at the first watch tick that saw the spawn PID dead, or -## 0 when it is alive / has been healed onto the real PID. Only meaningful -## while a Windows trampoline handoff is being waited out (#797): it preserves -## the true exit time so a diagnosis raised after the wait still reports when -## the process actually died. Reset per spawn alongside `_server_spawn_ms`. -var _spawn_dead_since_ms: int = 0 - -## Version metadata. `expected_version` is what the plugin shipped with; -## `actual_version` is what the live server reported via handshake_ack. -var _server_expected_version: String = "" -var _server_actual_version: String = "" -var _server_actual_name: String = "" - -## Diagnostic + recovery flags surfaced to the dock via `get_status()`. -var _server_status_message: String = "" -## #647: when a post-crash probe pins the failure on a specific port held -## by a foreign process, this names that port (HTTP or WS) so the dock's -## status line and port-picker gating don't blame the wrong one. Zero when -## no conflict was diagnosed. -var _conflict_port: int = 0 -var _can_recover_incompatible: bool = false -var _connection_blocked: bool = false - -## One-shot guard for the stale-uvx-index recovery (#172). Reset at the -## top of `start_server` so each fresh spawn attempt gets its own -## refresh budget. -var _refresh_retried: bool = false - -## One-shot guard for the spawn-lost-port-race re-adoption (see -## `_diagnose_spawn_fast_exit`). #805: the budget is per RECOVERY, not per -## walk — a walk the re-adopt arm itself triggered must NOT refresh it -## (`_readopt_walk_pending` skips the top-of-walk reset), or a flapping -## godot-ai occupant sustains spawn → fast-exit → re-walk forever. The -## budget refreshes on the paths that prove recovery: a fresh -## user/plugin-initiated walk, a successful `adopt_compatible_server`, -## or a spawn that survives to publish its pid-file. -var _readopt_after_spawn_exit_retried: bool = false - -## #805: set by the re-adopt arm just before it re-runs `start_server`, -## consumed by the top of `_start_server_impl` to skip that walk's -## `_readopt_after_spawn_exit_retried` reset. Never true outside that -## one triggered walk. -var _readopt_walk_pending: bool = false - -## Rounds of weak-proof (`status_name`-tier) recovery available against a -## brand-verified stale-VERSION godot-ai occupant. Zero by default: the -## startup walk may only kill via strong ownership proof. Armed by an -## explicit user action that already authorizes replacing the old backend — -## the post-self-update startup (`authorize_stale_recovery` from plugin.gd, -## consented by the Update click) and the dock's recovery click. This is -## what lets the walk finish an update against an attach-bridge-owned old -## server: bridges pinned to the previous version respawn it the moment it -## is killed, and each respawn is an external server with no strong proof, -## so without this budget every lost port race dead-ends in INCOMPATIBLE -## and the user is left toggling the plugin by hand. Never spent on a -## same-version or foreign occupant; each spent round re-proves brand at -## kill time. Reset on the proven recoveries — adoption and the verified -## compatible handshake — so leftover rounds can't authorize a kill in a -## later, unrelated episode. Pid-file publication deliberately does NOT -## reset it: the server writes that file before binding either port, so a -## bridge respawn can still steal the bind afterwards and the fast-exit -## needs its remaining rounds (#890 review P1). -var _stale_recovery_budget: int = 0 - -## Bounded deadline for the foreign-port adoption-confirmation watcher. -## Zero when disarmed. -var _adoption_watch_deadline_ms: int = 0 - -## Branch-tag from the most recent `start_server` walk. See -## `McpStartupPath`. Drives the startup-trace log. -var _startup_path: String = McpStartupPathScript.UNSET - -## Version-check seam. Lazily constructed on `arm_version_check` so -## tests that exercise the manager without a connection don't have to -## stub it out. -var _version_check - -## #678: when true, the blocking primitives on the startup path (port -## scrapes, per-PID brand shells, the HTTP status probe, kill + port-drain -## waits) run on a WorkerThreadPool thread while the main thread keeps -## pumping frames — the editor stays responsive during plugin init/reload -## on a contended port; the dock panel just arrives a beat later. The -## plugin enables this in production. Default false: unit tests (and any -## legacy caller) keep the historical fully-synchronous behavior, where -## the startup coroutines never actually suspend and call-then-assert -## still works. -var defer_blocking_work: bool = false - -## Cancellation for in-flight async startup work: bumped by `stop_server` -## (and therefore by `_exit_tree` and update-reload prep), checked after -## every await so a suspended `start_server` can't resurrect state — or -## spawn a server — after teardown started. -var _async_generation: int = 0 - -## Re-entrancy guard: with startup a coroutine, a second `start_server` -## call (respawn watch, dock button) can land mid-flight. -var _start_in_flight: bool = false - - -func _init(host) -> void: - _host = host - - -## The worker thread of the walk's current `_run_blocking` call, while it -## runs. `_invalidate_async_startup` JOINS it (bounded by the blocking -## op's own timeout) so no worker can still be executing a plugin method -## when `_exit_tree` frees the plugin — a mid-call free is use-after-free -## on the worker, which wedged the editor on macOS during rapid reload -## churn (main CI, post-#682). Null when no blocking work is in flight. -var _active_blocking_thread: Thread = null - - -## Run `work` off the main thread and suspend until it completes (#678). -## Falls back to inline execution when `defer_blocking_work` is off, or -## when no SceneTree is available to pump frames against. -## -## Uses a dedicated Thread (the dock's #238/#239 worker pattern) rather -## than WorkerThreadPool: `wait_to_finish()` hands the return value back -## without a shared mutable container, and this plugin has already seen -## WorkerThreadPool tasks SIGABRT under concurrency (see the notes in -## script_handler.gd / filesystem_handler.gd). `wait_to_finish` after -## `is_alive()` goes false joins an already-dead thread, so it never -## blocks the main thread. -## -## Returns null (without joining) when `_invalidate_async_startup` took -## ownership of the thread mid-flight — the walk is stale at that point -## and must bail. Callers therefore assign the result to an untyped -## local and bail on `_async_stale(...) or result == null` BEFORE any -## typed use — a typed assignment (or a bool()/int() constructor, both -## of which have no Nil form) trips on the null first. The null check is -## not redundant with the generation check: a caller that loses the slot -## without a generation bump — an invariant violation, but exactly what -## a concurrent fire-and-forget `_run_blocking` user produces — must -## still unwind instead of crashing on the Nil. -func _run_blocking(work: Callable) -> Variant: - if not defer_blocking_work: - return work.call() - var tree := Engine.get_main_loop() - if not (tree is SceneTree): - return work.call() - var thread := Thread.new() - if thread.start(work) != OK: - return work.call() - _active_blocking_thread = thread - while thread.is_alive(): - await (tree as SceneTree).process_frame - if _active_blocking_thread != thread: - ## Teardown/invalidation already joined this thread; the - ## result belongs to a cancelled walk. All resumes and joins - ## happen on the main thread, so this check cannot race. - return null - if _active_blocking_thread != thread: - return null - _active_blocking_thread = null - return thread.wait_to_finish() - - -func _async_stale(generation: int) -> bool: - return generation != _async_generation - - -## Cancel any in-flight async startup walk AND release the re-entrancy -## guard so the very next `start_server()` call walks fresh (#682 review). -## Every one-shot kill-and-restart path must call this before its -## follow-up start: without the generation bump the suspended walk -## resumes against post-kill reality (stale live-status snapshots), and -## without releasing the guard the follow-up start is silently swallowed. -## The cancelled walk unwinds via its post-await staleness checks and -## must NOT clear the guard itself — a newer walk may already own it -## (see the generation check in `start_server`). -## -## Also JOINS the walk's in-flight worker thread (bounded by that op's -## own timeout: lsof/netstat scrape, ≤800ms status probe, or kill + -## port-drain wait). `stop_server` runs this from `_exit_tree`, so once -## it returns no worker thread can still be executing a method of the -## plugin that is about to be freed — the macOS reload-churn wedge. -func _invalidate_async_startup() -> void: - _async_generation += 1 - _start_in_flight = false - var thread := _active_blocking_thread - _active_blocking_thread = null - if thread != null: - thread.wait_to_finish() - - -# ---- Public state accessors -------------------------------------------- - -func get_state() -> int: - return _server_state - - -func get_status_dict() -> Dictionary: - return { - "state": _server_state, - "exit_ms": _server_exit_ms, - "actual_name": _server_actual_name, - "actual_version": _server_actual_version, - "expected_version": _server_expected_version, - "message": _server_status_message, - "can_recover_incompatible": _can_recover_incompatible, - "connection_blocked": _connection_blocked, - "conflict_port": _conflict_port, - "keep_alive": _server_keep_alive, - "stale_recovery_budget": _stale_recovery_budget, - } - - -## Arm the bounded stale-occupant recovery (see `_stale_recovery_budget`). -## Called before the startup walk when a completed self-update is detected -## (plugin.gd) and by the dock recovery click — both are user actions that -## already authorize replacing the previous-version backend. `max()` so a -## click landing on an already-armed post-update episode never shrinks the -## remaining rounds. -func authorize_stale_recovery(rounds: int = 2) -> void: - _stale_recovery_budget = maxi(_stale_recovery_budget, rounds) - - -## True when a spent round of stale-occupant recovery may target the live -## occupant: budget remains and the occupant is a VERIFIED godot-ai whose -## version differs from ours. An empty version (foreign or unverified -## occupant) never qualifies — those stay on the strong-proof-only path. -func _stale_recovery_available(live_version: String, expected_version: String) -> bool: - if _stale_recovery_budget <= 0: - return false - if live_version.is_empty(): - return false - return live_version != expected_version - - -func get_server_pid() -> int: - return _server_pid - - -func get_startup_path() -> String: - return _startup_path - - -func get_adoption_watch_deadline_ms() -> int: - return _adoption_watch_deadline_ms - - -func is_awaiting_server_version() -> bool: - return _version_check != null and _version_check.is_active() - - -func is_connection_blocked() -> bool: - return _connection_blocked - - -# ---- State-machine entry points --------------------------------------- - -## Validated transition. Returns true on success; false (and logs a -## warning) when the transition is illegal under `McpServerState`'s -## table. Callers that need first-writer-wins among terminal diagnoses -## use `set_terminal_diagnosis` instead — that helper silently no-ops -## without warning when the diagnosis would be a regression. -func transition_state(target: int) -> bool: - if _server_state == target: - return true - if not McpServerStateScript.can_transition(_server_state, target): - push_warning( - "MCP | rejected illegal state transition %s -> %s" - % [ - McpServerStateScript.name_of(_server_state), - McpServerStateScript.name_of(target), - ] - ) - return false - _server_state = target - return true - - -## First-writer-wins mutator for terminal diagnoses (CRASHED, -## NO_COMMAND, PORT_EXCLUDED, INCOMPATIBLE, FOREIGN_PORT). Used during -## spawn to make sure a late watch-loop CRASHED doesn't clobber an -## earlier proactive PORT_EXCLUDED. Silent no-op when the current state -## is already a terminal diagnosis — the existing diagnosis is kept. -func set_terminal_diagnosis(target: int) -> bool: - if not McpServerStateScript.is_terminal_diagnosis(target): - push_warning( - "MCP | set_terminal_diagnosis called with non-terminal %s" - % McpServerStateScript.name_of(target) - ) - return false - if McpServerStateScript.is_terminal_diagnosis(_server_state): - return false - _server_state = target - return true - - -# ---- Adoption confirmation watcher ------------------------------------- - -## Arm the FOREIGN_PORT adoption-confirmation watcher. SPAWN_GRACE_MS -## ahead of `now`; `tick_adoption_watch` self-disarms after this expires -## so per-frame cost drops back to zero on a permanent foreign occupant. -func arm_adoption_watch() -> void: - _adoption_watch_deadline_ms = ( - Time.get_ticks_msec() + int(_host.SPAWN_GRACE_MS) - ) - - -func tick_adoption_watch(now_msec: int) -> void: - if _adoption_watch_deadline_ms > 0 and now_msec >= _adoption_watch_deadline_ms: - _adoption_watch_deadline_ms = 0 - - -# ---- Server version-check seam ---------------------------------------- - -func arm_version_check(connection, expected_version: String) -> void: - if _version_check == null: - _version_check = McpServerVersionCheckScript.new(self) - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - _version_check.arm(connection, expected) - - -func disarm_version_check() -> void: - if _version_check != null: - _version_check.disarm() - - -func get_version_check(): - return _version_check - - -## Resolves a possibly-empty expected version to the plugin's shipping -## version. Manager methods that are called via test fixtures may -## receive an empty string when the test never seeded -## `_server_expected_version`, so this is the one place that fallback -## lives. -func _resolve_expected_version(supplied: String) -> String: - if not supplied.is_empty(): - return supplied - return _expected_server_version() - - -func _expected_server_version() -> String: - return ClientConfigurator.get_plugin_version() - - -## Called by McpServerVersionCheck when handshake_ack carries a version -## string. Decides compatible vs incompatible and transitions the state. -func handle_server_version_verified(expected_version: String, version: String) -> void: - _server_actual_name = "godot-ai" - _server_actual_version = version - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - var compatibility := _server_version_compatibility(version, expected) - if compatibility.get("compatible", false): - _can_recover_incompatible = false - ## A verified compatible handshake ends any stale-recovery episode — - ## leftover authorized rounds must not survive into a later walk. - _stale_recovery_budget = 0 - ## Foreign-port and post-spawn handshakes both clear to READY - ## on a successful handshake. Late re-arms from READY also land - ## here and self-confirm. - transition_state(McpServerStateScript.READY) - _host._update_process_enabled() - return - var live := {"version": version, "status_code": 200, "name": "godot-ai"} - ## Connection propagation + version-check disarm + process re-evaluation - ## all live inside _set_incompatible_server now (#691) so the startup-walk - ## recovery-failure and force-restart-failure paths get them too. - _set_incompatible_server(live, expected, ClientConfigurator.http_port()) - ## Post-update the WebSocket usually reaches the OLD backend before the - ## startup walk finishes its probes — this verdict then cancels the walk - ## (the `_invalidate_async_startup` in `_set_incompatible_server`'s - ## fire-and-forget arm) and used to strand the update at INCOMPATIBLE - ## even though the Update click already authorized replacing that - ## backend. With authorized rounds left and a VERIFIED stale version, - ## spend one and fire the same recovery flow the dock's Restart click - ## runs — via the plugin wrapper so the connection unblocks on success, - ## with `user_initiated=false` so the trigger can only spend, never - ## re-arm. The INCOMPATIBLE latch it gates on is synchronous: the - ## `_set_incompatible_server` head above runs before its first await. - if _stale_recovery_available(version, expected) and _host.has_method("recover_incompatible_server"): - _stale_recovery_budget -= 1 - print( - "MCP | stale godot-ai v%s answered the WS handshake — replacing it (update/recovery authorized, %d retry round(s) left)" - % [version, _stale_recovery_budget] - ) - ## Fire-and-forget coroutine: this verdict lands on the main thread - ## from `_process`, and the recovery owns the flow from here - ## (mirroring the recovery click). - _host.recover_incompatible_server(false) - - -func handle_server_version_unverified(expected_version: String) -> void: - var expected := _resolve_expected_version(expected_version) - _server_expected_version = expected - var live := {"version": "", "status_code": 0, "error": "missing_handshake_ack"} - _set_incompatible_server(live, expected, ClientConfigurator.http_port()) - - -# ---- Compatibility / version helpers (pure) --------------------------- - -## Plugin and server speak a single, version-coupled protocol — new commands -## and response fields are added together. Treating dev-mode mismatches as -## "compatible" silently adopts a stale server whose code may differ from the -## live source tree (e.g. another worktree on a different branch holding -## port 8000). Strict match in all modes routes mismatches through -## `recover_strong_port_occupant`, which kills the branded port-holder and -## lets `start_server` spawn fresh against the current source. -static func _server_version_compatibility( - actual_version: String, - expected_version: String -) -> Dictionary: - if actual_version.is_empty(): - return {"compatible": false, "reason": "unknown"} - if actual_version == expected_version: - return {"compatible": true, "reason": "exact"} - return {"compatible": false, "reason": "version_mismatch"} - - -static func _server_status_compatibility( - actual_version: String, - expected_version: String, - actual_ws_port: int, - expected_ws_port: int, -) -> Dictionary: - var version_result := _server_version_compatibility(actual_version, expected_version) - if not bool(version_result.get("compatible", false)): - return version_result - if actual_ws_port != expected_ws_port: - return {"compatible": false, "reason": "ws_port_mismatch"} - return version_result - - -static func _managed_record_has_version_drift(record_version: String, current_version: String) -> bool: - return not record_version.is_empty() and record_version != current_version - - -# ---- Incompatible-server bookkeeping ---------------------------------- - -func _set_incompatible_server( - live: Dictionary, - expected_version: String, - port: int, - caller_owns_worker_slot := false -) -> void: - ## Latches the incompatible diagnosis into manager state and asks - ## the dock to re-sweep client rows so they don't show stale green. - ## Threads the caller's `live` snapshot through the recovery proof - ## helper so we don't double-probe the port (~500ms each). - ## - ## Coroutine (#712): the recovery-proof evaluation (port scrapes + - ## per-PID brand shells) and the free-port bind probes run via - ## `_run_blocking` — these fire in exactly the contended/crashed - ## scenarios #678 de-blocked, so they must not stall the main thread - ## either. Everything user-visible (status message, connection block, - ## version-check disarm) is latched synchronously before the first - ## await; only the recovery verdict and the suggested-port diagnostic - ## arrive with the worker. - ## - ## `_run_blocking` tracks a single active worker, so the tail below - ## needs exclusive ownership of that slot. The startup walk awaits - ## this call with `caller_owns_worker_slot=true` — it already owns the - ## slot and serializes the tail behind its own blocking ops. Sync - ## callers (the handshake verdicts via `handle_server_version_*`, the - ## force-restart failure arm) fire-and-forget the tail and leave the - ## flag false, so the head takes ownership for them: a handshake - ## verdict lands from `_process` while a startup walk can still be - ## suspended in `_run_blocking`, and starting the tail's worker then - ## would steal the slot — the walk's op is orphaned from the - ## `_invalidate_async_startup` join guarantee and its resume gets a - ## null without a generation bump (the Nil-into-Dictionary crash on - ## the incompatible-occupant walk). Cancelling the walk first mirrors - ## the recovery click (#712): the diagnosis in hand supersedes - ## whatever the walk was still probing for. - if not caller_owns_worker_slot: - _invalidate_async_startup() - transition_state(McpServerStateScript.INCOMPATIBLE) - _connection_blocked = true - _server_expected_version = expected_version - _server_actual_name = str(live.get("name", "")) - _server_actual_version = _live_version_for_message(live) - _server_status_message = _incompatible_server_message( - live, expected_version, port, int(_host._resolved_ws_port) - ) - ## Conservative default until the off-thread proof lands: the dock - ## paints "not recoverable" rather than offering a kill we have not - ## yet proven ownership for. - _can_recover_incompatible = false - _host._refresh_dock_client_statuses() - ## Propagate the verdict to the live connection (#691). Pre-#678 the - ## startup walk finished synchronously before `_connection` existed, so - ## plugin.gd captured the INCOMPATIBLE verdict when constructing it. - ## Post-#678 the walk suspends at its first `_run_blocking` and the - ## plugin snapshots the pre-walk defaults (`connect_blocked=false`) — so - ## a verdict landing later (startup-walk recovery failure, handshake - ## mismatch, force-restart failure) must reach the connection here, or - ## it keeps dialing the WS port forever. Also disarm the version check: - ## the diagnosis already landed, so leaving the check armed keeps - ## per-frame `_process` on for the plugin's whole lifetime. - if _host._connection != null: - _host._connection.connect_blocked = true - _host._connection.connect_block_reason = _server_status_message - _host._connection.disconnect_from_server() - disarm_version_check() - _host._update_process_enabled() - - ## Off-thread recovery proof (#712), mirroring recover_strong_port_occupant: - ## the EditorSettings record is read on the main thread up front and - ## injected as record_override — EditorSettings is main-thread-only. - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return - var proof: Dictionary = proof_result - var proof_name := str(proof.get("proof", "")) - _can_recover_incompatible = not proof_name.is_empty() - print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) - if not _can_recover_incompatible: - ## Non-recoverable: a foreign / unprovable occupant holds the port and - ## we have no ownership proof, so we must NOT kill it — surface a - ## concrete free port the user can switch to instead (the same hint - ## the dock crash body renders). Logging it to the editor output also - ## lets `ci-stale-server-smoke --mode foreign` assert this upstream - ## classification from CI. Reservation-aware on Windows; the bind - ## probes behind suggest_free_port also run off-thread (#712). - var suggested_result: Variant = await _run_blocking(func() -> Variant: - return ClientConfigurator.suggest_free_port(port + 1) - ) - if _async_stale(async_gen) or suggested_result == null: - return - print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, int(suggested_result)]) - ## Second sweep so the dock's recovery affordance reflects the verdict - ## that just landed. - _host._refresh_dock_client_statuses() - - -static func _incompatible_server_message( - live: Dictionary, - expected_version: String, - port: int, - expected_ws_port: int -) -> String: - var version := _live_version_for_message(live) - var actual_ws_port := _live_ws_port_for_message(live) - ## `package_path` is a v2.4.4+ field — older servers omit it. Suffix - ## the message with "(loaded from )" when present so the user - ## can tell *which* `src/godot_ai/` is serving the port without - ## walking the process tree. See #416. - var package_path := _live_package_path_for_message(live) - var path_suffix := " (loaded from %s)" % package_path if not package_path.is_empty() else "" - ## After a plugin update, the usual occupant is a backend kept alive by - ## AI-client attach bridges still pinned to the previous version (their - ## leases outrank us — #669/#839, we must not kill it). Name that repair - ## first; "stop the old server" alone reads as a dead end when the server - ## respawns the moment the user kills it. - var repair := ( - "If AI-client attach bridges are keeping it alive, run Configure all to " - + "repin them, then restart those client apps — the old server exits on " - + "its own. Otherwise stop it manually or change both HTTP and WS ports." - ) - if not version.is_empty(): - if actual_ws_port > 0 and actual_ws_port != expected_ws_port: - return ( - "Port %d is occupied by godot-ai server v%s using WS port %d%s; " - + "plugin expects v%s with WS port %d. %s" - ) % [port, version, actual_ws_port, path_suffix, expected_version, expected_ws_port, repair] - return ( - "Port %d is occupied by godot-ai server v%s%s; plugin expects v%s. %s" - ) % [port, version, path_suffix, expected_version, repair] - var status_code := int(live.get("status_code", 0)) - if status_code > 0: - return ( - "Port %d is occupied by an unverified server (status endpoint returned HTTP %d); " - + "plugin expects godot-ai v%s. Stop the other server or change both HTTP and WS ports." - ) % [port, status_code, expected_version] - return ( - "Port %d is occupied by another process; plugin expects godot-ai v%s. " - + "Stop the other process or change both HTTP and WS ports." - ) % [port, expected_version] - - -static func _live_status_identifies_godot_ai(live: Dictionary) -> bool: - return str(live.get("name", "")) == "godot-ai" - - -static func _live_version_for_message(live: Dictionary) -> String: - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return "" - return str(live.get("version", "")) - - -static func _live_ws_port_for_message(live: Dictionary) -> int: - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return 0 - return int(live.get("ws_port", 0)) - - -static func _live_package_path_for_message(live: Dictionary) -> String: - ## Only trust the path when the live snapshot confirms a godot-ai - ## server — a probe of some unrelated HTTP service could in theory - ## return a `package_path` JSON field, and we don't want to mislabel - ## that as "godot-ai loaded from …" in the incompatible banner. - if live.has("name") and str(live.get("name", "")) != "godot-ai": - return "" - return str(live.get("package_path", "")) - - -# ---- start_server / spawn watch / respawn ----------------------------- - - -## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the -## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY -## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does -## NOT count — it must not suppress a dock UI opt-out), and (b) the effective -## McpSettings.telemetry_enabled() is false. Returns true if the var was -## injected so the caller can unset it after spawning. -func _inject_telemetry_env() -> bool: - ## If telemetry is already disabled by a *truthy* env var, leave the env as - ## the user/CI set it — the post-spawn cleanup unsets what we inject, so - ## injecting here would strip their own var from the editor process. A - ## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled": - ## fall through so a dock UI opt-out still reaches the spawned server. The - ## truthy test mirrors McpSettings.telemetry_enabled() and the Python server. - if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"): - return false - if not McpSettings.telemetry_enabled(): - OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true") - return true - return false - - -## Set GODOT_AI_OWNER_PID to this editor's PID for the next OS.create_process, -## so the spawned server can self-reap if this editor crashes. Returns true if -## set (caller must unset right after spawning — keep it out of the persistent -## editor env). No-op on Windows, where the server's reaper is disabled. -func _set_owner_pid_env() -> bool: - if OS.get_name() == "Windows": - return false - ## keep_server_on_exit (#800): a server meant to outlive editors must not - ## self-reap when this editor dies — don't hand it an owner pid at all. - if ClientConfigurator.keep_server_on_exit(): - return false - OS.set_environment("GODOT_AI_OWNER_PID", str(OS.get_process_id())) - return true - - -## Mark the next OS.create_process as plugin-spawned so the server arms its -## session-idle self-terminate backstop (#498): with zero editor sessions for -## a grace window, it exits on its own. Unlike the owner-PID reaper this is -## pure session-count on the server side, so it is set on EVERY platform — -## including Windows, where owner-PID is skipped; this marker is what finally -## gives Windows orphan coverage (#497). Same env-channel rationale and same -## tight scoping as _set_owner_pid_env: callers unset it right after spawning -## so a later manually-started dev server can never inherit it and idle-kill -## itself. -func _set_plugin_spawned_env() -> void: - OS.set_environment("GODOT_AI_PLUGIN_SPAWNED", "1") - - -## keep_server_on_exit (#800): opt the spawned server out of the -## session-idle self-terminate backstop (#498) via its existing -## GODOT_AI_NO_IDLE_EXIT escape hatch — a keep-alive server sits at zero -## sessions between editor runs by design, which is exactly what the -## backstop reaps. Returns true if set (same tight scoping as -## _set_owner_pid_env: callers unset right after spawning, and only when -## WE set it, so a user's own NO_IDLE_EXIT env is never stripped). -func _set_keep_alive_env() -> bool: - if not ClientConfigurator.keep_server_on_exit(): - return false - OS.set_environment("GODOT_AI_NO_IDLE_EXIT", "1") - return true - - -## Generate a fresh per-launch WS handshake auth token (#690) and stage it -## in the env for the next OS.create_process, same channel and same tight -## scoping as _set_owner_pid_env (callers unset right after spawning — the -## secret must not linger in the editor env). The caller hands the returned -## token to the host on successful spawn so the connection echoes it in the -## handshake and the managed-server record persists it across reloads. -func _set_ws_token_env() -> String: - var token := Crypto.new().generate_random_bytes(32).hex_encode() - OS.set_environment("GODOT_AI_WS_TOKEN", token) - return token - - -## Branch table (recorded version is the "is this ours?" signal — uvx -## launcher PIDs go stale; #135/#137): -## port free -> spawn fresh, record PID -## port in use, record matches + live ok -> adopt port owner (heals PID) -## port in use, record drifts -> kill owner + respawn -## port in use, no verified live match -> block adoption + warn -## -## #678: this is a coroutine in production (`defer_blocking_work`) — the -## port scrapes, status probes, and kill-drain waits run off the main -## thread and the state machine resumes between frames, so the editor -## stays responsive when the port is contended. With the flag off (unit -## tests) nothing suspends and the call completes synchronously. -func start_server() -> void: - if _start_in_flight: - return - _start_in_flight = true - var gen := _async_generation - await _start_server_impl(gen) - ## Only release the guard if this walk is still the current one — a - ## cancelled (stale) walk unwinding here must not clobber the guard a - ## newer walk armed after `_invalidate_async_startup`. - if gen == _async_generation: - _start_in_flight = false - ## Walk-completion continuation lives HERE — on the RefCounted - ## manager, kept alive by its own suspended state — never on the - ## plugin: resuming a coroutine of a freed Node errors out, and - ## reload churn frees plugin instances while walks are suspended. - if is_instance_valid(_host) and _host.has_method("_finish_startup_trace_after_walk"): - _host._finish_startup_trace_after_walk() - - -func _start_server_impl(async_gen: int) -> void: - if _host._server_started_this_session: - ## Static flag persists across disable/enable cycles in one editor - ## session — re-entrant spawn guard for plugin-reload-during-update. - _startup_path = McpStartupPathScript.GUARDED - transition_state(McpServerStateScript.GUARDED) - return - - _refresh_retried = false - if _readopt_walk_pending: - ## #805: this walk was triggered by the fast-exit re-adopt arm. - ## Keep the spent budget: if this walk ends up spawning and that - ## spawn fast-exits against a live godot-ai again, the occupant is - ## flapping and the diagnosis must latch terminal instead of - ## re-walking forever. Recovery paths (adoption, healthy spawn) - ## refresh the budget explicitly. - _readopt_walk_pending = false - else: - _readopt_after_spawn_exit_retried = false - _conflict_port = 0 - - var port := ClientConfigurator.http_port() - var ws_port := ClientConfigurator.ws_port() - var current_version := _expected_server_version() - _server_expected_version = current_version - - ## The worker closures re-check the host: the plugin can be freed while - ## a bounded shell probe is still running, and the generation check only - ## protects state after resume, not calls inside the task (#682 review). - var port_in_use_result: Variant = await _run_blocking(func() -> Variant: - return is_instance_valid(_host) and _host._is_port_in_use(port) - ) - if _async_stale(async_gen) or port_in_use_result == null: - return - var port_in_use := bool(port_in_use_result) - if not port_in_use: - ## #745: after an editor crash (or under multi-editor churn) the - ## managed server keeps running, yet the bind probe can still say - ## "free" (Windows lets a SO_REUSEADDR bind succeed over a live - ## listener; the scrape fallback can fail transiently). The HTTP - ## status probe is the authoritative tie-breaker and runs - ## UNCONDITIONALLY: the pid-file evidence gate that used to guard it - ## goes stale exactly when it's needed most — same-named test - ## projects share one app_userdata dir, so another editor's walk can - ## clear or overwrite the pid-file, and blind-spawning here produced - ## the reproduced duplicate-spawn + 4003 token loop. A live godot-ai - ## answer forces the adopt/recover branch below; an unresponsive - ## port falls through to the normal spawn path at the cost of one - ## fast connection-refused probe (off-thread in production). - var evidence_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or evidence_result == null: - return - var evidence: Dictionary = evidence_result - if _live_status_identifies_godot_ai(evidence): - port_in_use = true - if port_in_use: - var record: Dictionary = _host._read_managed_server_record() - var record_version := str(record.get("version", "")) - var record_ws_port := int(record.get("ws_port", 0)) - _host._set_resolved_ws_port(PortResolver.resolved_ws_port_for_existing_server( - record_ws_port, - record_version, - current_version, - int(_host._resolve_ws_port()) - )) - ws_port = int(_host._resolved_ws_port) - ## Untyped first: a cancelled walk gets null back (see _run_blocking) - ## and must reach the staleness check before any typed cast. - var live_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or live_result == null: - return - var live: Dictionary = live_result - var live_version := str(_host._verified_status_version(live)) - var live_ws_port := int(_host._verified_status_ws_port(live)) - var compatibility: Dictionary = _server_status_compatibility( - live_version, - current_version, - live_ws_port, - ws_port, - ) - if compatibility.get("compatible", false): - _server_actual_name = "godot-ai" - _server_actual_version = live_version - _can_recover_incompatible = false - ## A matching version is compatibility evidence, not ownership - ## evidence (#759/#764). A stale EditorSettings record can name a - ## dead PID while an unrelated compatible server owns the port. - ## Retain managed ownership only when the recorded PID is itself - ## the live, branded listener. - var adoption_proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_strong_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or adoption_proof_result == null: - return - var adoption_proof: Dictionary = adoption_proof_result - var proof_pids: Array[int] = [] - proof_pids.assign(adoption_proof.get("pids", [])) - var owner := int(proof_pids[0]) if not proof_pids.is_empty() else 0 - var record_owns_listener := str(adoption_proof.get("proof", "")) == "managed_record" - var owner_label := adopt_compatible_server( - record_version, - current_version, - owner, - record_owns_listener - ) - _host._server_started_this_session = true - _startup_path = McpStartupPathScript.ADOPTED - transition_state(McpServerStateScript.READY) - print(_compatible_adoption_log_message( - owner_label, - int(_server_pid), - owner, - str(_server_actual_version), - live_ws_port, - current_version - )) - return - if bool(_managed_record_has_version_drift(record_version, current_version)): - print("MCP | managed server v%s does not match plugin v%s, restarting" - % [record_version, current_version]) - ## Forward `live` so the recovery proof helper reuses our snapshot. - ## The kill invalidates it, so the failure arm re-probes below. - var recovered: bool = await recover_strong_port_occupant(port, 3.0, live) - if _async_stale(async_gen): - return - if not recovered and _stale_recovery_available(live_version, current_version): - ## Post-update / recovery-click episode: the occupant is a verified - ## stale-VERSION godot-ai (typically the previous release kept alive - ## — or just respawned — by attach bridges still pinned to it) and a - ## user action already authorized replacing it. Strong proof cannot - ## exist for that server (external adoption dropped record and - ## pid-file by design), so spend one bounded round of weak-proof - ## recovery instead of latching INCOMPATIBLE and asking the user to - ## click through the replacement by hand. - _stale_recovery_budget -= 1 - print( - "MCP | stale godot-ai v%s holds port %d — replacing it (update/recovery authorized, %d retry round(s) left)" - % [live_version, port, _stale_recovery_budget] - ) - recovered = await recover_stale_port_occupant(port, 3.0) - if _async_stale(async_gen): - return - if not recovered: - _host._server_started_this_session = true - var post_recovery_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {} - return _host._probe_live_server_status_for_port(port) - ) - if _async_stale(async_gen) or post_recovery_result == null: - return - var post_recovery_live: Dictionary = post_recovery_result - ## Awaited with caller_owns_worker_slot=true (#712): the - ## diagnosis tail runs its own _run_blocking proof, and the walk - ## stays the single owner of the active-worker slot by - ## serializing that tail behind this await instead of letting it - ## re-take the slot. The status message is latched before the - ## tail's first await, so the push_warning below reads the final - ## text either way. - await _set_incompatible_server(post_recovery_live, current_version, port, true) - if _async_stale(async_gen): - return - _startup_path = McpStartupPathScript.INCOMPATIBLE - push_warning(str(_server_status_message)) - return - else: - _startup_path = McpStartupPathScript.FREE - - _host._set_resolved_ws_port(_host._resolve_ws_port()) - ws_port = _host._resolved_ws_port - - _host._startup_trace_count("server_command_discovery") - ## CLI-finder discovery shells out (which/where, login shell) on cache - ## misses — the same #238/#239 family the dock already runs off-thread. - var server_cmd_result: Variant = await _run_blocking(func() -> Variant: - return ClientConfigurator.get_server_command() - ) - if _async_stale(async_gen) or server_cmd_result == null: - return - var server_cmd: Array = server_cmd_result - if server_cmd.is_empty(): - set_terminal_diagnosis(McpServerStateScript.NO_COMMAND) - _startup_path = McpStartupPathScript.NO_COMMAND - push_warning("MCP | could not find server command") - return - - var cmd: String = server_cmd[0] - var args: Array[String] = [] - args.assign(server_cmd.slice(1)) - args.append_array(_host._build_server_flags(port, ws_port)) - - ## Wipe any stale pid-file so a failed launch can't leave last - ## session's PID for `_find_managed_pid` to read. - _host._clear_pid_file() - - ## Proactive Windows port-reservation check (#146) — bind would - ## fail silently with WinError 10013 inside a Hyper-V / WSL2 / - ## Docker exclusion range; netstat shows nothing. - if WindowsPortReservation.is_port_excluded(port): - _host._server_started_this_session = true - set_terminal_diagnosis(McpServerStateScript.PORT_EXCLUDED) - _startup_path = McpStartupPathScript.RESERVED - push_warning("MCP | port %d is reserved by Windows (Hyper-V / WSL2 / Docker)" % port) - return - - ## ---- Spawn-time env-mutation window (#691) ------------------------- - ## From here to the post-spawn unsets below, the editor's process-global - ## environment is mutated around OS.create_process (which has no - ## per-child env parameter). Two invariants keep this safe: - ## 1. The window is SYNCHRONOUS main-thread code — no `await` between - ## the first setenv and the last unsetenv — and worker dispatch also - ## only happens on the main thread, so no new worker can start inside - ## the window. - ## 2. Already-running workers never call OS.get_environment: every env - ## read reachable from a worker (path templates, config_home_override, - ## CLI finder, mode_override/startup-trace) routes through - ## McpPathTemplate.env_lookup, which serves worker threads from a - ## main-thread-warmed snapshot. A concurrent glibc getenv during - ## setenv can return a freed pointer — process-fatal. - ## Residual (accepted): a worker's own OS.execute child (CLI status - ## probe) launched while this window is open inherits the temp vars — - ## rare, and tame next to the crash class above. - var injected_telemetry_env := _inject_telemetry_env() - - ## PYTHONPATH handling for dev checkouts: when the editor is launched - ## against a worktree whose `src/godot_ai/__version__` differs from the - ## root repo's editable install, the dev-venv python's `sitecustomize` - ## adds the *root repo's* `src/` to `sys.path`. The spawned server then - ## reports the root repo's version, the plugin's compatibility check - ## flags it as incompatible, and the user gets a Restart-Server loop - ## with no exit. `start_dev_server` already prepends the worktree's - ## `src/` for its --reload spawn; mirror that here for the auto-spawn - ## path so the same worktree-vs-root version skew is impossible. Gated - ## on `is_dev_checkout()` so production user installs (no nearby `src/`) - ## are untouched. See #418. - var worktree_src := "" - var prev_pythonpath := "" - var pythonpath_set := false - if ClientConfigurator.is_dev_checkout(): - worktree_src = ClientConfigurator.find_worktree_src_dir( - ProjectSettings.globalize_path("res://") - ) - if not worktree_src.is_empty(): - prev_pythonpath = OS.get_environment("PYTHONPATH") - var sep := ";" if OS.get_name() == "Windows" else ":" - var new_pp := ( - worktree_src - if prev_pythonpath.is_empty() - else worktree_src + sep + prev_pythonpath - ) - OS.set_environment("PYTHONPATH", new_pp) - pythonpath_set = true - - ## Tell the spawned server which editor owns it so it can self-reap if we - ## die without a clean stop_server (crash / hard-kill). Passed via env, not - ## a CLI flag, so an older server (staggered user-mode upgrade) silently - ## ignores an unknown var instead of failing argparse. Scoped tightly around - ## create_process and unset right after (like PYTHONPATH below): the child - ## inherits it, but it must NOT linger in the editor env, or a later - ## non-reload `godot-ai` subprocess (dev server, future spawn) would inherit - ## it and wrongly arm a reaper keyed to this editor. - ## Skipped on Windows: the server's reaper is POSIX-only for now (Windows - ## process-liveness/self-shutdown isn't live-validated yet). The server - ## gates on this too. - var owner_env_set := _set_owner_pid_env() - _set_plugin_spawned_env() - var keep_alive_env_set := _set_keep_alive_env() - var ws_token := _set_ws_token_env() - - _server_pid = OS.create_process(cmd, args) - var spawned_pid := int(_server_pid) - - if owner_env_set: - OS.unset_environment("GODOT_AI_OWNER_PID") - OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") - if keep_alive_env_set: - OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") - OS.unset_environment("GODOT_AI_WS_TOKEN") - - ## Restore PYTHONPATH immediately — the spawned child has already - ## copied the env, so the editor's own process state returns to - ## baseline. Leaving it set would leak to any later OS.create_process - ## from unrelated paths. - if pythonpath_set: - if prev_pythonpath.is_empty(): - OS.unset_environment("PYTHONPATH") - else: - OS.set_environment("PYTHONPATH", prev_pythonpath) - - if injected_telemetry_env: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - - if spawned_pid > 0: - _server_spawn_ms = Time.get_ticks_msec() - _server_exit_ms = 0 - _spawn_dead_since_ms = 0 - _server_keep_alive = keep_alive_env_set - _host._server_started_this_session = true - transition_state(McpServerStateScript.SPAWNING) - ## The child copied the env, so this token is what the server will - ## verify handshakes against — adopt it BEFORE writing the record - ## (the record write persists _ws_auth_token). - _host._set_ws_auth_token(ws_token) - ## Record the launcher PID so same-session - ## prepare_for_update_reload has something to kill. The next - ## editor start's adopt branch heals it to the real port owner. - _host._write_managed_server_record(spawned_pid, current_version, _server_keep_alive) - _startup_path = McpStartupPathScript.SPAWNED - ## Log "PYTHONPATH prefix=" rather than "PYTHONPATH=" so the line - ## isn't misleading when an existing PYTHONPATH was present — - ## we prepended `worktree_src`, not replaced. Keeps the log - ## compact (worktree_src is the actionable piece; the full - ## prev_pythonpath can be 5+ entries long on dev machines). - var suffix := " (PYTHONPATH prefix=%s)" % worktree_src if not worktree_src.is_empty() else "" - print("MCP | started server (PID %d, v%s): %s %s%s" % [spawned_pid, current_version, cmd, " ".join(args), suffix]) - _host._start_server_watch() - else: - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - _startup_path = McpStartupPathScript.CRASHED - push_warning("MCP | failed to start server") - - -## Is the watched spawn PID's death still explainable as a launcher handoff -## rather than a server exit? (#797) -## -## Observed on Windows 11 with a uv-created venv: one boot in four logged -## "server exited after 5146ms" while the real server kept running and was -## then adopted. The watched PID had died on a healthy boot, and because the -## server had not yet written its pid-file there was nothing to heal onto, so -## the watch crossed SPAWN_GRACE_MS and reported an exit — rescued only by the -## crash-survivor adoption path. -## -## A uv venv's `python.exe` is a shim rather than the interpreter, and the real -## server does run under a *different* PID than the one `OS.create_process` -## hands back. But the original report's suspected mechanism — that the shim -## exits once its child is up — is **disproven**, not merely unconfirmed. A -## 12-boot run on Windows 11 with a uv venv found the spawned trampoline alive -## on every boot, with the child owning both the pid-file and the listener; a -## CI runner showed the same. The shim is a live parent for the process's whole -## life, so it is not what kills the watched PID. -## -## Two consequences worth keeping straight. First, this gate is keyed to the -## observable condition — watched PID dead, no pid-file yet — not to any theory -## of why it died, so it stays correct whatever the cause. Second, and less -## comfortable: in that same 12-boot run the false "server exited" line never -## appeared AND the watched PID never died, so the guard never fired. Those -## clean boots are evidence the symptom did not reproduce, NOT evidence this -## guard fixes it. The true cause of the original 1-in-4 report is still -## unknown; if it resurfaces, start from that rather than from the trampoline. -## -## `real_pid <= 0` means no pid-file exists yet, and that reliably means "this -## server has not published one" rather than "stale leftover": `start_server` -## wipes the pid-file immediately before every spawn. So an absent pid-file -## plus a dead spawn PID inside the window is the handoff signature. -## -## Deliberately gated to Windows. POSIX uv venvs exec rather than trampoline, -## so a dead spawn PID there really is a dead server, and delaying its -## diagnosis would only slow down honest crash reporting on the platforms -## where this cannot happen. `os_name` is a parameter rather than an -## `OS.get_name()` call so the Windows path is exercisable from any host. -static func is_spawn_handoff_pending( - os_name: String, real_pid: int, elapsed_ms: int, window_ms: int -) -> bool: - if os_name != "Windows": - return false - if real_pid > 0: - return false - return elapsed_ms < window_ms - - -## First-write-wins stamp for the elapsed time at which the spawn PID was first -## observed dead (#797). -## -## A diagnosis raised after waiting out a handoff must still report when the -## process actually exited, not when the wait gave up — the point of #797 is an -## honest log line. Returns the existing stamp once one is set, so later ticks -## in the same wait cannot overwrite it; `<= 0` means "not yet stamped", -## matching how the field is cleared per spawn. -static func first_death_stamp(current_stamp_ms: int, elapsed_ms: int) -> int: - return current_stamp_ms if current_stamp_ms > 0 else elapsed_ms - - -## One-line forensic snapshot taken the moment a spawn is judged to have -## fast-exited (#797). -## -## #797 reported `server exited after 5146ms` on a healthy Windows boot, once -## in four. It is still unexplained: a 12-boot run on the reported -## configuration reproduced neither the symptom nor its suspected mechanism — -## the uv trampoline was alive on every boot, with the child owning the -## pid-file and the listener, so the shim's exit is ruled out as the cause. -## What killed that watched PID is unknown, and the log line at the time -## carried no evidence to answer it with. -## -## So capture the state at the moment of judgement rather than asking the next -## person to reproduce a 1-in-4 bug under observation. Everything here is read -## through seams the surrounding diagnosis already uses, on a path that only -## runs when a spawn is being declared dead, so it costs nothing in the -## healthy case. -## Deliberately does NOT scrape the port for listener PIDs. This runs from the -## 1 Hz watch loop, on a live frame, so a `_find_all_pids_on_port` subprocess -## here would stall the editor for a diagnostic. Deferring it via -## `_run_blocking` was the alternative and is worse: that helper is -## `await`-based, so it would turn this, `_diagnose_spawn_fast_exit` and -## `check_server_health` into coroutines — making the watch callback resume -## across arbitrary frames while its branches set terminal state and trigger -## re-adoption walks. That is the teardown-ordering hazard -## `_invalidate_async_startup` exists to contain, and it is not worth taking -## on for a log line. -## -## Little is lost: the probe on the very next line already establishes whether -## a godot-ai server answers on the port, and `_diagnose_spawn_port_conflict` -## names a foreign occupant when there is one. If you are tempted to add the -## PID list back, put it behind that existing conflict path rather than here. -func _log_spawn_exit_forensics() -> void: - var spawn_pid := int(_server_pid) - var pid_file_pid := int(_host._read_pid_file_for_proof()) - ## Computed here rather than accepted as a parameter. The caller's - ## `elapsed` IS `_spawn_dead_since_ms` — #837 passes the true death time so - ## the user-facing "server exited after Nms" line stays honest — so taking - ## it would make these two fields report the same number, collapsing the - ## exact distinction they exist to record. - var diagnosed_at_ms := 0 - if int(_server_spawn_ms) > 0: - diagnosed_at_ms = Time.get_ticks_msec() - int(_server_spawn_ms) - _host._log_buffer.log(format_spawn_exit_forensics({ - "os": OS.get_name(), - "launch_mode": ClientConfigurator.get_server_launch_mode(), - "elapsed_ms": diagnosed_at_ms, - ## Differs from elapsed_ms when a Windows handoff window was waited out - ## (#824/#837): the true death time versus when we gave up on it. - "first_dead_ms": int(_spawn_dead_since_ms), - "spawn_pid": spawn_pid, - ## Re-read rather than trusted from the watch tick: if the spawn PID is - ## alive HERE, the death that triggered this was transient, which is a - ## different bug from a process that really exited. - "spawn_alive": spawn_pid > 0 and bool(_host._pid_alive_for_proof(spawn_pid)), - "pid_file_pid": pid_file_pid, - "pid_file_alive": pid_file_pid > 0 and bool(_host._pid_alive_for_proof(pid_file_pid)), - })) - - -## Render the forensic snapshot. Pure so the format is testable without a live -## editor, and kept to one line so it survives log truncation in a bug report. -static func format_spawn_exit_forensics(facts: Dictionary) -> String: - var spawn_pid := int(facts.get("spawn_pid", 0)) - var pid_file_pid := int(facts.get("pid_file_pid", 0)) - ## The single most diagnostic bit, stated rather than left to be inferred: - ## a live pid-file process while the watched one is gone is the launcher - ## handoff shape; both gone is a real crash. - var shape := "unknown" - var spawn_alive := bool(facts.get("spawn_alive", false)) - var file_alive := bool(facts.get("pid_file_alive", false)) - if spawn_alive: - shape = "watched_pid_still_alive" - elif file_alive and pid_file_pid != spawn_pid: - shape = "handoff_child_alive" - elif not file_alive and pid_file_pid <= 0: - shape = "no_pid_file_published" - else: - shape = "all_dead" - return ( - "#797 spawn-exit forensics: shape=%s os=%s launch=%s elapsed=%dms " - + "first_dead=%dms spawn_pid=%d(alive=%s) pid_file_pid=%d(alive=%s)" - ) % [ - shape, - str(facts.get("os", "")), - str(facts.get("launch_mode", "")), - int(facts.get("elapsed_ms", 0)), - int(facts.get("first_dead_ms", 0)), - spawn_pid, - str(spawn_alive), - pid_file_pid, - str(file_alive), - ] - - -## Watch-loop callback (1 Hz, capped by SERVER_WATCH_MS). -## `--pid-file` is the source of truth on Windows / uvx where the -## launcher PID dies quickly after spawning the real interpreter. -func check_server_health() -> void: - if int(_server_pid) <= 0: - _host._stop_server_watch() - return - var elapsed := Time.get_ticks_msec() - int(_server_spawn_ms) - var real_pid := PortResolver.read_pid_file() - var spawn_pid := int(_server_pid) - if real_pid > 0 and real_pid != spawn_pid and PortResolver.pid_alive(real_pid): - _spawn_dead_since_ms = 0 - _server_pid = real_pid - ## The spawn record initially contains the launcher PID so same-session - ## teardown can kill it. Heal it as soon as the server publishes its - ## authoritative PID; future adoption requires the recorded PID to be - ## the actual live listener (#759). - _host._write_managed_server_record(real_pid, _expected_server_version(), _server_keep_alive) - ## #805: the spawn survived to publish its pid-file — proven - ## recovery, so the fast-exit re-adopt budget refreshes. - ## - ## The stale-recovery budget is deliberately NOT reset here (#890 - ## review P1): the Python server writes its pid-file during import, - ## BEFORE uvicorn binds HTTP or the WebSocket listener starts - ## (src/godot_ai/__init__.py) — publication proves the child reached - ## that line, not that it owns either port. A bridge respawn can - ## still steal the bind after the pid-file lands, and zeroing here - ## left that fast-exit with no recovery round. The budget resets on - ## the genuinely proven recoveries: adoption and the verified - ## compatible handshake. - _readopt_after_spawn_exit_retried = false - elif not PortResolver.pid_alive(spawn_pid): - _spawn_dead_since_ms = first_death_stamp(_spawn_dead_since_ms, elapsed) - if is_spawn_handoff_pending( - OS.get_name(), real_pid, elapsed, int(_host.SPAWN_HANDOFF_MS) - ): - return - if elapsed >= int(_host.SPAWN_GRACE_MS) and not McpServerStateScript.is_terminal_diagnosis(_server_state): - _diagnose_spawn_fast_exit(_spawn_dead_since_ms) - return - if elapsed >= int(_host.SERVER_WATCH_MS): - ## Survived startup — mid-session crashes surface via WebSocket disconnect. - _host._stop_server_watch() - - -## The spawned server died inside the SPAWN_GRACE_MS window. Decide what -## that means, in order: -## 1. A live godot-ai server answers on the HTTP port -> our spawn lost -## a port race the bind probe never saw (#745 bind-trap: the walk -## thought the port was free, the duplicate exited unable to bind, -## and the token it staged in the record is now stale). Re-run the -## startup walk so the adopt/recover branch handles the survivor — -## latching CRASHED here left the connection redialing forever with -## a token the surviving server rejects (close code 4003). One -## re-adopt per recovery via `_readopt_after_spawn_exit_retried` -## (#805): the triggered walk preserves the spent budget, so a -## flapping occupant (alive at each fast-exit probe, gone by each -## walk's probes — sustained multi-editor churn) latches a specific -## CRASHED diagnosis on the second round instead of re-walking -## forever. Exception: a verified stale-VERSION occupant with -## `_stale_recovery_budget` rounds left keeps re-walking (each walk -## spends one round in its recovery arm), so a bridge-respawn race -## after a self-update converges instead of latching. -## 2. #647: foreign process on the HTTP or WS port -> FOREIGN_PORT with -## an actionable message (we can't read the child's "port already in -## use" stderr). Checked before the --refresh retry: respawning -## against an occupied port can only fail the same way. -## 3. #172: stale uvx index -> one `--refresh` respawn. -## 4. Otherwise -> CRASHED, pointing at the Godot output log. -func _diagnose_spawn_fast_exit(elapsed: int) -> void: - _log_spawn_exit_forensics() - var live: Dictionary = _host._probe_live_server_status_for_port( - ClientConfigurator.http_port() - ) - if _live_status_identifies_godot_ai(live): - ## Stale-version occupant with authorized recovery rounds left: allow - ## another re-adopt walk even after the #805 one-shot is spent. This is - ## the attach-bridge respawn race — a bridge pinned to the previous - ## version respawns its cached backend the instant the old server is - ## killed, and can beat our (possibly cold) spawn to the bind. Each - ## extra walk spends one `_stale_recovery_budget` round in its recovery - ## arm, so this stays bounded; the #805 flapping diagnosis below still - ## latches for same-version/unverified occupants and once the budget is - ## exhausted. - var stale_retry := _stale_recovery_available( - str(_host._verified_status_version(live)), _expected_server_version() - ) - if not _readopt_after_spawn_exit_retried or stale_retry: - if _readopt_after_spawn_exit_retried: - ## Second and later rounds ride the stale budget — spend HERE, - ## not only in the walk's recovery arm: an occupant that flaps - ## out of existence before each walk's probe (the #805 shape) - ## would otherwise re-walk without ever spending, and the - ## terminal latch below would never be reached. - _stale_recovery_budget -= 1 - _readopt_after_spawn_exit_retried = true - _readopt_walk_pending = true - _host._log_buffer.log( - "server exited after %dms but a live godot-ai server answers on port %d — re-running adoption" - % [elapsed, ClientConfigurator.http_port()] - ) - _host._stop_server_watch() - _server_pid = -1 - ## Clear the spawn guard so the re-walk isn't GUARDED away. The - ## walk's adopt arm re-sets it and fixes the stale token/record - ## (external adoption drops both; managed adoption re-records). - _host._server_started_this_session = false - ## Fire-and-forget (mirrors force_restart_server): the walk is a - ## coroutine in production; its continuation lives on the manager. - start_server() - return - ## #805: the re-adopt budget is spent and a live godot-ai still - ## answers while our spawns keep dying — a flapping occupant - ## (another editor's server starting/stopping under it). Re-walking - ## or respawning can only repeat the cycle; latch a terminal - ## diagnosis that names the actual conflict. Reload Plugin (a fresh - ## walk) refreshes the budget for a deliberate retry. - _server_exit_ms = elapsed - _server_status_message = ( - "The spawned server keeps exiting while another godot-ai server " - + "answers on port %d, and re-adoption was already attempted. " - + "Another editor may be repeatedly starting/stopping a server on " - + "this port. Stop the other process or pick a different port, " - + "then click Reload Plugin." - ) % ClientConfigurator.http_port() - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log(str(_server_status_message)) - push_warning("MCP | %s" % _server_status_message) - _host._stop_server_watch() - return - var conflict := _diagnose_spawn_port_conflict(live) - if not conflict.is_empty(): - _server_exit_ms = elapsed - _server_status_message = str(conflict.get("message", "")) - _conflict_port = int(conflict.get("port", 0)) - set_terminal_diagnosis(McpServerStateScript.FOREIGN_PORT) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log(str(_server_status_message)) - push_warning("MCP | %s" % _server_status_message) - _host._stop_server_watch() - return - if bool(_host._should_retry_with_refresh()): - _refresh_retried = true - respawn_with_refresh() - return - _server_exit_ms = elapsed - ## Generic crash: clear any stale per-state message so the dock's - ## CRASHED body falls back to its launch-mode copy instead of text - ## from an earlier diagnosis. - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log("server exited after %dms — see Godot output log" % int(_server_exit_ms)) - _host._stop_server_watch() - - -## #647: post-crash port-conflict probe. Returns `{}` when no foreign -## conflict is detected (fall through to the CRASHED / retry path), or -## `{"message": String, "port": int}` when the HTTP or WS port is held by -## a process we can't identify as godot-ai. An occupant that *does* -## identify as godot-ai is deliberately not diagnosed here — that's the -## stale-server / adoption territory handled by `_diagnose_spawn_fast_exit`'s -## re-adopt arm (or the next `start_server` walk), not a foreign conflict. -## `pre_probed_live`: an HTTP status snapshot the caller already has on -## hand; non-empty skips the internal ~500ms probe (the probe helper never -## returns a bare `{}`, so the sentinel is unambiguous). -func _diagnose_spawn_port_conflict(pre_probed_live: Dictionary = {}) -> Dictionary: - var http_port := ClientConfigurator.http_port() - if bool(_host._is_port_in_use(http_port)): - var live: Dictionary = ( - pre_probed_live - if not pre_probed_live.is_empty() - else _host._probe_live_server_status_for_port(http_port) - ) - if _live_status_identifies_godot_ai(live): - return {} - return { - "message": ( - "Port %d is in use by another application. Stop it or change " - + "the port in Editor Settings (godot_ai/http_port)." - ) % http_port, - "port": http_port, - } - var ws_port := int(_host._resolved_ws_port) - if ws_port > 0 and bool(_host._is_port_in_use(ws_port)): - return { - "message": ( - "WebSocket port %d is in use by another application. Stop it " - + "or change the port in Editor Settings (godot_ai/ws_port)." - ) % ws_port, - "port": ws_port, - } - return {} - - -## Retry the spawn with uvx `--refresh` prepended (PyPI index can lag a -## fresh publish ~10 min — #172). One-shot per session via _refresh_retried. -func respawn_with_refresh() -> void: - _host._startup_trace_count("server_command_discovery") - var server_cmd := ClientConfigurator.get_server_command(true) - if server_cmd.is_empty(): - return - var cmd: String = server_cmd[0] - var args: Array[String] = [] - args.assign(server_cmd.slice(1)) - args.append_array(_host._build_server_flags(ClientConfigurator.http_port(), int(_host._resolved_ws_port))) - _host._clear_pid_file() - _host._log_buffer.log("retrying with --refresh (PyPI index may be stale)") - var injected_telemetry_env := _inject_telemetry_env() - ## Set owner PID for THIS spawn too (don't rely on it lingering from - ## start_server) — and unset right after, same scoping as start_server. - var owner_env_set := _set_owner_pid_env() - _set_plugin_spawned_env() - var keep_alive_env_set := _set_keep_alive_env() - var ws_token := _set_ws_token_env() - _server_pid = OS.create_process(cmd, args) - if owner_env_set: - OS.unset_environment("GODOT_AI_OWNER_PID") - OS.unset_environment("GODOT_AI_PLUGIN_SPAWNED") - if keep_alive_env_set: - OS.unset_environment("GODOT_AI_NO_IDLE_EXIT") - OS.unset_environment("GODOT_AI_WS_TOKEN") - if injected_telemetry_env: - OS.unset_environment("GODOT_AI_DISABLE_TELEMETRY") - var spawn_pid := int(_server_pid) - if spawn_pid > 0: - _server_spawn_ms = Time.get_ticks_msec() - _server_exit_ms = 0 - _spawn_dead_since_ms = 0 - _server_keep_alive = keep_alive_env_set - var current_version := _expected_server_version() - _host._set_ws_auth_token(ws_token) - _host._write_managed_server_record(spawn_pid, current_version, _server_keep_alive) - print("MCP | retried server (PID %d, v%s): %s %s" % [spawn_pid, current_version, cmd, " ".join(args)]) - else: - ## OS.create_process returned -1 on the retry — surface CRASHED - ## rather than loop. `_refresh_retried` is already true. - _server_status_message = "" - set_terminal_diagnosis(McpServerStateScript.CRASHED) - disarm_version_check() - _host._update_process_enabled() - _host._log_buffer.log("refresh retry failed to spawn — see Godot output log") - _host._stop_server_watch() - - -func adopt_compatible_server( - record_version: String, - current_version: String, - owner: int, - record_owns_listener: bool = false -) -> String: - _server_actual_name = "godot-ai" - _can_recover_incompatible = false - ## #805: adoption (managed or external) is a proven recovery — the - ## session now has a live compatible server. Refresh the fast-exit - ## re-adopt budget so a later, unrelated port race can heal again. - _readopt_after_spawn_exit_retried = false - ## The stale-recovery episode (if any) is over: leftover authorized - ## rounds must not carry into a later, unrelated walk where they could - ## weak-proof-kill a server the user deliberately started. - _stale_recovery_budget = 0 - if record_version == current_version and owner > 0 and record_owns_listener: - ## Managed adoption keeps the record's token (loaded into - ## _ws_auth_token at plugin startup) — the running server was - ## spawned with it and still verifies against it (#690). Version - ## equality alone is deliberately insufficient: the record must also - ## identify the live branded listener (#759/#764). - _server_pid = owner - ## Recover the keep-alive launch flag from the record the spawning - ## session persisted — a keep-alive survivor adopted here must - ## detach again on THIS session's exit, and only the record knows - ## how the process was actually launched. - _server_keep_alive = bool(_host._read_managed_server_record().get("keep_alive", false)) - _host._write_managed_server_record(owner, current_version, _server_keep_alive) - return McpAdoptionLabelScript.MANAGED - _server_pid = -1 - _server_keep_alive = false - ## External server: we didn't spawn it and don't know its token (it - ## most likely has none — dev servers aren't launched with one). Drop - ## ours so the handshake omits the field instead of sending a stale - ## token the server would reject. - _host._set_ws_auth_token("") - _host._clear_managed_server_record() - _host._clear_pid_file() - return McpAdoptionLabelScript.EXTERNAL - - -static func _compatible_adoption_log_message( - owner_label: String, - owned_pid: int, - observed_owner_pid: int, - live_version: String, - live_ws_port: int, - current_version: String -) -> String: - if owner_label == McpAdoptionLabelScript.MANAGED: - return "MCP | adopted managed server (PID %d, live v%s, WS %d, plugin v%s)" % [ - owned_pid, - live_version, - live_ws_port, - current_version - ] - return "MCP | adopted external server owner_pid=%d (live v%s, WS %d, plugin v%s)" % [ - observed_owner_pid, - live_version, - live_ws_port, - current_version - ] - - -## `pre_kill_live` is forwarded into the proof helper so it doesn't -## re-probe a port the caller already probed. The kill invalidates the -## snapshot — callers MUST re-probe before consuming live-status data -## after this returns. -## -## #678: coroutine in production — the proof evaluation (port scrapes + -## per-PID brand shells) and the kill + port-drain wait run off the main -## thread. The EditorSettings record is read on the main thread up front -## and injected into the proof helper; record/pid-file clears stay on the -## main thread after the awaits. -func recover_strong_port_occupant(port: int, wait_s: float, pre_kill_live: Dictionary = {}) -> bool: - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_strong_port_occupant_proof(port, pre_kill_live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - - print("MCP | strong proof: %s" % str(proof.get("proof", ""))) - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## verify_brand=true: the proof above ran in a separate _run_blocking - ## task with main-thread frames in between — re-check each target at - ## kill time so a PID recycled inside that gap isn't killed (#686). - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, wait_s) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - return false - - _host._clear_managed_server_record() - _host._clear_pid_file() - return true - - -## Weak-proof sibling of `recover_strong_port_occupant`, reserved for the -## bounded stale-version episode (`_stale_recovery_budget` — callers gate on -## `_stale_recovery_available` and spend a round BEFORE calling). Uses the -## recovery-tier proof (`status_name` allowed) because the target is an -## external previous-version server with no managed record or pid-file to -## prove strong ownership with — the same authority the dock's recovery -## click exercises, here spent under an equally explicit user action (the -## Update click / a recovery click's retry). The kill still brand-verifies -## every PID at kill time (#686), so a recycled or unrelated PID on the -## port is never killed. No pre-probed `live` parameter on purpose: a prior -## failed strong recovery may have partially changed the port's state, so -## the worker re-probes — and re-verifies STALENESS on that fresh snapshot -## (#890 CodeRabbit): the caller's version gate ran on an earlier probe, -## and a same-version server that took the port in the interval must abort -## this recovery, not be killed by it. -func recover_stale_port_occupant(port: int, wait_s: float) -> bool: - var async_gen := _async_generation - var record: Dictionary = _host._read_managed_server_record() - ## Resolved on the main thread: the worker must not touch EditorSettings. - var expected_version := _expected_server_version() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - var live: Dictionary = _host._probe_live_server_status_for_port(port) - var live_version := str(_host._verified_status_version(live)) - if live_version.is_empty() or live_version == expected_version: - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, live, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - - print("MCP | stale-occupant proof: %s" % str(proof.get("proof", ""))) - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## Warm the current-version uv env in parallel with the kill + port - ## drain below (#890 review P1): on the release that first ships this - ## code the OLD updater ran the update, so no click-time pre-warm - ## happened and the respawn would resolve cold — exactly the race - ## this episode exists to win. Detached fire-and-forget via the host - ## seam (stubbed in tests); worker-safe (CliFinder is mutex-guarded, - ## no EditorSettings access). - _host._prewarm_server_package(expected_version) - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, wait_s) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - return false - - _host._clear_managed_server_record() - _host._clear_pid_file() - return true - - -## Editor-exit teardown chooser (#800): detach only when the RUNNING -## server was launched keep-alive (_server_keep_alive, set at spawn / -## recovered on adoption) — never on the live setting, which may have -## been toggled after spawn. Flag clear → stop_server kills as always, -## so enabling the setting mid-session takes effect on the next server -## start instead of leaving a record that points at a soon-reaped PID. -func teardown_for_editor_exit() -> void: - if _server_keep_alive: - detach_server() - return - ## #824: a backend we spawned may be keeping one or more MCP clients alive - ## through their `godot-ai attach` bridges. Killing it because *this* editor - ## is closing takes the server out from under them: an in-flight call can - ## become TRANSPORT_OUTCOME_UNKNOWN, and every bridge has to establish a new - ## backend before the next editor can reconnect. A live lease means the - ## backend has consumers beyond this editor, so hand it over instead. - var leased := active_lease_count_at_exit() - if leased > 0: - ## Give up kill authority along with the process: dropping the managed - ## record means the next editor adopts it through the external branch - ## rather than as a managed server it may kill. The server's own - ## pid-file is deliberately left in place — it is the backend's - ## publication, not our claim on it, and adoption reads it. - ## - ## The Python side remains the reaper of record: a plugin-spawned - ## backend keeps its idle backstop armed (only keep_server_on_exit - ## disarms it) and that backstop is lease-aware, so this defers the - ## stop to "no editors AND no leases AND grace elapsed" rather than - ## leaking the process. - _host._clear_managed_server_record() - detach_server( - "detaching server: %d attach lease(s) still held, leaving it to the " - % leased - + "server's own idle reaper" - ) - return - stop_server() - - -## Active attach-bridge leases on the backend this editor manages, or 0 when -## there is nothing to consult (#824). -## -## Returns 0 — preserving the historical kill-on-exit behavior — for every -## uncertain case: no managed PID, a probe that fails or times out, a server -## that does not identify as godot-ai, or one too old to publish the field. -## That direction is deliberate. A false 0 costs what today already costs -## (the backend is stopped and bridges reconnect); a false positive would -## leave a process running on a guess. -## -## Bounded by the status probe's own timeout (SERVER_STATUS_PROBE_TIMEOUT_MS), -## which is what keeps editor exit from hanging on a wedged HTTP server. -func active_lease_count_at_exit() -> int: - var pid := int(_server_pid) - if pid <= 0: - return 0 - ## Only a process we can still prove is our godot-ai server earns the - ## benefit of the doubt. The lease count comes from whoever answers on the - ## port, which is not by itself proof that it IS the process we are about - ## to stop — another editor's backend, or an attach-owned one, could hold - ## the port after ours died. Requiring the same alive+branded proof - ## `stop_server` uses before its kill closes that gap: without it, a - ## stranger's leases could talk this editor out of stopping its own server. - ## - ## Failing this check is harmless either way. A dead PID has nothing to - ## kill, and a recycled-but-unbranded PID is rejected by stop_server's own - ## gate (#686) — both land on the historical path. - if not _host._pid_alive_for_proof(pid): - return 0 - if not _host._pid_cmdline_is_godot_ai_for_proof(pid): - return 0 - return active_lease_count( - _host._probe_live_server_status_for_port(ClientConfigurator.http_port()) - ) - - -## Read the advisory lease count out of a `/godot-ai/status` payload. -## -## Gated on the payload identifying as godot-ai, so an unrelated process -## answering on the port cannot talk this editor out of a clean stop. A -## missing field means an older backend that predates #824; it reads as 0, -## which keeps that pairing on today's behavior. -static func active_lease_count(live: Dictionary) -> int: - if not _live_status_identifies_godot_ai(live): - return 0 - var raw: Variant = live.get("active_lease_count") - if raw == null: - return 0 - return maxi(0, int(raw)) - - -## keep_server_on_exit (#800): editor teardown that leaves the server -## running. Mirrors stop_server's bookkeeping — cancel in-flight async -## startup, stop the watch, settle on STOPPED — but kills nothing and -## PRESERVES the managed-server record + pid-file, so the next editor -## session's start_server walk adopts the survivor through the existing -## record-matches branch (#758/#774). Explicit stops (dock Restart, -## update reload) still route through stop_server and kill as before. -## `log_reason` names why the server is being left alive; the default is the -## keep_server_on_exit wording this function was written for. #824 reuses the -## same bookkeeping for the active-lease handover, and a shared log line would -## have reported the wrong cause for it. -func detach_server( - log_reason: String = "keep_server_on_exit: leaving server running" -) -> void: - _invalidate_async_startup() - _host._stop_server_watch() - var detached_pid := int(_server_pid) - _server_pid = -1 - transition_state(McpServerStateScript.STOPPED) - if detached_pid > 0: - print("MCP | %s (PID %d)" % [log_reason, detached_pid]) - - -func stop_server() -> void: - ## Cancel any in-flight async startup (#678): a suspended start_server - ## resuming after teardown must not resurrect state or spawn a server. - _invalidate_async_startup() - _host._stop_server_watch() - if int(_server_pid) <= 0: - transition_state(McpServerStateScript.STOPPED) - return - transition_state(McpServerStateScript.STOPPING) - ## Kill the tracked PID AND the real Python PID — they differ for the - ## uvx tier (the launcher exits before its child) and on Windows - ## `OS.kill` is `TerminateProcess` which doesn't walk the child tree. - var port := ClientConfigurator.http_port() - var killed: Array = [] - var candidates: Array[int] = [] - ## Re-verify the tracked PID at kill time (#686): nothing clears - ## `_server_pid` when the server dies mid-session (`check_server_health` - ## stops watching after SERVER_WATCH_MS), so hours later the kernel may - ## have recycled this PID to an unrelated process. Every other candidate - ## in this function is brand-gated; the tracked seed must be too. A false - ## negative is fail-safe: the port stays held and the record is preserved, - ## so the next start_server's drift branch retries the kill. - var tracked_pid := int(_server_pid) - if ( - tracked_pid > 0 - and _host._pid_alive_for_proof(tracked_pid) - and _host._pid_cmdline_is_godot_ai_for_proof(tracked_pid) - ): - candidates.append(tracked_pid) - var real_pid := int(_host._find_managed_pid(port)) - ## Add the real Python PID only if it isn't already tracked and proves out - ## as ours — re-appending an already-present PID just produces a duplicate - ## kill candidate. - if real_pid > 0 and not candidates.has(real_pid) and _host._pid_cmdline_is_godot_ai_for_proof(real_pid): - candidates.append(real_pid) - var listener_pids: Array = _host._find_all_pids_on_port(port) - for pid in listener_pids: - var listener_pid := int(pid) - if candidates.has(listener_pid): - continue - if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): - candidates.append(listener_pid) - killed = _host._kill_processes_and_windows_spawn_children(candidates) - if not killed.is_empty(): - print("MCP | stopped server (PID %s)" % str(killed)) - _server_pid = -1 - _server_keep_alive = false - _host._wait_for_port_free(port, 2.0) - ## Preserve record/pid-file when port is still held — the drift - ## branch on the next start_server retries the kill (#159 follow-up). - _host._finalize_stop_if_port_free(port) - transition_state(McpServerStateScript.STOPPED) - - ## Server's `_pydantic_core.pyd` hard-link is now released — sweep - ## stale uvx builds before they trip the next attach launcher. - UvCacheCleanup.purge_stale_builds() - - -## Kill the server, reset the re-entrancy guard so the re-enabled plugin -## spawns fresh (#132). User-mode only kills via strong proof. -func prepare_for_update_reload() -> void: - stop_server() - _host._server_started_this_session = false - if ClientConfigurator.is_dev_checkout(): - return - - var port := ClientConfigurator.http_port() - if not bool(_host._is_port_in_use(port)): - return - - var proof: Dictionary = _host._evaluate_strong_port_occupant_proof(port) - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return - - _host._kill_processes_and_windows_spawn_children(targets) - _host._wait_for_port_free(port, 3.0) - if not bool(_host._is_port_in_use(port)): - _host._clear_managed_server_record() - _host._clear_pid_file() - - -# ---- Recovery click ---------------------------------------------------- - -## Returns true when a pure-state probe says recovery is allowed: -## current state is INCOMPATIBLE, the port is still held, and the -## incompatible diagnosis latched an ownership proof. Pure-state in the -## sense that nothing is killed — that's `recover_incompatible_server`. -## -## Consults the `_can_recover_incompatible` verdict that -## `_set_incompatible_server` computed off-thread instead of re-running -## the proof's port scrapes + per-PID brand shells on the main thread -## (#712): the dock polls this on refresh, and -## `recover_incompatible_server` re-proves at kill time anyway, so a -## stale latch can never kill an unproven occupant — worst case is a -## recovery click that comes back false. The port liveness re-check is -## a single local bind probe, cheap enough to stay synchronous. -func can_recover_incompatible_server() -> bool: - if _server_state != McpServerStateScript.INCOMPATIBLE: - return false - if not _can_recover_incompatible: - return false - return bool(_host._is_port_in_use(ClientConfigurator.http_port())) - - -func recover_incompatible_server() -> bool: - if _server_state != McpServerStateScript.INCOMPATIBLE: - return false - - var port := ClientConfigurator.http_port() - ## Cancel any suspended contended-port walk BEFORE the off-thread proof - ## (#712): `_run_blocking` tracks a single active worker for the - ## teardown join, so starting ours while another walk's worker is alive - ## would orphan that thread from the join guarantee. This also releases - ## the guard so the respawn at the bottom isn't silently swallowed - ## (#682 review). The user's recovery click owns the flow from here. - _invalidate_async_startup() - var async_gen := _async_generation - ## EditorSettings record read on the main thread, injected so the - ## worker never touches EditorSettings (#712, mirroring - ## recover_strong_port_occupant). - var record: Dictionary = _host._read_managed_server_record() - var proof_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return {"proof": "", "pids": []} - return _host._evaluate_recovery_port_occupant_proof(port, {}, record) - ) - if _async_stale(async_gen) or proof_result == null: - return false - var proof: Dictionary = proof_result - var targets: Array[int] = [] - targets.assign(proof.get("pids", [])) - if targets.is_empty(): - return false - print("MCP | proof: %s" % str(proof.get("proof", ""))) - - ## Move into STOPPING so the post-kill respawn passes the - ## first-writer-wins guards. - transition_state(McpServerStateScript.STOPPING) - ## Main-thread read for the worker below (EditorSettings-backed). - var recovery_expected_version := _expected_server_version() - var freed_result: Variant = await _run_blocking(func() -> Variant: - if not is_instance_valid(_host): - return false - ## Warm the current-version uv env in parallel with the kill + drain - ## so the respawn below wins the bind race even when no click-time - ## pre-warm ran (#890 review P1 — the transition release's update runs - ## the OLD updater). Same rationale and seam as - ## recover_stale_port_occupant. - _host._prewarm_server_package(recovery_expected_version) - ## verify_brand=true: the proof above ran in a separate - ## _run_blocking task with main-thread frames in between — re-check - ## each target at kill time so a PID recycled inside that gap isn't - ## killed (#686, mirroring recover_strong_port_occupant). - var killed: Array = _host._kill_processes_and_windows_spawn_children(targets, true) - if not killed.is_empty(): - print("MCP | killed pids %s on port %d" % [str(killed), port]) - _host._wait_for_port_free(port, 5.0) - return not bool(_host._is_port_in_use(port)) - ) - if _async_stale(async_gen) or freed_result == null: - return false - if not bool(freed_result): - ## Kill failed; re-latch INCOMPATIBLE so the dock keeps the - ## diagnostic UI. - transition_state(McpServerStateScript.INCOMPATIBLE) - return false - - UvCacheCleanup.purge_stale_builds() - _host._clear_managed_server_record() - _host._clear_pid_file() - transition_state(McpServerStateScript.STOPPED) - _connection_blocked = false - _server_status_message = "" - _conflict_port = 0 - _server_actual_version = "" - _server_actual_name = "" - _can_recover_incompatible = false - _host._server_started_this_session = false - _server_pid = -1 - ## Await the respawn walk: the plugin gates its connection unblock on - ## the post-walk state (SPAWNING/READY), so returning true while the - ## walk is still suspended would leave the connection blocked forever - ## after a successful recovery click (#682 review). - ## NOTE: arming the stale-recovery budget for the click lives in the - ## plugin wrapper (`plugin.gd::recover_incompatible_server`), NOT here — - ## the automatic handshake-mismatch trigger reuses this manager flow and - ## must only SPEND budget; re-arming inside it would unbound the - ## kill/respawn loop against a persistent bridge respawner. - await start_server() - return true - - -## Restart authorisation — a live PID means we spawned/adopted, a -## non-empty managed record is the cross-session proof used by the -## drift branch. -func can_restart_managed_server() -> bool: - if _server_pid > 0: - return true - var record: Dictionary = _host._read_managed_server_record() - return not str(record.get("version", "")).is_empty() - - -func has_managed_server() -> bool: - return _server_pid > 0 - - -## Reset state for a force-restart. Drops the managed record, clears -## the pid-file, and resets the spawn guard so the follow-up -## `start_server()` walks the spawn arm. -func reset_for_force_restart() -> void: - ## The user's explicit restart takes over: cancel any suspended - ## contended-port walk and release the re-entrancy guard so the - ## follow-up start isn't silently swallowed (#682 review). - _invalidate_async_startup() - _host._clear_managed_server_record() - _host._clear_pid_file() - _host._server_started_this_session = false - _server_pid = -1 - transition_state(McpServerStateScript.UNINITIALIZED) - - -## Ownership-checked kill of the port occupant + respawn. Driven from -## the dock's "Restart Server" button when the plugin adopted a foreign -## server whose version drifted from the plugin. -func force_restart_server() -> void: - if not can_restart_managed_server(): - push_warning("MCP | refusing to kill server on port %d without managed-server ownership proof" - % ClientConfigurator.http_port()) - return - var port := ClientConfigurator.http_port() - ## Kill every LISTENER on the port, not just the first one. A dev - ## server run via `uvicorn --reload` owns port 8000 through both a - ## reloader parent AND a worker child — killing only one (or zero, - ## if the single-pid parse fell over on multi-line lsof output) leaves - ## the other holding the port past `_wait_for_port_free`'s window. - ## - ## Brand-gate each raw listener PID (#686): `can_restart_managed_server()` - ## only proves we once managed *a* server, not that the port's current - ## occupants are ours — an adopted server that exited on its own can be - ## replaced on the port by an unrelated dev tool before the user clicks - ## Restart. Unbranded PIDs fall through to `_set_incompatible_server` - ## below instead of being killed. - transition_state(McpServerStateScript.STOPPING) - var restart_targets: Array[int] = [] - for pid in _host._find_all_pids_on_port(port): - var listener_pid := int(pid) - if _host._pid_cmdline_is_godot_ai_for_proof(listener_pid): - restart_targets.append(listener_pid) - _host._kill_processes_and_windows_spawn_children(restart_targets) - _host._wait_for_port_free(port, 5.0) - if _host._is_port_in_use(port): - ## Kill failed; clean baseline for the follow-up - ## `_set_incompatible_server`. - transition_state(McpServerStateScript.UNINITIALIZED) - _set_incompatible_server( - _host._probe_live_server_status_for_port(port), - _expected_server_version(), - port - ) - return - ## Same rationale as `stop_server`: the server child python just - ## released its `pydantic_core` mapping, so this is the only window in - ## which the hard-linked copies under `builds-v0\.tmp*` are deletable. - ## Sweep before respawning so the next uvx attach build doesn't - ## inherit the same cleanup-failure path that triggered the restart. - UvCacheCleanup.purge_stale_builds() - reset_for_force_restart() - start_server() diff --git a/addons/godot_ai/utils/server_lifecycle.gd.uid b/addons/godot_ai/utils/server_lifecycle.gd.uid deleted file mode 100644 index 8e62667..0000000 --- a/addons/godot_ai/utils/server_lifecycle.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwfx8b0w2mgf6 diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd deleted file mode 100644 index 4bb1371..0000000 --- a/addons/godot_ai/utils/server_version_check.gd +++ /dev/null @@ -1,126 +0,0 @@ -@tool -class_name McpServerVersionCheck -extends RefCounted - -## Standalone polling seam for the post-connection server-version -## handshake gate. Extracted from `plugin.gd` so the lifecycle manager -## stays focused on spawn/adopt/stop and the version-verify dance has -## its own home. -## -## The seam itself does NOT transition `McpServerState` on arm/disarm — -## the version check runs concurrently with whatever spawn-state the -## caller had latched (typically FOREIGN_PORT during adoption -## confirmation, or no-op directly to READY for a fresh spawn). Result -## transitions land on the manager via `handle_server_version_verified` -## (READY / INCOMPATIBLE) or `handle_server_version_unverified` -## (INCOMPATIBLE on deadline expiry); arm() leaves the state alone so a -## FOREIGN_PORT diagnosis isn't accidentally cleared before the -## handshake actually arrives. -## -## Owns the deadline timer (`_deadline_ms`) and requires the manager to -## feed it `tick(now_msec)` from the plugin's `_process` while -## `is_active()` is true. -## -## Decoupled from the connection's signal surface: `tick()` polls -## `_connection.is_connected` and `_connection.server_version` directly. -## A same-release signal addition plus a new consumer is shape-coupled work -## for old two-phase runners; they can parse the consumer while the -## McpConnection Script object still reflects v(N). We still null-check -## `_connection` because `disarm()` releases it. - -## How long to wait after the WebSocket opens before declaring the -## handshake_ack overdue. This is the sole owner of the 5s budget -## — kept at this layer so the version-check seam is self-contained. -const TIMEOUT_MS := 5 * 1000 - -## Untyped on purpose for the same self-update field-storage reason -## plugin.gd's fields are untyped. `_connection` is the live -## `McpConnection`; `_manager` is `McpServerLifecycleManager`. -## `_connection` is null between disarm() and the next arm() — the -## seam can spend most of the plugin's life dormant and we don't want -## to pin a Node that may be queue_freed in `_exit_tree`. `_manager` is -## set once at construction and held for the seam's lifetime (the -## manager owns this instance, so the cycle is short). -var _connection -var _manager -var _active: bool = false -var _deadline_ms: int = 0 -var _expected_version: String = "" - - -func _init(manager) -> void: - _manager = manager - - -## Arm the version-check. Marks the seam active, (re)attaches the -## connection it should poll, and starts watching for -## `_connection.server_version`. Does NOT transition manager state — -## the version check runs concurrently with whatever spawn-state was -## latched (e.g. FOREIGN_PORT during adoption confirmation, READY for -## a fresh spawn). Result transitions land on the manager via -## `handle_server_version_verified` / `_unverified` once the handshake -## (or its deadline) lands. -## -## The deadline starts the moment the connection actually opens, not at -## arm-time, because uvx cold-starts can take ~30s to bind the -## WebSocket and we don't want to count that against the handshake. -func arm(connection, expected_version: String) -> void: - _active = true - _deadline_ms = 0 - _expected_version = expected_version - _connection = connection - - -## Disarm without firing a verdict. Used when the manager moves on -## (e.g. recovery click → STOPPING). Releases the connection / -## manager references so the seam doesn't pin them past the active -## window — the plugin can spend most of its life with the version -## check disarmed, and `_connection` is a Node that may be queue_free'd -## by `_exit_tree`. Caller has already transitioned state, so we don't -## touch the manager. -func disarm() -> void: - _active = false - _deadline_ms = 0 - _connection = null - - -## True while the version-check needs `_process` ticks. Plugin uses -## this to gate `set_process(true)`. -func is_active() -> bool: - return _active - - -## Per-frame tick from the plugin's `_process`. No-op when disarmed. -## Returns true when the check finished this tick (verified or -## unverified) so the plugin can re-evaluate `set_process` enable. -func tick(now_msec: int) -> bool: - if not _active: - return false - if _connection == null: - return false - if not bool(_connection.is_connected): - return false - if _deadline_ms == 0: - _deadline_ms = now_msec + TIMEOUT_MS - var server_version := str(_connection.server_version) - if not server_version.is_empty(): - _complete_with_version(server_version) - return true - if now_msec >= _deadline_ms: - _complete_unverified() - return true - return false - - -func _complete_with_version(version: String) -> void: - _active = false - _deadline_ms = 0 - if _manager != null: - _manager.handle_server_version_verified(_expected_version, version) - - -func _complete_unverified() -> void: - _active = false - _deadline_ms = 0 - if _manager != null: - _manager.handle_server_version_unverified(_expected_version) diff --git a/addons/godot_ai/utils/server_version_check.gd.uid b/addons/godot_ai/utils/server_version_check.gd.uid deleted file mode 100644 index 7baacfd..0000000 --- a/addons/godot_ai/utils/server_version_check.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ciqldbuaq8i8u diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd deleted file mode 100644 index 9ce2c68..0000000 --- a/addons/godot_ai/utils/settings.gd +++ /dev/null @@ -1,135 +0,0 @@ -@tool -class_name McpSettings -extends RefCounted - -## Shared EditorSettings key constants for the godot_ai/* namespace. -## -## Centralised here so lightweight files (e.g. telemetry.gd) can reference -## settings keys without pulling in the full client_configurator.gd dep tree. -## All keys must keep their raw string values stable across releases because -## they are persisted in the user's editor_settings-4.tres. - -const SETTING_HTTP_PORT := "godot_ai/http_port" -## Comma-separated list of tool domains excluded from the server at spawn time. -const SETTING_EXCLUDED_DOMAINS := "godot_ai/excluded_domains" -const SETTING_TELEMETRY_ENABLED := "godot_ai/telemetry_enabled" -## Comma-separated CIDRs / bare IPs passed to the server as `--allow-host` -## at spawn time (#507, server core #421). Empty means loopback-only. -const SETTING_ALLOW_HOSTS := "godot_ai/allow_remote_hosts" -## Whether MCP log lines echo to the Godot console (dock "Log" toggle). -## The dock's ring-buffer log panel keeps recording regardless. -const SETTING_MCP_LOGGING := "godot_ai/mcp_logging" -## Which scope CLI-configured clients register the server in. Claude Code's -## `--scope user` writes the global block of ~/.claude.json, which that client -## loads in EVERY workspace — so the server gets spawned in unrelated, -## non-Godot projects. `project` writes /.mcp.json instead, so the -## entry travels with the project. Defaults to `user` to preserve the -## historical behaviour for existing installs. -const SETTING_CLIENT_SCOPE := "godot_ai/mcp_client_scope" - -## Scopes accepted for SETTING_CLIENT_SCOPE. These are the values the Claude -## Code CLI's `--scope` flag takes; an unrecognised setting falls back to -## DEFAULT_CLIENT_SCOPE rather than passing junk to the CLI. -const CLIENT_SCOPES := ["user", "project", "local"] -const DEFAULT_CLIENT_SCOPE := "user" - - -## Returns true if the string value is truthy -## ("1", "true", "yes", "on", case-insensitive, whitespace-trimmed). -static func truthy(value: String) -> bool: - return value.strip_edges().to_lower() in ["1", "true", "yes", "on"] - - -## Returns true if the named environment variable is set to a truthy value. -static func env_truthy(var_name: String) -> bool: - return truthy(OS.get_environment(var_name)) - - -## #691: EditorInterface / EditorSettings are not thread-safe, and the dock -## runs Configure / Remove on a worker Thread (mcp_dock.gd -## `_run_client_action_worker` -> McpClientConfigurator -> McpCliStrategy -## `_format_args` -> client_scope()). Same contract as -## McpClientConfigurator._editor_setting_lookup: the main thread does a live -## read and refreshes the snapshot; worker threads read the snapshot only, so -## a never-warmed value reads as null (unset) rather than touching -## EditorInterface off-thread. Warmed on the main thread by -## McpClientConfigurator.warm_env_snapshot() before each worker dispatch. -## -## Kept here rather than reusing the configurator's `_editor_setting_lookup` -## so utils/settings.gd stays free of the client_configurator.gd dep tree — -## see this file's header. -static var _client_scope_snapshot: Variant = null -static var _client_scope_mutex := Mutex.new() - - -static func _client_scope_setting() -> Variant: - if OS.get_thread_caller_id() == OS.get_main_thread_id(): - var live: Variant = null - if Engine.is_editor_hint(): - var es := EditorInterface.get_editor_settings() - if es != null and es.has_setting(SETTING_CLIENT_SCOPE): - live = es.get_setting(SETTING_CLIENT_SCOPE) - _client_scope_mutex.lock() - _client_scope_snapshot = live - _client_scope_mutex.unlock() - return live - _client_scope_mutex.lock() - var cached: Variant = _client_scope_snapshot - _client_scope_mutex.unlock() - return cached - - -## Main-thread pre-warm of the scope snapshot so the dock's Configure/Remove -## worker resolves `{scope}` from a value captured while EditorSettings access -## was safe. Idempotent; called from warm_env_snapshot(). -static func warm_client_scope() -> void: - _client_scope_setting() - - -## Returns the scope CLI clients should register the MCP server in, as -## substituted into `{scope}` in a descriptor's cli_register_template / -## cli_unregister_template. Safe to call from either thread (see -## _client_scope_setting). Unrecognised values fall back to -## DEFAULT_CLIENT_SCOPE so a hand-edited editor_settings-4.tres can never make -## the plugin shell out with a bad flag. -static func client_scope() -> String: - var value: Variant = _client_scope_setting() - ## String(null) is a hard error (#850) — an unset/never-warmed key must - ## short-circuit before the cast. - if value == null: - return DEFAULT_CLIENT_SCOPE - var raw := String(value).strip_edges().to_lower() - if raw in CLIENT_SCOPES: - return raw - return DEFAULT_CLIENT_SCOPE - - -## Returns true if telemetry should be active, checking in priority order: -## 1. GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY env vars -## 2. The godot_ai/telemetry_enabled EditorSetting written by the dock UI -## Defaults to true when neither source has set a preference. -static func telemetry_enabled() -> bool: - if env_truthy("GODOT_AI_DISABLE_TELEMETRY") or env_truthy("DISABLE_TELEMETRY"): - return false - var es := EditorInterface.get_editor_settings() - if es != null and es.has_setting(SETTING_TELEMETRY_ENABLED): - return bool(es.get_setting(SETTING_TELEMETRY_ENABLED)) - return true - - -## Returns whether MCP log lines should echo to the Godot console. Read at -## plugin startup (to apply the persisted choice to the log buffer and -## dispatcher) and by the dock's LogViewer toggle for its initial state. -## Defaults to true when the user has never touched the toggle. -static func mcp_logging_enabled() -> bool: - var es := EditorInterface.get_editor_settings() - if es != null and es.has_setting(SETTING_MCP_LOGGING): - return bool(es.get_setting(SETTING_MCP_LOGGING)) - return true - - -## Persist the dock "Log" toggle so the choice survives editor restarts (#626). -static func set_mcp_logging_enabled(enabled: bool) -> void: - var es := EditorInterface.get_editor_settings() - if es != null: - es.set_setting(SETTING_MCP_LOGGING, enabled) diff --git a/addons/godot_ai/utils/settings.gd.uid b/addons/godot_ai/utils/settings.gd.uid deleted file mode 100644 index b8b1547..0000000 --- a/addons/godot_ai/utils/settings.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://pefrtofs7ijw diff --git a/addons/godot_ai/utils/structured_log_ring.gd b/addons/godot_ai/utils/structured_log_ring.gd deleted file mode 100644 index 3e00b4e..0000000 --- a/addons/godot_ai/utils/structured_log_ring.gd +++ /dev/null @@ -1,156 +0,0 @@ -@tool -class_name McpStructuredLogRing -extends RefCounted - -## Head-indexed circular buffer of structured log entries shared by -## game_log_buffer and editor_log_buffer. -## -## Once `_max_lines` (set in subclass `_init`) is reached, new appends -## overwrite the oldest slot at `_head`, keeping append O(1) on overflow -## — the previous slice() approach reallocated the full retained array -## on every drop, which a chatty game would pay for thousands of times -## per second. -## -## Lockless. Subclasses needing thread-safety (editor_log_buffer is -## written from any thread a Godot Logger virtual can fire on) wrap each -## public method with their own Mutex around the `_*_unlocked` helpers. -## Keeping the base lockless means the hot game-side path (single thread, -## called from _process) doesn't pay an unused mutex cost. -## -## Entry shape is owned by subclasses — `_append_entry` takes a -## ready-built Dictionary so each buffer can carry the fields it needs -## (game: `source/level/text`; editor: adds `path/line/function`). - -const VALID_LEVELS := ["info", "warn", "error"] - -var _max_lines: int -var _storage: Array[Dictionary] = [] -## Next write position within `_storage`. While filling (before first -## wrap) equals `_storage.size()`; once full, points at the oldest entry -## (the one about to be overwritten). -var _head := 0 -var _dropped_count := 0 -## Monotonic number of entries appended since this ring was created. Unlike -## `_storage.size()` and `_dropped_count`, this intentionally survives clear() -## so callers can use it as a stable "next entry to read" cursor. -var _appended_total := 0 - - -func _init(max_lines: int) -> void: - _max_lines = max_lines - - -## Append `entry` to the ring, evicting the oldest slot when full. -## Subclasses build the dict with their per-source shape and pass it in. -func _append_entry(entry: Dictionary) -> void: - if _storage.size() < _max_lines: - _storage.append(entry) - _head = _storage.size() % _max_lines - else: - ## Full — overwrite oldest in place, advance head, count the drop. - _storage[_head] = entry - _head = (_head + 1) % _max_lines - _dropped_count += 1 - _appended_total += 1 - - -## Lockless slice. Subclasses with a mutex wrap their `get_range` / -## `get_recent` overrides around this; the lockless base implementations -## of those public methods just delegate here. -func _get_range_unlocked(offset: int, count: int) -> Array[Dictionary]: - var size := _storage.size() - var start := maxi(0, offset) - var stop := mini(size, start + count) - var out: Array[Dictionary] = [] - for i in range(start, stop): - out.append(_storage[_logical_to_physical(i)]) - return out - - -func get_range(offset: int, count: int) -> Array[Dictionary]: - return _get_range_unlocked(offset, count) - - -func get_recent(count: int) -> Array[Dictionary]: - var size := _storage.size() - var start := maxi(0, size - count) - return _get_range_unlocked(start, size - start) - - -## Lockless cursor read. The cursor is the next sequence to read: calling -## get_since(appended_total()) after a snapshot returns only later appends. -func _get_since_unlocked(since_seq: int, limit: int = -1) -> Dictionary: - var size := _storage.size() - var oldest_seq := _appended_total - size - var start_seq := mini(maxi(since_seq, oldest_seq), _appended_total) - var start := start_seq - oldest_seq - var available := maxi(0, size - start) - var count := available - if limit >= 0: - count = mini(available, limit) - var entries := _get_range_unlocked(start, count) - var next_cursor := start_seq + entries.size() - return { - "cursor": since_seq, - "oldest_cursor": oldest_seq, - "next_cursor": next_cursor, - "appended_total": _appended_total, - "truncated": since_seq < oldest_seq, - "has_more": next_cursor < _appended_total, - "entries": entries, - } - - -func get_since(since_seq: int, limit: int = -1) -> Dictionary: - return _get_since_unlocked(since_seq, limit) - - -## Lockless accessors. Subclasses with a mutex use these under their lock -## so the field reads stay encapsulated in the base instead of leaking -## `_storage` / `_dropped_count` reach-through into the subclass. -func _total_count_unlocked() -> int: - return _storage.size() - - -func _dropped_count_unlocked() -> int: - return _dropped_count - - -func _appended_total_unlocked() -> int: - return _appended_total - - -func total_count() -> int: - return _total_count_unlocked() - - -func dropped_count() -> int: - return _dropped_count_unlocked() - - -func appended_total() -> int: - return _appended_total_unlocked() - - -## Translate a logical index (0 = oldest retained) to a physical -## `_storage` slot. Before the first wrap, storage-order is logical- -## order. After wrapping, the oldest entry lives at `_head`. -func _logical_to_physical(logical: int) -> int: - if _storage.size() < _max_lines: - return logical - return (_head + logical) % _max_lines - - -## Reset the ring to empty. Subclasses with a mutex wrap this with their -## lock; subclasses that surface `clear` to callers (McpEditorLogBuffer) -## return the prior size from their wrapper. -func _clear_storage() -> void: - _storage.clear() - _head = 0 - _dropped_count = 0 - - -## Coerce unknown levels to "info" so a misbehaving sender can't poison -## downstream filters with arbitrary strings. -static func _coerce_level(level: String) -> String: - return level if level in VALID_LEVELS else "info" diff --git a/addons/godot_ai/utils/structured_log_ring.gd.uid b/addons/godot_ai/utils/structured_log_ring.gd.uid deleted file mode 100644 index 57012ba..0000000 --- a/addons/godot_ai/utils/structured_log_ring.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c4yh3jqfn6dwe diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd b/addons/godot_ai/utils/surfaced_error_tracker.gd deleted file mode 100644 index 5bd3022..0000000 --- a/addons/godot_ai/utils/surfaced_error_tracker.gd +++ /dev/null @@ -1,642 +0,0 @@ -@tool -class_name McpSurfacedErrorTracker -extends RefCounted - -## Central source for "errors the agent should know exist". -## -## Editor log cursors only cover McpEditorLogBuffer. Runtime errors from the -## game subprocess can land solely in the Debugger Errors tab, so this tracker -## promotes visible Debugger-tab rows into a monotonic sequence before the -## dispatcher stamps a watermark on each response envelope. - -const MAX_PROMOTED_DEBUGGER_ENTRIES := 500 -const MAX_PROMOTED_DEBUGGER_KEYS := 5000 -const DEBUGGER_REFRESH_MIN_INTERVAL_MS := 250 -const DEBUGGER_SCAN_AFTER_STOP_MS := 5000 -## #641: delays for the self-scheduled forced scans armed on run stop (and on -## game-helper hello, via McpDebuggerPlugin). Two ticks: an early one for rows -## the remote debugger delivers right around the event, and a late one past -## Godot's per-frame Errors-tab insertion throttle for error floods. -const DEFERRED_SCAN_DELAYS_SEC: Array[float] = [1.0, 5.0] -## #635: cap on accounted per-key row-time signatures. The live Errors tab is -## itself bounded, so this only guards a pathological flood of same-keyed rows -## with distinct time texts; past the cap the set resets to the current scan. -const MAX_ACCOUNTED_ROW_TIMES_PER_KEY := 512 - -var _editor_log_buffer -var _game_log_buffer -var _debugger_errors_root: Node -var _debugger_search_root_cache: Node -var _promoted_debugger_keys: Dictionary = {} -## #635: per-key set of Errors-tab row time texts already promoted, so a row -## observed after a clear+repopulate that no scan saw as empty still counts as -## new (see the re-promotion comment in refresh_debugger_errors). -var _promoted_debugger_row_times: Dictionary = {} -var _promoted_debugger_key_order: Array[String] = [] -var _promoted_debugger_entries: Array[Dictionary] = [] -var _debugger_promoted_total := 0 -var _run_seq := 0 -var _oldest_retained_debugger_sequence := 1 -var _last_debugger_refresh_msec := -DEBUGGER_REFRESH_MIN_INTERVAL_MS -var _debugger_scan_active := false -var _debugger_scan_until_msec := 0 -var _deferred_scans_scheduled_total := 0 - - -func _init(editor_log_buffer = null, game_log_buffer = null, debugger_errors_root: Node = null) -> void: - _editor_log_buffer = editor_log_buffer - _game_log_buffer = game_log_buffer - _debugger_errors_root = debugger_errors_root - - -func note_game_run_started(sticky_scan: bool = true) -> void: - _run_seq += 1 - _debugger_scan_active = sticky_scan - _debugger_scan_until_msec = 0 - if not sticky_scan: - _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS - refresh_debugger_errors(true) - - -func note_game_run_stopped() -> void: - _debugger_scan_active = false - _debugger_scan_until_msec = Time.get_ticks_msec() + DEBUGGER_SCAN_AFTER_STOP_MS - schedule_deferred_scans() - - -## #641: promotion into the watermark used to depend on a tool call arriving -## while the scan gate was open (run active, or within DEBUGGER_SCAN_AFTER_STOP_MS -## of stop). Boot parse errors that landed in the Errors tab with no tool call -## in that window were never promoted, so the agent never got the -## new_errors_since_last_call hint. These editor-side timers force a scan -## regardless of tool-call cadence; the next stamped response then carries the -## already-promoted count even after the gate closes. Scans are content-keyed -## and idempotent, so a timer firing after an unrelated new run is harmless. -func schedule_deferred_scans(delays: Array = DEFERRED_SCAN_DELAYS_SEC) -> void: - var tree := Engine.get_main_loop() as SceneTree - if tree == null: - return - for delay in delays: - var timer := tree.create_timer(maxf(0.05, float(delay))) - timer.timeout.connect(_on_deferred_scan_timeout) - _deferred_scans_scheduled_total += 1 - - -func deferred_scans_scheduled_total() -> int: - return _deferred_scans_scheduled_total - - -func _on_deferred_scan_timeout() -> void: - refresh_debugger_errors(true) - - -func refresh_debugger_errors(force: bool = true) -> void: - var now := Time.get_ticks_msec() - if not force and not _should_scan_debugger_for_cached_watermark(now): - return - _last_debugger_refresh_msec = now - var current_by_key: Dictionary = {} - for entry in _raw_debugger_error_entries(): - if str(entry.get("level", "")) != "error": - continue - var key := _log_entry_key(entry) - var info: Dictionary = current_by_key.get(key, {"count": 0, "entry": entry, "times": {}}) - info["count"] = int(info.get("count", 0)) + 1 - var time_text := _row_time_text(entry) - if not time_text.is_empty(): - (info["times"] as Dictionary)[time_text] = true - current_by_key[key] = info - for key in _promoted_debugger_keys.keys(): - if not current_by_key.has(key): - _promoted_debugger_keys[key] = 0 - for key in current_by_key.keys(): - var info: Dictionary = current_by_key[key] - var current := int(info.get("count", 0)) - var stored := int(_promoted_debugger_keys.get(key, 0)) - ## #635: a count increase alone misses rows observed after a run - ## boundary. Godot clears the Errors tab at run start; when the new run - ## re-fires an error identical to one promoted before the clear, and no - ## scan happened to observe the tab empty in between, the per-key count - ## never dips — so the row kept its pre-run sequence and run-scoping - ## (editor_entries_since against the run-start cursor) misclassified an - ## in-run error as retained_recent. Each Errors-tab row carries its own - ## time text; an unaccounted (key, time) signature is a row we have not - ## promoted yet, so it earns a fresh sequence even at an equal or lower - ## count. Boundary condition: rows with an empty time text, or a - ## repopulated row whose time text is byte-identical to a pre-clear row, - ## fall back to count-only dedup and can still be missed. - var unseen_times := _unaccounted_row_times(key, info.get("times", {})) - var delta := current - stored - if delta <= 0 and not unseen_times.is_empty(): - delta = mini(unseen_times.size(), current) - if delta <= 0: - if current != stored: - _promoted_debugger_keys[key] = current - continue - if not _promoted_debugger_keys.has(key): - _promoted_debugger_key_order.append(key) - _promoted_debugger_keys[key] = current - _account_row_times(key, info.get("times", {})) - _debugger_promoted_total += delta - var source_entry: Dictionary = info.get("entry", {}) - var promoted := source_entry.duplicate(true) - promoted["_debugger_key"] = key - promoted["_debugger_occurrences"] = current - promoted["_debugger_sequence"] = _debugger_promoted_total - _remove_promoted_debugger_entry(key) - _promoted_debugger_entries.append(promoted) - _trim_promoted_debugger_entries() - _trim_promoted_debugger_key_counts() - - -## #645: promote an error record that has no Errors-tab row to scrape — e.g. a -## boot-time parse error that parked the game in a remote-debugger break before -## any surface got a record. The entry joins the same promoted sequence as -## scraped Debugger rows, so run-scoping (editor_entries_since), the retained -## fallback, and the response watermark all see it with no extra plumbing. -## Re-recording the same key later (the same script still broken on the next -## run) re-promotes it with a fresh sequence, mirroring how re-appearing -## Errors-tab rows behave; scan reconciliation zeroes the key's count once the -## break ends since the row never exists in the live tab. -func record_synthetic_error(entry: Dictionary) -> void: - var key := _log_entry_key(entry) - var occurrences := int(_promoted_debugger_keys.get(key, 0)) + 1 - if not _promoted_debugger_keys.has(key): - _promoted_debugger_key_order.append(key) - _promoted_debugger_keys[key] = occurrences - _debugger_promoted_total += 1 - var promoted := entry.duplicate(true) - promoted["_debugger_key"] = key - promoted["_debugger_occurrences"] = occurrences - promoted["_debugger_sequence"] = _debugger_promoted_total - promoted["_debugger_synthetic"] = true - _remove_promoted_debugger_entry(key) - _promoted_debugger_entries.append(promoted) - _trim_promoted_debugger_entries() - _trim_promoted_debugger_key_counts() - - -## Monotonicity contract (#767): run_seq and the session-scoped components -## (editor_ring, debugger_promoted, editor_ring_warn) must NEVER decrease -## within an editor session, and the per-run components (game_error_warn, -## game_warn) must never decrease within a run — they may reset only when -## run_seq increments in the same stamp (the run boundary that rotates the -## game buffer's counters). Released servers diff consecutive stamps -## (websocket.py::_sync_error_watermark_for_session) and treat any other -## decrease as a counter reset, counting the FULL current value as new — one -## dip makes every old server out there over-report errors. Any future -## hold/classification feature must therefore DEFER an increment until its -## entry is released, never subtract an already-stamped one: a stamp like -## `raw_total - currently_held_entries` is exactly the regression this -## guards against. Producer-side coverage: -## test_editor.gd::test_surfaced_error_tracker_watermark_components_never_decrease. -func watermark(force_debugger_scan: bool = false) -> Dictionary: - refresh_debugger_errors(force_debugger_scan) - return { - "run_seq": _run_seq, - "editor_ring": _error_appended_total(), - "debugger_promoted": _debugger_promoted_total, - ## Historically misnamed: carries game-process ERROR counts only. - "game_error_warn": _game_error_total(), - ## Warn-level components, parallel to the error counts above. The server - ## diffs these into `new_warnings_since_last_call` so a warning-only run - ## surfaces instead of reading as clean. Debugger Errors-tab warning rows - ## are not promoted here yet (buffers cover push_warning from the game and - ## editor parse/@tool warnings) — tracked as a follow-up. - "editor_ring_warn": _warn_appended_total(), - "game_warn": _game_warn_total(), - } - - -static func stamp_watermark(response: Dictionary, tracker) -> void: - if tracker == null: - return - if not tracker.has_method("watermark"): - return - response["error_watermark"] = tracker.watermark() - - -func debugger_promoted_total(force_debugger_scan: bool = true) -> int: - refresh_debugger_errors(force_debugger_scan) - return _debugger_promoted_total - - -func collect_editor_log_entries() -> Array[Dictionary]: - refresh_debugger_errors(true) - var entries: Array[Dictionary] = [] - var seen_keys: Dictionary = {} - if _editor_log_buffer != null: - for entry in _editor_log_buffer.get_range(0, _editor_log_buffer.total_count()): - seen_keys[_log_entry_key(entry)] = true - entries.append(entry) - for entry in read_debugger_error_entries(): - var key := _log_entry_key(entry) - if seen_keys.has(key): - continue - seen_keys[key] = true - entries.append(entry) - ## #645: synthesized break records have no live Errors-tab row to scrape — - ## merge them from the promoted list so logs_read(source="editor") shows - ## the record that run/game responses point at. - for entry in _promoted_debugger_entries: - if not bool(entry.get("_debugger_synthetic", false)): - continue - var key := _log_entry_key(entry) - if seen_keys.has(key): - continue - seen_keys[key] = true - entries.append(_strip_promotion_bookkeeping(entry)) - return entries - - -static func _strip_promotion_bookkeeping(entry: Dictionary) -> Dictionary: - var clean := entry.duplicate(true) - for key in ["_debugger_key", "_debugger_occurrences", "_debugger_sequence", "_debugger_synthetic"]: - clean.erase(key) - return clean - - -func editor_entries_since(editor_cursor: int, debugger_cursor: int, force_debugger_scan: bool = true) -> Dictionary: - refresh_debugger_errors(force_debugger_scan) - var entries: Array[Dictionary] = [] - var seen_keys: Dictionary = {} - var truncated := false - if _editor_log_buffer != null: - var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, editor_cursor), -1) - truncated = bool(captured.get("truncated", false)) - for entry in captured.get("entries", []): - seen_keys[_log_entry_key(entry)] = true - entries.append(entry) - if debugger_cursor < _oldest_retained_debugger_sequence - 1: - truncated = true - for entry in _promoted_debugger_entries: - if int(entry.get("_debugger_sequence", 0)) <= debugger_cursor: - continue - var key := _log_entry_key(entry) - if seen_keys.has(key): - continue - seen_keys[key] = true - entries.append(entry) - return { - "entries": entries, - "truncated": truncated, - } - - -func retained_recent_editor_entries() -> Array[Dictionary]: - ## There is no shared timestamp across the editor logger ring and Godot's - ## Debugger Errors tree. Preserve the pre-PR fallback contract: newest - ## buffered editor entries first, then debugger-only rows that were not in - ## the ring, so stale Debugger rows cannot outrank newer ring entries. - var entries: Array[Dictionary] = [] - var seen_keys: Dictionary = {} - if _editor_log_buffer != null: - entries = _editor_log_buffer.get_recent(_editor_log_buffer.total_count()) - entries.reverse() - for entry in entries: - seen_keys[_log_entry_key(entry)] = true - for entry in collect_editor_log_entries(): - var key := _log_entry_key(entry) - if seen_keys.has(key): - continue - seen_keys[key] = true - entries.append(entry) - return entries - - -func read_debugger_error_entries() -> Array[Dictionary]: - var entries: Array[Dictionary] = [] - var seen_keys: Dictionary = {} - for entry in _raw_debugger_error_entries(): - var key := _log_entry_key(entry) - if seen_keys.has(key): - continue - seen_keys[key] = true - entries.append(entry) - return entries - - -func locate_debugger_error_trees() -> Array[Tree]: - var trees: Array[Tree] = [] - var root: Node = _debugger_errors_root - ## #641: a deferred-scan timer can outlive an injected root (tests, - ## teardown). A freed root must not fall through to the live editor UI — - ## that would promote unrelated real errors into a tracker scoped to the - ## dead root — so treat it as "nothing to scan". - if root != null and not is_instance_valid(root): - return trees - if root == null: - root = _debugger_search_root() - if root == null: - return trees - _collect_debugger_error_trees(root, trees) - return trees - - -func clear_debugger_error_trees() -> int: - var cleared := 0 - for tree in locate_debugger_error_trees(): - cleared += entries_from_debugger_error_tree(tree).size() - if not _press_debugger_clear_button(tree): - ## Synthetic roots in tests do not have Godot's Clear button. - tree.clear() - return cleared - - -func _debugger_search_root() -> Node: - if is_instance_valid(_debugger_search_root_cache): - return _debugger_search_root_cache - _debugger_search_root_cache = null - var base := EditorInterface.get_base_control() - if base == null: - return null - _debugger_search_root_cache = _find_first_of_class(base, "EditorDebuggerNode") - if _debugger_search_root_cache == null: - return base - return _debugger_search_root_cache - - -static func _find_first_of_class(node: Node, klass: String) -> Node: - if node.get_class() == klass: - return node - for child in node.get_children(): - var found := _find_first_of_class(child, klass) - if found != null: - return found - return null - - -static func _collect_debugger_error_trees(node: Node, out: Array[Tree]) -> void: - if node is Tree and _tree_has_debugger_errors(node as Tree): - out.append(node as Tree) - for child in node.get_children(): - if child is Node: - _collect_debugger_error_trees(child as Node, out) - - -static func _tree_has_debugger_errors(tree: Tree) -> bool: - var root := tree.get_root() - if root == null: - return false - var item := root.get_first_child() - while item != null: - if _is_debugger_error_item(item): - return true - item = item.get_next() - return false - - -static func _press_debugger_clear_button(tree: Tree) -> bool: - var parent := tree.get_parent() - if parent == null: - return false - var stack: Array[Node] = [parent] - while not stack.is_empty(): - var node: Node = stack.pop_back() - if node is BaseButton: - for conn in node.get_signal_connection_list("pressed"): - if str(conn.get("callable", "")).contains("_clear_errors_list"): - node.emit_signal("pressed") - return true - for child in node.get_children(): - stack.push_back(child) - return false - - -static func entries_from_debugger_error_tree(tree: Tree) -> Array[Dictionary]: - var entries: Array[Dictionary] = [] - var root := tree.get_root() - if root == null: - return entries - var item := root.get_first_child() - while item != null: - if _is_debugger_error_item(item): - entries.append(_entry_from_debugger_error_item(item)) - item = item.get_next() - return entries - - -static func _entry_from_debugger_error_item(item: TreeItem) -> Dictionary: - var title := item.get_text(1) - var loc := _location_from_metadata(item.get_metadata(0)) - var function := _function_from_title(title) - return { - "source": "editor", - "level": "warn" if item.has_meta("_is_warning") else "error", - "text": title, - "path": str(loc.get("path", "")), - "line": int(loc.get("line", 0)), - "function": function, - "details": _details_from_debugger_error_item(item, loc, function), - } - - -static func _details_from_debugger_error_item(item: TreeItem, loc: Dictionary, function: String) -> Dictionary: - var children: Array[Dictionary] = [] - var child := item.get_first_child() - while child != null: - var child_loc := _location_from_metadata(child.get_metadata(0)) - children.append({ - "label": child.get_text(0), - "text": child.get_text(1), - "path": str(child_loc.get("path", "")), - "line": int(child_loc.get("line", 0)), - }) - child = child.get_next() - return { - "debugger_tab": "Errors", - "time": item.get_text(0), - "message": item.get_text(1), - "error_type_name": "warning" if item.has_meta("_is_warning") else "error", - "source": { - "path": str(loc.get("path", "")), - "line": int(loc.get("line", 0)), - "function": function, - }, - "resolved": { - "path": str(loc.get("path", "")), - "line": int(loc.get("line", 0)), - "function": function, - }, - "children": children, - "frames": _frames_from_error_children(children), - } - - -static func _is_debugger_error_item(item: TreeItem) -> bool: - return item.has_meta("_is_warning") or item.has_meta("_is_error") - - -static func _frames_from_error_children(children: Array[Dictionary]) -> Array[Dictionary]: - var start := -1 - for i in children.size(): - if str(children[i].label).contains("Stack Trace"): - start = i - break - if start < 0: - for i in children.size(): - if str(children[i].label).is_empty() and not str(children[i].path).is_empty(): - start = maxi(i - 1, 0) - break - if start < 0: - return [] - var frames: Array[Dictionary] = [] - for i in range(start, children.size()): - if str(children[i].path).is_empty(): - continue - frames.append({ - "path": children[i].path, - "line": children[i].line, - "function": _function_from_frame_text(children[i].text), - }) - return frames - - -static func _location_from_metadata(meta: Variant) -> Dictionary: - if meta is Array and meta.size() >= 2: - return {"path": str(meta[0]), "line": int(meta[1])} - return {"path": "", "line": 0} - - -static func _function_from_title(title: String) -> String: - var colon := title.find(": ") - if colon <= 0: - return "" - return title.substr(0, colon) - - -static func _function_from_frame_text(text: String) -> String: - var marker := text.find(" @ ") - if marker < 0: - return "" - var fn := text.substr(marker + 3).strip_edges() - if fn.ends_with("()"): - fn = fn.substr(0, fn.length() - 2) - return fn - - -## Shared one-line rendering of a compact editor-error entry for messages and -## hints ("text (path:line)"). Single home so the debugger plugin, project -## handler, and editor handler can't drift apart. -static func format_editor_error_summary(entry: Dictionary) -> String: - var text := str(entry.get("text", "editor error")) - var path := str(entry.get("path", "")) - var line := int(entry.get("line", 0)) - if not path.is_empty() and line > 0: - return "%s (%s:%d)" % [text, path, line] - if not path.is_empty(): - return "%s (%s)" % [text, path] - return text - - -static func _log_entry_key(entry: Dictionary) -> String: - return "%s|%s|%s|%s" % [ - str(entry.get("level", "")), - str(entry.get("text", "")), - str(entry.get("path", "")), - str(entry.get("line", 0)), - ] - - -func _error_appended_total() -> int: - if _editor_log_buffer == null: - return 0 - if _editor_log_buffer.has_method("error_appended_total"): - return int(_editor_log_buffer.call("error_appended_total")) - return 0 - - -func _game_error_total() -> int: - if _game_log_buffer == null: - return 0 - if _game_log_buffer.has_method("error_total"): - return int(_game_log_buffer.call("error_total")) - return 0 - - -func _warn_appended_total() -> int: - if _editor_log_buffer == null: - return 0 - if _editor_log_buffer.has_method("warn_appended_total"): - return int(_editor_log_buffer.call("warn_appended_total")) - return 0 - - -func _game_warn_total() -> int: - if _game_log_buffer == null: - return 0 - if _game_log_buffer.has_method("warn_total"): - return int(_game_log_buffer.call("warn_total")) - return 0 - - -func _should_scan_debugger_for_cached_watermark(now_msec: int) -> bool: - if not _debugger_scan_active and now_msec > _debugger_scan_until_msec: - return false - return now_msec - _last_debugger_refresh_msec >= DEBUGGER_REFRESH_MIN_INTERVAL_MS - - -func _trim_promoted_debugger_entries() -> void: - while _promoted_debugger_entries.size() > MAX_PROMOTED_DEBUGGER_ENTRIES: - _promoted_debugger_entries.pop_front() - if _promoted_debugger_entries.is_empty(): - _oldest_retained_debugger_sequence = _debugger_promoted_total + 1 - else: - _oldest_retained_debugger_sequence = int(_promoted_debugger_entries[0].get("_debugger_sequence", 1)) - - -func _trim_promoted_debugger_key_counts() -> void: - while _promoted_debugger_key_order.size() > MAX_PROMOTED_DEBUGGER_KEYS: - var key := _promoted_debugger_key_order.pop_front() - _promoted_debugger_keys.erase(key) - _promoted_debugger_row_times.erase(key) - - -## #635: per-row time text from a scraped Errors-tab entry (column 0 of the -## row, carried in details.time). Empty when the entry has no details — e.g. -## synthetic records — which keeps those on count-only dedup. -static func _row_time_text(entry: Dictionary) -> String: - var details: Variant = entry.get("details", {}) - if details is Dictionary: - return str((details as Dictionary).get("time", "")) - return "" - - -func _unaccounted_row_times(key: String, times: Dictionary) -> Array: - var accounted: Dictionary = _promoted_debugger_row_times.get(key, {}) - var unseen := [] - for time_text in times.keys(): - if not accounted.has(time_text): - unseen.append(time_text) - return unseen - - -func _account_row_times(key: String, times: Dictionary) -> void: - if times.is_empty(): - return - var accounted: Dictionary = _promoted_debugger_row_times.get(key, {}) - for time_text in times.keys(): - accounted[time_text] = true - ## Enforce the bound AFTER merging: a pre-merge `>` check let the set - ## reach the cap and keep growing (and a batch of new times could jump - ## past it). Past the cap, reset to just this scan's times — the live - ## Errors tab is itself bounded, so this only fires under a pathological - ## same-key flood, where "recent scan only" is an acceptable memory of - ## what was promoted (worst case: a re-observed ancient row re-promotes). - if accounted.size() > MAX_ACCOUNTED_ROW_TIMES_PER_KEY: - accounted = times.duplicate() - _promoted_debugger_row_times[key] = accounted - - -func _remove_promoted_debugger_entry(key: String) -> void: - for i in range(_promoted_debugger_entries.size() - 1, -1, -1): - if str(_promoted_debugger_entries[i].get("_debugger_key", "")) == key: - _promoted_debugger_entries.remove_at(i) - return - - -func _raw_debugger_error_entries() -> Array[Dictionary]: - var entries: Array[Dictionary] = [] - for tree in locate_debugger_error_trees(): - entries.append_array(entries_from_debugger_error_tree(tree)) - return entries diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd.uid b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid deleted file mode 100644 index 433a191..0000000 --- a/addons/godot_ai/utils/surfaced_error_tracker.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://o0ulahkt83re diff --git a/addons/godot_ai/utils/update_activation_runner.gd b/addons/godot_ai/utils/update_activation_runner.gd new file mode 100644 index 0000000..2f302a2 --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd @@ -0,0 +1,391 @@ +@tool +extends Node + +## Compile this source into a GDScript with no resource_path before creating +## the node. Its named callbacks must survive replacement of the file itself. +## The caller verifies/stages/quiesces and owns the update lock. No plugin, +## dock, handler, or suspended caller frame crosses the deferred handoff. +const LIVE_ROOT := "res://addons/godot_ai" +const PLUGIN_CFG := LIVE_ROOT + "/plugin.cfg" +const STAGE_ROOT := "res://addons/.godot_ai_update/stage/addons/godot_ai" +const INSTALLER_PATH := LIVE_ROOT + "/utils/update_installer.gd" +const VERIFIER_PATH := LIVE_ROOT + "/utils/release_verifier.gd" +const RECEIPT_PATH := "res://addons/.godot_ai_update/activation.json" +const SCAN_TIMEOUT_MS := 60000 + +enum Phase { IDLE, DRAIN, REQUEST_OLD_SCAN, WAIT_OLD_SCAN, SWAPPING, REQUEST_NEW_SCAN, WAIT_NEW_SCAN, ENABLING, DONE } + +var _phase := Phase.IDLE +var _package: Dictionary = {} +var _installer: Script +var _verifier: Script +var _frames := 0 +var _deadline := 0 +var _filesystem: EditorFileSystem +var _failure := "" +var _outcome := "" +var _backup_root := "" +var _retired_scripts: Array[Script] = [] +var _inspected_objects := {} +var _inspection_budget := 100000 +## Copied by a caller when start() refuses, before the runner is freed. +var refusal_reason := "The independent activation handoff was refused." + + +func start(package: Dictionary) -> bool: + if _phase != Phase.IDLE: + refusal_reason = "The activation runner has already started." + return false + if not is_inside_tree(): + refusal_reason = "The activation runner must belong to the editor scene tree." + return false + if not str(get_script().resource_path).is_empty(): + refusal_reason = "The activation runner must be source-free before replacing its files." + return false # A file-backed runner can be replaced by the scan it starts. + if package.get("stage_root") != STAGE_ROOT: + refusal_reason = "The update stage root does not match the verified activation location." + return false + if not package.get("record") is Dictionary: + refusal_reason = "The update handoff requires an installer record Dictionary." + return false + var record: Dictionary = package.record + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.get(key) is String or str(record[key]).is_empty(): + refusal_reason = "The update record requires a nonempty String field: %s." % key + return false + if not record.get("replace_owned_mismatches") is bool: + refusal_reason = "The update record requires a boolean replace_owned_mismatches field." + return false + # Copy only values that belong to the existing installer's swap record. + _package = {"stage_root": STAGE_ROOT, "record": {}} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce", "replace_owned_mismatches"]: + _package.record[key] = record[key] + _installer = ResourceLoader.load(INSTALLER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + _verifier = ResourceLoader.load(VERIFIER_PATH, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as Script + if _installer == null or _verifier == null: + refusal_reason = "The update installer or release verifier script could not be loaded." + return false + for method in ["swap", "verify_after_restart", "release_lock", "read_lock", "discard_stage"]: + if not _declares(_installer, method): + refusal_reason = "The update installer is missing its required %s method." % method + return false + if not _declares(_verifier, "hash_tree"): + refusal_reason = "The release verifier is missing its required hash_tree method." + return false + var lock: Dictionary = _installer.call("read_lock") + if int(lock.get("pid", 0)) != OS.get_process_id() or str(lock.get("fingerprint", "")).is_empty(): + refusal_reason = "The update lock does not identify this editor with a process fingerprint." + return false + _inspected_objects.clear() + _inspection_budget = 100000 + if not _user_resources_can_keep_paths(): + return false + refusal_reason = "" + process_mode = Node.PROCESS_MODE_ALWAYS + _phase = Phase.DRAIN + set_process(false) + _begin.call_deferred() + return true + + +func _declares(script: Script, method: String) -> bool: + for entry in script.get_script_method_list(): + if str(entry.get("name", "")) == method: + return true + return false + + +func _begin() -> void: + _filesystem = EditorInterface.get_resource_filesystem() + if _filesystem == null: + _stop("The editor filesystem is unavailable; no update swap was attempted.") + return + _filesystem.sources_changed.connect(_scan_completed) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + if EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("Godot could not disable the plugin; the verified stage was retained.") + return + _frames = 2 + set_process(true) + + +func _process(_delta: float) -> void: + if _phase == Phase.DRAIN: + _frames -= 1 + if _frames <= 0: + _phase = Phase.REQUEST_OLD_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.WAIT_OLD_SCAN, Phase.REQUEST_NEW_SCAN, Phase.WAIT_NEW_SCAN]: + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan did not complete. The plugin remains disabled; the staged update and any backup were retained.") + elif _phase in [Phase.REQUEST_OLD_SCAN, Phase.REQUEST_NEW_SCAN] and not _filesystem.is_scanning(): + _phase = Phase.WAIT_OLD_SCAN if _phase == Phase.REQUEST_OLD_SCAN else Phase.WAIT_NEW_SCAN + # scan() may decline while a finished worker still awaits GUI + # completion. Its sources_changed signal remains a valid barrier. + _filesystem.scan() + + +func _swap() -> void: + set_process(false) + # A cache lookup may resolve dependencies even when the requested script + # is cached. Retain all old references while their complete tree exists; + # never load a canonical path after the swap to recover an old script. + if not _collect_cached_scripts(LIVE_ROOT): + _stop("The previous script cache could not be captured safely; no update swap was attempted.") + return + var before: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(before.get("ok", false)): + _stop("Could not hash the previous tree before activation; no swap was attempted.") + return + var swapped: Dictionary = _installer.call("swap", STAGE_ROOT, LIVE_ROOT, _package.record) + if not bool(swapped.get("ok", false)): + _failure = "Update swap failed: %s" % str(swapped.get("error", "unknown error")) + var restored: Dictionary = _verifier.call("hash_tree", LIVE_ROOT) + if not bool(restored.get("ok", false)) or restored.get("tree_sha256") != before.get("tree_sha256"): + _stop(_failure + "; the original tree could not be proven restored. Explicit repair is required.") + return + _installer.call("discard_stage") + _outcome = "previous_tree_retained" + else: + print("MCP | update to %s swapped in; reloading the plugin in this editor" % str(_package.record.to_version)) + var verified: Dictionary = _installer.call("verify_after_restart", LIVE_ROOT) + _outcome = str(verified.get("status", "")) + if _outcome != "success" and _outcome != "rolled_back": + _stop("The installed tree could not be verified: %s" % str(verified.get("error", "unknown outcome"))) + return + if _outcome == "rolled_back": + _failure = "Update verification failed; the previous tree was restored: %s" % str(verified.get("error", "")) + else: + _backup_root = str(verified.get("backup_root", "")) + var expected_backup := "res://addons/.godot_ai_update/backup/" + str(_package.record.from_version) + if _backup_root != expected_backup: + _stop("The update marker names an unexpected backup. The plugin remains disabled.") + return + var backup: Dictionary = _verifier.call("hash_tree", _backup_root) + if not bool(backup.get("ok", false)) or backup.get("tree_sha256") != before.get("tree_sha256"): + _stop("The retained backup could not be proven to match the previous tree. The plugin remains disabled.") + return + if _outcome == "success" and not _retire_cached_scripts(_backup_root): + _stop("The previous script cache could not be retained safely. The plugin remains disabled.") + return + _release_scripts() + _phase = Phase.REQUEST_NEW_SCAN + _deadline = Time.get_ticks_msec() + SCAN_TIMEOUT_MS + set_process(true) + + +func _scan_completed(_sources_changed: bool) -> void: + if _phase not in [Phase.WAIT_OLD_SCAN, Phase.WAIT_NEW_SCAN]: + return + if _filesystem.is_scanning(): + return # Another completion listener already started a newer scan. + if Time.get_ticks_msec() >= _deadline: + _stop("The filesystem scan completion arrived after the activation deadline. The plugin remains disabled.") + return + set_process(false) + # sources_changed follows scan actions, documentation, and GUI updates. + # filesystem_changed can occur earlier; a deferred completion can become + # stale during a newer GUI scan. Consume this boundary synchronously. + if _phase == Phase.WAIT_OLD_SCAN: + _phase = Phase.SWAPPING + _swap() + else: + _phase = Phase.ENABLING + _filesystem.sources_changed.disconnect(_scan_completed) + _enable() + + +func _enable() -> void: + if not _failure.is_empty() and str(_package.record.from_version).get_slice(".", 0).to_int() < 4: + # A restored capsule must show Retry instead of automatically repeating + # the failed update. This is its existing project-scoped status key. + var settings := EditorInterface.get_editor_settings() + if settings != null: + var key := "godot_ai/v4_migration_bridge_status_" + ProjectSettings.globalize_path("res://").sha256_text() + settings.set_setting(key, JSON.stringify({"error": _failure})) + EditorInterface.set_plugin_enabled(PLUGIN_CFG, true) + if not EditorInterface.is_plugin_enabled(PLUGIN_CFG): + _stop("The scanned plugin could not be enabled. The installed tree and backup were retained.") + return + if _outcome == "success": + var plugin := _find_plugin(get_tree().root) + if plugin == null or str(plugin.get("_loaded_plugin_version")) != str(_package.record.to_version): + EditorInterface.set_plugin_enabled(PLUGIN_CFG, false) + _stop("The enabled plugin did not report the installed version. The update evidence was retained.") + return + var saved := ProjectSettings.save() + if saved != OK: + _stop("The plugin is enabled, but saving its enabled state failed: %s" % error_string(saved)) + return + _phase = Phase.DONE + _record(_outcome, _failure) + if not _failure.is_empty(): + _show_failure(_failure) + return + print("MCP | update activation completed in editor PID %d" % OS.get_process_id()) + queue_free() + + +func _user_resources_can_keep_paths() -> bool: + # Retiring a Script changes resource_path for all its existing users. + # Refuse serializable scene/resource references instead of silently making + # a user's next save point into the updater's retained backup directory. + var roots: Array = [] + if EditorInterface.has_method("get_open_scene_roots"): + roots = EditorInterface.call("get_open_scene_roots") + else: + if EditorInterface.get_open_scenes().size() > 1: + refusal_reason = "This Godot version cannot inspect every open scene safely. Close the other scene tabs before retrying the update." + return false + var current := EditorInterface.get_edited_scene_root() + if current != null: + roots.append(current) + for root in roots: + if not _inspect_user_node(root): + return false + var inspected := EditorInterface.get_inspector().get_edited_object() + if inspected is Resource and not _inspect_user_value(inspected, 0): + return false + return true + + +func _inspect_user_node(node: Node) -> bool: + if not _inspect_user_value(node, 0): + return false + for child in node.get_children(): + if not _inspect_user_node(child): + return false + return true + + +func _inspect_user_value(value: Variant, depth: int) -> bool: + _inspection_budget -= 1 + if _inspection_budget < 0 or depth > 64: + refusal_reason = "Open scene or resource data exceeded the safe inspection limit. Close the affected scenes or resources before retrying the update." + return false + if value is Script: + if value.resource_path.begins_with(LIVE_ROOT + "/"): + refusal_reason = "An open scene or resource references addon script %s. In-editor activation would change its saved script path. Close the scene or resource before retrying; the update was not installed." % value.resource_path + return false + return true + if value is Resource or value is Node: + var identity: int = value.get_instance_id() + if _inspected_objects.has(identity): + return true + _inspected_objects[identity] = true + for property in value.get_property_list(): + if int(property.usage) & PROPERTY_USAGE_STORAGE: + if not _inspect_user_value(value.get(property.name), depth + 1): + return false + elif value is Array: + for entry in value: + if not _inspect_user_value(entry, depth + 1): + return false + elif value is Dictionary: + for key in value: + if not _inspect_user_value(key, depth + 1) or not _inspect_user_value(value[key], depth + 1): + return false + return true + + +func _retire_cached_scripts(backup_root: String) -> bool: + # All references were captured before replacement. Godot moves + # both its resource and GDScript cache entries through take_over_path(). + # Existing UndoRedo callbacks keep the old compiled code and typed state; + # canonical loads create a fresh graph, including fresh static variables. + for script in _retired_scripts: + var previous_path := script.resource_path + var retained_path := backup_root + previous_path.trim_prefix(LIVE_ROOT) + if not previous_path.begins_with(LIVE_ROOT + "/") or not FileAccess.file_exists(retained_path): + return false + if ResourceLoader.has_cached(retained_path): + return false # Never displace a different retained generation. + # A later update in this same editor must not prune source files still + # referenced by an earlier generation's undo callbacks. + get_tree().root.set_meta("godot_ai_retained_update_scripts", true) + for script in _retired_scripts: + var retained_path := backup_root + script.resource_path.trim_prefix(LIVE_ROOT) + script.take_over_path(retained_path) + if script.resource_path != retained_path: + return false + return true + + +func _collect_cached_scripts(folder: String) -> bool: + var directory := DirAccess.open(folder) + if directory == null: + return false + for filename in directory.get_files(): + if not filename.ends_with(".gd"): + continue + var canonical := folder.path_join(filename) + if ResourceLoader.has_cached(canonical): + var script := ResourceLoader.load(canonical) as Script + if script == null or script.resource_path != canonical: + return false + _retired_scripts.append(script) + for child in directory.get_directories(): + if not _collect_cached_scripts(folder.path_join(child)): + return false + return true + + +func _find_plugin(node: Node) -> Node: + var script := node.get_script() as Script + if script != null and script.resource_path == LIVE_ROOT + "/plugin.gd": + return node + for child in node.get_children(): + var found := _find_plugin(child) + if found != null: + return found + return null + + +func _release_scripts() -> void: + if _installer != null: + _installer.call("release_lock") + _installer = null + _verifier = null + + +func _stop(message: String) -> void: + _phase = Phase.DONE + set_process(false) + if _filesystem != null and _filesystem.sources_changed.is_connected(_scan_completed): + _filesystem.sources_changed.disconnect(_scan_completed) + _release_scripts() + _record("activation_failed", message) + _show_failure(message) + + +func _record(status: String, message: String) -> void: + var file := FileAccess.open(RECEIPT_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"status": status, "error": message, + "editor_pid": OS.get_process_id(), "from_version": _package.record.from_version, + "to_version": _package.record.to_version, "unix": Time.get_unix_time_from_system()})) + else: + push_error("MCP | could not write update activation evidence: %s" % error_string(FileAccess.get_open_error())) + + +func _show_failure(message: String) -> void: + push_error("MCP | %s" % message) + if DisplayServer.get_name() == "headless": + queue_free() + return + var dialog := AcceptDialog.new() + dialog.title = "Godot AI update needs attention" + dialog.dialog_text = message + "\nUpdate evidence: res://addons/.godot_ai_update/" + add_child(dialog) + dialog.confirmed.connect(_dismiss_failure) + dialog.canceled.connect(_dismiss_failure) + dialog.popup_centered(Vector2i(620, 220)) + + +func _dismiss_failure() -> void: + ## Godot may have left its shared progress dialog under the failure window. + ## Return it before freeing our subtree and leaving the editor's pointer dangling. + if is_inside_tree(): + for dialog in find_children("*", "ProgressDialog", true, false): + dialog.reparent(get_tree().root) + queue_free() diff --git a/addons/godot_ai/utils/update_activation_runner.gd.uid b/addons/godot_ai/utils/update_activation_runner.gd.uid new file mode 100644 index 0000000..fd3597f --- /dev/null +++ b/addons/godot_ai/utils/update_activation_runner.gd.uid @@ -0,0 +1 @@ +uid://mj7jpaq6wt27 diff --git a/addons/godot_ai/utils/update_installer.gd b/addons/godot_ai/utils/update_installer.gd new file mode 100644 index 0000000..a74c893 --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd @@ -0,0 +1,548 @@ +@tool +class_name McpUpdateInstaller +extends RefCounted + +## The in-editor half of the lean self-updater: stage a verified archive +## beside the live add-on, take the project-wide update lock, swap the two +## trees with two directory renames, and after the restart verify the live +## tree against the signed inventory, rolling back to the retained backup on +## any mismatch. See docs/self-update.md. +## +## Everything lives under `res://addons/.godot_ai_update/` (dot-prefixed so +## Godot's scanner ignores it; a `.gdignore` and a `.gitignore` are written +## on first use) plus the live root the caller passes in. No function writes +## anywhere else, no failure path deletes a backup, and every function fails +## closed with a specific error string instead of raising. + +const UPDATE_ROOT := "res://addons/.godot_ai_update" +const STAGE_DIR := UPDATE_ROOT + "/stage" +const STAGE_PLUGIN_ROOT := STAGE_DIR + "/addons/godot_ai" +const BACKUP_DIR := UPDATE_ROOT + "/backup" +const QUARANTINE_DIR := UPDATE_ROOT + "/quarantine" +const PENDING_FILE := UPDATE_ROOT + "/pending.json" +const LOCK_FILE := UPDATE_ROOT + "/lock.json" +const DEFAULT_LIVE_ROOT := "res://addons/godot_ai" +const ENABLED_PLUGINS_SETTING := "editor_plugins/enabled" + +const STATUS_SWAPPED := "swapped" +const STATUS_SUCCESS := "success" +const STATUS_ROLLED_BACK := "rolled_back" +const STATUS_REPAIR_REQUIRED := "repair_required" + +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ReleaseVerifier := preload("res://addons/godot_ai/utils/release_verifier.gd") + + +# ----- staging ---------------------------------------------------------- + + +## Extract `zip_path` into a fresh `stage/addons/godot_ai/` and prove the +## extracted tree hashes to exactly the signed inventory. Returns +## `{"ok", "error", "stage_root", "tree_sha256"}`; `stage_root` is the +## `res://` path of the staged plugin directory. +static func stage(zip_path: String, manifest: Dictionary) -> Dictionary: + var result := {"ok": false, "error": "", "stage_root": "", "tree_sha256": ""} + var validated := ReleaseVerifier.validate_manifest_dict(manifest) + if not bool(validated.ok): + result.error = "stage: %s" % str(validated.error) + return result + var normalised: Dictionary = validated.manifest + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "stage: %s" % str(rooted.error) + return result + var stage_abs := _absolute(STAGE_DIR) + if _exists(stage_abs): + var removed := remove_under_update_root(STAGE_DIR) + if not bool(removed.ok): + result.error = "stage: cannot clear previous stage: %s" % str(removed.error) + return result + var reader := ZIPReader.new() + var open_error := reader.open(_absolute(zip_path)) + if open_error != OK: + result.error = "stage: cannot open archive %s: %s" % [zip_path, error_string(open_error)] + return result + var rows: Array = normalised["inventory"] + var expected: Array = [] + for row in rows: + expected.append(str(row["path"])) + if Array(reader.get_files()) != expected: + reader.close() + result.error = "stage: archive entries do not match the signed inventory (run verify_archive for detail)" + return result + for row in rows: + var path := str(row["path"]) + var target := stage_abs.path_join(path) + var make_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir()) + if make_error != OK: + reader.close() + result.error = "stage: cannot create %s: %s" % [target.get_base_dir(), error_string(make_error)] + return result + var write_error := _write_bytes(target, reader.read_file(path)) + if write_error != OK: + reader.close() + result.error = "stage: cannot write %s: %s" % [target, error_string(write_error)] + return result + reader.close() + var expected_hash := ReleaseVerifier.inventory_tree_hash(normalised) + var hashed := ReleaseVerifier.hash_tree(STAGE_PLUGIN_ROOT) + if not bool(hashed.ok): + remove_under_update_root(STAGE_DIR) + result.error = "stage: %s" % str(hashed.error) + return result + if expected_hash.is_empty() or str(hashed.tree_sha256) != expected_hash: + remove_under_update_root(STAGE_DIR) + result.error = "stage: staged tree hash %s differs from the signed inventory hash %s" % [ + str(hashed.tree_sha256), expected_hash, + ] + return result + result.stage_root = STAGE_PLUGIN_ROOT + result.tree_sha256 = str(hashed.tree_sha256) + result.ok = true + return result + + +## Remove `stage/` if present (for example when quiescence is refused after +## staging). Never touches backups, quarantine, or the live tree. +static func discard_stage() -> void: + if _exists(_absolute(STAGE_DIR)): + remove_under_update_root(STAGE_DIR) + + +# ----- lock ------------------------------------------------------------- + + +## Take `lock.json` for this editor. A lock whose recorded process is alive +## and carries a different fingerprint refuses; a dead or own lock is +## replaced. Returns `{"ok", "error"}`. +static func acquire_lock(pid: int, fingerprint: String) -> Dictionary: + var result := {"ok": false, "error": ""} + if pid <= 0 or fingerprint.is_empty(): + result.error = "lock: a positive pid and a non-empty fingerprint are required" + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "lock: %s" % str(rooted.error) + return result + var existing := read_lock() + if not existing.is_empty(): + var other_pid := int(existing.get("pid", 0)) + var other_fingerprint := str(existing.get("fingerprint", "")) + if other_pid > 0 and other_fingerprint != fingerprint and PortResolver.pid_alive(other_pid): + result.error = "lock: update lock is held by live process %d (%s) since %d" % [ + other_pid, other_fingerprint, int(existing.get("started_unix", 0)), + ] + return result + var write_error := _write_json(LOCK_FILE, { + "pid": pid, + "fingerprint": fingerprint, + "started_unix": int(Time.get_unix_time_from_system()), + }) + if write_error != OK: + result.error = "lock: cannot write %s: %s" % [LOCK_FILE, error_string(write_error)] + return result + result.ok = true + return result + + +## Drop `lock.json` when it belongs to this process or to a dead one. A +## lock held by another live process is left in place. +static func release_lock() -> void: + var existing := read_lock() + if existing.is_empty(): + if FileAccess.file_exists(_absolute(LOCK_FILE)): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + return + var pid := int(existing.get("pid", 0)) + if pid == OS.get_process_id() or not PortResolver.pid_alive(pid): + DirAccess.remove_absolute(_absolute(LOCK_FILE)) + + +## The current lock record, or `{}` when there is none or it is unreadable. +static func read_lock() -> Dictionary: + return _read_json(LOCK_FILE) + + +# ----- swap ------------------------------------------------------------- + + +## Replace the live tree with the staged one using two directory renames: +## live -> `backup//`, then stage -> live. If the second rename +## fails the first is undone. On success `pending.json` holds every key of +## `record` verbatim plus `status: "swapped"`, `backup_root`, `live_root`, +## `expected_tree_sha256` and `swapped_unix`. `record` must carry +## `from_version`, `to_version`, `manifest_sha256`, `expected_tree_sha256` +## and `editor_nonce`. Refuses when `backup//` already exists: +## backups are pruned only after a verified success, so a leftover one means +## an unresolved earlier update. +static func swap(stage_root: String, live_root: String, record: Dictionary) -> Dictionary: + var result := {"ok": false, "error": ""} + for key in ["from_version", "to_version", "manifest_sha256", "expected_tree_sha256", "editor_nonce"]: + if not record.has(key) or not record[key] is String or str(record[key]).is_empty(): + result.error = "swap: record is missing %s" % key + return result + var from_version := str(record["from_version"]) + var to_version := str(record["to_version"]) + if not _is_safe_segment(from_version) or not _is_safe_segment(to_version): + result.error = "swap: versions must be plain path segments, got %s -> %s" % [from_version, to_version] + return result + if not _is_hex(str(record["expected_tree_sha256"]), 64): + result.error = "swap: expected_tree_sha256 must be 64 lowercase hex characters" + return result + var stage_abs := _absolute(stage_root) + var live_abs := _absolute(live_root) + if not DirAccess.dir_exists_absolute(stage_abs): + result.error = "swap: stage root does not exist: %s" % stage_abs + return result + if not DirAccess.dir_exists_absolute(live_abs): + result.error = "swap: live root does not exist: %s" % live_abs + return result + if stage_abs.simplify_path() == live_abs.simplify_path(): + result.error = "swap: stage and live roots are the same directory" + return result + var pending := read_pending() + if not pending.is_empty() and str(pending.get("status", "")) == STATUS_SWAPPED: + result.error = "swap: an earlier swap is still pending verification (%s)" % PENDING_FILE + return result + var rooted := ensure_root() + if not bool(rooted.ok): + result.error = "swap: %s" % str(rooted.error) + return result + var backup_root := BACKUP_DIR + "/" + from_version + var backup_abs := _absolute(backup_root) + if _exists(backup_abs): + result.error = "swap: backup already exists at %s; an earlier update was never resolved" % backup_root + return result + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(BACKUP_DIR)) + if make_error != OK: + result.error = "swap: cannot create %s: %s" % [BACKUP_DIR, error_string(make_error)] + return result + var first := DirAccess.rename_absolute(live_abs, backup_abs) + if first != OK: + result.error = "swap: cannot move live tree %s to %s: %s" % [live_abs, backup_abs, error_string(first)] + return result + var second := DirAccess.rename_absolute(stage_abs, live_abs) + if second != OK: + var restore := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot move stage %s into %s: %s" % [stage_abs, live_abs, error_string(second)] + if restore != OK: + result.error += "; restoring the backup failed too (%s): live tree is at %s" % [ + error_string(restore), backup_abs, + ] + return result + var marker := record.duplicate(true) + marker["status"] = STATUS_SWAPPED + marker["backup_root"] = backup_root + marker["live_root"] = live_root + marker["expected_tree_sha256"] = str(record["expected_tree_sha256"]) + marker["swapped_unix"] = int(Time.get_unix_time_from_system()) + ## The editor that verifies the swap should be this same Godot. On macOS + ## the restart goes through LaunchServices, which has been seen to launch + ## another installed copy of Godot; recording the version lets the next + ## start explain that instead of silently refusing or verifying elsewhere. + marker["godot_version"] = str(Engine.get_version_info().get("string", "")) + var write_error := _write_json(PENDING_FILE, marker) + if write_error != OK: + var undo_stage := DirAccess.rename_absolute(live_abs, stage_abs) + var undo_backup := DirAccess.rename_absolute(backup_abs, live_abs) + result.error = "swap: cannot write %s: %s" % [PENDING_FILE, error_string(write_error)] + if undo_stage != OK or undo_backup != OK: + result.error += "; undoing the swap failed: new tree at %s, old tree at %s" % [ + live_abs if undo_stage != OK else stage_abs, backup_abs if undo_backup != OK else live_abs, + ] + return result + ## The staged tree now lives; only its empty scaffold is left behind. + discard_stage() + result.ok = true + return result + + +# ----- pending marker --------------------------------------------------- + + +## The `pending.json` record, `{}` when there is none. An unreadable marker +## is reported as `status: "repair_required"` rather than ignored. +static func read_pending() -> Dictionary: + var absolute := _absolute(PENDING_FILE) + if not FileAccess.file_exists(absolute): + return {} + var marker := _read_json(PENDING_FILE) + if marker.is_empty(): + return { + "status": STATUS_REPAIR_REQUIRED, + "error": "pending marker %s exists but is not a JSON object; inspect it by hand" % PENDING_FILE, + } + return marker + + +## Record in the success marker that the plugin repinned client configuration, +## so later starts do not repeat the migration. No-op unless the marker +## records success. +static func record_clients_migrated() -> Error: + var marker := read_pending() + if str(marker.get("status", "")) != STATUS_SUCCESS: + return ERR_UNAVAILABLE + marker["clients_migrated"] = true + return _write_json(PENDING_FILE, marker) + + +## Remove `pending.json`. +static func clear_pending() -> void: + var absolute := _absolute(PENDING_FILE) + if FileAccess.file_exists(absolute): + DirAccess.remove_absolute(absolute) + + +## After the restart: settle a `swapped` marker. Hash the live tree and +## compare it to `expected_tree_sha256`. Equal: the marker becomes +## `success`. Different: the live tree is renamed to +## `quarantine//`, the backup is renamed back into place, and the +## marker becomes `rolled_back` with the reason. No backup: the marker becomes +## `repair_required` naming the exact paths and the live tree is left alone. +## Nothing is ever deleted. A marker in any other status is returned +## unchanged, and `{}` means there is nothing pending. Every marker key is +## preserved verbatim. +static func verify_after_restart(live_root: String) -> Dictionary: + var marker := read_pending() + if marker.is_empty(): + return {} + if str(marker.get("status", "")) != STATUS_SWAPPED: + ## A success marker whose client migration already ran is the durable + ## record of the last update, not work for this start. + if str(marker.get("status", "")) == STATUS_SUCCESS and bool(marker.get("clients_migrated", false)): + return {} + return marker + var live_abs := _absolute(live_root) + var expected := str(marker.get("expected_tree_sha256", "")) + var hashed := ReleaseVerifier.hash_tree(live_abs) + if bool(hashed.ok) and _is_hex(expected, 64) and str(hashed.tree_sha256) == expected: + marker["status"] = STATUS_SUCCESS + marker["error"] = "" + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + var reason := "" + if not bool(hashed.ok): + reason = "live tree could not be hashed: %s" % str(hashed.error) + elif not _is_hex(expected, 64): + reason = "pending marker carries no usable expected_tree_sha256" + else: + reason = "live tree hash %s does not match the expected %s" % [str(hashed.tree_sha256), expected] + var backup_root := str(marker.get("backup_root", "")) + var backup_abs := _absolute(backup_root) if not backup_root.is_empty() else "" + if backup_abs.is_empty() or not DirAccess.dir_exists_absolute(backup_abs): + return _settle_repair(marker, "%s; no backup exists at %s to restore, live tree left at %s" % [ + reason, backup_abs if not backup_abs.is_empty() else "(unrecorded)", live_abs, + ]) + var to_version := str(marker.get("to_version", "")) + var quarantine_root := QUARANTINE_DIR + "/" + (to_version if _is_safe_segment(to_version) else "unknown") + var quarantine_abs := _absolute(quarantine_root) + if _exists(quarantine_abs): + return _settle_repair(marker, "%s; quarantine already exists at %s, live tree left at %s, backup at %s" % [ + reason, quarantine_abs, live_abs, backup_abs, + ]) + if DirAccess.dir_exists_absolute(live_abs): + var make_error := DirAccess.make_dir_recursive_absolute(_absolute(QUARANTINE_DIR)) + if make_error != OK: + return _settle_repair(marker, "%s; cannot create %s (%s), live tree left at %s, backup at %s" % [ + reason, QUARANTINE_DIR, error_string(make_error), live_abs, backup_abs, + ]) + var quarantine_error := DirAccess.rename_absolute(live_abs, quarantine_abs) + if quarantine_error != OK: + return _settle_repair(marker, "%s; cannot move live tree %s to %s (%s), backup at %s" % [ + reason, live_abs, quarantine_abs, error_string(quarantine_error), backup_abs, + ]) + var restore_error := DirAccess.rename_absolute(backup_abs, live_abs) + if restore_error != OK: + var undo := DirAccess.rename_absolute(quarantine_abs, live_abs) + return _settle_repair(marker, "%s; cannot restore backup %s to %s (%s); new tree is at %s" % [ + reason, backup_abs, live_abs, error_string(restore_error), + live_abs if undo == OK else quarantine_abs, + ]) + marker["status"] = STATUS_ROLLED_BACK + marker["error"] = reason + marker["quarantine_root"] = quarantine_root + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +# ----- retention -------------------------------------------------------- + + +## Remove every `backup//` except `keep_version`. Call only after a +## verified success. +static func prune_backups(keep_version: String) -> void: + var backups := DirAccess.open(_absolute(BACKUP_DIR)) + if backups == null: + return + backups.include_hidden = true + backups.include_navigational = false + for name in backups.get_directories(): + if name != keep_version: + remove_under_update_root(BACKUP_DIR + "/" + name) + + +# ----- restart ---------------------------------------------------------- + + +## Add `plugin_cfg` to `editor_plugins/enabled` and save project.godot so the +## next editor start loads the add-on, without enabling it in this process. +static func persist_next_start_enabled(plugin_cfg: String) -> Error: + if plugin_cfg.is_empty(): + return ERR_INVALID_PARAMETER + var enabled := PackedStringArray() + if ProjectSettings.has_setting(ENABLED_PLUGINS_SETTING): + var current: Variant = ProjectSettings.get_setting(ENABLED_PLUGINS_SETTING) + if current is PackedStringArray: + enabled = current + elif current is Array: + for item in current: + enabled.append(str(item)) + if not enabled.has(plugin_cfg): + enabled.append(plugin_cfg) + ProjectSettings.set_setting(ENABLED_PLUGINS_SETTING, enabled) + return ProjectSettings.save() + + +## Restart the editor, saving open scenes first. +static func request_restart() -> void: + if Engine.is_editor_hint(): + EditorInterface.restart_editor(true) + + +# ----- update root ------------------------------------------------------ + + +## Create `res://addons/.godot_ai_update/` with its `.gdignore` and +## `.gitignore` (`*`). Returns `{"ok", "error"}`. +static func ensure_root() -> Dictionary: + var result := {"ok": false, "error": ""} + var root_abs := _absolute(UPDATE_ROOT) + var make_error := DirAccess.make_dir_recursive_absolute(root_abs) + if make_error != OK: + result.error = "cannot create %s: %s" % [UPDATE_ROOT, error_string(make_error)] + return result + for marker in [[".gdignore", ""], [".gitignore", "*\n"]]: + var path := root_abs.path_join(str(marker[0])) + if FileAccess.file_exists(path): + continue + var write_error := _write_bytes(path, str(marker[1]).to_utf8_buffer()) + if write_error != OK: + result.error = "cannot write %s: %s" % [path, error_string(write_error)] + return result + result.ok = true + return result + + +## Recursively remove a file or directory, refusing anything outside the +## update root. Returns `{"ok", "error"}`. +static func remove_under_update_root(path: String) -> Dictionary: + var result := {"ok": false, "error": ""} + var absolute := _absolute(path).simplify_path() + var root_abs := _absolute(UPDATE_ROOT).simplify_path() + if absolute == root_abs or not absolute.begins_with(root_abs + "/"): + result.error = "refusing to remove %s: outside %s" % [absolute, UPDATE_ROOT] + return result + var remove_error := _remove_tree(absolute) + if remove_error != OK: + result.error = "cannot remove %s: %s" % [absolute, error_string(remove_error)] + return result + result.ok = true + return result + + +# ----- helpers ---------------------------------------------------------- + + +static func _settle_repair(marker: Dictionary, error: String) -> Dictionary: + marker["status"] = STATUS_REPAIR_REQUIRED + marker["error"] = error + marker["verified_unix"] = int(Time.get_unix_time_from_system()) + _write_json(PENDING_FILE, marker) + return marker + + +static func _remove_tree(absolute: String) -> Error: + var parent := DirAccess.open(absolute.get_base_dir()) + if parent == null: + return DirAccess.get_open_error() + if not DirAccess.dir_exists_absolute(absolute) or parent.is_link(absolute): + return DirAccess.remove_absolute(absolute) + var directory := DirAccess.open(absolute) + if directory == null: + return DirAccess.get_open_error() + directory.include_hidden = true + directory.include_navigational = false + for name in directory.get_files(): + var file_error := DirAccess.remove_absolute(absolute.path_join(name)) + if file_error != OK: + return file_error + for name in directory.get_directories(): + var child_error := _remove_tree(absolute.path_join(name)) + if child_error != OK: + return child_error + return DirAccess.remove_absolute(absolute) + + +static func _write_bytes(absolute: String, data: PackedByteArray) -> Error: + var file := FileAccess.open(absolute, FileAccess.WRITE) + if file == null: + return FileAccess.get_open_error() + file.store_buffer(data) + var error := file.get_error() + file.close() + return error + + +static func _write_json(path: String, data: Dictionary) -> Error: + return _write_bytes(_absolute(path), (JSON.stringify(data, "\t") + "\n").to_utf8_buffer()) + + +static func _read_json(path: String) -> Dictionary: + var absolute := _absolute(path) + if not FileAccess.file_exists(absolute): + return {} + var bytes := FileAccess.get_file_as_bytes(absolute) + if bytes.is_empty() or not ReleaseVerifier.is_valid_utf8(bytes): + return {} + var parsed: Variant = JSON.parse_string(bytes.get_string_from_utf8()) + if not parsed is Dictionary: + return {} + return parsed + + +static func _exists(absolute: String) -> bool: + return DirAccess.dir_exists_absolute(absolute) or FileAccess.file_exists(absolute) + + +static func _is_safe_segment(name: String) -> bool: + if name.is_empty() or name.length() > 64 or name == "." or name == ".." or name.begins_with("."): + return false + for index in name.length(): + var code := name.unicode_at(index) + var ok := ( + (code >= 48 and code <= 57) + or (code >= 65 and code <= 90) + or (code >= 97 and code <= 122) + or code == 45 or code == 46 or code == 95 + ) + if not ok: + return false + return true + + +static func _is_hex(text: String, length: int) -> bool: + if text.length() != length: + return false + for index in text.length(): + var code := text.unicode_at(index) + if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)): + return false + return true + + +static func _absolute(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path diff --git a/addons/godot_ai/utils/update_installer.gd.uid b/addons/godot_ai/utils/update_installer.gd.uid new file mode 100644 index 0000000..337a3af --- /dev/null +++ b/addons/godot_ai/utils/update_installer.gd.uid @@ -0,0 +1 @@ +uid://c30k72xyjmfrp diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd deleted file mode 100644 index 795d7ac..0000000 --- a/addons/godot_ai/utils/update_manager.gd +++ /dev/null @@ -1,775 +0,0 @@ -@tool -class_name McpUpdateManager -extends Node - -## Self-update manager for pre-runner work. Owns release checks, HTTP ZIP -## download, the install-in-flight gate, and install state signals back to -## the dock. Once `_install_zip()` calls -## `plugin.gd::install_downloaded_update(...)`, ownership transfers to -## `update_reload_runner.gd`, which owns extract, scan, plugin re-enable, -## and detached-dock cleanup. -## -## The dock owns banner rendering and forwards button clicks. The split -## exists because the dock script is one of the files overwritten on disk -## during install — keeping pipeline state on a separate Node lets the dock -## tear down cleanly without losing the in-flight gate that other dock spawn -## paths consult. -## -## `class_name McpUpdateManager` is retained because it shipped in a -## published release. If this class is ever retired, follow CLAUDE.md's -## never-delete-published-class_name shim policy instead of deleting the -## declaration. -## -## `_plugin` and `_dock` are deliberately untyped: the same self-update -## window that overwrites this script also overwrites the dock and plugin -## scripts, and a static-typed reference into a script being hot-reloaded -## crashes inside `GDScriptFunction::call`. `server_lifecycle.gd` follows -## the same convention. - -const RELEASES_URL := ( - "https://api.github.com/repos/hi-godot/godot-ai/releases/latest" -) -const RELEASES_PAGE := "https://github.com/hi-godot/godot-ai/releases/latest" -const UPDATE_TEMP_DIR := "user://godot_ai_update/" -const UPDATE_TEMP_ZIP := "user://godot_ai_update/update.zip" -const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") - -## RSA-4096 public key for release-signature verification (#687). The paired -## private key exists only in the GitHub Actions secret RELEASE_SIGNING_KEY_PEM -## (plus the maintainer's offline backup) — deliberately outside the repo -## token's scope, because the threat model is release-asset substitution by a -## leaked token or compromised workflow, and a token that can rewrite assets -## still cannot read secrets. Rotation requires shipping a new plugin release -## embedding the new key (and bumping SIGNING_REQUIRED_FROM_VERSION past the -## last release signed with the old one). -const RELEASE_SIGNING_PUBLIC_KEY_PEM := """-----BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4OmbONFTONGFcXSUQ2p -e54YaUhWDA75wxeDWhOc476vsdo53YnXEFT7EPr2hUKqeNxv++LqKOkFuAsxSNZy -wBe6P1tmQA4Og6Ezv4CGnZdEj1uhlDJFK9ShQ29oWfC6bf/84625SvvBxZos2Br9 -yPKl7h5wzqDoeUSpv+f0ynTiC0i/HAUo/NQBlkgGwkomK2Fr3pP1VDxxq2xvgHSk -lU6Qcomr9WjJxI+HkDN5tRPPn0pDrg6YFx2J18OfD8KIa/kMGxuXOcHlPyRYpjyu -qTtg2oL0NyUIG+1TmJ3DcN4GlKC55eOrkfJ04vudS5pxdnUIFRmkGBXZLdaetoPc -ixtlD4w6gi8KIH1CTG+/TtHP1KVdOogCWDcjRCAmMJPFZe6eEKXmGQUZDb9wfnbx -h++XiVe5tq83BTLWmaFTy+fZbNo12uhNCNS1LJ42/yj+S1xvo0yMbkkNr1hIYk0P -584XnBQeBSVJDf3667NZXaxnWv94K9zbb+1OvOvPwhbOdgi2Ymcw5QEOQIavtg86 -XLLcWzG+SJsycz1imikjv6sStWh8WHneKSTMq6A7V6PBj7oJyEJp10696BDw287k -YlH+9VGqowPEMXpWX57wOBKiWb4K1kw1LfxjT8W1e/pcX9pJqiv0DkjTXUxo9CDG -1X1+ZXBBR3MkGuFAOCjy0x8CAwEAAQ== ------END PUBLIC KEY----- -""" - -## Every release at or above this version ships a signed sidecar -## (release.yml hard-fails without the signing secret). At or above it, a -## missing `.sha256.sig` asset is treated as tampering — an attacker who can -## rewrite release assets could otherwise just strip the signature to skip -## verification. Below it (releases published before signing existed), the -## legacy checksum-only path still installs. -const SIGNING_REQUIRED_FROM_VERSION := "2.9.3" - -## Host -> required path prefix for self-update downloads (ZIP and checksum -## sidecar). The URLs are taken verbatim from the GitHub Releases API's -## `browser_download_url`, so before fetching we pin them to https on a -## GitHub-owned host AND to this repo's release-asset path (#599) — a -## tampered or unexpected API response can't point the in-editor updater at -## an arbitrary origin, nor at a release asset of a *different* repo on a -## trusted host. -## -## In practice `browser_download_url` is always the -## `https://github.com/hi-godot/godot-ai/releases/download//` -## shape; HTTPRequest then follows the github.com -> *.githubusercontent.com -## redirect internally (this guard validates the entry point, not each hop). -## The CDN hosts are kept as defense-in-depth should the API ever hand back -## a direct CDN URL — their object keys carry the repo *id*, not the repo -## name, so the tightest checkable prefix there is the release-asset key -## namespace. -const _TRUSTED_DOWNLOAD_PATH_PREFIXES := { - "github.com": "/hi-godot/godot-ai/releases/download/", - "www.github.com": "/hi-godot/godot-ai/releases/download/", - "api.github.com": "/repos/hi-godot/godot-ai/releases/assets/", - "objects.githubusercontent.com": "/github-production-release-asset-", - "release-assets.githubusercontent.com": "/github-production-release-asset-", -} - -## Emitted after `check_for_updates()` resolves a newer remote version. -## Payload mirrors the Dictionary returned by `parse_releases_response`: -## {has_update, version, forced, label_text, download_url} -signal update_check_completed(result: Dictionary) - -## Emitted at every UI-relevant step of the install pipeline. Payload -## keys are all optional and apply on top of the current banner state: -## label_text: String ## banner label override -## button_text: String ## update button text override -## button_disabled: bool ## update button disabled state -## banner_visible: bool ## banner visibility override -## outcome: String ## "success" -> dock paints green -signal install_state_changed(state: Dictionary) - -var _plugin -var _dock - -var _http_request: HTTPRequest -var _download_request: HTTPRequest -var _verify_request: HTTPRequest -var _signature_request: HTTPRequest -var _latest_download_url: String = "" -## URL of the `godot-ai-plugin.zip.sha256` sidecar asset. Used to verify the -## downloaded archive's integrity before extract (#523). Verification is -## mandatory (#599): when a release ships no sidecar this stays empty and -## `_verify_then_install` refuses the install. -var _latest_checksum_url: String = "" -## URL of the `godot-ai-plugin.zip.sha256.sig` signature asset (#687). Empty -## on releases published before signing existed; `_verify_then_install` -## refuses an empty URL once the remote version is inside the signing era -## (see SIGNING_REQUIRED_FROM_VERSION). -var _latest_signature_url: String = "" -## Remote version from the last update check — drives the -## signature-required compat gate in `_verify_then_install`. -var _latest_remote_version: String = "" -## Sidecar bytes + parsed digest held between the checksum download and the -## signature verdict, so the signature is checked against exactly the bytes -## the digest was parsed from. -var _pending_sidecar_body := PackedByteArray() -var _pending_expected_digest: String = "" - -## Set for the duration of `_install_zip` — extract-overwrite of plugin -## scripts on disk would crash any worker mid-`GDScriptFunction::call` -## (confirmed via SIGABRT in the dock's refresh worker). Dock spawn paths -## consult this via `is_install_in_flight()`; in-flight workers are -## drained before any disk write. -var _install_in_flight: bool = false - - -# ---- Setup ------------------------------------------------------------- - -func setup(plugin, dock) -> void: - _plugin = plugin - _dock = dock - - -# ---- Public API --------------------------------------------------------- - -## Kick off the GitHub Releases API check. No-ops in dev checkouts — -## `addons/godot_ai/` is a symlink into canonical `plugin/` source there, -## and an extract would clobber tracked files (#116). `is_dev_checkout()` -## honours the mode override (EditorSetting `godot_ai/mode_override` > -## `GODOT_AI_MODE` env), so -## testers can force `user` to exercise the AssetLib flow from a dev tree; -## `_install_zip` still gates on the physical symlink check so a forced- -## user mode can never clobber source. -func check_for_updates() -> void: - if ClientConfigurator.is_dev_checkout(): - return - if _http_request == null: - _http_request = HTTPRequest.new() - _http_request.request_completed.connect(_on_update_check_completed) - add_child(_http_request) - _http_request.request(RELEASES_URL, ["Accept: application/vnd.github+json"]) - - -## Cancel any in-flight check so a follow-up check_for_updates() can't -## hit ERR_BUSY on the shared HTTPRequest. No current dock caller — the -## mode-override dropdown that used it was removed in #408; kept as -## published API of the update flow. -func cancel_check() -> void: - if _http_request != null: - _http_request.cancel_request() - - -## Reset the cached download/checksum URLs so a fresh check paints over -## a clean banner. No current production caller — the mode-override -## dropdown that used it was removed in #408; kept for tests and any -## future re-check path. -func clear_pending_download() -> void: - _latest_download_url = "" - _latest_checksum_url = "" - _latest_signature_url = "" - _latest_remote_version = "" - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - -## True when the running Godot is within the supported self-update floor. -## Godot < 4.5 must not be offered a one-click update to a release whose -## always-loaded scripts depend on 4.5 APIs/classes. -## Guards `major` too so a future Godot 5.x (minor 0) isn't misclassified. -func _can_self_update() -> bool: - var v := Engine.get_version_info() - return _version_can_self_update(int(v.get("major", 0)), int(v.get("minor", 0))) - - -## Pure version predicate, split out so it's testable without faking the -## running engine. In-editor self-update needs Godot >= 4.5. -static func _version_can_self_update(major: int, minor: int) -> bool: - return major > 4 or (major == 4 and minor >= 5) - - -## Banner guidance for engines below the support floor. Shown up-front at -## check time so those users do not install an incompatible latest release. -static func _manual_update_label(version: String) -> String: - var release_noun := "release" - var suffix := "" - if not version.is_empty(): - release_noun = "version" - suffix = " (latest: v%s)" % version - return ( - "This is the last Godot AI %s for this Godot%s. " % [release_noun, suffix] - + "Upgrade to Godot 4.5+ to keep receiving updates." - ) - -## Driven by the dock's Update button. On Godot < 4.5 (see _can_self_update) -## the in-editor install is disabled so users cannot install an incompatible -## latest release. With no resolved download URL, falls back to opening the -## release page. Otherwise kicks off the download -> extract -> reload pipeline. -func start_install() -> void: - if not _can_self_update(): - install_state_changed.emit({ - "button_text": "Upgrade Godot", - "button_disabled": true, - "label_text": _manual_update_label(""), - "banner_visible": true, - }) - return - - if _latest_download_url.is_empty(): - OS.shell_open(RELEASES_PAGE) - return - - ## Pin the resolved asset URL to https on a GitHub host AND to this - ## repo's release-asset path before fetching (#523, #599). Fall back to - ## the release page (a user-driven browser download) rather than pulling - ## an executable plugin payload from an unexpected origin. - if not _is_trusted_download_url(_latest_download_url): - push_error( - "MCP | refusing self-update download from untrusted URL: %s" - % _latest_download_url - ) - OS.shell_open(RELEASES_PAGE) - install_state_changed.emit({ - "button_text": "Update via download page", - "button_disabled": false, - }) - return - - install_state_changed.emit({ - "button_text": "Downloading...", - "button_disabled": true, - }) - - ## Warm the uv cache for the NEW server version while the plugin zip - ## downloads (see prewarm_server_package): the post-update walk must win - ## a port bind race against attach bridges respawning the cached OLD - ## version, and a cold uvx resolve loses that race every time. uvx is - ## almost always cached in the CliFinder by now (the dock's setup probe); - ## a cold lookup is the same bounded main-thread cost as - ## `_reprobe_uv_if_negative`, on the same rare click-driven path. - ClientConfigurator.prewarm_server_package(_latest_remote_version) - - if _download_request != null: - _download_request.queue_free() - _download_request = HTTPRequest.new() - var global_zip := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) - var global_dir := ProjectSettings.globalize_path(UPDATE_TEMP_DIR) - DirAccess.make_dir_recursive_absolute(global_dir) - _download_request.download_file = global_zip - _download_request.max_redirects = 10 - _download_request.request_completed.connect(_on_download_completed) - add_child(_download_request) - var err := _download_request.request(_latest_download_url) - if err != OK: - ## `request_completed` never fires when `request()` itself errors, - ## so cleanup (queue_free + null + drop the staged zip) has to land - ## inline — otherwise the HTTPRequest stays parented under the - ## manager until the next click. - _download_request.queue_free() - _download_request = null - DirAccess.remove_absolute(global_zip) - install_state_changed.emit({ - "button_text": "Request failed", - "button_disabled": false, - }) - -## Consulted by the dock's spawn paths (focus-in refresh, manual button, -## deferred initial refresh) — true while plugin scripts are being -## overwritten. A worker mid-`GDScriptFunction::call` into a half- -## overwritten script SIGABRTs the editor. -func is_install_in_flight() -> bool: - return _install_in_flight - - -# ---- Releases-API parse (pure, testable) ------------------------------- - -## Parses the GitHub Releases API JSON response. Returns: -## has_update: bool ## true if remote tag > local version -## version: String ## remote tag minus leading "v" -## forced: bool ## mode_override() == "user" (banner-only hint) -## label_text: String ## "Update available: vX.Y.Z" + " (forced)" -## download_url: String ## matching `godot-ai-plugin.zip` asset URL -## checksum_url: String ## `godot-ai-plugin.zip.sha256` asset URL ("" if absent) -## signature_url: String ## `godot-ai-plugin.zip.sha256.sig` asset URL ("" if absent) -## -## Static so tests drive it without instancing the manager. -static func parse_releases_response( - result: int, response_code: int, body: PackedByteArray -) -> Dictionary: - var out := { - "has_update": false, - "version": "", - "forced": false, - "label_text": "", - "download_url": "", - "checksum_url": "", - "signature_url": "", - } - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - return out - var parsed = JSON.parse_string(body.get_string_from_utf8()) - if parsed == null or not (parsed is Dictionary): - return out - var json: Dictionary = parsed - var tag: String = String(json.get("tag_name", "")) - if tag.is_empty(): - return out - var remote_version := tag.trim_prefix("v") - var local_version := ClientConfigurator.get_plugin_version() - if not _is_newer(remote_version, local_version): - return out - - var url := "" - var checksum_url := "" - var signature_url := "" - var assets: Array = json.get("assets", []) - for asset in assets: - var asset_dict: Dictionary = asset - var asset_name := String(asset_dict.get("name", "")) - if asset_name == "godot-ai-plugin.zip": - url = String(asset_dict.get("browser_download_url", "")) - elif asset_name == "godot-ai-plugin.zip.sha256": - checksum_url = String(asset_dict.get("browser_download_url", "")) - elif asset_name == "godot-ai-plugin.zip.sha256.sig": - signature_url = String(asset_dict.get("browser_download_url", "")) - - var forced := ClientConfigurator.mode_override() == "user" - var label_text := "Update available: v%s" % remote_version - if forced: - ## Forced-user mode (EditorSetting or env) is the only way the banner - ## lights up in a dev tree; suffix so the operator notices. - label_text += " (forced)" - - out["has_update"] = true - out["version"] = remote_version - out["forced"] = forced - out["label_text"] = label_text - out["download_url"] = url - out["checksum_url"] = checksum_url - out["signature_url"] = signature_url - return out - - -## True only for an `https://` URL whose host is a key of -## `_TRUSTED_DOWNLOAD_PATH_PREFIXES` AND whose path starts with that host's -## required prefix — trusted host alone is not enough; the URL must be a -## hi-godot/godot-ai release asset (#599). Parses the authority by hand -## (GDScript has no URL parser): strips userinfo via the LAST `@` so a spoof -## like `https://github.com@evil.com/...` resolves to `evil.com` (rejected), -## and strips any `:port`. The path is compared case-sensitively (GitHub -## release paths are case-sensitive). Static so the guard is unit-testable -## without instancing the manager. -static func _is_trusted_download_url(url: String) -> bool: - const SCHEME := "https://" - if not url.begins_with(SCHEME): - return false - if url.find("\\") >= 0: - return false - var rest := url.substr(SCHEME.length()) - var authority := rest - var path := "" - var slash := rest.find("/") - if slash >= 0: - authority = rest.substr(0, slash) - path = rest.substr(slash) - ## Host is everything after the LAST '@' (userinfo precedes it). - var at := authority.rfind("@") - if at >= 0: - authority = authority.substr(at + 1) - var colon := authority.find(":") - if colon >= 0: - authority = authority.substr(0, colon) - var host := authority.to_lower() - if not _TRUSTED_DOWNLOAD_PATH_PREFIXES.has(host): - return false - ## Scope the checks below to the path proper (#713): direct CDN asset - ## URLs carry signed query params (X-Amz-Credential=...%2F...) whose - ## legitimate %2F tokens made every CDN prefix unreachable when the - ## needle scan covered the query string. Routing is decided by the - ## path, so the query is safe to ignore. - var qmark := path.find("?") - if qmark >= 0: - path = path.substr(0, qmark) - ## Reject dot-segments (and their percent-encoded forms) anywhere in the - ## path: "/hi-godot/godot-ai/releases/download/../../evil/..." passes a - ## raw string-prefix test but normalizes server-side to a different repo, - ## defeating the scoping (#599 review). Also reject percent-encoded - ## slashes, which some servers decode before routing. - var lower_path := path.to_lower() - for needle in ["/../", "/..", "%2e", "%2f", "%5c"]: - if lower_path.contains(needle): - return false - return path.begins_with(String(_TRUSTED_DOWNLOAD_PATH_PREFIXES[host])) - - -static func _is_newer(remote: String, local: String) -> bool: - var r := remote.split(".") - var l := local.split(".") - for i in range(max(r.size(), l.size())): - var rv := int(r[i]) if i < r.size() else 0 - var lv := int(l[i]) if i < l.size() else 0 - if rv > lv: - return true - if rv < lv: - return false - return false - - -# ---- HTTPRequest callbacks (instance-side) ----------------------------- - -func _on_update_check_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - var parsed := parse_releases_response(result, response_code, body) - if not bool(parsed.get("has_update", false)): - return - if not _can_self_update(): - install_state_changed.emit({ - "button_text": "Upgrade Godot", - "button_disabled": true, - "label_text": _manual_update_label(String(parsed.get("version", ""))), - "banner_visible": true, - }) - return - _latest_download_url = String(parsed.get("download_url", "")) - _latest_checksum_url = String(parsed.get("checksum_url", "")) - _latest_signature_url = String(parsed.get("signature_url", "")) - _latest_remote_version = String(parsed.get("version", "")) - update_check_completed.emit(parsed) - - -func _on_download_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - _body: PackedByteArray -) -> void: - if _download_request != null: - _download_request.queue_free() - _download_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - print("MCP | update download failed: result=%d code=%d" % [result, response_code]) - ## Failure parity with _fail_verification (#713): HTTPRequest's - ## download_file mode leaves whatever partial/error bytes it wrote - ## staged at UPDATE_TEMP_ZIP — drop them so no later step can ever - ## pick up a half-downloaded archive. - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - install_state_changed.emit({ - "button_text": "Download failed (%d)" % response_code, - "button_disabled": false, - }) - return - - # Deferred so the HTTPRequest callback returns before the next step starts. - _verify_then_install.call_deferred() - - -# ---- Integrity verification (#523, #599, #687) -------------------------- - -## Gate the extract on (1) an RSA signature over the checksum sidecar and -## (2) a SHA-256 match of the archive against that sidecar. TLS + host -## pinning constrain where the bytes came from; the digest verifies the -## bytes themselves (in-transit corruption, single-object substitution); -## the signature verifies the digest's *provenance*. Both `download_url` -## and `checksum_url` come from the same GitHub Releases API response over -## the same channel, so anyone able to modify the release's assets (leaked -## repo token, compromised release workflow) can regenerate the sidecar to -## match a tampered zip — but cannot forge the `.sha256.sig` signature, -## whose private key lives only in an Actions secret outside the repo -## token's scope (#687). -## -## Verification is MANDATORY (#599): no `.sha256` sidecar — mistake or -## tamper — refuses to install. The signature is mandatory for every -## release at or above SIGNING_REQUIRED_FROM_VERSION: a missing signature -## there is a strip-attack signal, not a compat case, and hard-fails. Only -## releases predating signing take the legacy checksum-only path. -func _verify_then_install() -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - if _latest_checksum_url.is_empty(): - _fail_verification( - "release published no godot-ai-plugin.zip.sha256 sidecar; " - + "refusing unverified install (#599)" - ) - return - - ## A present-but-untrusted checksum URL is a tamper signal, not a - ## backward-compat case — refuse rather than silently skip. Trusted - ## means a GitHub host AND this repo's release-asset path (#599). - if not _is_trusted_download_url(_latest_checksum_url): - _fail_verification("checksum URL is not a trusted hi-godot/godot-ai release asset") - return - - if _latest_signature_url.is_empty(): - if _signature_required(_latest_remote_version): - _fail_verification( - "release v%s ships no godot-ai-plugin.zip.sha256.sig signature. " - % _latest_remote_version - + "Every release from v%s on is signed" % SIGNING_REQUIRED_FROM_VERSION - + " — a missing signature means a stripped or tampered release (#687)" - ) - return - print( - "MCP | self-update: release v%s predates signing; " % _latest_remote_version - + "using legacy checksum-only verification (#687)" - ) - elif not _is_trusted_download_url(_latest_signature_url): - _fail_verification("signature URL is not a trusted hi-godot/godot-ai release asset") - return - - install_state_changed.emit({"button_text": "Verifying..."}) - if _verify_request != null: - _verify_request.queue_free() - _verify_request = HTTPRequest.new() - _verify_request.max_redirects = 10 - _verify_request.request_completed.connect(_on_checksum_completed) - add_child(_verify_request) - var err := _verify_request.request(_latest_checksum_url) - if err != OK: - _verify_request.queue_free() - _verify_request = null - _fail_verification("could not request checksum (error %d)" % err) - - -func _on_checksum_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - if _verify_request != null: - _verify_request.queue_free() - _verify_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - _fail_verification("checksum download failed (result=%d code=%d)" % [result, response_code]) - return - - var expected := _parse_sha256_digest(body.get_string_from_utf8()) - if expected.is_empty(): - _fail_verification("malformed checksum file") - return - - ## Signature verification (when armed) runs over the exact sidecar bytes - ## the digest was parsed from — hold both until the signature verdict. - if not _latest_signature_url.is_empty(): - _pending_sidecar_body = body - _pending_expected_digest = expected - _fetch_signature() - return - - ## Legacy pre-signing release: `_verify_then_install` already gated this - ## on the remote version predating SIGNING_REQUIRED_FROM_VERSION. - _finish_digest_check_and_install(expected) - - -## Download the `.sha256.sig` release asset; `_on_signature_completed` -## verifies it over the held sidecar bytes before the digest is trusted. -func _fetch_signature() -> void: - if _signature_request != null: - _signature_request.queue_free() - _signature_request = HTTPRequest.new() - _signature_request.max_redirects = 10 - _signature_request.request_completed.connect(_on_signature_completed) - add_child(_signature_request) - var err := _signature_request.request(_latest_signature_url) - if err != OK: - _signature_request.queue_free() - _signature_request = null - _fail_verification("could not request signature (error %d)" % err) - - -func _on_signature_completed( - result: int, - response_code: int, - _headers: PackedStringArray, - body: PackedByteArray -) -> void: - if _signature_request != null: - _signature_request.queue_free() - _signature_request = null - - if result != HTTPRequest.RESULT_SUCCESS or response_code != 200: - _fail_verification( - "signature download failed (result=%d code=%d)" % [result, response_code] - ) - return - - if not _verify_sidecar_signature(RELEASE_SIGNING_PUBLIC_KEY_PEM, _pending_sidecar_body, body): - _fail_verification( - "release signature does not verify against the embedded public key — " - + "the checksum sidecar was not produced by the release pipeline (#687)" - ) - return - - print("MCP | self-update release signature verified (rsa-4096/sha256)") - _finish_digest_check_and_install(_pending_expected_digest) - - -## Final gate shared by the signed and legacy paths: the staged archive's -## SHA-256 must match the (now-trusted) sidecar digest before extract. -func _finish_digest_check_and_install(expected: String) -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - - var zip_path := ProjectSettings.globalize_path(UPDATE_TEMP_ZIP) - var actual := FileAccess.get_sha256(zip_path).to_lower() - if actual.is_empty(): - _fail_verification("could not hash the downloaded archive") - return - if actual != expected: - _fail_verification( - "checksum mismatch (expected %s…, got %s…)" - % [expected.substr(0, 12), actual.substr(0, 12)] - ) - return - - print("MCP | self-update checksum verified (sha256 %s)" % actual) - install_state_changed.emit({"button_text": "Installing..."}) - _install_zip.call_deferred() - - -## True when `remote_version` falls inside the signing era — every release -## at or above SIGNING_REQUIRED_FROM_VERSION ships a signed sidecar, so a -## missing signature there must hard-fail rather than fall back to the -## legacy checksum-only path. An empty/unknown version fails closed. Static -## so it's unit-testable. -static func _signature_required(remote_version: String) -> bool: - if remote_version.strip_edges().is_empty(): - return true - return not _is_newer(SIGNING_REQUIRED_FROM_VERSION, remote_version) - - -## PKCS#1 v1.5 RSA verification of `signature` over SHA-256(`sidecar`) — -## the exact output of release.yml's `openssl dgst -sha256 -sign`. Takes -## the PEM as a parameter (rather than reading the const) so tests can -## exercise both verdicts with a generated throwaway keypair. Static so -## it's unit-testable without instancing the manager. -static func _verify_sidecar_signature( - public_key_pem: String, sidecar: PackedByteArray, signature: PackedByteArray -) -> bool: - if sidecar.is_empty() or signature.is_empty(): - return false - var key := CryptoKey.new() - if key.load_from_string(public_key_pem, true) != OK: - return false - var ctx := HashingContext.new() - if ctx.start(HashingContext.HASH_SHA256) != OK: - return false - ctx.update(sidecar) - var digest := ctx.finish() - var crypto := Crypto.new() - return crypto.verify(HashingContext.HASH_SHA256, digest, signature, key) - - -## Surface an integrity-check failure and drop the staged zip so the bad -## bytes can never reach the extract path. Keeps the button enabled for retry. -func _fail_verification(reason: String) -> void: - _pending_sidecar_body = PackedByteArray() - _pending_expected_digest = "" - push_error( - "MCP | self-update integrity check failed: %s. The download was not installed." - % reason - ) - print("MCP | self-update aborted (integrity): %s" % reason) - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - install_state_changed.emit({ - "button_text": "Verification failed — retry", - "button_disabled": false, - }) - - -## Extract the hex digest from a `sha256sum`-style file (" ") or a -## bare digest line. Returns lowercase 64-char hex, or "" if the content isn't -## a valid SHA-256 digest. Static so it's unit-testable. See #523. -static func _parse_sha256_digest(text: String) -> String: - var trimmed := text.strip_edges() - if trimmed.is_empty(): - return "" - ## First whitespace-delimited token; `sha256sum` separates digest and - ## filename with two spaces, but some tools use tabs. - var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ") - var tokens := normalized.split(" ", false) - if tokens.is_empty(): - return "" - var digest := String(tokens[0]).strip_edges().to_lower() - if digest.length() != 64: - return "" - for i in digest.length(): - var c := digest[i] - if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")): - return "" - return digest - - -# ---- Install orchestration --------------------------------------------- - -func _install_zip() -> void: - ## Symlinked addons dir means an extract would clobber canonical - ## `plugin/` source through the link. Symlink detection is independent - ## of the mode override: even forced-user aborts here. See #116. - if ClientConfigurator.addons_dir_is_symlink(): - install_state_changed.emit({ - "button_text": "Dev checkout — update via git", - "button_disabled": true, - "banner_visible": false, - }) - return - - ## Drain in-flight workers + block new ones BEFORE any disk write. - ## Without this, focus-in landing in the extract -> reload window spawns - ## a worker that walks into a partially-overwritten script and - ## SIGABRTs in `GDScriptFunction::call`. - _install_in_flight = true - _drain_dock_workers() - - var has_runner: bool = ( - _plugin != null - and _plugin.has_method("install_downloaded_update") - ) - if has_runner: - install_state_changed.emit({"button_text": "Reloading..."}) - ## Runner takes over: plugin tears down, runner extracts + scans + - ## re-enables. `install_downloaded_update` calls - ## `prepare_for_update_reload()` internally (kills the server, - ## resets the spawn guard) - see plugin.gd::install_downloaded_update. - _plugin.install_downloaded_update(UPDATE_TEMP_ZIP, UPDATE_TEMP_DIR, _dock) - return - - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_ZIP)) - DirAccess.remove_absolute(ProjectSettings.globalize_path(UPDATE_TEMP_DIR)) - _install_in_flight = false - install_state_changed.emit({ - "button_text": "Reload runner missing", - "button_disabled": false, - }) - - -func _reload_after_update() -> void: - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false) - EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true) - - -func _drain_dock_workers() -> void: - if _dock != null and _dock.has_method("prepare_for_self_update_drain"): - _dock.prepare_for_self_update_drain() diff --git a/addons/godot_ai/utils/update_manager.gd.uid b/addons/godot_ai/utils/update_manager.gd.uid deleted file mode 100644 index 4089a72..0000000 --- a/addons/godot_ai/utils/update_manager.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cegiyw3fjcwev diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd deleted file mode 100644 index 96d0024..0000000 --- a/addons/godot_ai/utils/update_mixed_state.gd +++ /dev/null @@ -1,140 +0,0 @@ -@tool -extends RefCounted - -## Scanner that detects whether `addons/godot_ai/` is in a half-installed -## state left behind by a self-update whose rollback couldn't restore the -## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). -## -## Without this surface the user sees "plugin won't start" with no actionable -## context, re-runs the update, and compounds the mismatch (issue #354 / -## audit-v2 #10). The dock paints a banner from `diagnose()` and -## `editor_handler.gd::get_editor_state` includes the same Dictionary so an -## MCP agent can see and report the state. - -const ADDON_DIR := "res://addons/godot_ai/" -## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a -## literal because old two-phase runners can parse this diagnostic script -## against stale runner Script-object content during their mixed-snapshot -## scan. `test_update_backup_suffix_stays_in_sync` guards against drift. -const BACKUP_SUFFIX := ".update_backup" -## Cap so a runaway addons tree (someone parented the wrong dir, an old -## crashed install left thousands of artifacts) can't blow the -## `editor_state` payload size or freeze the editor on first paint. -const MAX_BACKUP_RESULTS := 200 -## TTL for the `diagnose()` cache. `editor_state` is one of the highest- -## traffic MCP tools (agents poll it constantly) and a recursive -## `DirAccess` walk on every call would put I/O on the 4ms `_process()` -## budget. Mixed-state is rare and persistent across editor restarts, so -## a few seconds of staleness is acceptable; the dock's Re-scan button -## bypasses the cache via `force=true` for immediate feedback. -const CACHE_TTL_MSEC := 5000 - -static var _cache_value: Dictionary = {} -static var _cache_timestamp_msec: int = -1 - - -## Walk `dir` recursively and return every `res://`-relative path that ends -## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS` -## — the truncation flag is exposed via `diagnose()`. -## -## Walk order is deterministic: entries within each directory are sorted -## alphabetically, subdirs pushed reverse-sorted so DFS pops them in -## ascending order. Without this two scans of the same mixed tree could -## return different 200-file slices when truncation kicks in (Godot's -## `list_dir` order isn't guaranteed stable across filesystems). -static func find_backups(dir: String = ADDON_DIR) -> Array: - var results: Array = [] - var stack: Array = [dir] - while not stack.is_empty(): - if results.size() >= MAX_BACKUP_RESULTS: - break - var current: String = stack.pop_back() - var d := DirAccess.open(current) - ## Missing dir, permission error, or unreadable junction — skip - ## silently. A missing addons dir is the bare-clone case; mid-walk - ## errors stay quiet so a single permission glitch can't block the - ## diagnostic the rest of the scan would have produced. - if d == null: - continue - var entries: Array = [] - d.list_dir_begin() - while true: - var entry := d.get_next() - if entry.is_empty(): - break - if entry == "." or entry == "..": - continue - entries.append({"name": entry, "is_dir": d.current_is_dir()}) - d.list_dir_end() - entries.sort_custom(func(a, b): return a["name"] < b["name"]) - ## Push subdirs reverse-sorted so the next outer iteration pops - ## them in ascending order — see method docstring for why this - ## determinism matters for the truncated case. - for i in range(entries.size() - 1, -1, -1): - var entry: Dictionary = entries[i] - if entry["is_dir"]: - stack.append(current.path_join(entry["name"])) - for entry in entries: - if entry["is_dir"]: - continue - if not String(entry["name"]).ends_with(BACKUP_SUFFIX): - continue - results.append(current.path_join(entry["name"])) - if results.size() >= MAX_BACKUP_RESULTS: - break - results.sort() - return results - - -## Build the structured diagnostic Dictionary surfaced via `editor_state` -## and the dock banner. Empty when the addons tree is clean — callers -## gate banner visibility / response field on `is_empty()`. -## -## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so -## per-`editor_state` polls don't re-walk the addons tree every frame. -## Tests passing a custom `dir` always see a fresh scan (cache only -## tracks the production path). `force=true` bypasses the cache — used -## by the dock's Re-scan button so a manual fix is reflected immediately. -static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary: - var use_cache := dir == ADDON_DIR and not force - if use_cache and _cache_timestamp_msec >= 0: - if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC: - return _cache_value.duplicate(true) - - var backups := find_backups(dir) - var result: Dictionary = {} - if not backups.is_empty(): - ## Most commonly produced by `_rollback_paths_written` returning - ## FAILED_MIXED, but `_finalize_install_success` removes backups on - ## a best-effort basis so a successful install can also leave them - ## behind if the cleanup `remove_absolute` hit a permission error. - ## The recovery action — delete the *.update_backup files — is the - ## same in both cases, so the message acknowledges both - ## possibilities rather than asserting the alarming one. - result = { - "addon_dir": dir, - "backup_files": backups, - "backup_count": backups.size(), - "truncated": backups.size() >= MAX_BACKUP_RESULTS, - "message": ( - "Found .update_backup files in addons/godot_ai/. This usually" - + " means a self-update rollback couldn't restore the previous" - + " addon contents (FAILED_MIXED) — the plugin may load a mix" - + " of old and new files. Restore the addon from your VCS or a" - + " fresh release ZIP, then delete the listed *.update_backup" - + " files. If the plugin runs without issues these are likely" - + " stale from a successful install and safe to delete." - ), - } - if use_cache: - _cache_value = result.duplicate(true) - _cache_timestamp_msec = Time.get_ticks_msec() - return result - - -## Reset the `diagnose()` cache. Tests that flip the addons-tree state -## between calls use this to avoid TTL-bound flakiness; the dock's -## Re-scan button uses `force=true` instead. -static func clear_cache() -> void: - _cache_value = {} - _cache_timestamp_msec = -1 diff --git a/addons/godot_ai/utils/update_mixed_state.gd.uid b/addons/godot_ai/utils/update_mixed_state.gd.uid deleted file mode 100644 index 6d608d9..0000000 --- a/addons/godot_ai/utils/update_mixed_state.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dd5rti52vgs71 diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd deleted file mode 100644 index 86edbe1..0000000 --- a/addons/godot_ai/utils/uv_cache_cleanup.gd +++ /dev/null @@ -1,161 +0,0 @@ -@tool -class_name McpUvCacheCleanup -extends RefCounted - -## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`. -## -## Background -## ---------- -## When an MCP client's attach launcher invokes -## `uvx --from godot-ai==VERSION godot-ai attach ...`, uv builds an ephemeral venv under -## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions -## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from -## `archive-v0\\Lib\site-packages\...` into the build venv. -## -## If the godot-ai server's own Python child has that same `.pyd` mapped via -## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked -## under BOTH paths because hard links share the inode and Windows tracks -## handles per-file, not per-path. uv's post-install cleanup of the build -## venv then dies with: -## -## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311) -## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data` -## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32) -## -## (the `pywin32` mention is incidental — the actual lock is on the earlier -## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step -## in the wheel-resolution order that triggers the cleanup pass). -## -## What this does -## -------------- -## After the plugin stops/restarts the managed server — i.e. the moment when -## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy -## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans: -## -## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when -## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender -## and Softcamp SDS both do this), so this step always advances. -## 2. Recursively remove the renamed dir, swallowing per-file -## access-denied. Anything still genuinely locked is left for the next -## sweep — uv won't reuse the renamed name, so no future build collides. -## -## No-op on non-Windows (uv's hard-link strategy only causes this lock -## pattern on NTFS) and when the cache directory doesn't exist. - -const DEAD_PREFIX := "_dead_" -const TMP_PREFIX := ".tmp" - - -## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs -## the sweep. Returns the same counts the testable `purge_directory` returns, -## or all zeros on non-Windows / missing cache. -static func purge_stale_builds() -> Dictionary: - if OS.get_name() != "Windows": - return _empty_result() - var local_appdata := OS.get_environment("LOCALAPPDATA") - if local_appdata.is_empty(): - return _empty_result() - var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0") - return purge_directory(builds_root) - - -## Pure-ish entrypoint that takes a directory path. Returns -## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`. -## - `scanned`: how many `.tmp*` subdirs we saw on entry. -## - `renamed`: how many we successfully renamed to `_dead_*`. -## - `deleted`: how many we then fully removed. -## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep -## (left for the next call to retry). -## -## Errors are swallowed — the caller is on a server-stop hot path and -## must not raise. -static func purge_directory(builds_root: String) -> Dictionary: - var result := _empty_result() - if not DirAccess.dir_exists_absolute(builds_root): - return result - var dir := DirAccess.open(builds_root) - if dir == null: - return result - dir.include_hidden = true - - ## Pass 1: collect names. Iterating + renaming in the same walk would - ## confuse DirAccess's internal cursor on NTFS. - var tmp_names: Array[String] = [] - var dead_names: Array[String] = [] - dir.list_dir_begin() - var entry := dir.get_next() - while entry != "": - if dir.current_is_dir() and not (entry == "." or entry == ".."): - if entry.begins_with(TMP_PREFIX): - tmp_names.append(entry) - elif entry.begins_with(DEAD_PREFIX): - dead_names.append(entry) - entry = dir.get_next() - dir.list_dir_end() - result.scanned = tmp_names.size() - - ## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on - ## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename - ## doesn't need delete share). Any rename failure is non-fatal. - for name in tmp_names: - var src := builds_root.path_join(name) - var dst := builds_root.path_join(DEAD_PREFIX + name) - if dir.rename(src, dst) == OK: - result.renamed += 1 - dead_names.append(DEAD_PREFIX + name) - - ## Pass 3: best-effort recursive delete of every `_dead_*`, including - ## ones left over from earlier sweeps that couldn't be cleaned then. - for name in dead_names: - var path := builds_root.path_join(name) - if _remove_recursive(path): - result.deleted += 1 - - ## Final pass: count `_dead_*` survivors so the caller (and tests) can - ## see how many genuinely-locked dirs we couldn't reach. - var dir2 := DirAccess.open(builds_root) - if dir2 != null: - dir2.include_hidden = true - dir2.list_dir_begin() - var e := dir2.get_next() - while e != "": - if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX): - result.remaining += 1 - e = dir2.get_next() - dir2.list_dir_end() - - return result - - -## Recursive `rm -rf` that swallows access-denied per-file. Returns true -## only when the target directory itself was removed. -static func _remove_recursive(path: String) -> bool: - var dir := DirAccess.open(path) - if dir == null: - ## Already gone, or unreadable — try a direct remove just in case - ## (an empty dir handle-leak path) and report based on existence. - DirAccess.remove_absolute(path) - return not DirAccess.dir_exists_absolute(path) - dir.include_hidden = true - dir.list_dir_begin() - var entry := dir.get_next() - while entry != "": - if entry == "." or entry == "..": - entry = dir.get_next() - continue - var child := path.path_join(entry) - if dir.current_is_dir(): - _remove_recursive(child) - else: - DirAccess.remove_absolute(child) - entry = dir.get_next() - dir.list_dir_end() - ## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is - ## still mapped by a surviving process, this fails silently and the - ## caller sees `remaining > 0` so it can retry on the next sweep. - DirAccess.remove_absolute(path) - return not DirAccess.dir_exists_absolute(path) - - -static func _empty_result() -> Dictionary: - return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 } diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid deleted file mode 100644 index 321659d..0000000 --- a/addons/godot_ai/utils/uv_cache_cleanup.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d33ukg65qf7q0 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd deleted file mode 100644 index 21d8c6e..0000000 --- a/addons/godot_ai/utils/variant_serializer.gd +++ /dev/null @@ -1,102 +0,0 @@ -@tool -extends RefCounted - -## Converts Godot Variants into values that can be encoded as JSON. - - -## Non-finite floats (NaN/INF) have no JSON representation: JSON.stringify -## emits them as the bare tokens `inf`/`nan`, which are invalid JSON — the -## server drops the whole frame and the pending request times out (#688). -## Serialize them as null instead (the same choice web JSON.stringify makes), -## applied uniformly across the supported 4.5+ floor — no version gate, so -## wire output is identical on every supported engine. -static func _safe_float(f: float) -> Variant: - return f if is_finite(f) else null - - -static func serialize(value: Variant) -> Variant: - if value == null: - return null - match typeof(value): - TYPE_BOOL, TYPE_INT, TYPE_STRING: - return value - TYPE_FLOAT: - return _safe_float(value) - TYPE_STRING_NAME: - return str(value) - # Integer vector types are listed separately from their float twins so - # int components stay ints on the wire (no float coercion via - # _safe_float's typed parameter). - TYPE_VECTOR2I: - return {"x": value.x, "y": value.y} - TYPE_VECTOR2: - return {"x": _safe_float(value.x), "y": _safe_float(value.y)} - TYPE_VECTOR3I: - return {"x": value.x, "y": value.y, "z": value.z} - TYPE_VECTOR3: - return {"x": _safe_float(value.x), "y": _safe_float(value.y), "z": _safe_float(value.z)} - TYPE_VECTOR4I: - return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} - TYPE_VECTOR4, TYPE_QUATERNION: - return { - "x": _safe_float(value.x), - "y": _safe_float(value.y), - "z": _safe_float(value.z), - "w": _safe_float(value.w), - } - TYPE_COLOR: - return { - "r": _safe_float(value.r), - "g": _safe_float(value.g), - "b": _safe_float(value.b), - "a": _safe_float(value.a), - } - TYPE_RECT2, TYPE_RECT2I, TYPE_AABB: - return { - "position": serialize(value.position), - "size": serialize(value.size), - } - TYPE_PLANE: - return {"normal": serialize(value.normal), "d": _safe_float(value.d)} - TYPE_BASIS: - return { - "x": serialize(value.x), - "y": serialize(value.y), - "z": serialize(value.z), - } - TYPE_TRANSFORM2D: - return { - "x": serialize(value.x), - "y": serialize(value.y), - "origin": serialize(value.origin), - } - TYPE_TRANSFORM3D: - return { - "basis": serialize(value.basis), - "origin": serialize(value.origin), - } - TYPE_PROJECTION: - return { - "x": serialize(value.x), - "y": serialize(value.y), - "z": serialize(value.z), - "w": serialize(value.w), - } - TYPE_NODE_PATH: - return str(value) - TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_VECTOR4_ARRAY, TYPE_PACKED_COLOR_ARRAY: - var arr: Array = [] - for item in value: - arr.append(serialize(item)) - return arr - TYPE_DICTIONARY: - var out := {} - for key in value: - out[str(key)] = serialize(value[key]) - return out - TYPE_OBJECT: - if value is Resource and value.resource_path: - return value.resource_path - return str(value) - _: - return str(value) diff --git a/addons/godot_ai/utils/variant_serializer.gd.uid b/addons/godot_ai/utils/variant_serializer.gd.uid deleted file mode 100644 index b8e0a28..0000000 --- a/addons/godot_ai/utils/variant_serializer.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cte37mtbd61n3 diff --git a/addons/godot_ai/vision_routing.gd b/addons/godot_ai/vision_routing.gd deleted file mode 100644 index 2f42cef..0000000 --- a/addons/godot_ai/vision_routing.gd +++ /dev/null @@ -1,1043 +0,0 @@ -@tool -extends RefCounted - -## Vision Routing - route screenshot-tool images through a curated vision API. -## -## Models without image support (e.g. DeepSeek) cannot read the image blocks the -## screenshot tool returns. When routing is enabled, every single-image capture is sent to a -## vision model on a worker thread and the resulting text description is -## returned to the AI instead: -## -## - Editor (non-game) screenshots are captured normally by -## editor_handler.gd, then described on a worker thread; the reply is -## deferred until the description is ready. -## - Game screenshots are intercepted in mcp_debugger_plugin.gd the same way. -## - On success the response keeps a valid but tiny 2x2 placeholder image and -## carries the description as text metadata (`vision_description` plus a -## `note`, which the server forwards to the model). -## - On failure (missing key, network, API error) the original image payload -## passes through unchanged, so the screenshot tool never breaks. -## -## Providers are curated: label, API dialect, endpoint shape, environment -## variable and encrypted key slot are fixed in the PROVIDERS table. The model -## id is NOT - it is required per provider and entered by the user (stored in -## Editor Settings next to the key), because third-party model ids retire and -## only the account holder gets notified. Switching providers switches to that -## provider's entered model, so the software never guesses a model name: -## - Groq (free tier) - OpenAI chat-completions -## - Google Gemini (free tier, AI Studio key) - generateContent REST -## - xAI Grok (paid) - OpenAI chat-completions -## Both dialects are handled in this file. -## -## Settings live in Editor Settings (`vision_routing/enabled`, -## `vision_routing/provider`, plus one encrypted key slot and one model-id -## slot per provider). Keys are stored encrypted (AES-256-CBC, key derived -## from this machine) rather than in plain text, and each provider's -## environment variable (GROQ_API_KEY / GOOGLE_API_KEY / XAI_API_KEY) takes -## priority over the stored key. -## -## UI: a "Vision Routing" section inside the Clients & Tools Settings tab. - -## Curated providers: label, dialect, endpoint shape, env var and key/model -## setting slots are fixed here. The model id itself is user-entered per -## provider (see `_resolved_model`), so provider rows carry a `model_setting` -## slot and a `model_placeholder` suggestion instead of a baked-in id - -## third-party model ids retire, and only the account holder gets notified -## when one does. -const PROVIDERS := { - "groq": { - "label": "Groq", - "dialect": "openai", - "model_setting": "vision_routing/groq_model", - "model_placeholder": "qwen/qwen3.6-27b", - "host": "api.groq.com", - "port": 443, - "path": "/openai/v1/chat/completions", - "env": "GROQ_API_KEY", - "setting": "vision_routing/api_key_enc", - "placeholder": "gsk_...", - "key_label": "Groq API key (free tier: console.groq.com)", - "reasoning_effort": "none", - }, - "google": { - "label": "Google Gemini", - "dialect": "gemini", - "host": "generativelanguage.googleapis.com", - "port": 443, - "path": "/v1beta/models/{model}:generateContent", - "model_setting": "vision_routing/google_model", - "model_placeholder": "gemini-flash-latest", - "env": "GOOGLE_API_KEY", - "setting": "vision_routing/google_api_key_enc", - "placeholder": "AIza...", - "key_label": "Google AI Studio API key (free tier: aistudio.google.com)", - }, - "grok": { - "label": "xAI Grok", - "dialect": "openai", - "model_setting": "vision_routing/grok_model", - "model_placeholder": "grok-4.5", - "host": "api.x.ai", - "port": 443, - "path": "/v1/chat/completions", - "env": "XAI_API_KEY", - "setting": "vision_routing/grok_api_key_enc", - "placeholder": "xai-...", - "key_label": "xAI API key (api.x.ai)", - }, -} -const PROVIDER_ORDER := ["groq", "google", "grok"] - -const SETTING_ENABLED := "vision_routing/enabled" -const SETTING_PROVIDER := "vision_routing/provider" -const SETTING_API_KEY_ENC := "vision_routing/api_key_enc" -const TAB_NAME := "Vision Routing" - -const MAX_IMAGE_EDGE := 1024 -const CONNECT_TIMEOUT_MS := 4000 -const REQUEST_TIMEOUT_MS := 8000 -## Total budget for the whole provider exchange (connect + request + body). -## Kept under the server's 15s editor_screenshot window so the fallback -## pass-through always lands before the server gives up on slow providers. -const TOTAL_ROUTE_BUDGET_MS := 10000 -## Response body cap: the deadline bounds time but not bytes, and the socket -## talks to third-party hosts - a faulty or hostile provider must not be able -## to stream an unbounded body inside the window. -const MAX_RESPONSE_BODY_BYTES := 1048576 -## Output-token budget shared by both dialects (OpenAI `max_tokens`, -## Gemini `generationConfig.maxOutputTokens`) so routed descriptions -## stay bounded. -const MAX_OUTPUT_TOKENS := 512 - -const _ENC_PREFIX := "v1" -const _SALT := "vision_routing::v1::godot-ai" -const _PLACEHOLDER_PNG := "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAALSURBVBhXY2BABwAAEgABp3qZbgAAAABJRU5ErkJggg==" - -## Plugin log buffer (McpLogBuffer), set by plugin.gd; null-safe. -var log_buffer: Object = null - -var _active := true -var _route_done: Callable -var _pending: Dictionary = {} # request_id -> {payload, data, connection, provider_id, provider, params} -var _threads: Dictionary = {} # request_id -> Thread ("_test" = ping thread) -var _ui_loading := false - -# UI references, kept in sync by _sync_ui_states(). -var _tab_provider: OptionButton = null -var _tab_enable: CheckButton = null -var _tab_key_label: Label = null -var _tab_key_hint: Label = null -var _tab_key_edit: LineEdit = null -var _tab_model_label: Label = null -var _tab_model_hint: Label = null -var _tab_model_edit: LineEdit = null -var _tab_status: Label = null -var _tab_test_button: Button = null - - -func _init() -> void: - _route_done = Callable(self, "_on_route_complete") - - -## Plugin teardown: stop workers and join in-flight threads so Godot never -## destroys a Thread mid-execution during a plugin reload. -func shutdown() -> void: - _active = false - ## Workers poll _active between HTTP polls, so this returns within one - ## poll interval (~50ms) unless a request is mid-flight in the OS; worst - ## case is a connect/request timeout. - for rid in _threads: - var thread: Thread = _threads[rid] - if thread != null and thread.is_started(): - thread.wait_to_finish() - _threads.clear() - _pending.clear() - _tab_provider = null - _tab_enable = null - _tab_key_label = null - _tab_key_hint = null - _tab_key_edit = null - _tab_status = null - _tab_test_button = null - _tab_model_label = null - _tab_model_hint = null - _tab_model_edit = null - - -# --- routing ------------------------------------------------------------------ - -## Entry point called from editor_handler.take_screenshot when routing is -## enabled. Runs the real capture (via `original`), then routes the image -## through the selected provider's vision API on a worker thread. Returns the deferred-response sentinel -## when the worker owns the reply, otherwise the capture result unchanged. -func route_editor_screenshot(params: Dictionary, original: Callable, connection: Object) -> Dictionary: - var rid := str(params.get("_request_id", "")) - if rid.is_empty(): - ## No deferred channel (e.g. batch_execute / dispatch_direct) - keep - ## the original synchronous result untouched. - return original.call(params) - var result := original.call(params) - if not (result is Dictionary) or not result.has("data"): - ## Deferred capture (source="game"): the frame arrives later through - ## route_game_payload. Stash the caller params (e.g. user_prompt) by - ## request id so the routed description keeps the agent's context. - if result is Dictionary and result.get("_deferred", false): - _pending[rid] = {"params": params} - return result - var data: Variant = result["data"] - if not (data is Dictionary) or not data.has("image_base64"): - return result - if _start_route(rid, str(params.get("source", "viewport")), result, data, connection, params): - ## Keep the deferred ledger inside the server's 15s editor_screenshot - ## window (the worker itself is capped at TOTAL_ROUTE_BUDGET_MS), so - ## a hung provider surfaces as a clean plugin timeout, not a - ## server-side abort. - return {"_deferred": true, "_deferred_timeout_ms": 13000} - return result - - -## Entry point called from McpDebuggerPlugin._on_screenshot_response before -## the frame is sent. Returns true when a worker owns the reply (the caller -## must NOT send the payload itself); false means pass through unchanged. -func route_game_payload(connection: Object, rid: String, payload: Dictionary) -> bool: - var data: Variant = payload.get("data") - if not (data is Dictionary) or not data.has("image_base64"): - ## Not a routeable frame - drop any params stashed by - ## route_editor_screenshot for this request id. - _pending.erase(rid) - return false - ## The editor handler stashed the caller params (user_prompt etc.) by - ## request id when it deferred the capture; hand them to the prompt - ## builder. Absent (older helper, direct call) = generic prompt. - var params: Dictionary = _pending.get(rid, {}).get("params", {}) - return _start_route(rid, "game", payload, data, connection, params) - - -## Returns true when a worker owns the reply; false means the caller should -## pass the original payload through unchanged. -func _start_route(rid: String, source: String, payload: Dictionary, data: Dictionary, connection: Object, params: Dictionary) -> bool: - var provider_id := _active_provider_id() - var base_provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) - ## Worker-thread snapshot: the entered model id is resolved into the - ## provider dict here, once, so the thread sees a stable copy and - ## routed_via can report the exact model that was pinged. - var provider := _provider_with_model(provider_id) - var api_key := _resolved_api_key(provider_id) - if api_key.is_empty(): - _log("vision routing: no API key for %s (set %s or paste one in the Vision Routing section) - screenshot %s passed through" % [provider_id, base_provider.get("env", ""), rid]) - _pending.erase(rid) - return false - if str(provider.get("model", "")).is_empty(): - ## Empty model behaves exactly like empty key: routing declines, the - ## image passes through, and the log line says what is missing. - _log("vision routing: no model id set for %s - screenshot %s passed through (set a model id in the Vision Routing section)" % [provider_id, rid]) - _pending.erase(rid) - return false - _pending[rid] = {"payload": payload, "data": data, "connection": connection, "provider_id": provider_id, "provider": provider, "params": params} - var prompt := _build_prompt(params) - var thread := Thread.new() - var start_err := thread.start(_route_worker.bind(provider, str(data.get("image_base64", "")), prompt, api_key, rid)) - if start_err != OK: - _pending.erase(rid) - _log("vision routing: could not start worker for %s: %s - screenshot %s passed through" % [rid, error_string(start_err), source]) - return false - _threads[rid] = thread - _log("vision routing: routing %s screenshot %s (%d b64 chars) via %s (%s)" % [source, rid, str(data.get("image_base64", "")).length(), base_provider.get("label", provider_id), provider.get("model", "")]) - return true - - -func _on_route_complete(rid: String, result: Variant) -> void: - _join_thread(rid) - if not _active: - _pending.erase(rid) - return - var entry: Dictionary = _pending.get(rid, {}) - _pending.erase(rid) - if entry.is_empty(): - return - var connection: Object = entry.get("connection") - if connection == null or not is_instance_valid(connection): - return - var provider_id := str(entry.get("provider_id", "groq")) - ## Provider snapshot taken at route start (with the entered model id); - ## falls back to a fresh resolve if absent (direct test callers). - var provider: Dictionary = entry.get("provider", {}) - if provider.is_empty(): - provider = _provider_with_model(provider_id) - ## Workers return {"desc": ..., "error": ...}; plain strings are accepted - ## for backwards compatibility (tests / older callers). - var description_str := "" - var error_str := "" - if result is Dictionary: - description_str = str(result.get("desc", "")) - error_str = str(result.get("error", "")) - elif result != null: - description_str = str(result) - if description_str.is_empty(): - if error_str.is_empty(): - error_str = "unknown error" - _log("vision routing: %s failed for %s (%s) - returning original image with failure note" % [provider_id, rid, error_str]) - ## Append a short templated reason to the note so text-only agents - ## (who otherwise just receive a useless image block) learn the - ## feature is down and why. - var failed_data: Dictionary = entry.get("data", {}).duplicate() - var failure_note := "Vision routing unavailable (%s): %s" % [provider_id, error_str] - var existing_note := str(failed_data.get("note", "")) - failed_data["note"] = (existing_note + " | " if not existing_note.is_empty() else "") + failure_note - _pass_through(connection, rid, {"data": failed_data}) - return - var data: Dictionary = entry.get("data", {}).duplicate() - var routed_via := "%s:%s" % [provider_id, provider.get("model", "")] - ## The server forwards a fixed whitelist of metadata keys into the text - ## result; `note` is the free-form one, so a self-attributing description - ## rides there (plus an explicit `vision_description` key) so text-only - ## models can read it. The label keeps provider text from reading as if - ## the plugin said it. The image is replaced by a valid 2x2 placeholder - ## so the payload stays well-formed. - data["vision_description"] = description_str - data["routed_via"] = routed_via - var original_note := str(data.get("note", "")) - var labeled := "Vision description (%s): %s" % [routed_via, description_str] - data["note"] = (original_note + " | " if not original_note.is_empty() else "") + labeled - data["image_base64"] = _PLACEHOLDER_PNG - data["format"] = "png" - _log("vision routing: description ready for %s (%d chars)" % [rid, description_str.length()]) - _pass_through(connection, rid, {"data": data}) - - -func _pass_through(connection: Object, rid: String, payload: Dictionary) -> void: - if connection != null and is_instance_valid(connection): - connection.send_deferred_response(rid, payload) - - -func _build_prompt(params: Dictionary) -> String: - var lines := PackedStringArray([ - "You are the vision module of a text-only AI agent driving the Godot editor through MCP.", - "Describe this screenshot so the agent can act without seeing it. Report:", - "- What is shown: Godot editor viewport, game window, 2D/3D scene, UI panel, dialog, or other.", - "- Objects/nodes: what they are, position, color, size, and any labels or text (quote text exactly).", - "- UI text: menus, buttons, error dialogs, console output, warnings, line numbers.", - "- State: selected node outlines, gizmos, play/stop status, panels that are open.", - "- Problems: errors, red highlights, missing textures, black screens, glitches, stretching.", - "Be concise (under 200 words), factual, and use exact quotes instead of paraphrase. Do not give advice.", - ]) - var user_prompt := str(params.get("user_prompt", "")) - if not user_prompt.is_empty(): - lines.append("Context from the agent that requested this screenshot: %s" % user_prompt) - return "\n".join(lines) - - -# --- routing worker (thread) --------------------------------------------------- - -func _route_worker(provider: Dictionary, image_b64: String, prompt: String, api_key: String, rid: String) -> void: - var result := _describe_blocking(provider, image_b64, prompt, api_key) - _route_done.call_deferred(rid, result) - - -## Returns {"desc": String, "error": String}; "desc" is empty on failure and -## "error" carries the reason. Runs on a worker thread, so it never writes -## shared state - everything it needs is passed in and returned. `provider` -## is the route-start snapshot (includes the entered model id). -func _describe_blocking(provider: Dictionary, image_b64: String, prompt: String, api_key: String) -> Dictionary: - var b64 := _downscale_image_if_needed(image_b64) - var body := _build_request_body(provider, prompt, b64) - var headers := _build_headers(provider, api_key) - var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), headers, body) - return _parse_description(provider, response) - - -## Provider paths may carry a {model} placeholder (Gemini's endpoint embeds -## the model id); substitute the entered model id verbatim. -func _resolve_path(provider: Dictionary) -> String: - return str(provider.get("path", "")).replace("{model}", str(provider.get("model", ""))) - - -func _build_request_body(provider: Dictionary, prompt: String, image_b64: String) -> String: - var model := str(provider.get("model", "")) - if str(provider.get("dialect", "")) == "gemini": - return JSON.stringify({ - "contents": [{ - "role": "user", - "parts": [ - {"text": prompt}, - {"inline_data": {"mime_type": "image/png", "data": image_b64}}, - ], - }], - "generationConfig": {"maxOutputTokens": MAX_OUTPUT_TOKENS}, - }) - var payload := { - "model": model, - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": prompt}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64," + image_b64}}, - ], - }], - "max_tokens": MAX_OUTPUT_TOKENS, - "temperature": 0.2, - } - if provider.has("reasoning_effort"): - payload["reasoning_effort"] = provider["reasoning_effort"] - return JSON.stringify(payload) - - -func _build_headers(provider: Dictionary, api_key: String) -> PackedStringArray: - if str(provider.get("dialect", "")) == "gemini": - return PackedStringArray([ - "Content-Type: application/json", - "x-goog-api-key: %s" % api_key, - ]) - return PackedStringArray([ - "Content-Type: application/json", - "Authorization: Bearer %s" % api_key, - ]) - - -func _parse_description(provider: Dictionary, response: Dictionary) -> Dictionary: - var code: int = int(response.get("code", 0)) - var label := str(provider.get("label", str(provider.get("model", "?")))) - if code == 0: - return {"desc": "", "error": str(response.get("error", "HTTP request failed"))} - if code != 200: - return {"desc": "", "error": "%s HTTP %d: %s" % [label, code, _body_snippet(str(response.get("text", "")))]} - var parsed: Variant = JSON.parse_string(str(response.get("text", ""))) - if not (parsed is Dictionary): - return {"desc": "", "error": "%s response was not JSON: %s" % [label, _body_snippet(str(response.get("text", "")))]} - if str(provider.get("dialect", "")) == "gemini": - return _parse_gemini(parsed, label, response) - return _parse_openai(parsed, label, response) - - -func _parse_openai(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary: - var choices: Variant = parsed.get("choices") - if not (choices is Array) or choices.is_empty(): - return {"desc": "", "error": "%s response had no choices: %s" % [label, _body_snippet(str(response.get("text", "")))]} - if not (choices[0] is Dictionary): - return {"desc": "", "error": "%s response choice was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} - var message: Variant = choices[0].get("message", {}) - if not (message is Dictionary): - return {"desc": "", "error": "%s response message was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} - var content: Variant = message.get("content", "") - if content == null: - return {"desc": "", "error": "%s response content was null: %s" % [label, _body_snippet(str(response.get("text", "")))]} - return {"desc": _strip_think(str(content)), "error": ""} - - -func _parse_gemini(parsed: Dictionary, label: String, response: Dictionary) -> Dictionary: - var candidates: Variant = parsed.get("candidates") - if not (candidates is Array) or candidates.is_empty(): - var reason := "" - var feedback: Variant = parsed.get("promptFeedback") - if feedback is Dictionary: - reason = str(feedback.get("blockReason", "")) - var reason_part := "" - if not reason.is_empty(): - reason_part = " (blocked: %s)" % reason - return {"desc": "", "error": "%s response had no candidates%s: %s" % [label, reason_part, _body_snippet(str(response.get("text", "")))]} - if not (candidates[0] is Dictionary): - return {"desc": "", "error": "%s response candidate was not an object: %s" % [label, _body_snippet(str(response.get("text", "")))]} - var content: Variant = candidates[0].get("content", {}) - if not (content is Dictionary): - return {"desc": "", "error": "%s response content was missing: %s" % [label, _body_snippet(str(response.get("text", "")))]} - var parts: Variant = content.get("parts") - if not (parts is Array) or parts.is_empty(): - return {"desc": "", "error": "%s response had no parts: %s" % [label, _body_snippet(str(response.get("text", "")))]} - var texts := PackedStringArray() - for part in parts: - if part is Dictionary: - var part_text := str(part.get("text", "")) - if not part_text.is_empty(): - texts.append(part_text) - if texts.is_empty(): - return {"desc": "", "error": "%s response parts had no text: %s" % [label, _body_snippet(str(response.get("text", "")))]} - return {"desc": _strip_think("\n".join(texts)), "error": ""} - - -func _strip_think(text: String) -> String: - var out := text.strip_edges() - ## Reasoning models may wrap their answer in ... blocks. - var think_end := out.rfind("") - if think_end != -1: - out = out.substr(think_end + "".length()).strip_edges() - return out - - -## Minimal vision call used by the "Test connection" button. Sends the same -## image-bearing body shape as real routing (with the 2x2 placeholder PNG), -## so one click validates key + model existence + image capability - a -## text-only or retired model id fails loudly here instead of at screenshot -## time. `provider` is the route-style snapshot (includes the model id). -func _ping_blocking(provider: Dictionary, api_key: String) -> Dictionary: - var body := _build_request_body(provider, "Reply with exactly: OK", _PLACEHOLDER_PNG) - var response := _http_post_json(str(provider.get("host", "")), int(provider.get("port", 443)), _resolve_path(provider), _build_headers(provider, api_key), body) - var code: int = int(response.get("code", 0)) - if code == 200: - return {"ok": true, "error": ""} - if code == 0: - return {"ok": false, "error": str(response.get("error", "request failed"))} - return {"ok": false, "error": "%s HTTP %d: %s" % [provider.get("label", "provider"), code, _body_snippet(str(response.get("text", "")))]} - - -func _http_post_json(host: String, port: int, path: String, headers: PackedStringArray, body: String) -> Dictionary: - if host.is_empty() or body.is_empty(): - return {"code": 0, "error": "invalid request (empty host or body)"} - var http := HTTPClient.new() - var connect_err := http.connect_to_host(host, port, TLSOptions.client()) - if connect_err != OK: - http.close() - return {"code": 0, "error": "connect_to_host failed: %s" % error_string(connect_err)} - ## One total deadline for the whole exchange (see TOTAL_ROUTE_BUDGET_MS), - ## so connect + request + body can never exceed it. - var budget_deadline := Time.get_ticks_msec() + TOTAL_ROUTE_BUDGET_MS - var deadline := mini(Time.get_ticks_msec() + CONNECT_TIMEOUT_MS, budget_deadline) - var first_poll := true - var connected := false - var last_status := -1 - while Time.get_ticks_msec() < deadline: - if not _active: - http.close() - return {"code": 0, "error": "aborted (plugin teardown)"} - http.poll() - var status := http.get_status() - last_status = status - if status == HTTPClient.STATUS_CONNECTED: - connected = true - break - if status == HTTPClient.STATUS_DISCONNECTED and not first_poll: - break - first_poll = false - OS.delay_msec(50) - if not connected: - http.close() - return {"code": 0, "error": "could not connect (status %d)" % last_status} - if http.request(HTTPClient.METHOD_POST, path, headers, body) != OK: - http.close() - return {"code": 0, "error": "request() failed"} - deadline = mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline) - var timed_out := false - var status_at_timeout := -1 - while http.get_status() == HTTPClient.STATUS_REQUESTING: - if not _active: - http.close() - return {"code": 0, "error": "aborted (plugin teardown)"} - http.poll() - if Time.get_ticks_msec() > deadline: - timed_out = true - status_at_timeout = http.get_status() - break - OS.delay_msec(50) - if timed_out: - http.close() - return {"code": 0, "error": "request timed out (status %d)" % status_at_timeout} - if not http.has_response(): - var st := http.get_status() - http.close() - return {"code": 0, "error": "no HTTP response (status %d)" % st} - var code := http.get_response_code() - var chunks := PackedByteArray() - var body_deadline := mini(Time.get_ticks_msec() + REQUEST_TIMEOUT_MS, budget_deadline) - while http.get_status() == HTTPClient.STATUS_BODY: - if not _active: - http.close() - return {"code": 0, "error": "aborted (plugin teardown)"} - chunks.append_array(http.read_response_body_chunk()) - if chunks.size() > MAX_RESPONSE_BODY_BYTES: - http.close() - return {"code": 0, "error": "response body exceeded %d bytes" % MAX_RESPONSE_BODY_BYTES} - http.poll() - if Time.get_ticks_msec() > body_deadline: - break - OS.delay_msec(10) - http.close() - return {"code": code, "text": chunks.get_string_from_utf8()} - - -func _body_snippet(text: String) -> String: - if text.is_empty(): - return "(empty body)" - if text.length() > 300: - return text.substr(0, 300) + "..." - return text - - -func _join_thread(rid: String) -> void: - ## Join the worker before dropping the Thread reference, otherwise Godot - ## warns "Thread object destroyed without completion". - var thread: Thread = _threads.get(rid) - if thread != null and thread.is_started(): - thread.wait_to_finish() - _threads.erase(rid) - - -func _downscale_image_if_needed(image_b64: String) -> String: - if image_b64.is_empty(): - return image_b64 - var raw := Marshalls.base64_to_raw(image_b64) - if raw.is_empty(): - return image_b64 - var image := Image.new() - if image.load_png_from_buffer(raw) != OK: - return image_b64 - var width := image.get_width() - var height := image.get_height() - if width <= MAX_IMAGE_EDGE and height <= MAX_IMAGE_EDGE: - return image_b64 - if width >= height: - height = maxi(1, int(round(height * MAX_IMAGE_EDGE / float(width)))) - width = MAX_IMAGE_EDGE - else: - width = maxi(1, int(round(width * MAX_IMAGE_EDGE / float(height)))) - height = MAX_IMAGE_EDGE - image.resize(width, height, Image.INTERPOLATE_BILINEAR) - var out := image.save_png_to_buffer() - if out.is_empty(): - return image_b64 - return Marshalls.raw_to_base64(out) - - -func _ping_worker(provider: Dictionary, api_key: String, status_label: Label, key_source: String) -> void: - var result := _ping_blocking(provider, api_key) - Callable(self, "_on_ping_done").call_deferred(result, provider, status_label, key_source) - - -func _on_ping_done(result: Dictionary, provider: Dictionary, status_label: Label, key_source: String) -> void: - _join_thread("_test") - if _tab_test_button != null and is_instance_valid(_tab_test_button): - _tab_test_button.disabled = false - if status_label != null and is_instance_valid(status_label): - var label := str(provider.get("label", "provider")) - if result.get("ok", false): - status_label.text = "OK - %s responded (%s)." % [label, key_source] - else: - status_label.text = "FAILED - %s (%s)." % [result.get("error", "unknown error"), key_source] - _log("vision routing: ping result: %s" % status_label.text) - - -# --- settings / key storage --------------------------------------------------- - -func _settings() -> EditorSettings: - return EditorInterface.get_editor_settings() - - -func is_routing_enabled() -> bool: - var es := _settings() - if es == null or not es.has_setting(SETTING_ENABLED): - return false - var value = es.get_setting(SETTING_ENABLED) - return value != null and value - - -func _active_provider_id() -> String: - var es := _settings() - if es == null: - return "groq" - var stored := "" - if es.has_setting(SETTING_PROVIDER): - stored = str(es.get_setting(SETTING_PROVIDER)) - if stored.is_empty() or not PROVIDERS.has(stored): - return "groq" - return stored - - -func _provider_setting(provider_id: String) -> String: - return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("setting", SETTING_API_KEY_ENC)) - - -func _resolved_api_key(provider_id: String) -> String: - ## Environment variable takes priority over the stored (encrypted) key. - var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) - var env_key := OS.get_environment(str(provider.get("env", ""))) - if not env_key.is_empty(): - return env_key - var es := _settings() - if es == null: - return "" - var setting := _provider_setting(provider_id) - var blob := "" - if es.has_setting(setting): - blob = str(es.get_setting(setting)) - if blob.is_empty(): - return "" - return _decrypt(blob) - - -func _decrypted_key(provider_id: String) -> String: - var es := _settings() - if es == null: - return "" - var setting := _provider_setting(provider_id) - if not es.has_setting(setting): - return "" - return _decrypt(str(es.get_setting(setting))) - - -func set_api_key(provider_id: String, plain: String) -> void: - var es := _settings() - if es == null: - return - if plain.is_empty(): - es.set_setting(_provider_setting(provider_id), "") - return - var blob := _encrypt(plain) - if blob.is_empty(): - _log("vision routing: cannot store %s key - this machine reports no unique id; set %s instead" % [provider_id, PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", "")]) - return - es.set_setting(_provider_setting(provider_id), blob) - - -## The model id is stored per provider next to the key (a plain Editor -## Setting - it is not a secret). Empty clears the slot. -func set_model_id(provider_id: String, model: String) -> void: - var es := _settings() - if es == null: - return - es.set_setting(_model_setting(provider_id), model.strip_edges()) - - -func _model_setting(provider_id: String) -> String: - return str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("model_setting", "")) - - -func _resolved_model(provider_id: String) -> String: - var es := _settings() - if es == null: - return "" - var setting := _model_setting(provider_id) - if not es.has_setting(setting): - return "" - return str(es.get_setting(setting)).strip_edges() - - -## Route-start snapshot: the curated provider table plus the user's entered -## model id. Used by the worker thread and by _on_route_complete for the -## routed_via label, so it always reports the model that was actually pinged. -func _provider_with_model(provider_id: String) -> Dictionary: - var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]).duplicate() - provider["model"] = _resolved_model(provider_id) - return provider - - -## Machine-derived key: not a password, but enough that a casually-opened -## editor_settings-4.tres does not reveal the key in plain text. Separate -## domain tags give AES and HMAC independent keys so neither reuses the -## other's bytes. -func _derive_key(tag: String) -> PackedByteArray: - var parts := PackedStringArray([ - OS.get_unique_id(), - OS.get_environment("USERNAME"), - OS.get_environment("USERPROFILE"), - OS.get_environment("USER"), - OS.get_environment("HOME"), - OS.get_name(), - _SALT, - tag, - ]) - var ctx := HashingContext.new() - ctx.start(HashingContext.HASH_SHA256) - ctx.update(("|".join(parts)).to_utf8_buffer()) - return ctx.finish() - - -func _encrypt(plain: String) -> String: - if OS.get_unique_id().is_empty(): - ## Without a machine id the key would be near-constant across users - ## on the same machine - refuse, and let callers fall back to the - ## provider's environment variable. - return "" - var aes_key := _derive_key("aes") - var mac_key := _derive_key("mac") - var iv := Crypto.new().generate_random_bytes(16) - var raw := plain.to_utf8_buffer() - ## PKCS7 padding. - var pad := 16 - (raw.size() % 16) - var padded := raw.duplicate() - for i in pad: - padded.append(pad) - var aes := AESContext.new() - aes.start(AESContext.MODE_CBC_ENCRYPT, aes_key, iv) - var cipher := aes.update(padded) - aes.finish() - var hmac := HMACContext.new() - hmac.start(HashingContext.HASH_SHA256, mac_key) - hmac.update(iv) - hmac.update(cipher) - var mac := hmac.finish() - return "%s:%s:%s:%s" % [_ENC_PREFIX, Marshalls.raw_to_base64(iv), Marshalls.raw_to_base64(mac), Marshalls.raw_to_base64(cipher)] - - -func _decrypt(blob: String) -> String: - var parts := blob.split(":") - if parts.size() != 4 or parts[0] != _ENC_PREFIX: - return "" - var iv := Marshalls.base64_to_raw(parts[1]) - var mac := Marshalls.base64_to_raw(parts[2]) - var cipher := Marshalls.base64_to_raw(parts[3]) - if iv.size() != 16 or cipher.is_empty() or cipher.size() % 16 != 0: - return "" - var aes_key := _derive_key("aes") - var mac_key := _derive_key("mac") - var hmac := HMACContext.new() - hmac.start(HashingContext.HASH_SHA256, mac_key) - hmac.update(iv) - hmac.update(cipher) - var expected := hmac.finish() - if expected != mac: - return "" - var aes := AESContext.new() - aes.start(AESContext.MODE_CBC_DECRYPT, aes_key, iv) - var padded := aes.update(cipher) - aes.finish() - if padded.is_empty(): - return "" - var pad := int(padded[padded.size() - 1]) - if pad < 1 or pad > 16 or pad > padded.size(): - return "" - var raw := padded.slice(0, padded.size() - pad) - return raw.get_string_from_utf8() - - -# --- UI: tab in Clients & Tools ------------------------------------------------- - -## Builds the "Vision Routing" section inside the Clients & Tools Settings -## tab. Called by mcp_dock._build_settings_tab; `refresh_ui()` re-syncs the -## controls from Editor Settings each time the window opens. -func build_section(parent: VBoxContainer) -> void: - var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 8) - parent.add_child(box) - - var header := Label.new() - header.text = "Vision Routing" - header.add_theme_font_size_override("font_size", 18) - box.add_child(header) - - var provider_row := HBoxContainer.new() - var provider_label := Label.new() - provider_label.text = "Provider" - provider_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - provider_row.add_child(provider_label) - _tab_provider = OptionButton.new() - _tab_provider.tooltip_text = "Which vision provider routes the screenshots. The model id is required per provider and is yours to maintain." - var active_provider := _active_provider_id() - for provider_index in PROVIDER_ORDER.size(): - var provider_item: Dictionary = PROVIDERS[PROVIDER_ORDER[provider_index]] - _tab_provider.add_item(str(provider_item.get("label", PROVIDER_ORDER[provider_index])), provider_index) - if PROVIDER_ORDER[provider_index] == active_provider: - _tab_provider.select(provider_index) - _tab_provider.item_selected.connect(_on_provider_changed) - provider_row.add_child(_tab_provider) - box.add_child(provider_row) - - var enable_row := HBoxContainer.new() - var enable_label := Label.new() - enable_label.text = "Enable routing" - enable_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - enable_row.add_child(enable_label) - _tab_enable = CheckButton.new() - _tab_enable.button_pressed = is_routing_enabled() - _tab_enable.toggled.connect(_on_enable_toggled) - enable_row.add_child(_tab_enable) - box.add_child(enable_row) - - var description := Label.new() - description.text = ( - "When enabled, every single-image screenshot the AI model takes through " - + "the godot-ai screenshot tool is sent to the selected provider's vision model (see " - + "the Provider dropdown) for a text description. The description is " - + "returned to the AI instead of the raw image, so models without image " - + "support (e.g. DeepSeek) can still \"see\" the editor and game. When " - + "the connected model analyzes images itself, switch this off here so " - + "screenshots pass through unchanged." - ) - description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - description.size_flags_horizontal = Control.SIZE_EXPAND_FILL - box.add_child(description) - - _tab_key_label = Label.new() - box.add_child(_tab_key_label) - - var key_row := HBoxContainer.new() - _tab_key_edit = LineEdit.new() - _tab_key_edit.secret = true - _tab_key_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL - ## Persist on commit (Enter / focus loss) instead of per keystroke, so - ## pasting a key does not re-encrypt and rewrite Editor Settings dozens - ## of times. - _tab_key_edit.text_submitted.connect(_on_key_committed) - _tab_key_edit.focus_exited.connect(func() -> void: _on_key_committed(_tab_key_edit.text)) - key_row.add_child(_tab_key_edit) - var show_button := CheckButton.new() - show_button.tooltip_text = "Show / hide key" - show_button.toggled.connect(func(show: bool) -> void: _tab_key_edit.secret = not show) - key_row.add_child(show_button) - box.add_child(key_row) - - _tab_key_hint = Label.new() - _tab_key_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _tab_key_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) - box.add_child(_tab_key_hint) - - _tab_model_label = Label.new() - box.add_child(_tab_model_label) - - var model_row := HBoxContainer.new() - _tab_model_edit = LineEdit.new() - _tab_model_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL - ## Persist on commit (Enter / focus loss), same pattern as the key field. - _tab_model_edit.text_submitted.connect(_on_model_committed) - _tab_model_edit.focus_exited.connect(func() -> void: _on_model_committed(_tab_model_edit.text)) - model_row.add_child(_tab_model_edit) - box.add_child(model_row) - - _tab_model_hint = Label.new() - _tab_model_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _tab_model_hint.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) - box.add_child(_tab_model_hint) - - var test_row := HBoxContainer.new() - var test_button := Button.new() - test_button.text = "Test connection" - test_button.pressed.connect(_on_test_connection) - _tab_test_button = test_button - test_row.add_child(test_button) - _tab_status = Label.new() - _tab_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _tab_status.add_theme_color_override("font_color", Color(0.55, 0.55, 0.55)) - test_row.add_child(_tab_status) - box.add_child(test_row) - - refresh_ui() - - -## Re-syncs every control from Editor Settings. Called after the section is -## built and again by mcp_dock each time the Clients & Tools window opens, -## so a change made in another editor instance (or a hand-edit of -## editor_settings-4.tres) is reflected. -func refresh_ui() -> void: - _sync_ui_states() - _sync_provider_ui() - _ui_loading = true - if _tab_key_edit != null and is_instance_valid(_tab_key_edit): - _tab_key_edit.text = _decrypted_key(_active_provider_id()) - if _tab_model_edit != null and is_instance_valid(_tab_model_edit): - _tab_model_edit.text = _resolved_model(_active_provider_id()) - _ui_loading = false - - -func _on_key_committed(_new_text: String) -> void: - if _ui_loading: - return - set_api_key(_active_provider_id(), _tab_key_edit.text) - - -func _on_model_committed(_new_text: String) -> void: - if _ui_loading: - return - set_model_id(_active_provider_id(), _tab_model_edit.text) - - -func _on_provider_changed(index: int) -> void: - if index < 0 or index >= PROVIDER_ORDER.size(): - return - var provider_id: String = PROVIDER_ORDER[index] - var es := _settings() - if es != null: - es.set_setting(SETTING_PROVIDER, provider_id) - _ui_loading = true - if _tab_key_edit != null and is_instance_valid(_tab_key_edit): - _tab_key_edit.text = _decrypted_key(provider_id) - if _tab_model_edit != null and is_instance_valid(_tab_model_edit): - _tab_model_edit.text = _resolved_model(provider_id) - _ui_loading = false - _sync_provider_ui() - _log("vision routing: provider changed to %s (%s)" % [provider_id, PROVIDERS[provider_id].get("label", provider_id)]) - - -func _sync_provider_ui() -> void: - var provider_id := _active_provider_id() - var provider: Dictionary = PROVIDERS.get(provider_id, PROVIDERS["groq"]) - if _tab_provider != null and is_instance_valid(_tab_provider): - var provider_index := PROVIDER_ORDER.find(provider_id) - if provider_index != -1 and _tab_provider.selected != provider_index: - _tab_provider.select(provider_index) - if _tab_key_label != null and is_instance_valid(_tab_key_label): - _tab_key_label.text = str(provider.get("key_label", "API key")) - if _tab_key_hint != null and is_instance_valid(_tab_key_hint): - var hint := ( - "Stored encrypted (AES-256, key derived from this machine) in Editor Settings " - + "- not plain text, but local obfuscation only. You can also set the " - + "%s environment variable; it takes priority over this field." - ) % provider.get("env", "") - if OS.get_unique_id().is_empty(): - hint += " This machine reports no unique id, so keys cannot be stored locally - use the %s environment variable." % provider.get("env", "") - _tab_key_hint.text = hint - if _tab_key_edit != null and is_instance_valid(_tab_key_edit): - _tab_key_edit.placeholder_text = str(provider.get("placeholder", "")) - if _tab_model_label != null and is_instance_valid(_tab_model_label): - _tab_model_label.text = "Model id (required) - %s" % provider.get("label", provider_id) - if _tab_model_edit != null and is_instance_valid(_tab_model_edit): - _tab_model_edit.placeholder_text = str(provider.get("model_placeholder", "")) - _tab_model_edit.tooltip_text = "The vision model this provider should ping. Yours to maintain: when a model id retires, replace it here." - if _tab_model_hint != null and is_instance_valid(_tab_model_hint): - _tab_model_hint.text = ( - "Required per provider; empty behaves like an empty key (routing " - + "declines and screenshots pass through). Suggested id (as of " - + "2026-08 - check your provider console, ids retire): %s." - ) % provider.get("model_placeholder", "") - - -func _on_test_connection() -> void: - if _tab_status == null: - return - var provider_id := _active_provider_id() - var provider := _provider_with_model(provider_id) - var env_name := str(PROVIDERS.get(provider_id, PROVIDERS["groq"]).get("env", "")) - if str(provider.get("model", "")).is_empty(): - _tab_status.text = "No model id set for %s - add one below." % provider.get("label", provider_id) - return - var api_key := _resolved_api_key(provider_id) - if api_key.is_empty(): - _tab_status.text = "No key set - add one below or set %s." % env_name - return - var running: Thread = _threads.get("_test") - if running != null and running.is_started() and running.is_alive(): - return - var previous_status := _tab_status.text - _tab_status.text = "Testing..." - if _tab_test_button != null and is_instance_valid(_tab_test_button): - _tab_test_button.disabled = true - var key_source := "stored key" - if not OS.get_environment(env_name).is_empty(): - key_source = "via %s" % env_name - var thread := Thread.new() - _threads["_test"] = thread - var start_err := thread.start(_ping_worker.bind(provider, api_key, _tab_status, key_source)) - if start_err != OK: - ## A failed thread start must not leave the button disabled and the - ## status stuck on "Testing..." forever. - _threads.erase("_test") - _tab_status.text = previous_status - if _tab_test_button != null and is_instance_valid(_tab_test_button): - _tab_test_button.disabled = false - _log("vision routing: could not start ping thread: %s" % error_string(start_err)) - - -func _on_enable_toggled(enabled: bool) -> void: - var es := _settings() - if es != null: - es.set_setting(SETTING_ENABLED, enabled) - _sync_ui_states() - - -func _sync_ui_states() -> void: - var enabled := is_routing_enabled() - if _tab_enable != null and is_instance_valid(_tab_enable) and _tab_enable.button_pressed != enabled: - _tab_enable.set_pressed_no_signal(enabled) - - -# --- logging -------------------------------------------------------------------- - -func _log(message: String) -> void: - if log_buffer != null and is_instance_valid(log_buffer) and log_buffer.has_method("log"): - log_buffer.log(message, false) diff --git a/addons/godot_ai/vision_routing.gd.uid b/addons/godot_ai/vision_routing.gd.uid deleted file mode 100644 index c4c5bc2..0000000 --- a/addons/godot_ai/vision_routing.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dp444q4ocpx45

_rAmX9 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