From 9da1042577a6c880d8a6b4aa469d12cbb6685d53 Mon Sep 17 00:00:00 2001 From: Diego De la Viuda Llorente Date: Thu, 1 Oct 2026 13:13:51 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9E=95ADD:=20Initial=20Files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 4 + .gitattributes | 2 + .gitignore | 16 +- addons/godot_ai/LICENSE | 21 + addons/godot_ai/README.md | 53 + addons/godot_ai/client_configurator.gd | 1388 +++++++ 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 | 427 +++ 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 | 215 ++ addons/godot_ai/clients/_cli_strategy.gd.uid | 1 + addons/godot_ai/clients/_json_strategy.gd | 341 ++ addons/godot_ai/clients/_json_strategy.gd.uid | 1 + addons/godot_ai/clients/_manual_command.gd | 264 ++ .../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 | 151 + 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 | 544 +++ 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 | 53 + 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/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/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 | 1046 ++++++ addons/godot_ai/connection.gd.uid | 1 + .../godot_ai/debugger/mcp_debugger_plugin.gd | 1412 +++++++ .../debugger/mcp_debugger_plugin.gd.uid | 1 + addons/godot_ai/dispatcher.gd | 463 +++ 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 | 170 + 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 | 123 + .../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 | 309 ++ 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 | 3341 +++++++++++++++++ addons/godot_ai/mcp_dock.gd.uid | 1 + addons/godot_ai/plugin.cfg | 7 + addons/godot_ai/plugin.gd | 2008 ++++++++++ addons/godot_ai/plugin.gd.uid | 1 + 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 | 1295 +++++++ 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 | 109 + addons/godot_ai/tool_catalog.gd.uid | 1 + addons/godot_ai/update_reload_runner.gd | 557 +++ 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 | 370 ++ addons/godot_ai/utils/port_resolver.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 | 1863 +++++++++ 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 | 63 + 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 + addons/godot_ai/utils/update_manager.gd | 766 ++++ 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 + .../utils/windows_port_reservation.gd | 146 + .../utils/windows_port_reservation.gd.uid | 1 + addons/godot_ai/vision_routing.gd | 1043 +++++ addons/godot_ai/vision_routing.gd.uid | 1 + assets/characters/ankarde.tscn | 272 ++ .../characters/ankarde/Diego-idle-trimmed.png | Bin 0 -> 170556 bytes .../ankarde/Diego-idle-trimmed.png.import | 40 + .../characters/ankarde/Diego-walk-trimmed.png | Bin 0 -> 334004 bytes .../ankarde/Diego-walk-trimmed.png.import | 40 + assets/characters/camera_2d.gd | 14 + assets/characters/camera_2d.gd.uid | 1 + assets/photo_5837080277660930029_w.jpg | Bin 0 -> 231483 bytes assets/photo_5837080277660930029_w.jpg.import | 40 + export_presets.cfg | 229 ++ godot-ai-LICENSE.txt | 21 + icon.svg | 1 + icon.svg.import | 43 + main.tscn | 30 + player.gd | 518 +++ player.gd.uid | 1 + project.godot | 59 + 271 files changed, 42251 insertions(+), 15 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 addons/godot_ai/LICENSE create mode 100644 addons/godot_ai/README.md create mode 100644 addons/godot_ai/client_configurator.gd create mode 100644 addons/godot_ai/client_configurator.gd.uid create mode 100644 addons/godot_ai/clients/_atomic_write.gd create mode 100644 addons/godot_ai/clients/_atomic_write.gd.uid create mode 100644 addons/godot_ai/clients/_base.gd create mode 100644 addons/godot_ai/clients/_base.gd.uid create mode 100644 addons/godot_ai/clients/_cli_exec.gd create mode 100644 addons/godot_ai/clients/_cli_exec.gd.uid create mode 100644 addons/godot_ai/clients/_cli_finder.gd create mode 100644 addons/godot_ai/clients/_cli_finder.gd.uid create mode 100644 addons/godot_ai/clients/_cli_strategy.gd create mode 100644 addons/godot_ai/clients/_cli_strategy.gd.uid create mode 100644 addons/godot_ai/clients/_json_strategy.gd create mode 100644 addons/godot_ai/clients/_json_strategy.gd.uid create mode 100644 addons/godot_ai/clients/_manual_command.gd create mode 100644 addons/godot_ai/clients/_manual_command.gd.uid create mode 100644 addons/godot_ai/clients/_path_template.gd create mode 100644 addons/godot_ai/clients/_path_template.gd.uid create mode 100644 addons/godot_ai/clients/_registry.gd create mode 100644 addons/godot_ai/clients/_registry.gd.uid create mode 100644 addons/godot_ai/clients/_toml_strategy.gd create mode 100644 addons/godot_ai/clients/_toml_strategy.gd.uid create mode 100644 addons/godot_ai/clients/_yaml_strategy.gd create mode 100644 addons/godot_ai/clients/_yaml_strategy.gd.uid create mode 100644 addons/godot_ai/clients/antigravity.gd create mode 100644 addons/godot_ai/clients/antigravity.gd.uid create mode 100644 addons/godot_ai/clients/cherry_studio.gd create mode 100644 addons/godot_ai/clients/cherry_studio.gd.uid create mode 100644 addons/godot_ai/clients/claude_code.gd create mode 100644 addons/godot_ai/clients/claude_code.gd.uid create mode 100644 addons/godot_ai/clients/claude_desktop.gd create mode 100644 addons/godot_ai/clients/claude_desktop.gd.uid create mode 100644 addons/godot_ai/clients/cline.gd create mode 100644 addons/godot_ai/clients/cline.gd.uid create mode 100644 addons/godot_ai/clients/codex.gd create mode 100644 addons/godot_ai/clients/codex.gd.uid create mode 100644 addons/godot_ai/clients/cursor.gd create mode 100644 addons/godot_ai/clients/cursor.gd.uid create mode 100644 addons/godot_ai/clients/gemini_cli.gd create mode 100644 addons/godot_ai/clients/gemini_cli.gd.uid create mode 100644 addons/godot_ai/clients/grok.gd create mode 100644 addons/godot_ai/clients/grok.gd.uid create mode 100644 addons/godot_ai/clients/hermes.gd create mode 100644 addons/godot_ai/clients/hermes.gd.uid create mode 100644 addons/godot_ai/clients/kilo_code.gd create mode 100644 addons/godot_ai/clients/kilo_code.gd.uid create mode 100644 addons/godot_ai/clients/kimi_code.gd create mode 100644 addons/godot_ai/clients/kimi_code.gd.uid create mode 100644 addons/godot_ai/clients/kiro.gd create mode 100644 addons/godot_ai/clients/kiro.gd.uid create mode 100644 addons/godot_ai/clients/opencode.gd create mode 100644 addons/godot_ai/clients/opencode.gd.uid create mode 100644 addons/godot_ai/clients/qwen_code.gd create mode 100644 addons/godot_ai/clients/qwen_code.gd.uid create mode 100644 addons/godot_ai/clients/roo_code.gd create mode 100644 addons/godot_ai/clients/roo_code.gd.uid create mode 100644 addons/godot_ai/clients/trae.gd create mode 100644 addons/godot_ai/clients/trae.gd.uid create mode 100644 addons/godot_ai/clients/vscode.gd create mode 100644 addons/godot_ai/clients/vscode.gd.uid create mode 100644 addons/godot_ai/clients/vscode_insiders.gd create mode 100644 addons/godot_ai/clients/vscode_insiders.gd.uid create mode 100644 addons/godot_ai/clients/windsurf.gd create mode 100644 addons/godot_ai/clients/windsurf.gd.uid create mode 100644 addons/godot_ai/clients/zed.gd create mode 100644 addons/godot_ai/clients/zed.gd.uid create mode 100644 addons/godot_ai/clients/zoo_code.gd create mode 100644 addons/godot_ai/clients/zoo_code.gd.uid create mode 100644 addons/godot_ai/connection.gd create mode 100644 addons/godot_ai/connection.gd.uid create mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd create mode 100644 addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid create mode 100644 addons/godot_ai/dispatcher.gd create mode 100644 addons/godot_ai/dispatcher.gd.uid create mode 100644 addons/godot_ai/dock_panels/log_viewer.gd create mode 100644 addons/godot_ai/dock_panels/log_viewer.gd.uid create mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd create mode 100644 addons/godot_ai/dock_panels/port_picker_panel.gd.uid create mode 100644 addons/godot_ai/export/mcp_export_plugin.gd create mode 100644 addons/godot_ai/export/mcp_export_plugin.gd.uid create mode 100644 addons/godot_ai/handlers/_node_validator.gd create mode 100644 addons/godot_ai/handlers/_node_validator.gd.uid create mode 100644 addons/godot_ai/handlers/_param_validators.gd create mode 100644 addons/godot_ai/handlers/_param_validators.gd.uid create mode 100644 addons/godot_ai/handlers/_property_errors.gd create mode 100644 addons/godot_ai/handlers/_property_errors.gd.uid create mode 100644 addons/godot_ai/handlers/animation_handler.gd create mode 100644 addons/godot_ai/handlers/animation_handler.gd.uid create mode 100644 addons/godot_ai/handlers/animation_presets.gd create mode 100644 addons/godot_ai/handlers/animation_presets.gd.uid create mode 100644 addons/godot_ai/handlers/animation_values.gd create mode 100644 addons/godot_ai/handlers/animation_values.gd.uid create mode 100644 addons/godot_ai/handlers/api_handler.gd create mode 100644 addons/godot_ai/handlers/api_handler.gd.uid create mode 100644 addons/godot_ai/handlers/audio_handler.gd create mode 100644 addons/godot_ai/handlers/audio_handler.gd.uid create mode 100644 addons/godot_ai/handlers/autoload_handler.gd create mode 100644 addons/godot_ai/handlers/autoload_handler.gd.uid create mode 100644 addons/godot_ai/handlers/batch_handler.gd create mode 100644 addons/godot_ai/handlers/batch_handler.gd.uid create mode 100644 addons/godot_ai/handlers/camera_handler.gd create mode 100644 addons/godot_ai/handlers/camera_handler.gd.uid create mode 100644 addons/godot_ai/handlers/camera_presets.gd create mode 100644 addons/godot_ai/handlers/camera_presets.gd.uid create mode 100644 addons/godot_ai/handlers/camera_values.gd create mode 100644 addons/godot_ai/handlers/camera_values.gd.uid create mode 100644 addons/godot_ai/handlers/client_handler.gd create mode 100644 addons/godot_ai/handlers/client_handler.gd.uid create mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd create mode 100644 addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid create mode 100644 addons/godot_ai/handlers/csg_handler.gd create mode 100644 addons/godot_ai/handlers/csg_handler.gd.uid create mode 100644 addons/godot_ai/handlers/curve_handler.gd create mode 100644 addons/godot_ai/handlers/curve_handler.gd.uid create mode 100644 addons/godot_ai/handlers/editor_handler.gd create mode 100644 addons/godot_ai/handlers/editor_handler.gd.uid create mode 100644 addons/godot_ai/handlers/environment_handler.gd create mode 100644 addons/godot_ai/handlers/environment_handler.gd.uid create mode 100644 addons/godot_ai/handlers/filesystem_handler.gd create mode 100644 addons/godot_ai/handlers/filesystem_handler.gd.uid create mode 100644 addons/godot_ai/handlers/gridmap_handler.gd create mode 100644 addons/godot_ai/handlers/gridmap_handler.gd.uid create mode 100644 addons/godot_ai/handlers/input_handler.gd create mode 100644 addons/godot_ai/handlers/input_handler.gd.uid create mode 100644 addons/godot_ai/handlers/material_handler.gd create mode 100644 addons/godot_ai/handlers/material_handler.gd.uid create mode 100644 addons/godot_ai/handlers/material_presets.gd create mode 100644 addons/godot_ai/handlers/material_presets.gd.uid create mode 100644 addons/godot_ai/handlers/material_values.gd create mode 100644 addons/godot_ai/handlers/material_values.gd.uid create mode 100644 addons/godot_ai/handlers/node_handler.gd create mode 100644 addons/godot_ai/handlers/node_handler.gd.uid create mode 100644 addons/godot_ai/handlers/particle_handler.gd create mode 100644 addons/godot_ai/handlers/particle_handler.gd.uid create mode 100644 addons/godot_ai/handlers/particle_presets.gd create mode 100644 addons/godot_ai/handlers/particle_presets.gd.uid create mode 100644 addons/godot_ai/handlers/particle_values.gd create mode 100644 addons/godot_ai/handlers/particle_values.gd.uid create mode 100644 addons/godot_ai/handlers/physics_shape_handler.gd create mode 100644 addons/godot_ai/handlers/physics_shape_handler.gd.uid create mode 100644 addons/godot_ai/handlers/project_handler.gd create mode 100644 addons/godot_ai/handlers/project_handler.gd.uid create mode 100644 addons/godot_ai/handlers/resource_handler.gd create mode 100644 addons/godot_ai/handlers/resource_handler.gd.uid create mode 100644 addons/godot_ai/handlers/scene_handler.gd create mode 100644 addons/godot_ai/handlers/scene_handler.gd.uid create mode 100644 addons/godot_ai/handlers/script_handler.gd create mode 100644 addons/godot_ai/handlers/script_handler.gd.uid create mode 100644 addons/godot_ai/handlers/signal_handler.gd create mode 100644 addons/godot_ai/handlers/signal_handler.gd.uid create mode 100644 addons/godot_ai/handlers/test_handler.gd create mode 100644 addons/godot_ai/handlers/test_handler.gd.uid create mode 100644 addons/godot_ai/handlers/texture_handler.gd create mode 100644 addons/godot_ai/handlers/texture_handler.gd.uid create mode 100644 addons/godot_ai/handlers/theme_handler.gd create mode 100644 addons/godot_ai/handlers/theme_handler.gd.uid create mode 100644 addons/godot_ai/handlers/tilemap_handler.gd create mode 100644 addons/godot_ai/handlers/tilemap_handler.gd.uid create mode 100644 addons/godot_ai/handlers/tileset_handler.gd create mode 100644 addons/godot_ai/handlers/tileset_handler.gd.uid create mode 100644 addons/godot_ai/handlers/ui_handler.gd create mode 100644 addons/godot_ai/handlers/ui_handler.gd.uid create mode 100644 addons/godot_ai/mcp_dock.gd create mode 100644 addons/godot_ai/mcp_dock.gd.uid create mode 100644 addons/godot_ai/plugin.cfg create mode 100644 addons/godot_ai/plugin.gd create mode 100644 addons/godot_ai/plugin.gd.uid create mode 100644 addons/godot_ai/runtime/draw_recipe.gd create mode 100644 addons/godot_ai/runtime/draw_recipe.gd.uid create mode 100644 addons/godot_ai/runtime/editor_logger.gd create mode 100644 addons/godot_ai/runtime/editor_logger.gd.uid create mode 100644 addons/godot_ai/runtime/game_helper.gd create mode 100644 addons/godot_ai/runtime/game_helper.gd.uid create mode 100644 addons/godot_ai/runtime/game_logger.gd create mode 100644 addons/godot_ai/runtime/game_logger.gd.uid create mode 100644 addons/godot_ai/runtime/validation_logger.gd create mode 100644 addons/godot_ai/runtime/validation_logger.gd.uid create mode 100644 addons/godot_ai/telemetry.gd create mode 100644 addons/godot_ai/telemetry.gd.uid create mode 100644 addons/godot_ai/testing/script_error_capture.gd create mode 100644 addons/godot_ai/testing/script_error_capture.gd.uid create mode 100644 addons/godot_ai/testing/test_runner.gd create mode 100644 addons/godot_ai/testing/test_runner.gd.uid create mode 100644 addons/godot_ai/testing/test_suite.gd create mode 100644 addons/godot_ai/testing/test_suite.gd.uid create mode 100644 addons/godot_ai/tool_catalog.gd create mode 100644 addons/godot_ai/tool_catalog.gd.uid create mode 100644 addons/godot_ai/update_reload_runner.gd create mode 100644 addons/godot_ai/update_reload_runner.gd.uid create mode 100644 addons/godot_ai/utils/allow_hosts.gd create mode 100644 addons/godot_ai/utils/allow_hosts.gd.uid create mode 100644 addons/godot_ai/utils/class_introspection.gd create mode 100644 addons/godot_ai/utils/class_introspection.gd.uid create mode 100644 addons/godot_ai/utils/diagnostics_capture.gd create mode 100644 addons/godot_ai/utils/diagnostics_capture.gd.uid create mode 100644 addons/godot_ai/utils/editor_log_buffer.gd create mode 100644 addons/godot_ai/utils/editor_log_buffer.gd.uid create mode 100644 addons/godot_ai/utils/error_codes.gd create mode 100644 addons/godot_ai/utils/error_codes.gd.uid create mode 100644 addons/godot_ai/utils/fuzzy_suggestions.gd create mode 100644 addons/godot_ai/utils/fuzzy_suggestions.gd.uid create mode 100644 addons/godot_ai/utils/game_log_buffer.gd create mode 100644 addons/godot_ai/utils/game_log_buffer.gd.uid create mode 100644 addons/godot_ai/utils/json_values.gd create mode 100644 addons/godot_ai/utils/json_values.gd.uid create mode 100644 addons/godot_ai/utils/log_backtrace.gd create mode 100644 addons/godot_ai/utils/log_backtrace.gd.uid create mode 100644 addons/godot_ai/utils/log_buffer.gd create mode 100644 addons/godot_ai/utils/log_buffer.gd.uid create mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd create mode 100644 addons/godot_ai/utils/mcp_adoption_label.gd.uid create mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd create mode 100644 addons/godot_ai/utils/mcp_client_refresh_state.gd.uid create mode 100644 addons/godot_ai/utils/mcp_server_state.gd create mode 100644 addons/godot_ai/utils/mcp_server_state.gd.uid create mode 100644 addons/godot_ai/utils/mcp_startup_path.gd create mode 100644 addons/godot_ai/utils/mcp_startup_path.gd.uid create mode 100644 addons/godot_ai/utils/path_validator.gd create mode 100644 addons/godot_ai/utils/path_validator.gd.uid create mode 100644 addons/godot_ai/utils/port_resolver.gd create mode 100644 addons/godot_ai/utils/port_resolver.gd.uid create mode 100644 addons/godot_ai/utils/resource_io.gd create mode 100644 addons/godot_ai/utils/resource_io.gd.uid create mode 100644 addons/godot_ai/utils/scene_path.gd create mode 100644 addons/godot_ai/utils/scene_path.gd.uid create mode 100644 addons/godot_ai/utils/screenshot_encode.gd create mode 100644 addons/godot_ai/utils/screenshot_encode.gd.uid create mode 100644 addons/godot_ai/utils/server_lifecycle.gd create mode 100644 addons/godot_ai/utils/server_lifecycle.gd.uid create mode 100644 addons/godot_ai/utils/server_version_check.gd create mode 100644 addons/godot_ai/utils/server_version_check.gd.uid create mode 100644 addons/godot_ai/utils/settings.gd create mode 100644 addons/godot_ai/utils/settings.gd.uid create mode 100644 addons/godot_ai/utils/structured_log_ring.gd create mode 100644 addons/godot_ai/utils/structured_log_ring.gd.uid create mode 100644 addons/godot_ai/utils/surfaced_error_tracker.gd create mode 100644 addons/godot_ai/utils/surfaced_error_tracker.gd.uid create mode 100644 addons/godot_ai/utils/update_manager.gd create mode 100644 addons/godot_ai/utils/update_manager.gd.uid create mode 100644 addons/godot_ai/utils/update_mixed_state.gd create mode 100644 addons/godot_ai/utils/update_mixed_state.gd.uid create mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd create mode 100644 addons/godot_ai/utils/uv_cache_cleanup.gd.uid create mode 100644 addons/godot_ai/utils/variant_serializer.gd create mode 100644 addons/godot_ai/utils/variant_serializer.gd.uid create mode 100644 addons/godot_ai/utils/windows_port_reservation.gd create mode 100644 addons/godot_ai/utils/windows_port_reservation.gd.uid create mode 100644 addons/godot_ai/vision_routing.gd create mode 100644 addons/godot_ai/vision_routing.gd.uid create mode 100644 assets/characters/ankarde.tscn create mode 100644 assets/characters/ankarde/Diego-idle-trimmed.png create mode 100644 assets/characters/ankarde/Diego-idle-trimmed.png.import create mode 100644 assets/characters/ankarde/Diego-walk-trimmed.png create mode 100644 assets/characters/ankarde/Diego-walk-trimmed.png.import create mode 100644 assets/characters/camera_2d.gd create mode 100644 assets/characters/camera_2d.gd.uid create mode 100644 assets/photo_5837080277660930029_w.jpg create mode 100644 assets/photo_5837080277660930029_w.jpg.import create mode 100644 export_presets.cfg create mode 100644 godot-ai-LICENSE.txt create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 main.tscn create mode 100644 player.gd create mode 100644 player.gd.uid create mode 100644 project.godot diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index bf83296..0af181c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,3 @@ -# ---> Godot # Godot 4+ specific ignores .godot/ - -# Godot-specific ignores -.import/ -export.cfg -export_presets.cfg - -# Imported translations (automatically generated from CSV files) -*.translation - -# Mono-specific ignores -.mono/ -data_*/ -mono_crash.*.json - +/android/ diff --git a/addons/godot_ai/LICENSE b/addons/godot_ai/LICENSE new file mode 100644 index 0000000..7806d22 --- /dev/null +++ b/addons/godot_ai/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..6cd4e7d --- /dev/null +++ b/addons/godot_ai/README.md @@ -0,0 +1,53 @@ +# 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, 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 new file mode 100644 index 0000000..9468f6d --- /dev/null +++ b/addons/godot_ai/client_configurator.gd @@ -0,0 +1,1388 @@ +@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 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" +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) + + +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], + }) + + +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 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, + ## 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 + + +## 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) + ## 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) + + +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) + + +## 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) + # 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, {}, "") + 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) + return _verify_post_state(client, result, Client.Status.NOT_CONFIGURED, url, "remove", launch) + + +## 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 = {}) -> Dictionary: + launch = launch_for_client(client, launch) + match client.config_type: + "json": + return JsonStrategy.configure(client, SERVER_NAME, url, launch) + "toml": + return TomlStrategy.configure(client, SERVER_NAME, url, launch) + "yaml": + return YamlStrategy.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 JsonStrategy.configure(client, SERVER_NAME, url, launch) + 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) -> Dictionary: + match client.config_type: + "json": + return JsonStrategy.remove(client, SERVER_NAME) + "toml": + return TomlStrategy.remove(client, SERVER_NAME) + "yaml": + return YamlStrategy.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) + 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) + "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) + "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. + if client.command_shape != Client.CommandShape.NONE and client.has_json_fallback(): + var command_launch := _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return JsonStrategy.check_status_details(client, SERVER_NAME, url, command_launch) + 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) + var cli_launch := {} + if client.command_shape != Client.CommandShape.NONE: + cli_launch = _resolved_or_discovered_launch(client, resolved_launch, launch_context) + return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli, cli_launch) + return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""} + + +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 = {}, +) -> Dictionary: + if result.get("status") != "ok": + return result + var actual := _dispatch_check_status_with_cli_path_details( + client, url, "", {}, resolved_launch + ).get("status", Client.Status.NOT_CONFIGURED) + if actual == expected: + return result + var path := client.resolved_config_path() + var path_hint := "" if path.is_empty() else " Inspect %s and remove the godot-ai entry by hand if needed." % path + 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, + ], + } + + +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, + ) + 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 "" + + +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()) + + +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 new file mode 100644 index 0000000..9182096 --- /dev/null +++ b/addons/godot_ai/client_configurator.gd.uid @@ -0,0 +1 @@ +uid://1kiy8hqyymyj diff --git a/addons/godot_ai/clients/_atomic_write.gd b/addons/godot_ai/clients/_atomic_write.gd new file mode 100644 index 0000000..f772c10 --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd @@ -0,0 +1,206 @@ +@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 new file mode 100644 index 0000000..add9f7f --- /dev/null +++ b/addons/godot_ai/clients/_atomic_write.gd.uid @@ -0,0 +1 @@ +uid://6fkb5uau0r4h diff --git a/addons/godot_ai/clients/_base.gd b/addons/godot_ai/clients/_base.gd new file mode 100644 index 0000000..348be67 --- /dev/null +++ b/addons/godot_ai/clients/_base.gd @@ -0,0 +1,427 @@ +@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" + +# 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 = {} + +## 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() + +## 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() + +# 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 new file mode 100644 index 0000000..5ea77d3 --- /dev/null +++ b/addons/godot_ai/clients/_base.gd.uid @@ -0,0 +1 @@ +uid://cyowqr1x12ilg diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd new file mode 100644 index 0000000..1e09811 --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd @@ -0,0 +1,169 @@ +@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 new file mode 100644 index 0000000..4a97a2c --- /dev/null +++ b/addons/godot_ai/clients/_cli_exec.gd.uid @@ -0,0 +1 @@ +uid://dhoe3ypkhm12v diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd new file mode 100644 index 0000000..394e76c --- /dev/null +++ b/addons/godot_ai/clients/_cli_finder.gd @@ -0,0 +1,181 @@ +@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 new file mode 100644 index 0000000..9985270 --- /dev/null +++ b/addons/godot_ai/clients/_cli_finder.gd.uid @@ -0,0 +1 @@ +uid://cnp5b6fcwou2y diff --git a/addons/godot_ai/clients/_cli_strategy.gd b/addons/godot_ai/clients/_cli_strategy.gd new file mode 100644 index 0000000..c13fa71 --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd @@ -0,0 +1,215 @@ +@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. + +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(): + var pre_args := _format_args(client.cli_unregister_template, server_name, server_url) + 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. +static func format_args( + template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {} +) -> Array[String]: + return _format_args(template, server_name, server_url, launch) + + +static func _format_args( + template: PackedStringArray, server_name: String, server_url: String, launch: Dictionary = {} +) -> Array[String]: + 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) + out.append(s) + return out + + +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 new file mode 100644 index 0000000..f84a92b --- /dev/null +++ b/addons/godot_ai/clients/_cli_strategy.gd.uid @@ -0,0 +1 @@ +uid://bvib7d8eabbcm diff --git a/addons/godot_ai/clients/_json_strategy.gd b/addons/godot_ai/clients/_json_strategy.gd new file mode 100644 index 0000000..421143b --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd @@ -0,0 +1,341 @@ +@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 = {}, +) -> 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} + + 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, client.server_key_path) + ## 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) + + 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)} + + +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) + + +## 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 = {}, +) -> 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 config: Dictionary = read["data"] + var holder := _walk_path(config, client.server_key_path) + if not (holder is Dictionary) or not holder.has(server_name): + return {"status": McpClient.Status.NOT_CONFIGURED, "error_msg": ""} + var entry = holder[server_name] + 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} + ## An entry under `server_name` exists — if the URL doesn't match, + ## that's drift (the user changed the port and the client config is stale), + ## not "never configured". The dock surfaces that as an amber banner. + 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_or_init(path) + if not read["ok"]: + return {"status": "error", "message": "Refusing to rewrite %s: %s." % [path, read["error"]]} + var config: Dictionary = read["data"] + var holder := _walk_path(config, client.server_key_path) + if holder is Dictionary and holder.has(server_name): + holder.erase(server_name) + 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": "%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 + + +## 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: + if not FileAccess.file_exists(path): + return {"ok": true, "data": {}} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + var err := FileAccess.get_open_error() + return {"ok": false, "error": "could not open for reading (error %d)" % err} + var content := file.get_as_text() + file.close() + # 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 content.begins_with(""): + content = content.substr(1) + if content.strip_edges().is_empty(): + return {"ok": true, "data": {}} + var json := JSON.new() + if json.parse(content) != 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 {"ok": false, "error": msg} + if not (json.data is Dictionary): + return {"ok": false, "error": "top-level value is %s, expected object" % type_string(typeof(json.data))} + return {"ok": true, "data": json.data} + + +## 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 + + +## 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 diff --git a/addons/godot_ai/clients/_json_strategy.gd.uid b/addons/godot_ai/clients/_json_strategy.gd.uid new file mode 100644 index 0000000..5e41fbb --- /dev/null +++ b/addons/godot_ai/clients/_json_strategy.gd.uid @@ -0,0 +1 @@ +uid://g8a4iijpk22w diff --git a/addons/godot_ai/clients/_manual_command.gd b/addons/godot_ai/clients/_manual_command.gd new file mode 100644 index 0000000..254fb45 --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd @@ -0,0 +1,264 @@ +@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 = {}, +) -> String: + match client.config_type: + "cli": + return _build_cli(client, server_name, server_url, resolved_path, launch) + "json": + return _build_json(client, server_name, server_url, resolved_path, launch) + "toml": + return _build_toml(client, server_name, server_url, resolved_path, launch) + "yaml": + return _build_yaml(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 = {}, +) -> 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)) + cmd = _format_shell_command(parts, shell_kind) + 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)) + cmd = _format_shell_command(parts, shell_kind) + # #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), + ] + return cmd + + +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: + var rendered: Array[String] = [] + for part in parts: + rendered.append(_shell_display_arg(part, shell_kind)) + var label := "Run in PowerShell:" if shell_kind == SHELL_POWERSHELL else "Run in a POSIX shell:" + return "%s\n%s" % [label, " ".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 = {}, +) -> String: + var key := client.server_key_path[0] if client.server_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\":" % [resolved_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\":" % [resolved_path, key]) + var fallback_entry := McpJsonStrategy.build_url_entry(client, server_url) + lines.append(" \"%s\": %s" % [server_name, _format_entry_inline(fallback_entry)]) + return "\n".join(lines) + var entry := McpJsonStrategy.build_entry(client, server_url) + return "Edit %s and add under \"%s\":\n \"%s\": %s" % [resolved_path, key, server_name, _format_entry_inline(entry)] + + +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) + + +## 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 new file mode 100644 index 0000000..07a96f4 --- /dev/null +++ b/addons/godot_ai/clients/_manual_command.gd.uid @@ -0,0 +1 @@ +uid://ct1wmgfk408x0 diff --git a/addons/godot_ai/clients/_path_template.gd b/addons/godot_ai/clients/_path_template.gd new file mode 100644 index 0000000..f7493d1 --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd @@ -0,0 +1,206 @@ +@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 new file mode 100644 index 0000000..f2403d7 --- /dev/null +++ b/addons/godot_ai/clients/_path_template.gd.uid @@ -0,0 +1 @@ +uid://5pd418va35ms diff --git a/addons/godot_ai/clients/_registry.gd b/addons/godot_ai/clients/_registry.gd new file mode 100644 index 0000000..5ce71b4 --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd @@ -0,0 +1,151 @@ +@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", +] + +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 + ) + + +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 new file mode 100644 index 0000000..57edcf7 --- /dev/null +++ b/addons/godot_ai/clients/_registry.gd.uid @@ -0,0 +1 @@ +uid://bxougoq8xwg1 diff --git a/addons/godot_ai/clients/_toml_strategy.gd b/addons/godot_ai/clients/_toml_strategy.gd new file mode 100644 index 0000000..a929dbd --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd @@ -0,0 +1,730 @@ +@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 new file mode 100644 index 0000000..723cb79 --- /dev/null +++ b/addons/godot_ai/clients/_toml_strategy.gd.uid @@ -0,0 +1 @@ +uid://cwdvxgn0aurqv diff --git a/addons/godot_ai/clients/_yaml_strategy.gd b/addons/godot_ai/clients/_yaml_strategy.gd new file mode 100644 index 0000000..640c5e5 --- /dev/null +++ b/addons/godot_ai/clients/_yaml_strategy.gd @@ -0,0 +1,544 @@ +@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. +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. +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. +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 new file mode 100644 index 0000000..d8adce8 --- /dev/null +++ b/addons/godot_ai/clients/_yaml_strategy.gd.uid @@ -0,0 +1 @@ +uid://cfnw4oe71ra1l diff --git a/addons/godot_ai/clients/antigravity.gd b/addons/godot_ai/clients/antigravity.gd new file mode 100644 index 0000000..a707735 --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd @@ -0,0 +1,39 @@ +@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 new file mode 100644 index 0000000..0721a2f --- /dev/null +++ b/addons/godot_ai/clients/antigravity.gd.uid @@ -0,0 +1 @@ +uid://b4l1g0apa2hch diff --git a/addons/godot_ai/clients/cherry_studio.gd b/addons/godot_ai/clients/cherry_studio.gd new file mode 100644 index 0000000..873b9fe --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd @@ -0,0 +1,18 @@ +@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 new file mode 100644 index 0000000..7ada8cd --- /dev/null +++ b/addons/godot_ai/clients/cherry_studio.gd.uid @@ -0,0 +1 @@ +uid://dwbuykxvbv5f7 diff --git a/addons/godot_ai/clients/claude_code.gd b/addons/godot_ai/clients/claude_code.gd new file mode 100644 index 0000000..b5bbcfd --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd @@ -0,0 +1,53 @@ +@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 stays + ## `user` — the same ~/.claude.json the pre-attach HTTP entry lived in. + cli_register_template = PackedStringArray( + ["mcp", "add", "--scope", "user", "{name}", "--", "{command}", "{args...}"] + ) + ## Explicit scope: an unscoped `mcp remove` deletes from whichever scope + ## matches first, which could eat a project-local entry the user made. + cli_unregister_template = PackedStringArray(["mcp", "remove", "--scope", "user", "{name}"]) + cli_status_args = PackedStringArray(["mcp", "list"]) + ## #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 always reads this file — it is the CLI's own store for user + ## scope, and file reads give exact launch-drift detection that `mcp list` + ## stdout scanning cannot. + 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 user --transport http` writes {type: http, url}. + 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 new file mode 100644 index 0000000..3d3335f --- /dev/null +++ b/addons/godot_ai/clients/claude_code.gd.uid @@ -0,0 +1 @@ +uid://cp1u1hdpa6f8d diff --git a/addons/godot_ai/clients/claude_desktop.gd b/addons/godot_ai/clients/claude_desktop.gd new file mode 100644 index 0000000..c1aa9bb --- /dev/null +++ b/addons/godot_ai/clients/claude_desktop.gd @@ -0,0 +1,38 @@ +@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 new file mode 100644 index 0000000..9759a1a --- /dev/null +++ b/addons/godot_ai/clients/claude_desktop.gd.uid @@ -0,0 +1 @@ +uid://bilntn5n8oqe3 diff --git a/addons/godot_ai/clients/cline.gd b/addons/godot_ai/clients/cline.gd new file mode 100644 index 0000000..2a1f025 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd @@ -0,0 +1,43 @@ +@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 new file mode 100644 index 0000000..95e20f1 --- /dev/null +++ b/addons/godot_ai/clients/cline.gd.uid @@ -0,0 +1 @@ +uid://d36nywn2nkgts diff --git a/addons/godot_ai/clients/codex.gd b/addons/godot_ai/clients/codex.gd new file mode 100644 index 0000000..b8231bc --- /dev/null +++ b/addons/godot_ai/clients/codex.gd @@ -0,0 +1,45 @@ +@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 new file mode 100644 index 0000000..1e1c3ae --- /dev/null +++ b/addons/godot_ai/clients/codex.gd.uid @@ -0,0 +1 @@ +uid://hdlwcfdr8mdk diff --git a/addons/godot_ai/clients/cursor.gd b/addons/godot_ai/clients/cursor.gd new file mode 100644 index 0000000..e4fe98d --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd @@ -0,0 +1,21 @@ +@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 new file mode 100644 index 0000000..e0c7ddf --- /dev/null +++ b/addons/godot_ai/clients/cursor.gd.uid @@ -0,0 +1 @@ +uid://bvpbssfanukef diff --git a/addons/godot_ai/clients/gemini_cli.gd b/addons/godot_ai/clients/gemini_cli.gd new file mode 100644 index 0000000..fda58a3 --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd @@ -0,0 +1,25 @@ +@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 new file mode 100644 index 0000000..2d4e85a --- /dev/null +++ b/addons/godot_ai/clients/gemini_cli.gd.uid @@ -0,0 +1 @@ +uid://b8288pxninajy diff --git a/addons/godot_ai/clients/grok.gd b/addons/godot_ai/clients/grok.gd new file mode 100644 index 0000000..7b6710e --- /dev/null +++ b/addons/godot_ai/clients/grok.gd @@ -0,0 +1,35 @@ +@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 new file mode 100644 index 0000000..f2d12c2 --- /dev/null +++ b/addons/godot_ai/clients/grok.gd.uid @@ -0,0 +1 @@ +uid://ckchsj5s3q1b0 diff --git a/addons/godot_ai/clients/hermes.gd b/addons/godot_ai/clients/hermes.gd new file mode 100644 index 0000000..64a66bb --- /dev/null +++ b/addons/godot_ai/clients/hermes.gd @@ -0,0 +1,46 @@ +@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 new file mode 100644 index 0000000..14cabd8 --- /dev/null +++ b/addons/godot_ai/clients/hermes.gd.uid @@ -0,0 +1 @@ +uid://ewmadhrvs5d7 diff --git a/addons/godot_ai/clients/kilo_code.gd b/addons/godot_ai/clients/kilo_code.gd new file mode 100644 index 0000000..4f7b3f7 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd @@ -0,0 +1,38 @@ +@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 new file mode 100644 index 0000000..3ee5152 --- /dev/null +++ b/addons/godot_ai/clients/kilo_code.gd.uid @@ -0,0 +1 @@ +uid://dc1x77i1cmb6w diff --git a/addons/godot_ai/clients/kimi_code.gd b/addons/godot_ai/clients/kimi_code.gd new file mode 100644 index 0000000..a80fef8 --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd @@ -0,0 +1,34 @@ +@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 new file mode 100644 index 0000000..5a05a0e --- /dev/null +++ b/addons/godot_ai/clients/kimi_code.gd.uid @@ -0,0 +1 @@ +uid://d2whd6a5fofhg diff --git a/addons/godot_ai/clients/kiro.gd b/addons/godot_ai/clients/kiro.gd new file mode 100644 index 0000000..c9d4247 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd @@ -0,0 +1,23 @@ +@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 new file mode 100644 index 0000000..dde3e87 --- /dev/null +++ b/addons/godot_ai/clients/kiro.gd.uid @@ -0,0 +1 @@ +uid://dqdmd2jw5qen7 diff --git a/addons/godot_ai/clients/opencode.gd b/addons/godot_ai/clients/opencode.gd new file mode 100644 index 0000000..cee555b --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd @@ -0,0 +1,39 @@ +@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 new file mode 100644 index 0000000..dc2ad00 --- /dev/null +++ b/addons/godot_ai/clients/opencode.gd.uid @@ -0,0 +1 @@ +uid://s8n0vfirf2pj diff --git a/addons/godot_ai/clients/qwen_code.gd b/addons/godot_ai/clients/qwen_code.gd new file mode 100644 index 0000000..63bc7b2 --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd @@ -0,0 +1,26 @@ +@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 new file mode 100644 index 0000000..5f2eb1a --- /dev/null +++ b/addons/godot_ai/clients/qwen_code.gd.uid @@ -0,0 +1 @@ +uid://qwb5udkf423q diff --git a/addons/godot_ai/clients/roo_code.gd b/addons/godot_ai/clients/roo_code.gd new file mode 100644 index 0000000..5d7bd6a --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd @@ -0,0 +1,42 @@ +@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 new file mode 100644 index 0000000..1dcae84 --- /dev/null +++ b/addons/godot_ai/clients/roo_code.gd.uid @@ -0,0 +1 @@ +uid://denjdf50qrf66 diff --git a/addons/godot_ai/clients/trae.gd b/addons/godot_ai/clients/trae.gd new file mode 100644 index 0000000..afc7a68 --- /dev/null +++ b/addons/godot_ai/clients/trae.gd @@ -0,0 +1,23 @@ +@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 new file mode 100644 index 0000000..f10046e --- /dev/null +++ b/addons/godot_ai/clients/trae.gd.uid @@ -0,0 +1 @@ +uid://cwpu48772vfj1 diff --git a/addons/godot_ai/clients/vscode.gd b/addons/godot_ai/clients/vscode.gd new file mode 100644 index 0000000..a18b97d --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd @@ -0,0 +1,30 @@ +@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 new file mode 100644 index 0000000..1c79881 --- /dev/null +++ b/addons/godot_ai/clients/vscode.gd.uid @@ -0,0 +1 @@ +uid://dl6cm044pihub diff --git a/addons/godot_ai/clients/vscode_insiders.gd b/addons/godot_ai/clients/vscode_insiders.gd new file mode 100644 index 0000000..7f6a65b --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd @@ -0,0 +1,23 @@ +@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 new file mode 100644 index 0000000..c763703 --- /dev/null +++ b/addons/godot_ai/clients/vscode_insiders.gd.uid @@ -0,0 +1 @@ +uid://cad5w4ofyg8a2 diff --git a/addons/godot_ai/clients/windsurf.gd b/addons/godot_ai/clients/windsurf.gd new file mode 100644 index 0000000..b3ef210 --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd @@ -0,0 +1,29 @@ +@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 new file mode 100644 index 0000000..af34b60 --- /dev/null +++ b/addons/godot_ai/clients/windsurf.gd.uid @@ -0,0 +1 @@ +uid://b6pqiok2mlsmg diff --git a/addons/godot_ai/clients/zed.gd b/addons/godot_ai/clients/zed.gd new file mode 100644 index 0000000..fbd5f29 --- /dev/null +++ b/addons/godot_ai/clients/zed.gd @@ -0,0 +1,29 @@ +@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 new file mode 100644 index 0000000..b9b313a --- /dev/null +++ b/addons/godot_ai/clients/zed.gd.uid @@ -0,0 +1 @@ +uid://d152l0u0r6fsc diff --git a/addons/godot_ai/clients/zoo_code.gd b/addons/godot_ai/clients/zoo_code.gd new file mode 100644 index 0000000..870ff39 --- /dev/null +++ b/addons/godot_ai/clients/zoo_code.gd @@ -0,0 +1,36 @@ +@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 new file mode 100644 index 0000000..dd3713e --- /dev/null +++ b/addons/godot_ai/clients/zoo_code.gd.uid @@ -0,0 +1 @@ +uid://fmp0nlzcm3ukl diff --git a/addons/godot_ai/connection.gd b/addons/godot_ai/connection.gd new file mode 100644 index 0000000..32c588d --- /dev/null +++ b/addons/godot_ai/connection.gd @@ -0,0 +1,1046 @@ +@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 + + +## 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 new file mode 100644 index 0000000..ff78405 --- /dev/null +++ b/addons/godot_ai/connection.gd.uid @@ -0,0 +1 @@ +uid://bmnk8rsotiks2 diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd new file mode 100644 index 0000000..e9981bb --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd @@ -0,0 +1,1412 @@ +@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 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 + 10s backstop = 13s, 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 +## #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 -> {connection, timer, timeout_callable}. +## We retain the bound timeout lambda so `_clear_pending` can disconnect +## it on success/error; otherwise the SceneTreeTimer pins the captured +## request_id until `timeout_sec` elapses (8s default). +var _pending: Dictionary = {} + +## 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: + _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() + + +## --- #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_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: the *not-ready* path adds EVAL_READY_WAIT_SEC (3s) on top of this 10s +## backstop. That sum (13s) 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 — which is exactly the residual ~15s +## TimeoutError bucket #500 tracked down. Keep EVAL_READY_WAIT_SEC + timeout_sec +## < 15s if you tune either. +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 + + if is_game_capture_ready(): + _send_eval(tree, code, request_id, connection, timeout_sec) + 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) + + +func _wait_then_eval( + tree: SceneTree, + code: String, + request_id: String, + connection: McpConnection, + timeout_sec: float, +) -> 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. + 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. + 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(): + ## #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". + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY)) + return + _send_eval(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] = { + "connection": connection, + "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 + _clear_pending(request_id) + var conn: McpConnection = pending_entry.connection + if conn == null or not is_instance_valid(conn): + 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 new file mode 100644 index 0000000..1d5c148 --- /dev/null +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd.uid @@ -0,0 +1 @@ +uid://bd1k63iye1bsl diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd new file mode 100644 index 0000000..9db7a03 --- /dev/null +++ b/addons/godot_ai/dispatcher.gd @@ -0,0 +1,463 @@ +@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} + + +## 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 new file mode 100644 index 0000000..25a05bd --- /dev/null +++ b/addons/godot_ai/dispatcher.gd.uid @@ -0,0 +1 @@ +uid://ctldk7ivsoo3i diff --git a/addons/godot_ai/dock_panels/log_viewer.gd b/addons/godot_ai/dock_panels/log_viewer.gd new file mode 100644 index 0000000..1d7b986 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd @@ -0,0 +1,100 @@ +@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 new file mode 100644 index 0000000..c261627 --- /dev/null +++ b/addons/godot_ai/dock_panels/log_viewer.gd.uid @@ -0,0 +1 @@ +uid://cr5nbnd6vj3b8 diff --git a/addons/godot_ai/dock_panels/port_picker_panel.gd b/addons/godot_ai/dock_panels/port_picker_panel.gd new file mode 100644 index 0000000..30c3ed3 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd @@ -0,0 +1,78 @@ +@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 new file mode 100644 index 0000000..38b6320 --- /dev/null +++ b/addons/godot_ai/dock_panels/port_picker_panel.gd.uid @@ -0,0 +1 @@ +uid://hlggbo1q65eq diff --git a/addons/godot_ai/export/mcp_export_plugin.gd b/addons/godot_ai/export/mcp_export_plugin.gd new file mode 100644 index 0000000..5a8b885 --- /dev/null +++ b/addons/godot_ai/export/mcp_export_plugin.gd @@ -0,0 +1,69 @@ +@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 new file mode 100644 index 0000000..f2a3634 --- /dev/null +++ b/addons/godot_ai/export/mcp_export_plugin.gd.uid @@ -0,0 +1 @@ +uid://np0cp6fwpim7 diff --git a/addons/godot_ai/handlers/_node_validator.gd b/addons/godot_ai/handlers/_node_validator.gd new file mode 100644 index 0000000..61bbe10 --- /dev/null +++ b/addons/godot_ai/handlers/_node_validator.gd @@ -0,0 +1,71 @@ +@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 new file mode 100644 index 0000000..b3153a6 --- /dev/null +++ b/addons/godot_ai/handlers/_node_validator.gd.uid @@ -0,0 +1 @@ +uid://dn75jifad0ghx diff --git a/addons/godot_ai/handlers/_param_validators.gd b/addons/godot_ai/handlers/_param_validators.gd new file mode 100644 index 0000000..a316b32 --- /dev/null +++ b/addons/godot_ai/handlers/_param_validators.gd @@ -0,0 +1,30 @@ +@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 new file mode 100644 index 0000000..cd2061e --- /dev/null +++ b/addons/godot_ai/handlers/_param_validators.gd.uid @@ -0,0 +1 @@ +uid://difa877m8dsla diff --git a/addons/godot_ai/handlers/_property_errors.gd b/addons/godot_ai/handlers/_property_errors.gd new file mode 100644 index 0000000..316b941 --- /dev/null +++ b/addons/godot_ai/handlers/_property_errors.gd @@ -0,0 +1,82 @@ +@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 new file mode 100644 index 0000000..c29d21c --- /dev/null +++ b/addons/godot_ai/handlers/_property_errors.gd.uid @@ -0,0 +1 @@ +uid://c74d560g4l86b diff --git a/addons/godot_ai/handlers/animation_handler.gd b/addons/godot_ai/handlers/animation_handler.gd new file mode 100644 index 0000000..8000b78 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd @@ -0,0 +1,825 @@ +@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 new file mode 100644 index 0000000..934f665 --- /dev/null +++ b/addons/godot_ai/handlers/animation_handler.gd.uid @@ -0,0 +1 @@ +uid://c0jrius46xsd4 diff --git a/addons/godot_ai/handlers/animation_presets.gd b/addons/godot_ai/handlers/animation_presets.gd new file mode 100644 index 0000000..40f1ddc --- /dev/null +++ b/addons/godot_ai/handlers/animation_presets.gd @@ -0,0 +1,536 @@ +@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 new file mode 100644 index 0000000..f463501 --- /dev/null +++ b/addons/godot_ai/handlers/animation_presets.gd.uid @@ -0,0 +1 @@ +uid://c4s3h78bwvr6w diff --git a/addons/godot_ai/handlers/animation_values.gd b/addons/godot_ai/handlers/animation_values.gd new file mode 100644 index 0000000..b1553df --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd @@ -0,0 +1,442 @@ +@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 new file mode 100644 index 0000000..5d2a8b7 --- /dev/null +++ b/addons/godot_ai/handlers/animation_values.gd.uid @@ -0,0 +1 @@ +uid://bguta2eb8blgf diff --git a/addons/godot_ai/handlers/api_handler.gd b/addons/godot_ai/handlers/api_handler.gd new file mode 100644 index 0000000..acfeda5 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd @@ -0,0 +1,89 @@ +@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 new file mode 100644 index 0000000..f519c48 --- /dev/null +++ b/addons/godot_ai/handlers/api_handler.gd.uid @@ -0,0 +1 @@ +uid://v3rkd7ueunii diff --git a/addons/godot_ai/handlers/audio_handler.gd b/addons/godot_ai/handlers/audio_handler.gd new file mode 100644 index 0000000..79dea56 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd @@ -0,0 +1,361 @@ +@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 new file mode 100644 index 0000000..2510ee7 --- /dev/null +++ b/addons/godot_ai/handlers/audio_handler.gd.uid @@ -0,0 +1 @@ +uid://cjtvod52xxocs diff --git a/addons/godot_ai/handlers/autoload_handler.gd b/addons/godot_ai/handlers/autoload_handler.gd new file mode 100644 index 0000000..22e7764 --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd @@ -0,0 +1,91 @@ +@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 new file mode 100644 index 0000000..921ed4e --- /dev/null +++ b/addons/godot_ai/handlers/autoload_handler.gd.uid @@ -0,0 +1 @@ +uid://bb0inov044jn6 diff --git a/addons/godot_ai/handlers/batch_handler.gd b/addons/godot_ai/handlers/batch_handler.gd new file mode 100644 index 0000000..fe2cee0 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd @@ -0,0 +1,170 @@ +@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) + + 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 new file mode 100644 index 0000000..630a596 --- /dev/null +++ b/addons/godot_ai/handlers/batch_handler.gd.uid @@ -0,0 +1 @@ +uid://dt7um75oofdrh diff --git a/addons/godot_ai/handlers/camera_handler.gd b/addons/godot_ai/handlers/camera_handler.gd new file mode 100644 index 0000000..fb986e5 --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd @@ -0,0 +1,1145 @@ +@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 new file mode 100644 index 0000000..86d2aae --- /dev/null +++ b/addons/godot_ai/handlers/camera_handler.gd.uid @@ -0,0 +1 @@ +uid://c0lcviccrlrl8 diff --git a/addons/godot_ai/handlers/camera_presets.gd b/addons/godot_ai/handlers/camera_presets.gd new file mode 100644 index 0000000..a1922c2 --- /dev/null +++ b/addons/godot_ai/handlers/camera_presets.gd @@ -0,0 +1,81 @@ +@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 new file mode 100644 index 0000000..9f9f839 --- /dev/null +++ b/addons/godot_ai/handlers/camera_presets.gd.uid @@ -0,0 +1 @@ +uid://bl3rfy72o3wy5 diff --git a/addons/godot_ai/handlers/camera_values.gd b/addons/godot_ai/handlers/camera_values.gd new file mode 100644 index 0000000..e3c8dea --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd @@ -0,0 +1,132 @@ +@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 new file mode 100644 index 0000000..45e3c09 --- /dev/null +++ b/addons/godot_ai/handlers/camera_values.gd.uid @@ -0,0 +1 @@ +uid://bgjnubgnv6ses diff --git a/addons/godot_ai/handlers/client_handler.gd b/addons/godot_ai/handlers/client_handler.gd new file mode 100644 index 0000000..ce5a09b --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd @@ -0,0 +1,123 @@ +@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() + # 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(_fallback_launch_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 new file mode 100644 index 0000000..25687f5 --- /dev/null +++ b/addons/godot_ai/handlers/client_handler.gd.uid @@ -0,0 +1 @@ +uid://bmo4foc5fq75c diff --git a/addons/godot_ai/handlers/control_draw_recipe_handler.gd b/addons/godot_ai/handlers/control_draw_recipe_handler.gd new file mode 100644 index 0000000..29e65be --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd @@ -0,0 +1,318 @@ +@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 new file mode 100644 index 0000000..da0aaf9 --- /dev/null +++ b/addons/godot_ai/handlers/control_draw_recipe_handler.gd.uid @@ -0,0 +1 @@ +uid://buat1mt0fjlqb diff --git a/addons/godot_ai/handlers/csg_handler.gd b/addons/godot_ai/handlers/csg_handler.gd new file mode 100644 index 0000000..6380a9a --- /dev/null +++ b/addons/godot_ai/handlers/csg_handler.gd @@ -0,0 +1,118 @@ +@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 new file mode 100644 index 0000000..0f7b976 --- /dev/null +++ b/addons/godot_ai/handlers/csg_handler.gd.uid @@ -0,0 +1 @@ +uid://c4mb0tptu52x2 diff --git a/addons/godot_ai/handlers/curve_handler.gd b/addons/godot_ai/handlers/curve_handler.gd new file mode 100644 index 0000000..e21c47a --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd @@ -0,0 +1,243 @@ +@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 new file mode 100644 index 0000000..8eb5b25 --- /dev/null +++ b/addons/godot_ai/handlers/curve_handler.gd.uid @@ -0,0 +1 @@ +uid://dboqr06a1fvqx diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd new file mode 100644 index 0000000..8516eed --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -0,0 +1,1060 @@ +@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 new file mode 100644 index 0000000..16d785f --- /dev/null +++ b/addons/godot_ai/handlers/editor_handler.gd.uid @@ -0,0 +1 @@ +uid://dcro7yc8bor6v diff --git a/addons/godot_ai/handlers/environment_handler.gd b/addons/godot_ai/handlers/environment_handler.gd new file mode 100644 index 0000000..6729f1e --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd @@ -0,0 +1,181 @@ +@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 new file mode 100644 index 0000000..f495f8f --- /dev/null +++ b/addons/godot_ai/handlers/environment_handler.gd.uid @@ -0,0 +1 @@ +uid://b1k7jldwjp5jt diff --git a/addons/godot_ai/handlers/filesystem_handler.gd b/addons/godot_ai/handlers/filesystem_handler.gd new file mode 100644 index 0000000..b17ccc9 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd @@ -0,0 +1,312 @@ +@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 new file mode 100644 index 0000000..94135a4 --- /dev/null +++ b/addons/godot_ai/handlers/filesystem_handler.gd.uid @@ -0,0 +1 @@ +uid://c7ovtpdiumtju diff --git a/addons/godot_ai/handlers/gridmap_handler.gd b/addons/godot_ai/handlers/gridmap_handler.gd new file mode 100644 index 0000000..75016e4 --- /dev/null +++ b/addons/godot_ai/handlers/gridmap_handler.gd @@ -0,0 +1,192 @@ +@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 new file mode 100644 index 0000000..35bb1d0 --- /dev/null +++ b/addons/godot_ai/handlers/gridmap_handler.gd.uid @@ -0,0 +1 @@ +uid://cdv1yrjeyo5es diff --git a/addons/godot_ai/handlers/input_handler.gd b/addons/godot_ai/handlers/input_handler.gd new file mode 100644 index 0000000..78ddd22 --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd @@ -0,0 +1,462 @@ +@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 new file mode 100644 index 0000000..7d509f4 --- /dev/null +++ b/addons/godot_ai/handlers/input_handler.gd.uid @@ -0,0 +1 @@ +uid://buk68rbwssqwp diff --git a/addons/godot_ai/handlers/material_handler.gd b/addons/godot_ai/handlers/material_handler.gd new file mode 100644 index 0000000..d8a4981 --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd @@ -0,0 +1,809 @@ +@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 new file mode 100644 index 0000000..fdf789a --- /dev/null +++ b/addons/godot_ai/handlers/material_handler.gd.uid @@ -0,0 +1 @@ +uid://blh4norn3rjga diff --git a/addons/godot_ai/handlers/material_presets.gd b/addons/godot_ai/handlers/material_presets.gd new file mode 100644 index 0000000..db48036 --- /dev/null +++ b/addons/godot_ai/handlers/material_presets.gd @@ -0,0 +1,92 @@ +@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 new file mode 100644 index 0000000..2f46051 --- /dev/null +++ b/addons/godot_ai/handlers/material_presets.gd.uid @@ -0,0 +1 @@ +uid://bnuwye1r8ow7g diff --git a/addons/godot_ai/handlers/material_values.gd b/addons/godot_ai/handlers/material_values.gd new file mode 100644 index 0000000..d6a7f1d --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd @@ -0,0 +1,194 @@ +@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 new file mode 100644 index 0000000..7f8bbe9 --- /dev/null +++ b/addons/godot_ai/handlers/material_values.gd.uid @@ -0,0 +1 @@ +uid://daqgjkflia8nk diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd new file mode 100644 index 0000000..6d7e1ff --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd @@ -0,0 +1,1390 @@ +@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 new file mode 100644 index 0000000..86149b4 --- /dev/null +++ b/addons/godot_ai/handlers/node_handler.gd.uid @@ -0,0 +1 @@ +uid://qhhd5mm5awym diff --git a/addons/godot_ai/handlers/particle_handler.gd b/addons/godot_ai/handlers/particle_handler.gd new file mode 100644 index 0000000..5604355 --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd @@ -0,0 +1,860 @@ +@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 new file mode 100644 index 0000000..ead3722 --- /dev/null +++ b/addons/godot_ai/handlers/particle_handler.gd.uid @@ -0,0 +1 @@ +uid://byfc0pnyb5qww diff --git a/addons/godot_ai/handlers/particle_presets.gd b/addons/godot_ai/handlers/particle_presets.gd new file mode 100644 index 0000000..3bbf1f4 --- /dev/null +++ b/addons/godot_ai/handlers/particle_presets.gd @@ -0,0 +1,293 @@ +@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 new file mode 100644 index 0000000..7c1aac9 --- /dev/null +++ b/addons/godot_ai/handlers/particle_presets.gd.uid @@ -0,0 +1 @@ +uid://bss2ccpmsxo4p diff --git a/addons/godot_ai/handlers/particle_values.gd b/addons/godot_ai/handlers/particle_values.gd new file mode 100644 index 0000000..66782f6 --- /dev/null +++ b/addons/godot_ai/handlers/particle_values.gd @@ -0,0 +1,246 @@ +@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 new file mode 100644 index 0000000..71f6adc --- /dev/null +++ b/addons/godot_ai/handlers/particle_values.gd.uid @@ -0,0 +1 @@ +uid://bnnnjq06dmclc diff --git a/addons/godot_ai/handlers/physics_shape_handler.gd b/addons/godot_ai/handlers/physics_shape_handler.gd new file mode 100644 index 0000000..dc5156c --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd @@ -0,0 +1,338 @@ +@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 new file mode 100644 index 0000000..09acb4a --- /dev/null +++ b/addons/godot_ai/handlers/physics_shape_handler.gd.uid @@ -0,0 +1 @@ +uid://cdg8kthqla1cj diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd new file mode 100644 index 0000000..eb7d36f --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd @@ -0,0 +1,572 @@ +@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 new file mode 100644 index 0000000..ec2b5d7 --- /dev/null +++ b/addons/godot_ai/handlers/project_handler.gd.uid @@ -0,0 +1 @@ +uid://brf8u32hvha68 diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd new file mode 100644 index 0000000..553ed9b --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -0,0 +1,591 @@ +@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 new file mode 100644 index 0000000..d79d3cf --- /dev/null +++ b/addons/godot_ai/handlers/resource_handler.gd.uid @@ -0,0 +1 @@ +uid://dwwd0n3c56ir diff --git a/addons/godot_ai/handlers/scene_handler.gd b/addons/godot_ai/handlers/scene_handler.gd new file mode 100644 index 0000000..1c1f2b4 --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd @@ -0,0 +1,420 @@ +@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 new file mode 100644 index 0000000..af3711f --- /dev/null +++ b/addons/godot_ai/handlers/scene_handler.gd.uid @@ -0,0 +1 @@ +uid://7ms40gm6t2r4 diff --git a/addons/godot_ai/handlers/script_handler.gd b/addons/godot_ai/handlers/script_handler.gd new file mode 100644 index 0000000..698b462 --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd @@ -0,0 +1,501 @@ +@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 new file mode 100644 index 0000000..6be1ac4 --- /dev/null +++ b/addons/godot_ai/handlers/script_handler.gd.uid @@ -0,0 +1 @@ +uid://dhub87454jxb3 diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd new file mode 100644 index 0000000..ad85c7d --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd @@ -0,0 +1,274 @@ +@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 new file mode 100644 index 0000000..d95e1d3 --- /dev/null +++ b/addons/godot_ai/handlers/signal_handler.gd.uid @@ -0,0 +1 @@ +uid://b4n8byjeqeddm diff --git a/addons/godot_ai/handlers/test_handler.gd b/addons/godot_ai/handlers/test_handler.gd new file mode 100644 index 0000000..19f35ed --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd @@ -0,0 +1,309 @@ +@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 + 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 + ) + + +## 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 new file mode 100644 index 0000000..e56fce1 --- /dev/null +++ b/addons/godot_ai/handlers/test_handler.gd.uid @@ -0,0 +1 @@ +uid://bfg3c6iinhwmx diff --git a/addons/godot_ai/handlers/texture_handler.gd b/addons/godot_ai/handlers/texture_handler.gd new file mode 100644 index 0000000..4e90d18 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd @@ -0,0 +1,199 @@ +@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 new file mode 100644 index 0000000..a0a0f73 --- /dev/null +++ b/addons/godot_ai/handlers/texture_handler.gd.uid @@ -0,0 +1 @@ +uid://cmloikhre8lhe diff --git a/addons/godot_ai/handlers/theme_handler.gd b/addons/godot_ai/handlers/theme_handler.gd new file mode 100644 index 0000000..a71c745 --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd @@ -0,0 +1,476 @@ +@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 new file mode 100644 index 0000000..b77af13 --- /dev/null +++ b/addons/godot_ai/handlers/theme_handler.gd.uid @@ -0,0 +1 @@ +uid://gjyldaddj7mu diff --git a/addons/godot_ai/handlers/tilemap_handler.gd b/addons/godot_ai/handlers/tilemap_handler.gd new file mode 100644 index 0000000..ac68e34 --- /dev/null +++ b/addons/godot_ai/handlers/tilemap_handler.gd @@ -0,0 +1,163 @@ +@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 new file mode 100644 index 0000000..8cbed53 --- /dev/null +++ b/addons/godot_ai/handlers/tilemap_handler.gd.uid @@ -0,0 +1 @@ +uid://cm8s7a0ey2q6k diff --git a/addons/godot_ai/handlers/tileset_handler.gd b/addons/godot_ai/handlers/tileset_handler.gd new file mode 100644 index 0000000..a443b01 --- /dev/null +++ b/addons/godot_ai/handlers/tileset_handler.gd @@ -0,0 +1,178 @@ +@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 new file mode 100644 index 0000000..643cfd3 --- /dev/null +++ b/addons/godot_ai/handlers/tileset_handler.gd.uid @@ -0,0 +1 @@ +uid://de3v4m1pnk7tr diff --git a/addons/godot_ai/handlers/ui_handler.gd b/addons/godot_ai/handlers/ui_handler.gd new file mode 100644 index 0000000..9795097 --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd @@ -0,0 +1,525 @@ +@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 new file mode 100644 index 0000000..f48f841 --- /dev/null +++ b/addons/godot_ai/handlers/ui_handler.gd.uid @@ -0,0 +1 @@ +uid://ckm6f1objpgvw diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd new file mode 100644 index 0000000..369155d --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd @@ -0,0 +1,3341 @@ +@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() +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] = [] +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) + + var config_path := ClientConfigurator.config_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": + _apply_row_status(client_id, success_status) + 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) + + 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() + + +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() + + +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() + + +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 + name_label.text = 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 + name_label.text = "%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 + var config_path := String(row.get("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 new file mode 100644 index 0000000..5868719 --- /dev/null +++ b/addons/godot_ai/mcp_dock.gd.uid @@ -0,0 +1 @@ +uid://b8yknttdjanm5 diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg new file mode 100644 index 0000000..a41f101 --- /dev/null +++ b/addons/godot_ai/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot AI" +description="MCP server and AI tools for Godot" +author="Godot AI" +version="3.1.5" +script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd new file mode 100644 index 0000000..68d8959 --- /dev/null +++ b/addons/godot_ai/plugin.gd @@ -0,0 +1,2008 @@ +@tool +extends EditorPlugin + +const GAME_HELPER_AUTOLOAD_NAME := "_mcp_game_helper" +const GAME_HELPER_AUTOLOAD_PATH := "res://addons/godot_ai/runtime/game_helper.gd" + +## Editor-process Logger subclass — captures parse errors, @tool runtime +## errors, and push_error/push_warning so the LLM can read them via +## `logs_read(source="editor")`. +const EditorLogger := preload("res://addons/godot_ai/runtime/editor_logger.gd") + +## EditorSettings keys used to remember which server process the plugin +## spawned — survives editor restarts, lets a later editor session adopt +## and manage a server it didn't spawn itself. See #135. +const MANAGED_SERVER_PID_SETTING := "godot_ai/managed_server_pid" +const MANAGED_SERVER_VERSION_SETTING := "godot_ai/managed_server_version" +const MANAGED_SERVER_WS_PORT_SETTING := "godot_ai/managed_server_ws_port" +## Per-launch WS handshake auth token (#690), generated at spawn and handed +## to the server via the GODOT_AI_WS_TOKEN spawn env. Persisted alongside +## the managed-server record so a reloaded plugin instance adopting the +## same server keeps authenticating; cleared with the rest of the record. +const MANAGED_SERVER_WS_TOKEN_SETTING := "godot_ai/managed_server_ws_token" +## keep_server_on_exit (#800): records whether the managed server was +## launched with the keep-alive env opt-outs, so a later session adopting +## the survivor routes its own editor exit through detach too. The live +## setting can't answer that — it may have changed since the spawn. +const MANAGED_SERVER_KEEP_ALIVE_SETTING := "godot_ai/managed_server_keep_alive" +const UPDATE_RELOAD_RUNNER_SCRIPT := preload("res://addons/godot_ai/update_reload_runner.gd") + +## Server lifecycle + port discovery extracted from this file (#297 PR 5). +## State enums + version-check seam extracted in PR 6 (#297). Plugin.gd +## keeps thin shims so the dock and characterization tests see an +## unchanged public surface; spawn-machinery state now lives in the +## lifecycle manager. +const ServerLifecycleManager := preload("res://addons/godot_ai/utils/server_lifecycle.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") +const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd") + +## Plugin-class scripts used by this file. The script-local preload aliases +## are ordinary dependency shorthand and keep construction sites compact. +## They are not the self-update safety boundary; #398 was stale Script-object +## content from a mixed old/new snapshot, fixed by the runner's single-phase +## write-before-scan model. +const Connection := preload("res://addons/godot_ai/connection.gd") +const Dispatcher := preload("res://addons/godot_ai/dispatcher.gd") +const Telemetry := preload("res://addons/godot_ai/telemetry.gd") +const LogBuffer := preload("res://addons/godot_ai/utils/log_buffer.gd") +const GameLogBuffer := preload("res://addons/godot_ai/utils/game_log_buffer.gd") +const EditorLogBuffer := preload("res://addons/godot_ai/utils/editor_log_buffer.gd") +const SurfacedErrorTracker := preload("res://addons/godot_ai/utils/surfaced_error_tracker.gd") +const Dock := preload("res://addons/godot_ai/mcp_dock.gd") +const DebuggerPlugin := preload("res://addons/godot_ai/debugger/mcp_debugger_plugin.gd") +const VisionRoutingScript := preload("res://addons/godot_ai/vision_routing.gd") +const ExportPlugin := preload("res://addons/godot_ai/export/mcp_export_plugin.gd") +const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd") +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + +## Handlers are intentionally NOT preloaded here (#736). The old +## `const X := preload("res://addons/godot_ai/handlers/...")` block pulled +## every handler — and everything handlers preload — into plugin.gd's +## compile closure, so Godot parsed/compiled ~119 addon scripts before the +## first instruction of _enter_tree ran. GDScript has no cross-restart +## compile cache, so that stalled "Initializing plugins" on every editor +## boot and every plugin re-enable. Handlers are now registered by script +## path via McpDispatcher.register_lazy_handler / register_lazy and are +## load()ed at the first dispatch of one of their commands. +## +## Handlers remain preload-style scripts with no `class_name` so they don't +## pollute the project-wide global scope (#253): a user project that happens +## to define its own `InputHandler`, `SceneHandler`, etc. would otherwise +## hard-error on plugin enable. +const HANDLERS_DIR := "res://addons/godot_ai/handlers/" + +## The Python server writes its own PID here on startup (passed as +## `--pid-file`) and unlinks on clean exit. Deterministic replacement +## for scraping `netstat -ano` to find the port owner — especially on +## Windows where `OS.kill` on the uvx launcher doesn't take the Python +## child with it, and the scrape was the only path to the real PID. +## See issue for #154-era Windows update friction. +## Re-export of PortResolver.SERVER_PID_FILE so the spawn flags, the +## resolver, and characterization tests share one source of truth. +const SERVER_PID_FILE := PortResolver.SERVER_PID_FILE + +## How long we watch the spawned server for early exit. If the process is +## still alive when this expires, we stop watching. Mid-session crashes +## after this point get caught by the WebSocket disconnect flow. +const SERVER_WATCH_MS := 30 * 1000 +## Python's import graph (FastMCP + Rich + uvicorn) plus the pid-file write +## take a beat on cold starts, especially on Windows. Hold off on declaring +## a spawn a crash until this window elapses so the watch loop has time to +## observe either the pid-file (dev venv) or the port listening (uvx). +const SPAWN_GRACE_MS := 5 * 1000 +## Windows only (#797). A uv-created venv launches the real server under a +## different PID than the one `OS.create_process` returns, and that watched PID +## has been seen dying on a healthy boot while the server was still starting +## and had not written its pid-file yet. Past SPAWN_GRACE_MS that reads as +## "server exited" and only the crash-survivor adoption path rescues the +## session. While no pid-file has appeared we keep watching until this longer +## window closes, rather than calling a handoff an exit. Sized to cover a cold +## uvx resolve on top of the launcher hop, and kept well under SERVER_WATCH_MS +## so a genuinely dead Windows spawn is still diagnosed inside the watch rather +## than falling off the end of it. +const SPAWN_HANDOFF_MS := 15 * 1000 +const SERVER_STATUS_PATH := "/godot-ai/status" +const SERVER_STATUS_PROBE_TIMEOUT_MS := 800 +const STARTUP_TRACE_COUNTER_NAMES := [ + "powershell", + "netstat", + "netsh", + "lsof", + "http_status_probe", + "server_command_discovery", +] + +## Untyped on purpose — see policy below. Type fences move to handler `_init` +## sites that take typed parameters. +## +## Self-update field and load-surface policy: plugin entry-load fields that +## survive reload stay untyped. Typed fields against plugin-defined classes +## were the #242 / #244 crash class: Godot can reparse a long-lived script +## while its old field storage and the new type shape disagree. Static-var +## initializers are the most dangerous form because they execute at +## script-load; a top-level typed Dictionary/Array storage change can fail +## before `_enter_tree` runs. +## +## The mitigation is two-part: +## (1) Field declarations are untyped (this block). +## (2) Construction and static access use local names declared at the top +## of the file (e.g. `Connection`, `Dispatcher`, `LogBuffer`, +## `ClientConfigurator`, `WindowsPortReservation`, ...), which keeps +## this entry script's load surface explicit and reviewable. +## +## Constructors, constants, and static methods on `Mcp*` classes are not the +## self-update safety metric under the single-phase runner. The old syntactic +## lint counted bare `Mcp*.MEMBER` references, but #398 was caused by the +## runner scanning a mixed old/new snapshot and reusing stale Script-object +## content. Bare names and preload aliases can both be parsed against stale +## content under an old two-phase runner; from the fixed runner onward the +## full v(N+1) snapshot is written before the scan. In short: preload aliases +## are not the self-update safety metric. +## +## `tests/unit/test_plugin_self_update_safety.py` locks this wording in. +## +var _connection +var _dispatcher +var _telemetry +var _log_buffer +var _game_log_buffer +var _editor_log_buffer +var _surfaced_error_tracker +var _editor_logger: Logger +var _dock +var _debugger_plugin +var _vision_routing +var _export_plugin +## Spawn / stop / adopt orchestration plus state machine; allocated in +## `_init` so test fixtures (which never enter the tree) can drive +## `_start_server`. Owns `_server_pid`, `_server_state`, the version- +## check seam, and the adoption-confirmation deadline — see +## `utils/server_lifecycle.gd`. +var _lifecycle +static var _server_started_this_session := false # guard against re-entrant spawns +static var _resolved_ws_port := ClientConfigurator.DEFAULT_WS_PORT +## True once a startup walk has published a port via `_set_resolved_ws_port` +## this editor session. Gates the `_enter_tree` pre-resolution seed: a fresh +## session seeds `_resolved_ws_port` from the configured EditorSettings +## value, but a plugin reload must keep the prior instance's published +## resolution, which can legitimately differ from the configured value +## (Windows-reservation remap, adopted-server record). +static var _ws_port_resolution_published := false +## Per-launch WS handshake auth token (#690). Static for the same reason as +## _resolved_ws_port: a plugin reload in the same editor session adopts the +## server the previous instance spawned, and must keep its token. Empty +## when this editor never spawned a token-carrying server (dev servers, +## fresh installs) — the handshake then omits the field. +static var _ws_auth_token := "" + +## Server-watch timer lives on the plugin because it's a Node — the +## manager is RefCounted and can't host children. +var _server_watch_timer: Timer = null +var _headless_disabled := false +var _startup_trace_enabled := false +var _startup_trace_start_ms := 0 +var _startup_trace_last_ms := 0 +var _startup_trace_counters: Dictionary = {} +## Startup-path probes can now run on a worker thread (#678); the trace +## counters they bump are shared with the main thread, so serialize. +var _startup_trace_mutex := Mutex.new() +var _startup_trace_netsh_start_count := 0 + + +func _init() -> void: + _lifecycle = ServerLifecycleManager.new(self) + + +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") + 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 + _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() + + _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") + + # 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: + return + _telemetry.record_dev_server_toggle(action) + + +## 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: + 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_version + to_version are empty strings here + ## — only ``status`` and ``error`` are known at flush time. + _telemetry.record_self_update(status, "", "", error) + + + + +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 + + ## 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: + 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") + + +## 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) + + +## 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: + 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) + + +func _detach_editor_logger() -> void: + if _editor_logger != null: + OS.remove_logger(_editor_logger) + _editor_logger = null + + +## 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(): + return + _ensure_game_helper_autoload() + + +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") + ) + + +static func _mcp_disabled_for_headless(args: PackedStringArray, display_name: String, allow_value: String) -> bool: + if McpSettings.truthy(allow_value): + return false + return _args_request_headless(args) or display_name.to_lower() == "headless" + + +static func _args_request_headless(args: PackedStringArray) -> bool: + for i in range(args.size()): + var arg := args[i] + if arg == "--headless": + return true + if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless": + return true + if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless": + return true + 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": []} + + +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() -> bool: + ## `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() diff --git a/addons/godot_ai/plugin.gd.uid b/addons/godot_ai/plugin.gd.uid new file mode 100644 index 0000000..4c550dd --- /dev/null +++ b/addons/godot_ai/plugin.gd.uid @@ -0,0 +1 @@ +uid://d3ui3yx6vdigl diff --git a/addons/godot_ai/runtime/draw_recipe.gd b/addons/godot_ai/runtime/draw_recipe.gd new file mode 100644 index 0000000..8204bd7 --- /dev/null +++ b/addons/godot_ai/runtime/draw_recipe.gd @@ -0,0 +1,86 @@ +@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 new file mode 100644 index 0000000..5de2df2 --- /dev/null +++ b/addons/godot_ai/runtime/draw_recipe.gd.uid @@ -0,0 +1 @@ +uid://da3fqfqv6gtgm diff --git a/addons/godot_ai/runtime/editor_logger.gd b/addons/godot_ai/runtime/editor_logger.gd new file mode 100644 index 0000000..2d19daa --- /dev/null +++ b/addons/godot_ai/runtime/editor_logger.gd @@ -0,0 +1,139 @@ +@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 new file mode 100644 index 0000000..9db6035 --- /dev/null +++ b/addons/godot_ai/runtime/editor_logger.gd.uid @@ -0,0 +1 @@ +uid://cxfregddqj5b8 diff --git a/addons/godot_ai/runtime/game_helper.gd b/addons/godot_ai/runtime/game_helper.gd new file mode 100644 index 0000000..b8515a0 --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd @@ -0,0 +1,1295 @@ +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 + + +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": + _handle_eval(data) + return true + "eval_check": + _handle_eval_check(data) + return true + "game_command": + _handle_game_command(data) + return true + return false + + +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: + 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. Nothing enforces the order — +## change one, re-check the other two. +## +## 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 new file mode 100644 index 0000000..7224a50 --- /dev/null +++ b/addons/godot_ai/runtime/game_helper.gd.uid @@ -0,0 +1 @@ +uid://gfybkdtsclti diff --git a/addons/godot_ai/runtime/game_logger.gd b/addons/godot_ai/runtime/game_logger.gd new file mode 100644 index 0000000..6384960 --- /dev/null +++ b/addons/godot_ai/runtime/game_logger.gd @@ -0,0 +1,142 @@ +@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 new file mode 100644 index 0000000..d4e263e --- /dev/null +++ b/addons/godot_ai/runtime/game_logger.gd.uid @@ -0,0 +1 @@ +uid://dwcs6d1y7vhqi diff --git a/addons/godot_ai/runtime/validation_logger.gd b/addons/godot_ai/runtime/validation_logger.gd new file mode 100644 index 0000000..672b11f --- /dev/null +++ b/addons/godot_ai/runtime/validation_logger.gd @@ -0,0 +1,43 @@ +@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 new file mode 100644 index 0000000..4e693b4 --- /dev/null +++ b/addons/godot_ai/runtime/validation_logger.gd.uid @@ -0,0 +1 @@ +uid://b2ff3aot6t2l4 diff --git a/addons/godot_ai/telemetry.gd b/addons/godot_ai/telemetry.gd new file mode 100644 index 0000000..dc90bf9 --- /dev/null +++ b/addons/godot_ai/telemetry.gd @@ -0,0 +1,198 @@ +## 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 new file mode 100644 index 0000000..6f46be6 --- /dev/null +++ b/addons/godot_ai/telemetry.gd.uid @@ -0,0 +1 @@ +uid://dlul2gculiy1p diff --git a/addons/godot_ai/testing/script_error_capture.gd b/addons/godot_ai/testing/script_error_capture.gd new file mode 100644 index 0000000..92c53ed --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture.gd @@ -0,0 +1,49 @@ +@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 new file mode 100644 index 0000000..6b2d712 --- /dev/null +++ b/addons/godot_ai/testing/script_error_capture.gd.uid @@ -0,0 +1 @@ +uid://crk8w2nei087v diff --git a/addons/godot_ai/testing/test_runner.gd b/addons/godot_ai/testing/test_runner.gd new file mode 100644 index 0000000..1aa0041 --- /dev/null +++ b/addons/godot_ai/testing/test_runner.gd @@ -0,0 +1,441 @@ +@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 new file mode 100644 index 0000000..c0befb0 --- /dev/null +++ b/addons/godot_ai/testing/test_runner.gd.uid @@ -0,0 +1 @@ +uid://367b77qh5grt diff --git a/addons/godot_ai/testing/test_suite.gd b/addons/godot_ai/testing/test_suite.gd new file mode 100644 index 0000000..57b3bef --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd @@ -0,0 +1,330 @@ +@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 new file mode 100644 index 0000000..b75e726 --- /dev/null +++ b/addons/godot_ai/testing/test_suite.gd.uid @@ -0,0 +1 @@ +uid://dlrq2s7jhp71s diff --git a/addons/godot_ai/tool_catalog.gd b/addons/godot_ai/tool_catalog.gd new file mode 100644 index 0000000..f2d3e1e --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd @@ -0,0 +1,109 @@ +@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": "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 new file mode 100644 index 0000000..c0c179b --- /dev/null +++ b/addons/godot_ai/tool_catalog.gd.uid @@ -0,0 +1 @@ +uid://d1vqyt4uyo378 diff --git a/addons/godot_ai/update_reload_runner.gd b/addons/godot_ai/update_reload_runner.gd new file mode 100644 index 0000000..80f6340 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd @@ -0,0 +1,557 @@ +@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 := "" +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 + _wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin") + + +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() + _record_pending_self_update({"status": "success"}) + + +## 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 new file mode 100644 index 0000000..9cc6615 --- /dev/null +++ b/addons/godot_ai/update_reload_runner.gd.uid @@ -0,0 +1 @@ +uid://cu6c75n3x2pik diff --git a/addons/godot_ai/utils/allow_hosts.gd b/addons/godot_ai/utils/allow_hosts.gd new file mode 100644 index 0000000..a34c964 --- /dev/null +++ b/addons/godot_ai/utils/allow_hosts.gd @@ -0,0 +1,170 @@ +@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 new file mode 100644 index 0000000..02fecd1 --- /dev/null +++ b/addons/godot_ai/utils/allow_hosts.gd.uid @@ -0,0 +1 @@ +uid://b7qk3vw2nxr4d diff --git a/addons/godot_ai/utils/class_introspection.gd b/addons/godot_ai/utils/class_introspection.gd new file mode 100644 index 0000000..3076740 --- /dev/null +++ b/addons/godot_ai/utils/class_introspection.gd @@ -0,0 +1,259 @@ +@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 new file mode 100644 index 0000000..3eda5ca --- /dev/null +++ b/addons/godot_ai/utils/class_introspection.gd.uid @@ -0,0 +1 @@ +uid://caedbsmsl6fk4 diff --git a/addons/godot_ai/utils/diagnostics_capture.gd b/addons/godot_ai/utils/diagnostics_capture.gd new file mode 100644 index 0000000..22441ad --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd @@ -0,0 +1,66 @@ +@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 new file mode 100644 index 0000000..95973c9 --- /dev/null +++ b/addons/godot_ai/utils/diagnostics_capture.gd.uid @@ -0,0 +1 @@ +uid://b3npxxpuobbc2 diff --git a/addons/godot_ai/utils/editor_log_buffer.gd b/addons/godot_ai/utils/editor_log_buffer.gd new file mode 100644 index 0000000..0ea44e7 --- /dev/null +++ b/addons/godot_ai/utils/editor_log_buffer.gd @@ -0,0 +1,127 @@ +@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 new file mode 100644 index 0000000..8c8d823 --- /dev/null +++ b/addons/godot_ai/utils/editor_log_buffer.gd.uid @@ -0,0 +1 @@ +uid://b6ynms0856hhq diff --git a/addons/godot_ai/utils/error_codes.gd b/addons/godot_ai/utils/error_codes.gd new file mode 100644 index 0000000..8600569 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd @@ -0,0 +1,163 @@ +@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: 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-side _mcp_game_helper autoload never registered its debugger +## capture within EVAL_READY_WAIT_SEC. Carved out of INTERNAL_ERROR so this +## boot-window / missing-autoload race stops masquerading as the opaque "eval +## hung" 10s timeout in telemetry — the same split #490 made for compile/runtime +## errors. NOT a hang: it fires fast (~3s) and is caller-actionable (let the game +## finish booting and retry, or check the autoload is enabled). +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" + +## #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 new file mode 100644 index 0000000..fcc1e82 --- /dev/null +++ b/addons/godot_ai/utils/error_codes.gd.uid @@ -0,0 +1 @@ +uid://d2klnglf5p861 diff --git a/addons/godot_ai/utils/fuzzy_suggestions.gd b/addons/godot_ai/utils/fuzzy_suggestions.gd new file mode 100644 index 0000000..de7cc19 --- /dev/null +++ b/addons/godot_ai/utils/fuzzy_suggestions.gd @@ -0,0 +1,39 @@ +@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 new file mode 100644 index 0000000..c663d01 --- /dev/null +++ b/addons/godot_ai/utils/fuzzy_suggestions.gd.uid @@ -0,0 +1 @@ +uid://bxwaws6w0xw60 diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd new file mode 100644 index 0000000..2f1b3b0 --- /dev/null +++ b/addons/godot_ai/utils/game_log_buffer.gd @@ -0,0 +1,105 @@ +@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 new file mode 100644 index 0000000..6c7ce7d --- /dev/null +++ b/addons/godot_ai/utils/game_log_buffer.gd.uid @@ -0,0 +1 @@ +uid://biojw0xl64haw diff --git a/addons/godot_ai/utils/json_values.gd b/addons/godot_ai/utils/json_values.gd new file mode 100644 index 0000000..d745d02 --- /dev/null +++ b/addons/godot_ai/utils/json_values.gd @@ -0,0 +1,92 @@ +@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 new file mode 100644 index 0000000..b3eeba9 --- /dev/null +++ b/addons/godot_ai/utils/json_values.gd.uid @@ -0,0 +1 @@ +uid://4wbf83hwckms diff --git a/addons/godot_ai/utils/log_backtrace.gd b/addons/godot_ai/utils/log_backtrace.gd new file mode 100644 index 0000000..3fdf838 --- /dev/null +++ b/addons/godot_ai/utils/log_backtrace.gd @@ -0,0 +1,113 @@ +@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 new file mode 100644 index 0000000..4845ad8 --- /dev/null +++ b/addons/godot_ai/utils/log_backtrace.gd.uid @@ -0,0 +1 @@ +uid://b8t9kznr2pqxa diff --git a/addons/godot_ai/utils/log_buffer.gd b/addons/godot_ai/utils/log_buffer.gd new file mode 100644 index 0000000..34bd2b5 --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd @@ -0,0 +1,67 @@ +@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 new file mode 100644 index 0000000..58341ca --- /dev/null +++ b/addons/godot_ai/utils/log_buffer.gd.uid @@ -0,0 +1 @@ +uid://ddkslse7511e6 diff --git a/addons/godot_ai/utils/mcp_adoption_label.gd b/addons/godot_ai/utils/mcp_adoption_label.gd new file mode 100644 index 0000000..3114037 --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd @@ -0,0 +1,23 @@ +@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 new file mode 100644 index 0000000..aee58fe --- /dev/null +++ b/addons/godot_ai/utils/mcp_adoption_label.gd.uid @@ -0,0 +1 @@ +uid://klhsu1cuhcue diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd new file mode 100644 index 0000000..fbc0b84 --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd @@ -0,0 +1,107 @@ +@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 new file mode 100644 index 0000000..9e4129c --- /dev/null +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd.uid @@ -0,0 +1 @@ +uid://dv4tukg6eioww diff --git a/addons/godot_ai/utils/mcp_server_state.gd b/addons/godot_ai/utils/mcp_server_state.gd new file mode 100644 index 0000000..24597f4 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd @@ -0,0 +1,182 @@ +@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 new file mode 100644 index 0000000..dc5bef9 --- /dev/null +++ b/addons/godot_ai/utils/mcp_server_state.gd.uid @@ -0,0 +1 @@ +uid://d3ial4erjonlq diff --git a/addons/godot_ai/utils/mcp_startup_path.gd b/addons/godot_ai/utils/mcp_startup_path.gd new file mode 100644 index 0000000..130b8e1 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd @@ -0,0 +1,34 @@ +@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 new file mode 100644 index 0000000..fd01066 --- /dev/null +++ b/addons/godot_ai/utils/mcp_startup_path.gd.uid @@ -0,0 +1 @@ +uid://cikdvq2x4vs4x diff --git a/addons/godot_ai/utils/path_validator.gd b/addons/godot_ai/utils/path_validator.gd new file mode 100644 index 0000000..a199dc5 --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd @@ -0,0 +1,178 @@ +@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 new file mode 100644 index 0000000..8ed4acf --- /dev/null +++ b/addons/godot_ai/utils/path_validator.gd.uid @@ -0,0 +1 @@ +uid://blxntmd65ljyu diff --git a/addons/godot_ai/utils/port_resolver.gd b/addons/godot_ai/utils/port_resolver.gd new file mode 100644 index 0000000..789cfc6 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd @@ -0,0 +1,370 @@ +@tool +class_name McpPortResolver +extends RefCounted + +## Pure-static port discovery / OS-specific scrapers. No instance state, +## no editor dependencies. plugin.gd has thin instance shims that wrap +## these and increment the cold-start trace counters. + +## 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" +const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") + + +static func can_bind_local_port(port: int) -> bool: + var server := TCPServer.new() + var err := server.listen(port, "127.0.0.1") + if err == OK: + server.stop() + return true + return false + + +## True when `port` is bound on 127.0.0.1. Probes via TCPServer first, +## falls back to OS scraping. Callers that want per-scraper trace +## counters should call `is_port_in_use_via_scrape` with a trace hook +## after their own `can_bind_local_port` probe. +static func is_port_in_use(port: int) -> bool: + if can_bind_local_port(port): + ## On POSIX, an IPv6 wildcard listener can coexist with a + ## successful 127.0.0.1 bind probe. Confirm with lsof so startup + ## sees the same listener set that shutdown/recovery would see. + if OS.get_name() != "Windows": + return is_port_in_use_via_scrape(port) + return false + return is_port_in_use_via_scrape(port) + + +## `trace` mirrors `find_all_pids_on_port`'s hook: one call per OS +## invocation with the counter name of the scraper that actually ran, so +## a wrapping caller's startup trace sees a genuine PowerShell fallback +## as `powershell`, not as a silent extra second under `netstat`. +static func is_port_in_use_via_scrape(port: int, trace: Callable = Callable()) -> bool: + var output: Array = [] + if OS.get_name() == "Windows": + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and output.size() > 0: + var stdout := str(output[0]) + if parse_windows_netstat_listening(stdout, port): + return true + ## A healthy dump with no listener row IS the answer — don't + ## pay the ~1.2s powershell.exe spawn to confirm "not in use" + ## (see find_all_pids_on_port for the cost rationale). + if windows_netstat_dump_parseable(stdout): + return false + ## Fallback: netstat can be absent or unparseable on + ## stripped/locale-odd Windows installs. + _trace(trace, "powershell") + 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() + + +## Return the PID currently listening on the given TCP port, or 0 if +## the port is free. Thin convenience wrapper around `find_all_pids_on_port` +## — the per-OS scraping logic lives in one place. +static func find_pid_on_port(port: int, trace: Callable = Callable()) -> int: + var pids := find_all_pids_on_port(port, trace) + return pids[0] if not pids.is_empty() else 0 + + +## Returns every PID bound LISTEN on `port`. Used by the kill paths so +## both the uvicorn reloader parent AND its worker child are caught when +## 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 +## 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. +static func find_all_pids_on_port(port: int, trace: Callable = Callable()) -> Array[int]: + if OS.get_name() == "Windows": + var output: Array = [] + _trace(trace, "netstat") + var exit_code := OS.execute("netstat", ["-ano"], output, true) + if exit_code == 0 and not output.is_empty(): + var stdout := str(output[0]) + var netstat_pids := parse_windows_netstat_pids(stdout, port) + if not netstat_pids.is_empty(): + return netstat_pids + ## An empty per-port parse from a healthy dump IS the answer + ## ("no listener"). Confirming it through the PowerShell probe + ## costs a powershell.exe spawn (~1.2s measured) against ~30ms + ## for the netstat scrape — two such confirmations dominated a + ## ~7s Windows startup walk. Only fall through when the dump + ## itself is unusable (netstat absent, or so format-odd that + ## zero TCP rows parse). + if windows_netstat_dump_parseable(stdout): + var no_listeners: Array[int] = [] + return no_listeners + _trace(trace, "powershell") + return find_listener_pids_windows(port) + 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(): + var empty: Array[int] = [] + return empty + return parse_lsof_pids(str(output[0])) + + +static func _trace(trace: Callable, counter: String) -> void: + if trace.is_valid(): + trace.call(counter) + + +static func find_listener_pids_windows(port: int) -> Array[int]: + var script := ( + "Get-NetTCPConnection -LocalPort %d -State Listen " + + "-ErrorAction SilentlyContinue | " + + "Select-Object -ExpandProperty OwningProcess" + ) % port + var output: Array = [] + var exit_code := execute_windows_powershell(script, output) + return windows_listener_pids_from_execute_result(exit_code, output) + + +static func execute_windows_powershell(script: String, output: Array) -> int: + var args := ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script] + for exe in windows_powershell_candidates(): + output.clear() + var exit_code := OS.execute(exe, args, output, true) + if exit_code == 0: + return exit_code + return -1 + + +static func windows_powershell_candidates() -> Array[String]: + var candidates: Array[String] = [] + var system_root := OS.get_environment("SystemRoot") + if system_root.is_empty(): + system_root = "C:/Windows" + system_root = system_root.replace("\\", "/").trim_suffix("/") + candidates.append(system_root + "/System32/WindowsPowerShell/v1.0/powershell.exe") + candidates.append("powershell.exe") + candidates.append("pwsh.exe") + return candidates + + +static func windows_listener_pids_from_execute_result(exit_code: int, output: Array) -> Array[int]: + var empty: Array[int] = [] + if exit_code == 0 and not output.is_empty(): + return parse_pid_lines(str(output[0])) + return empty + + +static func windows_listener_execute_result_in_use(exit_code: int, output: Array) -> bool: + return not windows_listener_pids_from_execute_result(exit_code, output).is_empty() + + +## 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). +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 + + +static func parse_pid_lines(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(): + var pid := int(stripped) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +## Parse a Windows `netstat -ano` dump and return PIDs of rows whose +## local address ends with `:port` AND state is `LISTENING`. Substring +## matching the whole dump is wrong: a remote address containing +## `:port` would false-positive against an unrelated ESTABLISHED row. +static func parse_windows_netstat_pid(stdout: String, port: int) -> int: + var pids := parse_windows_netstat_pids(stdout, port) + return pids[0] if not pids.is_empty() else 0 + + +static func parse_windows_netstat_pids(stdout: String, port: int) -> Array[int]: + var pids: Array[int] = [] + var port_suffix := ":%d" % port + for line in stdout.split("\n"): + var s := line.strip_edges() + if s.is_empty(): + continue + var fields := split_on_whitespace(s) + if fields.size() < 5: # proto, local, remote, state, pid + continue + ## Locale-independent listener signal (mirrors script/_dev_env.py): + ## the state column is localized ("LISTENING"/"ABHÖREN"/"ÉCOUTE"...), + ## but a listener's FOREIGN address is always the wildcard ":0". + if not fields[2].ends_with(":0"): + continue + if not fields[1].ends_with(port_suffix): + continue + var pid_str := fields[fields.size() - 1] + if pid_str.is_valid_int(): + var pid := int(pid_str) + if pid > 0 and not pids.has(pid): + pids.append(pid) + return pids + + +static func parse_windows_netstat_listening(stdout: String, port: int) -> bool: + return parse_windows_netstat_pid(stdout, port) > 0 + + +## True when `stdout` looks like a healthy `netstat -ano` dump: at least +## one row parses as a TCP connection (proto column literally "TCP", an +## address containing ":", an integer PID in the last column). Locale- +## independent — protocol names are never localized, unlike the state +## column. Gates whether an empty per-port parse can be trusted as "no +## listener": a live Windows host always carries TCP rows (svchost/RPC +## listen on 135 at minimum), so a dump with zero parseable rows means +## netstat itself is absent/broken and the PowerShell fallback must run. +static func windows_netstat_dump_parseable(stdout: String) -> bool: + for line in stdout.split("\n"): + var fields := split_on_whitespace(line.strip_edges()) + if fields.size() < 5: + continue + if fields[0].to_upper() != "TCP": + continue + if fields[1].find(":") < 0: + continue + if fields[fields.size() - 1].is_valid_int(): + return true + return false + + +## `String.split(" ", false)` only splits on single spaces; netstat +## columns are separated by runs of spaces / tabs. Collapse manually. +static func split_on_whitespace(s: String) -> PackedStringArray: + var out: PackedStringArray = [] + var cur := "" + for i in s.length(): + var c := s.substr(i, 1) + if c == " " or c == "\t": + if not cur.is_empty(): + out.append(cur) + cur = "" + else: + cur += c + if not cur.is_empty(): + out.append(cur) + return out + + +static func read_pid_file() -> int: + if not FileAccess.file_exists(SERVER_PID_FILE): + return 0 + var f := FileAccess.open(SERVER_PID_FILE, FileAccess.READ) + if f == null: + return 0 + var content := f.get_as_text().strip_edges() + f.close() + if content.is_empty() or not content.is_valid_int(): + return 0 + var pid := int(content) + return pid if pid > 0 else 0 + + +static func clear_pid_file() -> void: + if FileAccess.file_exists(SERVER_PID_FILE): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SERVER_PID_FILE)) + + +## `kill -0` returns 0 for both running and zombie processes; Godot +## never `waitpid`s on `OS.create_process` children, so a fast-failing +## 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: + if pid <= 0: + return false + if OS.get_name() == "Windows": + var output: Array = [] + var exit_code := OS.execute("tasklist", ["/FI", "PID eq %d" % pid, "/NH", "/FO", "CSV"], output, true) + if exit_code != 0 or output.is_empty(): + return false + for line in output: + if str(line).find("\"%d\"" % pid) >= 0: + return true + return false + var output: Array = [] + var exit_code := OS.execute("ps", ["-p", str(pid), "-o", "stat="], output, true) + if exit_code != 0 or output.is_empty(): + return false + var stat := str(output[0]).strip_edges() + return not stat.is_empty() and not stat.begins_with("Z") + + +## 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): + 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) + + +## Choose a non-Windows-reserved WS port. Returns `configured` when free; +## otherwise the first non-excluded port within `span` of it. Optional +## `log_buffer` is a duck-typed sink (`log(String)`) that gets the +## remap notice so users see why the port shifted. +static func resolve_ws_port(configured: int, max_port: int, log_buffer = null) -> int: + var resolved := WindowsPortReservation.suggest_non_excluded_port( + configured, + 2048, + max_port + ) + if resolved != configured: + var message := "WebSocket port %d is reserved by Windows; using %d" % [configured, resolved] + print("MCP | %s" % message) + if log_buffer != null: + log_buffer.log(message) + 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, + max_port: int, + span: int = 2048 +) -> int: + return WindowsPortReservation.suggest_non_excluded_port_from_output( + netsh_output, + configured_port, + span, + max_port + ) diff --git a/addons/godot_ai/utils/port_resolver.gd.uid b/addons/godot_ai/utils/port_resolver.gd.uid new file mode 100644 index 0000000..54a3d73 --- /dev/null +++ b/addons/godot_ai/utils/port_resolver.gd.uid @@ -0,0 +1 @@ +uid://pk0212qfh61x diff --git a/addons/godot_ai/utils/resource_io.gd b/addons/godot_ai/utils/resource_io.gd new file mode 100644 index 0000000..cfac90f --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd @@ -0,0 +1,277 @@ +@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 new file mode 100644 index 0000000..94da4c9 --- /dev/null +++ b/addons/godot_ai/utils/resource_io.gd.uid @@ -0,0 +1 @@ +uid://de2rwdoa4wabf diff --git a/addons/godot_ai/utils/scene_path.gd b/addons/godot_ai/utils/scene_path.gd new file mode 100644 index 0000000..782aea1 --- /dev/null +++ b/addons/godot_ai/utils/scene_path.gd @@ -0,0 +1,155 @@ +@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 new file mode 100644 index 0000000..85795a9 --- /dev/null +++ b/addons/godot_ai/utils/scene_path.gd.uid @@ -0,0 +1 @@ +uid://c1irdrss0amex diff --git a/addons/godot_ai/utils/screenshot_encode.gd b/addons/godot_ai/utils/screenshot_encode.gd new file mode 100644 index 0000000..e3b0210 --- /dev/null +++ b/addons/godot_ai/utils/screenshot_encode.gd @@ -0,0 +1,38 @@ +@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 new file mode 100644 index 0000000..0bcdac2 --- /dev/null +++ b/addons/godot_ai/utils/screenshot_encode.gd.uid @@ -0,0 +1 @@ +uid://cl0xhoxwsbmow diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd new file mode 100644 index 0000000..167ed25 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -0,0 +1,1863 @@ +@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 + +## 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, + } + + +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 + ## 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()) + + +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: + _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. + _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. +## 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): + if not _readopt_after_spawn_exit_retried: + _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 + 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 + + +## 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) + 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, 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). + 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 new file mode 100644 index 0000000..8e62667 --- /dev/null +++ b/addons/godot_ai/utils/server_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://bwfx8b0w2mgf6 diff --git a/addons/godot_ai/utils/server_version_check.gd b/addons/godot_ai/utils/server_version_check.gd new file mode 100644 index 0000000..4bb1371 --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd @@ -0,0 +1,126 @@ +@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 new file mode 100644 index 0000000..7baacfd --- /dev/null +++ b/addons/godot_ai/utils/server_version_check.gd.uid @@ -0,0 +1 @@ +uid://ciqldbuaq8i8u diff --git a/addons/godot_ai/utils/settings.gd b/addons/godot_ai/utils/settings.gd new file mode 100644 index 0000000..5bcbfbc --- /dev/null +++ b/addons/godot_ai/utils/settings.gd @@ -0,0 +1,63 @@ +@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" + + +## 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)) + + +## 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 new file mode 100644 index 0000000..b8b1547 --- /dev/null +++ b/addons/godot_ai/utils/settings.gd.uid @@ -0,0 +1 @@ +uid://pefrtofs7ijw diff --git a/addons/godot_ai/utils/structured_log_ring.gd b/addons/godot_ai/utils/structured_log_ring.gd new file mode 100644 index 0000000..3e00b4e --- /dev/null +++ b/addons/godot_ai/utils/structured_log_ring.gd @@ -0,0 +1,156 @@ +@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 new file mode 100644 index 0000000..57012ba --- /dev/null +++ b/addons/godot_ai/utils/structured_log_ring.gd.uid @@ -0,0 +1 @@ +uid://c4yh3jqfn6dwe diff --git a/addons/godot_ai/utils/surfaced_error_tracker.gd b/addons/godot_ai/utils/surfaced_error_tracker.gd new file mode 100644 index 0000000..5bd3022 --- /dev/null +++ b/addons/godot_ai/utils/surfaced_error_tracker.gd @@ -0,0 +1,642 @@ +@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 new file mode 100644 index 0000000..433a191 --- /dev/null +++ b/addons/godot_ai/utils/surfaced_error_tracker.gd.uid @@ -0,0 +1 @@ +uid://o0ulahkt83re diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd new file mode 100644 index 0000000..c9a4961 --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd @@ -0,0 +1,766 @@ +@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, + }) + + 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 new file mode 100644 index 0000000..4089a72 --- /dev/null +++ b/addons/godot_ai/utils/update_manager.gd.uid @@ -0,0 +1 @@ +uid://cegiyw3fjcwev diff --git a/addons/godot_ai/utils/update_mixed_state.gd b/addons/godot_ai/utils/update_mixed_state.gd new file mode 100644 index 0000000..96d0024 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd @@ -0,0 +1,140 @@ +@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 new file mode 100644 index 0000000..6d608d9 --- /dev/null +++ b/addons/godot_ai/utils/update_mixed_state.gd.uid @@ -0,0 +1 @@ +uid://dd5rti52vgs71 diff --git a/addons/godot_ai/utils/uv_cache_cleanup.gd b/addons/godot_ai/utils/uv_cache_cleanup.gd new file mode 100644 index 0000000..86edbe1 --- /dev/null +++ b/addons/godot_ai/utils/uv_cache_cleanup.gd @@ -0,0 +1,161 @@ +@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 new file mode 100644 index 0000000..321659d --- /dev/null +++ b/addons/godot_ai/utils/uv_cache_cleanup.gd.uid @@ -0,0 +1 @@ +uid://d33ukg65qf7q0 diff --git a/addons/godot_ai/utils/variant_serializer.gd b/addons/godot_ai/utils/variant_serializer.gd new file mode 100644 index 0000000..21d8c6e --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd @@ -0,0 +1,102 @@ +@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 new file mode 100644 index 0000000..b8e0a28 --- /dev/null +++ b/addons/godot_ai/utils/variant_serializer.gd.uid @@ -0,0 +1 @@ +uid://cte37mtbd61n3 diff --git a/addons/godot_ai/utils/windows_port_reservation.gd b/addons/godot_ai/utils/windows_port_reservation.gd new file mode 100644 index 0000000..23b4f43 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd @@ -0,0 +1,146 @@ +@tool +class_name McpWindowsPortReservation +extends RefCounted + +## Detects whether Windows has reserved a TCP port range that covers the +## plugin's server port. Hyper-V, WSL2, Docker Desktop, and Windows +## Sandbox all grab port ranges at boot via the winnat service. When a +## user's chosen port sits inside a reserved range, bind(2) fails with +## WinError 10013 ("forbidden by its access permissions") rather than +## 10048 ("address in use") — `netstat` shows nothing because no process +## owns the port, making the failure invisible. See issue #146. + +const NETSH_ARGS := ["interface", "ipv4", "show", "excludedportrange", "protocol=tcp"] + +## Session-lifetime cache. winnat establishes its excluded-port ranges at +## boot, so the table is effectively static for an editor session — while +## a `netsh` spawn costs ~250ms (measured), which the old 2s TTL re-paid +## on every startup walk (and could even re-pay *within* one walk when +## server-command discovery ran long between the two netsh consumers). +## Staleness risk is bounded: a mid-session winnat change (Docker/WSL2 +## start) at worst yields the same failure mode as the pre-#146 code for +## the remainder of the session, and only if a spawn happens after it. +static var _netsh_cache_text := "" +static var _netsh_cache_valid := false +static var _netsh_query_count := 0 + + +## Returns true if `port` falls inside a currently-reserved range on this +## Windows host. No-op on non-Windows (returns false). +static func is_port_excluded(port: int) -> bool: + if OS.get_name() != "Windows": + return false + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return parse_excluded(str(cached.get("text", "")), port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return false + var text := str(output[0]) + _store_excluded_output(text) + return parse_excluded(text, port) + + +static func _store_excluded_output(text: String) -> void: + _netsh_cache_text = text + _netsh_cache_valid = true + + +static func _get_cached_excluded_output() -> Dictionary: + if not _netsh_cache_valid: + return {"hit": false, "text": ""} + return {"hit": true, "text": _netsh_cache_text} + + +static func _clear_cache_for_tests() -> void: + _netsh_cache_text = "" + _netsh_cache_valid = false + + +static func netsh_query_count() -> int: + return _netsh_query_count + + +static func _execute_netsh_excluded_ranges(output: Array) -> int: + _netsh_query_count += 1 + return OS.execute("netsh", NETSH_ARGS, output, true) + + +## Parse the `netsh` excluded-port-range output and return true if `port` +## sits inside any reserved range. Exposed for testing; the live check +## uses `is_port_excluded`. Expected input format: +## +## Protocol tcp Port Exclusion Ranges +## +## Start Port End Port +## ---------- -------- +## 80 80 +## 5040 5040 +## 8000 8099 +## +## * - Administered port exclusions. +static func parse_excluded(text: String, port: int) -> bool: + return _ranges_contain(parse_excluded_ranges(text), port) + + +## Parse the `netsh` excluded-port-range output once into inclusive ranges. +static func parse_excluded_ranges(text: String) -> Array[Vector2i]: + var ranges: Array[Vector2i] = [] + for line in text.split("\n"): + var trimmed := line.strip_edges() + if trimmed.is_empty() or trimmed.begins_with("-") or trimmed.begins_with("*"): + continue + var parts: PackedStringArray = trimmed.split(" ", false) + if parts.size() < 2: + continue + if not parts[0].is_valid_int() or not parts[1].is_valid_int(): + continue + var start_p := int(parts[0]) + var end_p := int(parts[1]) + ranges.append(Vector2i(start_p, end_p)) + return ranges + + +static func _ranges_contain(ranges: Array[Vector2i], port: int) -> bool: + for r in ranges: + if port >= r.x and port <= r.y: + return true + return false + + +## Return the first port in `start`..`start+span-1` that is not excluded by +## Windows' port reservation table. Runs `netsh` once, unlike probing every +## candidate with `is_port_excluded`, which keeps fallback port selection cheap +## when Hyper-V / WSL2 / Docker reserve many adjacent ranges. +static func suggest_non_excluded_port(start: int, span: int = 2048, max_port: int = 65535) -> int: + if OS.get_name() != "Windows": + return start + var cached := _get_cached_excluded_output() + if bool(cached.get("hit", false)): + return suggest_non_excluded_port_from_output(str(cached.get("text", "")), start, span, max_port) + var output: Array = [] + var exit_code := _execute_netsh_excluded_ranges(output) + if exit_code != 0 or output.is_empty(): + return start + var text := str(output[0]) + _store_excluded_output(text) + return suggest_non_excluded_port_from_output(text, start, span, max_port) + + +## Pure parser-backed helper for tests and for `suggest_non_excluded_port`. +static func suggest_non_excluded_port_from_output(text: String, start: int, span: int = 2048, max_port: int = 65535) -> int: + var ranges := parse_excluded_ranges(text) + var limit := mini(start + span - 1, max_port) + var p := start + while p <= limit: + var advanced := false + for r in ranges: + if p >= r.x and p <= r.y: + p = r.y + 1 + advanced = true + break + if not advanced: + return p + return start + diff --git a/addons/godot_ai/utils/windows_port_reservation.gd.uid b/addons/godot_ai/utils/windows_port_reservation.gd.uid new file mode 100644 index 0000000..ffc5746 --- /dev/null +++ b/addons/godot_ai/utils/windows_port_reservation.gd.uid @@ -0,0 +1 @@ +uid://bt7mxpjcdrobq diff --git a/addons/godot_ai/vision_routing.gd b/addons/godot_ai/vision_routing.gd new file mode 100644 index 0000000..2f42cef --- /dev/null +++ b/addons/godot_ai/vision_routing.gd @@ -0,0 +1,1043 @@ +@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 new file mode 100644 index 0000000..c4c5bc2 --- /dev/null +++ b/addons/godot_ai/vision_routing.gd.uid @@ -0,0 +1 @@ +uid://dp444q4ocpx45 diff --git a/assets/characters/ankarde.tscn b/assets/characters/ankarde.tscn new file mode 100644 index 0000000..c8589db --- /dev/null +++ b/assets/characters/ankarde.tscn @@ -0,0 +1,272 @@ +[gd_scene format=3 uid="uid://nwbjxttgghpq"] + +[ext_resource type="Script" uid="uid://dccfebno1gvh7" path="res://player.gd" id="1_seisg"] +[ext_resource type="Texture2D" uid="uid://cjxrw1p7eh82i" path="res://assets/characters/ankarde/Diego-idle-trimmed.png" id="2_yi62x"] +[ext_resource type="Texture2D" uid="uid://dg1x17ci14p4f" path="res://assets/characters/ankarde/Diego-walk-trimmed.png" id="3_sn41o"] +[ext_resource type="Script" uid="uid://kg3i7b0dj2p8" path="res://assets/characters/camera_2d.gd" id="4_yi62x"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_rcrqh"] +size = Vector2(65, 115) + +[sub_resource type="AtlasTexture" id="AtlasTexture_lquwl"] +atlas = ExtResource("2_yi62x") +region = Rect2(0, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_7mycd"] +atlas = ExtResource("2_yi62x") +region = Rect2(256, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_272bh"] +atlas = ExtResource("2_yi62x") +region = Rect2(512, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_5vw27"] +atlas = ExtResource("2_yi62x") +region = Rect2(768, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_kek77"] +atlas = ExtResource("2_yi62x") +region = Rect2(1024, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_4c57u"] +atlas = ExtResource("2_yi62x") +region = Rect2(0, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_efxa6"] +atlas = ExtResource("2_yi62x") +region = Rect2(256, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_dg77c"] +atlas = ExtResource("2_yi62x") +region = Rect2(512, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_ycdy4"] +atlas = ExtResource("2_yi62x") +region = Rect2(768, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_w48qg"] +atlas = ExtResource("2_yi62x") +region = Rect2(1024, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_vivmo"] +atlas = ExtResource("2_yi62x") +region = Rect2(0, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_2cqfq"] +atlas = ExtResource("2_yi62x") +region = Rect2(256, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_yaehf"] +atlas = ExtResource("3_sn41o") +region = Rect2(0, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_074og"] +atlas = ExtResource("3_sn41o") +region = Rect2(256, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_cegan"] +atlas = ExtResource("3_sn41o") +region = Rect2(512, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_82xsv"] +atlas = ExtResource("3_sn41o") +region = Rect2(768, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_getpj"] +atlas = ExtResource("3_sn41o") +region = Rect2(1024, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_ryguw"] +atlas = ExtResource("3_sn41o") +region = Rect2(0, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_d13ii"] +atlas = ExtResource("3_sn41o") +region = Rect2(256, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_1u8w0"] +atlas = ExtResource("3_sn41o") +region = Rect2(512, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_0odxb"] +atlas = ExtResource("3_sn41o") +region = Rect2(768, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_lswn8"] +atlas = ExtResource("3_sn41o") +region = Rect2(1024, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_a6jrf"] +atlas = ExtResource("3_sn41o") +region = Rect2(0, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_xuqvo"] +atlas = ExtResource("3_sn41o") +region = Rect2(256, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_qsp4k"] +atlas = ExtResource("3_sn41o") +region = Rect2(512, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_kq58d"] +atlas = ExtResource("3_sn41o") +region = Rect2(768, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_seu75"] +atlas = ExtResource("3_sn41o") +region = Rect2(1024, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_htxhm"] +atlas = ExtResource("3_sn41o") +region = Rect2(0, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_jq2sk"] +atlas = ExtResource("3_sn41o") +region = Rect2(256, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_4k2k6"] +atlas = ExtResource("3_sn41o") +region = Rect2(512, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_0rl1p"] +atlas = ExtResource("3_sn41o") +region = Rect2(768, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_ok6jj"] +atlas = ExtResource("3_sn41o") +region = Rect2(1024, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="AtlasTexture_facbu"] +atlas = ExtResource("3_sn41o") +region = Rect2(0, 1024, 256, 256) + +[sub_resource type="SpriteFrames" id="SpriteFrames_741h5"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_lquwl") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_7mycd") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_272bh") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_5vw27") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kek77") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4c57u") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_efxa6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_dg77c") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ycdy4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_w48qg") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_vivmo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_2cqfq") +}], +"loop": 1, +"name": &"idle", +"speed": 5.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_yaehf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_074og") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_cegan") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_82xsv") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_getpj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ryguw") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_d13ii") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_1u8w0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0odxb") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_lswn8") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_a6jrf") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_xuqvo") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_qsp4k") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_kq58d") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_seu75") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_htxhm") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jq2sk") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_4k2k6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_0rl1p") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_ok6jj") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_facbu") +}], +"loop": 1, +"name": &"walk", +"speed": 5.0 +}] + +[node name="Player" type="CharacterBody2D" unique_id=309719231] +script = ExtResource("1_seisg") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1243984711] +position = Vector2(0.5, 5.5) +shape = SubResource("RectangleShape2D_rcrqh") + +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="." unique_id=21110687] +scale = Vector2(0.80251265, 0.8025128) +sprite_frames = SubResource("SpriteFrames_741h5") +animation = &"walk" +frame_progress = 0.7021592 +speed_scale = 2.0 + +[node name="Camera2D" type="Camera2D" parent="." unique_id=1765495955] +scale = Vector2(0.5, 0.5) +zoom = Vector2(2, 2) +script = ExtResource("4_yi62x") diff --git a/assets/characters/ankarde/Diego-idle-trimmed.png b/assets/characters/ankarde/Diego-idle-trimmed.png new file mode 100644 index 0000000000000000000000000000000000000000..7e97374e2dc1765bff076f8df65f70128141b974 GIT binary patch literal 170556 zcmaI8cR*8F^FN$~Nbg-KK|qR9M3fEzX(CdrpddjkSm?bc0Ra)|3MybwR8&AfQ9(*V zhoytk1QL3cgqj4B+&8%Ee)rkud4KI$?GO z1lq&=FAIpBm3aw2`bQ@S#1FDEGdULmUZ%6R-wOD!J20JIFsL4-ESrbF_aK z7v0imrgj|idRYEtw}hEERMdaiG>8YZ2ta-hBg`bDqTs*p{#HfBOMxB`;Q+n61i?6S z+!$NqoVl! z`C}YYNIv&sy#BIDVRV)vWV`q`$-ljmkZ#Gep)48_yoYdxDenCL7oX2}`4l^D(f*t0 z-(-I68@(BisxA9PpxcB=wHs~5^{)ldo5`qJ>_68i*p<^Qm5md>wHOk*KM!y5`|cmM zw@VTd%}|D@yF)jslz)T7Kh^n{`;=@kwNClo(|hM%4}Uw*wuv$R-0l+SIrCjcxe`SW z{)^s{2sqOFp91YNV~%v6eD406Ca#?YOc(uq_ZR8ii;7$x7Y#cITPrpr^#PxL1KOFFnPrr^*1L$~O;UGwWsNzD9P{!2pO$UFZO{9h*2 zb$nO;;1>l<`~!pOy#FHoxBN+KmV_mG+&&O3EUho>e~-d7F)cOimvj(9O!$tnxmW&g zvHFzBNj`izZ9)QC^%(o6Otrm>FwZ+~HGY9+Uh=D8;MbMk~P_-X_8_n(;_@ClqA{E7A7xl5H8_R3|_b8tUfgbH& zK1*1#VCoB@eeTlff9QJ`Fe-+a_WIA)FX7IvU`vwH2LASTH81GK&wp)lcP{1Nu4xyd z^0S1|n|D#Q|4&%ez}vH+)LDxDM_Dv`9i`P zQnly1)~`M2XtP%jU0|Apd6el z0n7JT+B+B?J9Qs3`KJT$xn~eeiU3p@<0S%C0^KQ z+J$daSaz-c&tT3J4hdj5xiAWwGli_J z1f-YX4t+;0)8%*O<%MsNO-=3%oWi60%Fnuf&elOkoIE>fAUKWf3kGaSRHwJw-WRhYLo!`M`m+Fu&5h=?Pn+L&h0UK=_w0HM z(xHUy2f`lU0u*mI83~Wsg|*WV`-Ih70gDTa`rGa%()ma+9>})recTiFo-}e|9c-Yw zjxGDjDmtihC*cA=5>-EyrnJN16ktSY%_Y+egg~D=fMEE>XLA3woe1r0$K?RU8)D%? z(tjiro$^u>%OJjuj&(P(>Si)Qa5F>YQ}%vym%*4sGDQ?MQW?`{SVp?CxG7OmJ*%(l zdhdE+iKPp1y;ugDn-G(@s=ugTb95JF`bbstD>p1Kb#UVc;h|NykB{H%ac{lm_~PjP zX~B~vLq;d2M3aEDo`$%u#B1djOsj5`o};v`bCYVa_d@gh_jfL0m=NK7mpv88ci9qP z{h40hu)d0+*WG3Wv&C?%VP>Y`m3I4qvwnzL#UEGsjj8Ic&K}5Zqx-lckZGi$5~FBl zj_BUYoU@G?RGK!*ja-9sLOi_oo*ONp5chI@&gxR97p-C5yj6mTsY6bIj!-ONsO{{8 z>S1)R@Qw2k!^bB|k*M zY7LJ~HvQu-%T?P}f3HsA+3#BWeB=`Kg$3%RH)Rz;Rlr*|b)SwpTMRjCL*#zqVJXPp z!M~3KI=iQD)KZ#{Ai@zLbk(oX_3B65wJ}%gd=LxA^|S`QliGszgV5> z@SK64K6@IfrUn(KCDNvY-vVvJ<1)>SCwy3eww={D@PSezS^cBnw*brZ3&7e#oG)jk zfT0M4`$pr!9>0GCZzQ}}117hF2fv(#9)#*F#~{hTPDE(1>)*6v}QS6Jw*CneY}R++#Hg;2|f-T+I-`bP)_z)bU@5~}U|+>7Ic zSJe+fm9>{@#P}fch6Qx0!f@%3bI_fX?Qy+7jh24q@L zc-rdb1MxjixP(dDGFEjmu8XR4OBV;``v;xKg1!&j8iyg~H)6XhNm`cq(vdt2>oB0G z#F!z0cYP^ZdFZ)3XJ3&qe9z*QDF1X-+TE%=iCu(K;+FxOqG%=@JwLCKQtE)7X>l>j zs85W7@sexuD*OWwN8}LOwe;z!thV)0UCsc(YFX2MYbo+u;K#9LIY8I*Uf}s%|C}^N z^+#!I=ku`{Il~T%3lELD8Drx*Z4i*1Pny@>UB`idn}-ahxO#3pzUg@Ci`;{@$W0t4 z;7>_cd3;+G7Si-cPjH?ar?#A6bXmaB1Qi2%4Ba?Yh6YEuyh&$BR+Xiaf7b#irW3m4 zQ>@!=Kh6?^;tv^BJnwCkXRc~0Qffouzjr7f&~8%Vbwn+1l=Q z?D{ly6mRe2@g4ngN~CT0ray|H>9go7H_%mW97P+pC;t{NJm6~<9Y+dn9*qYix^ z#`!;c5VmTekds!l@X+XI^T-jdWfn*L2`N^v?xmhA85v7RFC$ocaW{EUao z3gz>{l_$LVCqvo5-M7A zKfV_@ytMsubH;oXCjM$taY2l2?}z%B_T|JrZ;sWaP0>^K4Q?eVPr7g%ED-@4SziU` zO{0~Ra-ZYgqPPTU?e~v!GeA-NZq7c$Cd? zT|++1o{D^?$~b#%GIAP@KkgRvCLecC$!iUBoYAcwV9&9Gib8_qXnZy+6ug~b7W72F z@CEM3HDm_yO8Bcn|~Mwsq%mB#GzSJiJp#qXAE0LO#h0B!x5 zI+SUoX*6XTpt*@D&W78*GeXf6Lpoa5Cf!>ioW8WUJyu{YBX&s~F{yrrO` zry$LOpa)H?j{N#)aKnw7xVLFqYIvQAk*k8KZFNVK@;d{Gi19c5U=GZES)V!qSCaSF znaKOTpyY4fW)nW;L`PGP9T4~Y`hmh}7}=me2Zn18nZK*aK4f=vNkE7Fo+fw*lL~wT znqN;z-YTL6`xGxFT^#gACSVOTNf&#mr>~}G7j|IPpBM;c-q%krLSXSG1H{Te+KZm( zGh$7)Pi}ti@zSy{{9dNVh3k5uZh|_+P1g-M(U?j&0cus`Z;3WRMNLe7@ZITB+8lM8 z^0NZeK#=WLYcX&#jwg%?7vG#Biz)cYAzOhsrIoY`4PaA7aF(bPp!+C`R*(vKzWw+V zI3<0UBUM;b!#}2-<}M?1bl?Cu!U~!S1W1cR?HT%y1M}H)f|)OdcH2M(l7@a(bd=o` zhMteGr5*sl$6NNcd7r5QqxfHw6KIqff)LSl+za z+S!Z;OGbP>4eip0l!GZTumIWu3u({oD>7huYZ4*lB^(92a`;}|U3d(mu<4_ykMB(rM)S4{pd^6KT(lb;S1tl*q}i4p2322sgu*)&7qeq&!FK z^=ci*cq?O8pjduA;{_-Kb&9c>nR$T~(kR&@7glq?$W(sQ`9+>YjEUKn#I(^>jtxK# zvCX*(RHdI_?}Y`^uxGMZDh>mS4F$E|ibLP>%+tkK!TW#?T*|;2pST20)?W?~f9noP z@it#e-;ks;QG_$1Tma1riGhMykjJ+TU>3k$5Fs+Rqd};ps$!|_qRJn>+K(Q%O;;`8 zs)N6&H!9z|C^EvKBObsMt^>3cnT#u@c;63+1wNlRO%+ z-mS;-IWdY5qux!}PtM_tn4g!FHt<{2rD2NLWPk;ahs_QQp~E6fh?7ZW20JR$?w2_i zXW^BV+vi2a^j%^0h34H8@J(bB0NH<%L)h4;kP0jxcSr}LxfDDei|=6p2dEun*bQdz z8PQ%Im1cJ|W<_TwsI^d{p*9Fdg%q6V0l4-!C;385i`|_0bG;L`wVHQyVXz1A~GJxQcww%WSW*!SmsJfx>s@BxRG_( z&Cui4V5#T3(#$TM28vhBA6w%h1UppC`sapfqoRJ-Xu1H%|VjX7)d=dJpTfnB#QpkhK&r9OnwRWElmmnId64v2iK1+KZ{q#U;Fwc$u&C*rU_Hh&V#7U#I{}^^DYtexI;9O;fH6v_ zd=lE2JK>7X6VDUh=phEBa*H+=l%CP{)dW8Vx$Yb*Q+zNa&JF2f#yuKY%d^LRSu&jc zTH^3VxdarXofwabC|kD?cn z=-yGq8Qxjzg+ZQ;vRGDnYQJYCjlV8=c3!TtL@Ks!f)K*?O4`jh;dA+G&q%v z#}Xqt4@m;UeD+bKpCxrX;c6|k<9wFxnLd8X7I2$aQD8m6Y>RB2Y#>6N%s<8~cHwkQ zQ3g{b^w8Hs_Pn=No;|vVkld=*T`nWRl?@zRgyP4fTI05Xp|@vk0mb(i@im~zrA!P@ ze@C~y$#Rb7jv`gabG!wfKwk>sK#*MaWUUvBcpJG9xd`>pyUfBha{&v&ZaULd_LQps z{VL^7&39i#b&V={aP@VDe>tD$)}o$JXkty{9^%zr;KvucsZQa_vgdtPm7WGR9nMFIF6@iO>z2wldTJa*lRWY~fttx$e;Dvza6ipU)b4SLe zMenWp6Ks#+s70VQEvtdPL$5uwp0t;jT=Hc!RK4q^=?S0P$^we}8{(3tD79azA=-#a zo|4MyPqKpuqqyu~8-hrcT0`gHGVVQ(oSJoOD3EF=f6 z@ftFSb69y6WC70NB&f$!Hec{O$j~m{D|P34cA?tB^Ih*`VHSFh+646{lu%&Q+Id2~ z>K0-FxrStBf}1;(ea_v%pEYsH9E<+B*tXudDS&BZXl){Xa(dIj20hA*zIN2`AU&2E zTQO(rRL@dXJ%Mk4fzfw$oU1%OaqdsW@Dl=le9fZ!`z6bvSU70wx6n|~q)IRy)UAB_ zG^!3hvTOkV+|zVcQOp^F)7)=yDbV94G-T|vK(#Mf!5Mp=qF%p#NIMyKqOas0+r1qP z(e%+Ir~>&GpuHrJ-6+HIz{*SdB^^1<8Q^ck-{bCF&+Z&N^Sxv?FPj0M7w7dLUhXbG`zn$RfEj=1;$qPrW-f9-1dg)>#!h}yj zjyQ@pocyt8DC^WW7N5OfuKiepo2EZ@5FULd+7LNZaZOR7PSWH$WfOTrOGc^3%TuM1MajgF&PNqN z?h@IbKVO*s@bl`>C%O7!ZO?lBlF{VOPpp(DPl!`xas|$cxHORD#nib31I;*CNO8zv zibObhCHLCpUG#GdhgJ;SxRQ9M|EP6EZHmKdHDgv^W}$nt)nkdHbD;`)x~A!klS5hayk|tK z7O)0+z06m?MGo@W*$2=KtxO|v+K*4g2^_v#1+Ps#1ocs)yQLRzLm!^^(%Hr;o_!oT zL4FsWozLq7mXX_I$DL!iFQ!#HS%Ue;*7t8euogjkeK}+luG<#4ZPtJRvuqk=1m-+# zhZ-yC?Teb$(N{MyU}diyN2oE56J{u7Wb9sG?wh&Nz2CKfDwm|-nkc?;#yiiDou%OS zt*Rv>i5S{=@KmvbXAo^J?7138kARb$&8WX=zwwtWnfR2`iQdB>J-aUin`rMkUl%{= z0BdUgXiuWhVX&(Q;h9Jrc!+HyGjp_dBK%EkZI!5dtkb@m7a-L0k_6oOA0*Y1D&CTD z-6R$VPd!t|tpR%YOOM1xaX$n9h~_2%H|TrAF6gBHTvck2rZ7%q-DO{8;@68}OmkYz z+)n2O>j6~bWXcBj2zDOgCpf03os$B3ZHo9{UL+sg_wfDU{(yq1_*<64TL%_Ut3L2R-Z@X`78EexD}n?yZw zZ{~fenCE6zpo&c2eviBTGWiwXK_HZv4w^X0a0K8&zbI5<@C@qXdgjr}BW_wu4wuU6e zv9oPmj?3-jPo$afvkJIgmKY@sADh4mu-)B8FfB@inO}_V?>RvmnWXO_j!2vLL*zm zSwG3toz*T`yWWJW8)k5ae1-u&*b3K^GcY)PYd_k!3-m^0PtYDYFMiyXO`!ICd;(kn zL|(pE8XPP^tc;S-(q44EJR(|+^tv*B07RED`*4(hCqL$u3EH4rBLBK?#u{vC_Ub@q z_G02>&vVn$!Kv71MRtCPJ+rP`sw;tYMKCE^P$78VRBZrzj-a^vlWTxqaO6azug?{4 z-lZ{v^K7wk@C%Xe)~kzREO6g8bhf;+8+W|Ff~&4|Z;8sY3=xAzFJ}nl-~6UBtG8$J zo2g>kmYNh|U$9u9e;0pGoVp?O!aAlONC0V~rj1_pw~sz_UBfhksQAPt%~YJsN5;CQ z{~;P)Lz#xxYg4#zH>bo_97@ojb{~LvMKbmI^%l3)g1)6(W>nKj2#S^xE%-6D6vWjIWRxRZi}23-`k0nd_#ajE|K#AKL!!p{EZ-)WeI%mOM# z<&*POW_mA-K4wn9y{GTAm#+hTY{vGchM>NqO>c+IRRY6TE?$|>@AY`@;*DkjvAzJ< zV#J-d-JfT;Uhx7GBW40A6W)=ZtU=#?oUuzsuFQVV9&Pz{;%@eqcY_lmkY&qrIC+on z#???AOQEvcHnLIQfZ8%8Q<>I}WH{r|&X-kYK9$E_qzcIa57~%CDIVW8y*N=fQeWKE z0^6KS*;M5{av0Y_KsQ0&f%p0%&(}Ke`?3I(ck3~eJ%ajVL?JetKlISJ02|u;?=^)S}7d*bcUVc6g;~$5+ zbj)Mm-G0xxQMto#%x8;J%>Htek{ z1LsWGGVrByel(K4OZ$)0B^>cHZziiHzcTU`*>nz~t|z&ncy2gmaj>mne`HzA$Wmb^ z@%ATE(KPinHywH7_Y1p6HB13pW^!Vx-3v#9&0!fk4|}GQ0iLowl=i4@NlabuRZ}`X z@$7iC_R4S!j5?4#R%S*XHR_UW3EyIQ)R+!>$>2L46PKk+$DH6t+qNWRtP_+_;sXmv ztuxp5FpsKrnhNeNnN60C24=P z3M*0=1&$R*a~)0oyx4kw^Z;0HDb_^TqNlsk%=kyGnZUj6nc3H>UXz1Fg#*<`nmn|9 z^P`y2x($BK_6IHx30E5Bof&!dNGmJ0 zoBhD+!^gDZvcG7NMjV`p7LcmDF+)<*@IkR+Y7s{V*$?+L(jQrSVIM?cy1K4*xLYa z=TZsS_BzTd1z`-aQM)B>o7}@v{)nV3z$rS6I`1e+B#;G1BR3O~$w0`D%OUfE0CGJWQ{NpiZA%*vgW-CqX59Bd&p7Ii#fgJ_w2MZ>?>sHE-Q4X-8({xc z%XpuOLDYP$lqnt3IaVuMt##REwD=Wfh**+DqVW}tj91^^P6 z=N+XJd@uN(c^%1Ik5-7mVJR@?KXe45F&-W&_U!UL8KDAMO|*m+;UMPI!hzur`aT1D z3@@8P%imfHplia(wl`eXYQN)i(O=4X1?&|@j4T=@=30^O*^D&t4IL4%kJfwj1gqyF zeMeiGd)aHsaUO~d6S4f|WcRPfT!Ion((;ppeqNm;8LkQ%^Y_(#k978`NBHbTmmhs| z1H2kqUGPwbg&cauY_xFK&PQbVPxu&ixBJ`*Ao$dDRK@)JMu>23JM+^LWLXk-A~W4-wKjKPlhb5Fa>euSuQZPKP>&zX=l? zV>U72M&wAXV|>+q&oQ)XO~{ulAp;d`X}}{DcEGM%NwM@_~fZBOIXuH#^pg#DpAq;5DlnLFdy~3-Nvz#+e%M&TzoANw8P?~DzFCs zxDqs$!w22inV6{>a1Rc^wIL ziEeU%tid~H_JrjBs^4frD^{}1CZhT!HlptFy`^+dh289Z$9D76rF+MFiHTA^e;_TR zgCs$OffZ3q9XT?`@&2kel6aL;hM=CMk&tv28qSWsk3mN6WK*a=jGA8OJgkgI(Awik zo&XJ5p$8BE`eDXTZ$Q~7l03B%rs5gM%IfA);s2{ZQE7>SmwqP(Vli6An@4#eZ@w=M z9X6c3%K~;Q<9nPp#|H_T>f5tiC@(|Y=|PMo0~=}Kq>L|-yx6S-%T*^#s3?}as@N-n zM~}MaX0)84t>$(UOfh3Nz^C$MX2c|zF;JE**!@FA=q2h1PHD^~nED*j|Hv!4F8x>X@_ z+xVc~$Vr)GI|94R+OsgqLml$1rym^!{VOvZh`Sing^hNgd_*SW5QO0+-a2`%w}eG3kbTv$I} zr9gV;neE#z;@~=$oHV4ijf|L**YcFin#vvOyZKRhkS!$LS+XsuYGB-FuR^d#UF$&S zQ28y+D(zGC+6P3xTF)m+;pB}o#ag*G$x^7@a~%PQ4L2jnd`-y2bxf!N3PQH&Te}5p zFWhGaevUay71xoEK{u(dtf77~_EiE`_9v~9+T<$Mdo*ZkE!YRZY8IxY#S6)zbT1qFK@E5fL2No!FgK#bBw%xdW$%5VN{JE}wcZBx@QTqus|!cqS(tqx2x8 z=))F!(e=a|suKO5y9^<9eyEZ#LAjkr0bz#rtS=txj?8_^@@^S#4NUk(~GMj8s01WTGx2)&d|%w)JEmxt?l<+VzCzfT^1E z@_6ROtRna>qUF#!Oi()nbW`Gcg>6^Xf7b$p%w3ZNN(ZbTgB-_HEJ40DYJO>L#*1z) z?rT_KC)dQyUlkDVkx$l;m;#^U5qB%t4!h#$FNUato_kwXJg+j#g{TU40gGeT%J_s6 z&p^ngEZOy1F2PhSv#8EawMFX>Xe&^HA*u^I=kzjmba|zxQ}?)Wb`4a3AZToL#)EvI z&8X_JoZ}YD5uu9Vki;73qYYLg*3vwwR8BlTs^J&~tzEpyA)XC$i9Ht7KCBvVj99G5`(~-ogm4nN8gQVng{i>loLMWD1 zvGf_74DmX=spgNS6x8D=F~E9J0^+iwYGU;163SvafBV`bf9kZ0Xug!l%_pZkD2|fb ztmD3PEe~(%PO>+*y0eIBeAzcoK9spbII@N`Yu7`cW+RsG@wJeNlg zILg>d%c?dm*od(!*Zw>=+WBXGXNqb@7PGb>^%w>z8IVT|z7J60L!X(Yk1qO7sV@^o zymJF+z03YG1J74)+`&MVjAjwk6eu} z(?E=&mf$fzx%Z<+aS~gnt^_@gkDt>EA+(;B%)NNrjq}0LMRCuE6^kFN?oF+XI6HUn zvmKC**xoxDK3|bhV5>5*(qLDyG%)LhIe|l=TVA;&$3`UwHORLAF=hTl^(*I}8#W=J z*`!M)mTugYV+^zP%)Yl63t}yU?i>jSFU#P5n=@Cjy=FD)*L1<>r_mZPEM!S4+u@5& zz+x-bt#qS6!G=)3FR;Lr5Th=wgX3AnLeeh|D_Pm)rx5Mk# zADr(Y@*T$Z_?Cf0J;Y%%yz9A9gHrReFDzWeYbxI$P9u`peW3h2QA=X!=tBZ0Wm4y= zMdL)_j81QyClWBE{KW3qE=v}It?s{8?cw$-ujkhPGEfS<4n~3Rw#CK&bkffE{h56_ zdxUi<{LnD`^Pt4WVJSzC%pk3J<)D*Wq}eYuY{bY;ujR*YV&p(Ng37bUap}~O@7y0G zrG$=R*T-X07^E0JqsOIEzpAME(1W!Z-dDZ_@a)v`tpS)?@^?xxEJ8nuYKc#5Z&_3e zw_|AE2U|EDTOJlDCLuGROO+Scsdzn^NaN1;B<$g1R-lbie>bSU_UE$Bl(NW{>u)GX zDy2MJO*>}`Z^ix8CYk+&BoK@b8N+{_O4bjne(%T2-=o0n9D#J#uOc?ElJ8wt`S6S| z+8PVgI^TIi!g{q`V8;e`#c00XMv3VX=M9`ODd3k7YHZ+beaGH&!&an zP);yeL~ema08Q-b7q;hgwHa*832kQbDQCQc-8*xy$mBYov5`Z$LG|3zpOnw^>@r{Z zR6*Ad7u-}fO0RUZy!W)B{Q=M;hiZb=#c_OE9!=i#KakBxDiPSLjgR1Y{W_il*_}z? zM)ig@42u-CzpTTIZE;cZ5p?Cz(r^emD@Kkx{9@@Oh4{=%*6K*!0ByDES;)LH8S9rG9 z%qjR(-AQ~ySm@p>${aa98|pvPRqWh#*;`?&b{~@eXi7p=Rv_%~$?T*-Z0eLk_a7IZ z-FMs4=;f~S$3Hy7F1GB?rd~SnRk~!|jv$8IFr;+D*R)A7-qn&#Wi1+yLz~AY#5DXk zW>@8t(Mt-7tB6{1E+rNg3S;#0@HhmU9Ep@mKlbu8F6`EYie7f#*}B4GQIsxIGQiP( zzDo)+vc?SQoInvzfV2Q8564iFR^+)mX#TVt3;uH-LZ;mE#AX(tISv7-R+*_ZV1XDW z#OTC_t#hqmmO$x1_EQ@go>;X9zDXxW@dDJmzQoaTWU__iG?kMXjQ#waGq{`hx_a_g zY%dm?KCkHqoLNz{0*3q2H{cY=US0e4{@3*oPmS^JS@;@c8bEIUAo|geGmrXh3oyli z&C}tQ7lY9wtR~rHL z7l#FRoVU^67U*TYuh&^9{A);jW+z48yK=luUk%?j$?W}%c`Z+;x4*|_zdt&J4d3_U zvuDh3Kx{_Pxo8HuJf*s*>HtdCO1gUL(T*?SzLj&$^V!AkuRmXDxflO+@5sIgPyu6b znnBfBkk_KTI^-vH57mGXilm2V^L6S?tS*S@lX@1tDw*J2tD=Qe%6N9#@O+Vu%W|R z(L7h~ueK7x$JZ~>hVVN-LhusajWcpKg9_R;IvHlBC=21kQKX}%);sms+2@u&4)A1u z^0NQoF=cD34ad_p{l#|6PAPxf=hU#6JW z2g(h8^gwcnVy+Anic6 z`*uzk{b{!+*+86@Bx9DyY)_(QCKodOgmD(AMy`deo5{{2XY~!&_-<>Gm zEE}7}2OhPrsF%8d<>wz>p&02go`M2Oa|)9_hbPD~r??o3K9n%2!f(664$J1E)U|YW z&}FSH$qNdPp%2#3rf)Hu8W`@t@LQLWC3MUPGe+2{kr}@#57i)NV85 zOfXkL7(wtjeEYpN${JZ_!YlrQ?{F$1PhZ_{rKe3$MV^Y8`_x<QTnI=m;#W%x(&U+-MKE?do%?i0+=nO6hoB z;4?q0+tIa=`sBoI$N;&EKQmYmCM<1g^g})St`%Fz;;^~d4P8~48E&;!ubC7u>XDG) z?#E!qEK#%)xvDst9Vg$jPlvjF&U#I&h%kUbEX5#dtDkRD{x<0&IJsksWeyXMbp*XRwvmKnnvBg$@5pFI71EgZS)>7 z)c%Qe3Anf{F9bu=P4mZ~1-$+?hZE$Y104~|{0=GEA(bVBveAqHenZ^VU!j5%REOow ztyO%Is^W2Ve>CupkF>ntp8J4{qet;g@qK}uTFx)c>KUPuARlV8sMg8jyIsm4S5nEl zY=u3nbiq@QTJl#ON^-V#)i6u0zR|)+$>RC&Zqq*QMPRTd#gOvq-6`@C3;MajMHn%x zt@e7F*qBNB{!*jc6FnyiSU}YB+X5CAm*&Ncq@#j3uoN+bF~Tj@x~y(aS~s~RB^Oth zZymDS#``Ws$-_RRYmE=NtP!z&D85&DUtSr~&8Xq*I*!FI$ESQ(`QyF2;XPnoJ8FK zlKeqN##D?+a#V>A4NI?`SOXQc+!-x&HimBWW=TW**>yJEZ`te6?U`u)3K@)n6e%!efsGXk!rJ3UBB&Xc}G37x$G*5Ek7=x+N2 z0P2xY>^e1`6rkvz5=DD*AY!4cp$kCFt1S6(jGhHF^SWzJxd;$$q~jbnR-O ze$Sg@Jy|>Al9COQ7QVCd2%|>vLmaH~T1Pdy&odjznBVfS0WQr?6%d*ted+<49UZDK z^iPcK12IpZ_J#?Hmp>M=1h`V9#N@_}uz@rAnZUJqUD zmeN#owp{&z+z_EnB8=|h>ECK7lMN|Xdqt10M1QF?L`q(Jjj8bWx{Yqh{Uh|aG0gv# ztm}nU%xHo9B)8cny9f|z^&N)TAjK+TGM52Rele?7a``GQ-)_nsAe^lL-kv=nZ+dSO z=7)(ykuYbW#E8kgEdYW#6;CRJA)!prs*(b#h8^axN%hw2Ff;5YS)XV8u5O-YIY^`A zZOkafX*sXMzkDBq6Sy-e%q@Q~WH7^jJ|W+hG3dlR*~4yI!dk{sx=?kIYnVw7&;-1I z2k$>$It6$qGJyP)@dP|$NUmb)d4c^Z{(e91v{~UMJeRixm;r(CcDq3WGwR|7y?pUS zOfOO)_ucE?wE&f-ba1Mrxk*Cr4T|a)2F%0DV*j{(HRh~T`^`(d%nfg-i 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

&6mbfR4XyD60FXOmH2ZFa2Cb6?(OKE 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

oXKhBoH6w! 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

?F|S_=KGrYNC=G(Y4_SV$bzE>d| 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 literal 0 HcmV?d00001 diff --git a/assets/characters/ankarde/Diego-idle-trimmed.png.import b/assets/characters/ankarde/Diego-idle-trimmed.png.import new file mode 100644 index 0000000..ee3ecca --- /dev/null +++ b/assets/characters/ankarde/Diego-idle-trimmed.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjxrw1p7eh82i" +path="res://.godot/imported/Diego-idle-trimmed.png-1a9c2689a01a47fb2644332ac8c6a1ff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/ankarde/Diego-idle-trimmed.png" +dest_files=["res://.godot/imported/Diego-idle-trimmed.png-1a9c2689a01a47fb2644332ac8c6a1ff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/ankarde/Diego-walk-trimmed.png b/assets/characters/ankarde/Diego-walk-trimmed.png new file mode 100644 index 0000000000000000000000000000000000000000..bd74f45837bbb0ce0e51da0d20c696b7cf3ffeb1 GIT binary patch literal 334004 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 void: + limit_left = 0 + limit_top = 0 + limit_right = 3000 + limit_bottom = 2000 + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta: float) -> void: + pass diff --git a/assets/characters/camera_2d.gd.uid b/assets/characters/camera_2d.gd.uid new file mode 100644 index 0000000..e6868c2 --- /dev/null +++ b/assets/characters/camera_2d.gd.uid @@ -0,0 +1 @@ +uid://kg3i7b0dj2p8 diff --git a/assets/photo_5837080277660930029_w.jpg b/assets/photo_5837080277660930029_w.jpg new file mode 100644 index 0000000000000000000000000000000000000000..abdcc8a0ad71ce71d4ebc287810fc6e533628767 GIT binary patch literal 231483 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

(o1qzdk2>HtiLK5cHK#t%WB`23q*td%X*h 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

X8{1U6g#G?B}YwQ6U7r{ZT|B&?+8ssIh0oNujxz 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 z�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 Y+XNlJ+AJ1cEi$V!Z literal 0 HcmV?d00001 diff --git a/assets/photo_5837080277660930029_w.jpg.import b/assets/photo_5837080277660930029_w.jpg.import new file mode 100644 index 0000000..0235f43 --- /dev/null +++ b/assets/photo_5837080277660930029_w.jpg.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jmadeu351yh6" +path="res://.godot/imported/photo_5837080277660930029_w.jpg-2c885eb9dd12f0ac600f73302174d1f7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/photo_5837080277660930029_w.jpg" +dest_files=["res://.godot/imported/photo_5837080277660930029_w.jpg-2c885eb9dd12f0ac600f73302174d1f7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..e13b58d --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,229 @@ +[runnable_presets] + +Android="Android" + +[preset.0] + +name="Android" +platform="Android" +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=false +gradle_build/gradle_build_directory="" +gradle_build/android_source_template="" +gradle_build/compress_native_libraries=false +gradle_build/export_format=0 +gradle_build/min_sdk="" +gradle_build/target_sdk="" +gradle_build/custom_theme_attributes={} +architectures/armeabi-v7a=false +architectures/arm64-v8a=true +architectures/x86=false +architectures/x86_64=false +version/code=1 +version/name="" +package/unique_name="com.example.$genname" +package/name="" +package/signed=true +package/app_category=2 +package/retain_data_on_uninstall=false +package/exclude_from_recents=false +package/show_in_android_tv=false +package/show_in_app_library=true +package/show_as_launcher_app=false +launcher_icons/main_192x192="" +launcher_icons/adaptive_foreground_432x432="" +launcher_icons/adaptive_background_432x432="" +launcher_icons/adaptive_monochrome_432x432="" +graphics/opengl_debug=false +shader_baker/enabled=false +xr_features/xr_mode=0 +gesture/swipe_to_dismiss=false +screen/immersive_mode=true +screen/edge_to_edge=false +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +screen/background_color=Color(0, 0, 0, 1) +splash_screen/disable_godot_boot_splash=false +splash_screen/icon="" +splash_screen/branding_image="" +splash_screen/background_color=Color(0, 0, 0, 1) +user_data_backup/allow=false +command_line/extra_args="" +permissions/custom_permissions=PackedStringArray() +permissions/access_checkin_properties=false +permissions/access_coarse_location=false +permissions/access_fine_location=false +permissions/access_location_extra_commands=false +permissions/access_media_location=false +permissions/access_mock_location=false +permissions/access_network_state=false +permissions/access_surface_flinger=false +permissions/access_wifi_state=false +permissions/account_manager=false +permissions/add_voicemail=false +permissions/authenticate_accounts=false +permissions/battery_stats=false +permissions/bind_accessibility_service=false +permissions/bind_appwidget=false +permissions/bind_device_admin=false +permissions/bind_input_method=false +permissions/bind_nfc_service=false +permissions/bind_notification_listener_service=false +permissions/bind_print_service=false +permissions/bind_remoteviews=false +permissions/bind_text_service=false +permissions/bind_vpn_service=false +permissions/bind_wallpaper=false +permissions/bluetooth=false +permissions/bluetooth_admin=false +permissions/bluetooth_privileged=false +permissions/brick=false +permissions/broadcast_package_removed=false +permissions/broadcast_sms=false +permissions/broadcast_sticky=false +permissions/broadcast_wap_push=false +permissions/call_phone=false +permissions/call_privileged=false +permissions/camera=false +permissions/capture_audio_output=false +permissions/capture_secure_video_output=false +permissions/capture_video_output=false +permissions/change_component_enabled_state=false +permissions/change_configuration=false +permissions/change_network_state=false +permissions/change_wifi_multicast_state=false +permissions/change_wifi_state=false +permissions/clear_app_cache=false +permissions/clear_app_user_data=false +permissions/control_location_updates=false +permissions/delete_cache_files=false +permissions/delete_packages=false +permissions/device_power=false +permissions/diagnostic=false +permissions/disable_keyguard=false +permissions/dump=false +permissions/expand_status_bar=false +permissions/factory_test=false +permissions/flashlight=false +permissions/force_back=false +permissions/get_accounts=false +permissions/get_package_size=false +permissions/get_tasks=false +permissions/get_top_activity_info=false +permissions/global_search=false +permissions/hardware_test=false +permissions/inject_events=false +permissions/install_location_provider=false +permissions/install_packages=false +permissions/install_shortcut=false +permissions/internal_system_window=false +permissions/internet=false +permissions/kill_background_processes=false +permissions/location_hardware=false +permissions/manage_accounts=false +permissions/manage_app_tokens=false +permissions/manage_documents=false +permissions/manage_external_storage=false +permissions/manage_media=false +permissions/master_clear=false +permissions/media_content_control=false +permissions/modify_audio_settings=false +permissions/modify_phone_state=false +permissions/mount_format_filesystems=false +permissions/mount_unmount_filesystems=false +permissions/nfc=false +permissions/persistent_activity=false +permissions/post_notifications=false +permissions/process_outgoing_calls=false +permissions/read_calendar=false +permissions/read_call_log=false +permissions/read_contacts=false +permissions/read_external_storage=false +permissions/read_frame_buffer=false +permissions/read_history_bookmarks=false +permissions/read_input_state=false +permissions/read_logs=false +permissions/read_media_audio=false +permissions/read_media_images=false +permissions/read_media_video=false +permissions/read_media_visual_user_selected=false +permissions/read_phone_state=false +permissions/read_profile=false +permissions/read_sms=false +permissions/read_social_stream=false +permissions/read_sync_settings=false +permissions/read_sync_stats=false +permissions/read_user_dictionary=false +permissions/reboot=false +permissions/receive_boot_completed=false +permissions/receive_mms=false +permissions/receive_sms=false +permissions/receive_wap_push=false +permissions/record_audio=false +permissions/reorder_tasks=false +permissions/restart_packages=false +permissions/send_respond_via_message=false +permissions/send_sms=false +permissions/set_activity_watcher=false +permissions/set_alarm=false +permissions/set_always_finish=false +permissions/set_animation_scale=false +permissions/set_debug_app=false +permissions/set_orientation=false +permissions/set_pointer_speed=false +permissions/set_preferred_applications=false +permissions/set_process_limit=false +permissions/set_time=false +permissions/set_time_zone=false +permissions/set_wallpaper=false +permissions/set_wallpaper_hints=false +permissions/signal_persistent_processes=false +permissions/status_bar=false +permissions/subscribed_feeds_read=false +permissions/subscribed_feeds_write=false +permissions/system_alert_window=false +permissions/transmit_ir=false +permissions/uninstall_shortcut=false +permissions/update_device_stats=false +permissions/use_credentials=false +permissions/use_sip=false +permissions/vibrate=false +permissions/wake_lock=false +permissions/write_apn_settings=false +permissions/write_calendar=false +permissions/write_call_log=false +permissions/write_contacts=false +permissions/write_external_storage=false +permissions/write_gservices=false +permissions/write_history_bookmarks=false +permissions/write_profile=false +permissions/write_secure_settings=false +permissions/write_settings=false +permissions/write_sms=false +permissions/write_social_stream=false +permissions/write_sync_settings=false +permissions/write_user_dictionary=false diff --git a/godot-ai-LICENSE.txt b/godot-ai-LICENSE.txt new file mode 100644 index 0000000..7806d22 --- /dev/null +++ b/godot-ai-LICENSE.txt @@ -0,0 +1,21 @@ +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/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..d170b26 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bv0b3keud2c0w" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/main.tscn b/main.tscn new file mode 100644 index 0000000..d6d402c --- /dev/null +++ b/main.tscn @@ -0,0 +1,30 @@ +[gd_scene format=3 uid="uid://dux51kbeg032e"] + +[ext_resource type="Texture2D" uid="uid://jmadeu351yh6" path="res://assets/photo_5837080277660930029_w.jpg" id="1_1bvp3"] +[ext_resource type="PackedScene" uid="uid://nwbjxttgghpq" path="res://assets/characters/ankarde.tscn" id="2_0xm2m"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_rcrqh"] +size = Vector2(1171, 20) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_741h5"] +size = Vector2(136, 156) + +[node name="Main" type="Node2D" unique_id=1908609963] + +[node name="Photo5837080277660930029W" type="Sprite2D" parent="." unique_id=2065196895] +position = Vector2(576, 324) +scale = Vector2(0.8181818, 0.84374994) +texture = ExtResource("1_1bvp3") + +[node name="Limits" type="StaticBody2D" parent="." unique_id=648220000] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Limits" unique_id=423774102] +position = Vector2(585, 465) +shape = SubResource("RectangleShape2D_rcrqh") + +[node name="CollisionShape2D2" type="CollisionShape2D" parent="Limits" unique_id=296247895] +position = Vector2(77, 361) +shape = SubResource("RectangleShape2D_741h5") + +[node name="Player" parent="." unique_id=309719231 instance=ExtResource("2_0xm2m")] +position = Vector2(95, 215) diff --git a/player.gd b/player.gd new file mode 100644 index 0000000..1c9fddf --- /dev/null +++ b/player.gd @@ -0,0 +1,518 @@ +extends CharacterBody2D + + +# ============================================================================ +# MOVIMIENTO +# ============================================================================ + +@export_category("Movement") + +@export var speed: float = 300.0 +@export var ground_acceleration: float = 2200.0 +@export var ground_friction: float = 2600.0 +@export var air_acceleration: float = 1000.0 + + +# ============================================================================ +# SALTO +# ============================================================================ + +@export_category("Jump") + +@export var jump_velocity: float = -600.0 + +# Permite saltar ligeramente después de abandonar el suelo. +@export var coyote_time: float = 0.12 + +# Permite pulsar salto ligeramente antes de tocar el suelo. +@export var jump_buffer_time: float = 0.12 + +# Al soltar el botón durante la subida reducimos la velocidad vertical. +# Cuanto más pequeño, más brusco será el salto corto. +@export_range(0.1, 1.0) var jump_cut_multiplier: float = 0.45 + +# La gravedad normal durante la subida. +@export var gravity_multiplier: float = 1.0 + +# La caída es más rápida que la subida. +@export var fall_gravity_multiplier: float = 1.7 + +@export var max_fall_speed: float = 1200.0 + + +# ============================================================================ +# PUÑETAZO +# ============================================================================ + +@export_category("Punch") + +@export var punch_damage: int = 1 +@export var punch_duration: float = 0.13 +@export var punch_cooldown: float = 0.28 + +@export var punch_hitbox_size := Vector2(55.0, 38.0) +@export var punch_offset := Vector2(38.0, 0.0) + + +# ============================================================================ +# GROUND POUND +# ============================================================================ + +@export_category("Ground Pound") + +@export var ground_pound_damage: int = 3 + +# Velocidad mínima que adquiere al comenzar el golpe hacia abajo. +@export var ground_pound_start_speed: float = 450.0 + +# Gravedad adicional mientras cae haciendo ground pound. +@export var ground_pound_gravity_multiplier: float = 4.5 + +# Control horizontal mientras cae. +@export_range(0.0, 1.0) var ground_pound_horizontal_control: float = 0.25 + +@export var ground_pound_radius: float = 85.0 +@export var ground_pound_offset := Vector2(0.0, 20.0) + + +# ============================================================================ +# DEBUG +# ============================================================================ + +@export_category("Debug") + +@export var show_attack_hitboxes: bool = true +@export var debug_impact_duration: float = 0.18 + + +# ============================================================================ +# NODOS +# ============================================================================ + +@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D + +@onready var punch_hitbox: Area2D = $PunchHitbox +@onready var punch_collision: CollisionShape2D = $PunchHitbox/CollisionShape2D + +@onready var ground_pound_hitbox: Area2D = $GroundPoundHitbox +@onready var ground_pound_collision: CollisionShape2D = \ + $GroundPoundHitbox/CollisionShape2D + + +# ============================================================================ +# ESTADO INTERNO +# ============================================================================ + +var facing_direction: float = 1.0 + +var coyote_timer: float = 0.0 +var jump_buffer_timer: float = 0.0 + +var punch_timer: float = 0.0 +var attack_cooldown_timer: float = 0.0 + +var ground_pound_active: bool = false +var ground_pound_debug_timer: float = 0.0 + +# Evita que un enemigo reciba daño 60 veces durante el mismo puñetazo. +var punch_hit_targets: Dictionary = {} + + +func _ready() -> void: + _setup_hitboxes() + + +func _physics_process(delta: float) -> void: + var was_on_floor := is_on_floor() + + _update_timers(delta) + _update_jump_windows(delta) + + _handle_attack_input() + _handle_jump() + _apply_gravity(delta) + _handle_horizontal_movement(delta) + + _update_punch_damage() + _update_animation() + + move_and_slide() + + # El impacto debe comprobarse DESPUÉS de move_and_slide(), + # porque es entonces cuando sabemos si hemos aterrizado. + if ground_pound_active and not was_on_floor and is_on_floor(): + _ground_pound_impact() + + queue_redraw() + + +# ============================================================================ +# MOVIMIENTO +# ============================================================================ + +func _handle_horizontal_movement(delta: float) -> void: + var direction := Input.get_axis("move_left", "move_right") + + if direction != 0.0: + facing_direction = sign(direction) + _update_facing() + + if ground_pound_active: + var target_speed := ( + direction + * speed + * ground_pound_horizontal_control + ) + + velocity.x = move_toward( + velocity.x, + target_speed, + air_acceleration * delta + ) + + return + + if is_on_floor(): + if direction != 0.0: + velocity.x = move_toward( + velocity.x, + direction * speed, + ground_acceleration * delta + ) + else: + velocity.x = move_toward( + velocity.x, + 0.0, + ground_friction * delta + ) + else: + velocity.x = move_toward( + velocity.x, + direction * speed, + air_acceleration * delta + ) + + +func _update_facing() -> void: + animated_sprite.flip_h = facing_direction < 0.0 + + punch_hitbox.position = Vector2( + abs(punch_offset.x) * facing_direction, + punch_offset.y + ) + + +# ============================================================================ +# GRAVEDAD +# ============================================================================ + +func _apply_gravity(delta: float) -> void: + if is_on_floor(): + return + + var gravity := get_gravity() + + if ground_pound_active: + velocity += ( + gravity + * ground_pound_gravity_multiplier + * delta + ) + + elif velocity.y > 0.0: + # Más gravedad al caer. + velocity += gravity * fall_gravity_multiplier * delta + + else: + # Gravedad normal durante la subida. + velocity += gravity * gravity_multiplier * delta + + velocity.y = min(velocity.y, max_fall_speed) + + +# ============================================================================ +# SALTO +# ============================================================================ + +func _update_jump_windows(delta: float) -> void: + if is_on_floor(): + coyote_timer = coyote_time + else: + coyote_timer = max(coyote_timer - delta, 0.0) + + if Input.is_action_just_pressed("jump"): + jump_buffer_timer = jump_buffer_time + else: + jump_buffer_timer = max( + jump_buffer_timer - delta, + 0.0 + ) + + +func _handle_jump() -> void: + if ( + jump_buffer_timer > 0.0 + and coyote_timer > 0.0 + and not ground_pound_active + ): + velocity.y = jump_velocity + + jump_buffer_timer = 0.0 + coyote_timer = 0.0 + + # Salto variable. + # + # Mantienes pulsado: + # salto alto + # + # Sueltas pronto: + # salto corto + if ( + Input.is_action_just_released("jump") + and velocity.y < 0.0 + ): + velocity.y *= jump_cut_multiplier + + +# ============================================================================ +# ATAQUE +# ============================================================================ + +func _handle_attack_input() -> void: + if not Input.is_action_just_pressed("attack"): + return + + if attack_cooldown_timer > 0.0: + return + + if is_on_floor(): + _start_punch() + else: + _start_ground_pound() + + +# ============================================================================ +# PUÑETAZO +# ============================================================================ + +func _start_punch() -> void: + punch_timer = punch_duration + attack_cooldown_timer = punch_cooldown + + punch_hit_targets.clear() + + +func _update_punch_damage() -> void: + if punch_timer <= 0.0: + return + + for body in punch_hitbox.get_overlapping_bodies(): + _damage_target( + body, + punch_damage, + punch_hit_targets + ) + + for area in punch_hitbox.get_overlapping_areas(): + _damage_target( + area, + punch_damage, + punch_hit_targets + ) + + +# ============================================================================ +# GROUND POUND +# ============================================================================ + +func _start_ground_pound() -> void: + if ground_pound_active: + return + + ground_pound_active = true + attack_cooldown_timer = punch_cooldown + + # Si todavía estamos subiendo, cortamos inmediatamente la subida. + velocity.y = max( + velocity.y, + ground_pound_start_speed + ) + + +func _ground_pound_impact() -> void: + ground_pound_active = false + ground_pound_debug_timer = debug_impact_duration + + var hit_targets: Dictionary = {} + + for body in ground_pound_hitbox.get_overlapping_bodies(): + _damage_target( + body, + ground_pound_damage, + hit_targets + ) + + for area in ground_pound_hitbox.get_overlapping_areas(): + _damage_target( + area, + ground_pound_damage, + hit_targets + ) + + +# ============================================================================ +# DAÑO +# ============================================================================ + +func _damage_target( + collider: Node, + damage: int, + hit_targets: Dictionary +) -> void: + if collider == self: + return + + var target := collider + + # Esto permite que el enemigo tenga una Hurtbox Area2D. + if not target.has_method("take_damage"): + var parent := target.get_parent() + + if parent != null and parent.has_method("take_damage"): + target = parent + + if not target.has_method("take_damage"): + return + + var target_id := target.get_instance_id() + + if hit_targets.has(target_id): + return + + hit_targets[target_id] = true + + target.take_damage( + damage, + global_position + ) + + +# ============================================================================ +# TIMERS +# ============================================================================ + +func _update_timers(delta: float) -> void: + punch_timer = max( + punch_timer - delta, + 0.0 + ) + + attack_cooldown_timer = max( + attack_cooldown_timer - delta, + 0.0 + ) + + ground_pound_debug_timer = max( + ground_pound_debug_timer - delta, + 0.0 + ) + + +# ============================================================================ +# ANIMACIONES +# ============================================================================ + +func _update_animation() -> void: + if ground_pound_active: + animated_sprite.play("walk") + + elif not is_on_floor(): + animated_sprite.play("walk") + + elif abs(velocity.x) > 10.0: + animated_sprite.play("walk") + + else: + animated_sprite.play("idle") + + +# ============================================================================ +# CONFIGURACIÓN DE HITBOX +# ============================================================================ + +func _setup_hitboxes() -> void: + # Puñetazo + var punch_shape := RectangleShape2D.new() + punch_shape.size = punch_hitbox_size + + punch_collision.shape = punch_shape + + punch_hitbox.collision_layer = 0 + punch_hitbox.collision_mask = 2 + punch_hitbox.monitoring = true + + # Ground pound + var pound_shape := CircleShape2D.new() + pound_shape.radius = ground_pound_radius + + ground_pound_collision.shape = pound_shape + + ground_pound_hitbox.position = ground_pound_offset + + ground_pound_hitbox.collision_layer = 0 + ground_pound_hitbox.collision_mask = 2 + ground_pound_hitbox.monitoring = true + + _update_facing() + + +# ============================================================================ +# DEBUG HITBOXES +# ============================================================================ + +func _draw() -> void: + if not show_attack_hitboxes: + return + + # Puñetazo + if punch_timer > 0.0: + var center := punch_hitbox.position + + var rect := Rect2( + center - punch_hitbox_size / 2.0, + punch_hitbox_size + ) + + draw_rect( + rect, + Color(1.0, 0.1, 0.1, 0.25), + true + ) + + draw_rect( + rect, + Color(1.0, 0.1, 0.1, 0.9), + false, + 2.0 + ) + + # Ground pound: + # solamente mostramos el área cuando realmente produce daño. + if ground_pound_debug_timer > 0.0: + var center := ground_pound_hitbox.position + + draw_circle( + center, + ground_pound_radius, + Color(1.0, 0.5, 0.0, 0.20) + ) + + draw_arc( + center, + ground_pound_radius, + 0.0, + TAU, + 48, + Color(1.0, 0.5, 0.0, 0.9), + 3.0 + ) diff --git a/player.gd.uid b/player.gd.uid new file mode 100644 index 0000000..1514a90 --- /dev/null +++ b/player.gd.uid @@ -0,0 +1 @@ +uid://dccfebno1gvh7 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..7d32197 --- /dev/null +++ b/project.godot @@ -0,0 +1,59 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Mortal Maze: The Game" +run/main_scene="uid://dux51kbeg032e" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[display] + +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[input] + +move_left={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +] +} +jump={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) +] +} +move_down={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] +} +attack={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) +] +} + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" -- 2.54.0